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:
@@ -1,12 +1,94 @@
|
||||
// src/rules-engine/actions/escalate.action.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||
import type { RuleAction } from '../types/rule.types';
|
||||
import type { RuleAction, EscalateActionParams } from '../types/rule.types';
|
||||
|
||||
/**
|
||||
* Persists a PerformanceAlert when a rule's escalate action fires.
|
||||
*
|
||||
* Dedupes by (agentId, alertType, IST date) — a single rule firing every
|
||||
* 5 min should only produce ONE alert per day per agent until dismissed.
|
||||
* If a row already exists for that key today and is not dismissed, the
|
||||
* action is a no-op (returns the existing id). If the existing row was
|
||||
* dismissed earlier today, we don't re-fire — supervisor explicitly
|
||||
* acknowledged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EscalateActionHandler implements ActionHandler {
|
||||
type = 'escalate';
|
||||
private readonly logger = new Logger(EscalateActionHandler.name);
|
||||
|
||||
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
|
||||
const params = action.params as EscalateActionParams & { ruleId?: string; alertType?: string };
|
||||
const agentId = context['agent.id'] as string | undefined;
|
||||
const agentName = (context['agent.name'] as string | undefined) ?? '';
|
||||
const valueRaw = context['_alertValue'];
|
||||
const valueText = valueRaw != null ? String(valueRaw) : null;
|
||||
|
||||
if (!agentId) {
|
||||
return { success: false, error: 'agent.id missing from facts' };
|
||||
}
|
||||
|
||||
const alertType = params.alertType ?? this.inferAlertType(params.message);
|
||||
const severity = (params.severity ?? 'warning').toUpperCase(); // INFO | WARNING | CRITICAL
|
||||
const today = this.todayIst();
|
||||
|
||||
// Dedupe: any non-dismissed alert today for this agent + type?
|
||||
try {
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ performanceAlerts(first: 1, filter: {
|
||||
agentId: { eq: "${agentId}" },
|
||||
alertType: { eq: ${alertType} },
|
||||
firedAt: { gte: "${today}T00:00:00+05:30", lte: "${today}T23:59:59+05:30" }
|
||||
}) { edges { node { id dismissedAt value } } } }`,
|
||||
);
|
||||
const existingNode = existing?.performanceAlerts?.edges?.[0]?.node;
|
||||
if (existingNode) {
|
||||
// Already fired today. If value changed, update it; otherwise no-op.
|
||||
if (!existingNode.dismissedAt && existingNode.value !== valueText) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||
{ id: existingNode.id, data: { value: valueText } },
|
||||
);
|
||||
}
|
||||
return { success: true, data: { id: existingNode.id, deduped: true, agentId, alertType } };
|
||||
}
|
||||
|
||||
const created = await this.platform.query<any>(
|
||||
`mutation($data: PerformanceAlertCreateInput!) { createPerformanceAlert(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
agentId,
|
||||
alertType,
|
||||
severity,
|
||||
message: params.message ?? alertType,
|
||||
value: valueText,
|
||||
ruleId: params.ruleId ?? null,
|
||||
firedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
const id = created?.createPerformanceAlert?.id;
|
||||
this.logger.log(`[ESCALATE] Created alert ${id} agent=${agentName ?? agentId} type=${alertType} value=${valueText}`);
|
||||
return { success: true, data: { id, agentId, alertType, severity, message: params.message } };
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[ESCALATE] Failed for agent=${agentId}: ${err?.message ?? err}`);
|
||||
return { success: false, error: String(err?.message ?? err) };
|
||||
}
|
||||
}
|
||||
|
||||
private inferAlertType(message: string | undefined): string {
|
||||
const m = (message ?? '').toLowerCase();
|
||||
if (m.includes('idle')) return 'EXCESSIVE_IDLE';
|
||||
if (m.includes('nps')) return 'LOW_NPS';
|
||||
if (m.includes('conversion')) return 'LOW_CONVERSION';
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
private todayIst(): string {
|
||||
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||
return ist.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user