Files
helix-engage-server/src/rules-engine/facts/performance-facts.provider.ts
saridsa2 8dcfa5a72f 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>
2026-04-15 09:02:02 +05:30

94 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}
}
}