mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(performance-alerts): rules-engine-driven alerts, persisted as PerformanceAlert
Phase A+B of the alerts overhaul: - New PerformanceFactsProvider exposes agent.idleMinutes (from AgentSession), agent.busyMinutes, agent.totalCallsToday, agent.bookedCallsToday, agent.conversionPercent - Implement EscalateActionHandler (was a stub): persists a PerformanceAlert row, dedupes per agent+type+IST date so a 5-min cron can't spam, updates value if it changes - New PerformanceConsumer: setInterval every 5 min, reads on_schedule rules referencing agent.* facts, evaluates per agent, dispatches escalate actions - Two starter rules in hospital-starter.json: excessive-idle (>60min) and low-conversion (<15% with >10 calls today). NPS deferred — no source signal exists yet - New PerformanceAlertsController: GET /api/supervisor/performance-alerts (active list), POST /:id/dismiss, POST /dismiss-all - Rules engine now injects EscalateActionHandler via DI so the action has access to PlatformGraphqlService for persistence Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||
|
||||
/**
|
||||
* Resolves per-agent performance facts for the rules engine.
|
||||
* Used by the PerformanceConsumer to evaluate alert rules every 5 min.
|
||||
*
|
||||
* Facts exposed:
|
||||
* - agent.idleMinutes — from today's AgentSession.idleTimeS
|
||||
* - agent.busyMinutes — from AgentSession.busyTimeS
|
||||
* - agent.totalCallsToday — count of Calls started today
|
||||
* - agent.bookedCallsToday — count of Calls today with disposition=APPOINTMENT_BOOKED
|
||||
* - agent.conversionPercent — bookedCallsToday / totalCallsToday × 100
|
||||
* - agent.id, agent.name — for routing alerts back to the right agent
|
||||
*
|
||||
* NPS deferred — no source signal exists yet.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PerformanceFactsProvider implements FactProvider {
|
||||
name = 'performance';
|
||||
private readonly logger = new Logger(PerformanceFactsProvider.name);
|
||||
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
/**
|
||||
* @param entityData { agentId: string, agentName?: string }
|
||||
*/
|
||||
async resolveFacts(entityData: { agentId: string; agentName?: string }): Promise<Record<string, FactValue>> {
|
||||
const agentId = entityData.agentId;
|
||||
const today = this.todayIst();
|
||||
|
||||
const session = await this.fetchTodaySession(agentId, today);
|
||||
const callTotals = await this.fetchTodayCallTotals(agentId, today);
|
||||
|
||||
const idleMinutes = Math.round((session?.idleTimeS ?? 0) / 60);
|
||||
const busyMinutes = Math.round((session?.busyTimeS ?? 0) / 60);
|
||||
const conversionPercent = callTotals.total > 0
|
||||
? Math.round((callTotals.booked / callTotals.total) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
'agent.id': agentId,
|
||||
'agent.name': entityData.agentName ?? '',
|
||||
'agent.idleMinutes': idleMinutes,
|
||||
'agent.busyMinutes': busyMinutes,
|
||||
'agent.totalCallsToday': callTotals.total,
|
||||
'agent.bookedCallsToday': callTotals.booked,
|
||||
'agent.conversionPercent': conversionPercent,
|
||||
};
|
||||
}
|
||||
|
||||
private todayIst(): string {
|
||||
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||
return ist.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private async fetchTodaySession(agentId: string, date: string): Promise<{ idleTimeS: number; busyTimeS: number } | null> {
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agentSessions(first: 1, filter: { agentId: { eq: "${agentId}" }, date: { eq: "${date}" } }) {
|
||||
edges { node { idleTimeS busyTimeS } }
|
||||
} }`,
|
||||
);
|
||||
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||
if (!node) return null;
|
||||
return { idleTimeS: node.idleTimeS ?? 0, busyTimeS: node.busyTimeS ?? 0 };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[PERF-FACTS] Session fetch failed for agent=${agentId}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchTodayCallTotals(agentId: string, date: string): Promise<{ total: number; booked: number }> {
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ calls(first: 200, filter: {
|
||||
agentId: { eq: "${agentId}" },
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" }
|
||||
}) { edges { node { disposition } } } }`,
|
||||
);
|
||||
const edges = data?.calls?.edges ?? [];
|
||||
const total = edges.length;
|
||||
const booked = edges.filter((e: any) => e.node.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
return { total, booked };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[PERF-FACTS] Call totals fetch failed for agent=${agentId}: ${err}`);
|
||||
return { total: 0, booked: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user