mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
143 lines
5.8 KiB
TypeScript
143 lines
5.8 KiB
TypeScript
// src/rules-engine/rules-engine.service.ts
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Engine } from 'json-rules-engine';
|
|
import { RulesStorageService } from './rules-storage.service';
|
|
import { LeadFactsProvider } from './facts/lead-facts.provider';
|
|
import { CallFactsProvider, computeSlaMultiplier, computeSlaStatus } from './facts/call-facts.provider';
|
|
import { AgentFactsProvider } from './facts/agent-facts.provider';
|
|
import { ScoreActionHandler } from './actions/score.action';
|
|
import { AssignActionHandler } from './actions/assign.action';
|
|
import { EscalateActionHandler } from './actions/escalate.action';
|
|
import type { Rule, ScoredItem, ScoreBreakdown, PriorityConfig } from './types/rule.types';
|
|
import type { ActionHandler } from './types/action.types';
|
|
|
|
@Injectable()
|
|
export class RulesEngineService {
|
|
private readonly logger = new Logger(RulesEngineService.name);
|
|
private readonly leadFacts = new LeadFactsProvider();
|
|
private readonly callFacts = new CallFactsProvider();
|
|
private readonly agentFacts = new AgentFactsProvider();
|
|
private readonly actionHandlers: Map<string, ActionHandler>;
|
|
|
|
constructor(
|
|
private readonly storage: RulesStorageService,
|
|
private readonly escalateHandler: EscalateActionHandler,
|
|
) {
|
|
this.actionHandlers = new Map([
|
|
['score', new ScoreActionHandler()],
|
|
['assign', new AssignActionHandler()],
|
|
['escalate', this.escalateHandler],
|
|
]);
|
|
}
|
|
|
|
async evaluate(triggerType: string, triggerValue: string, factContext: Record<string, any>): Promise<{ rulesApplied: string[]; results: any[] }> {
|
|
const rules = await this.storage.getByTrigger(triggerType, triggerValue);
|
|
if (rules.length === 0) return { rulesApplied: [], results: [] };
|
|
|
|
const engine = new Engine();
|
|
const ruleMap = new Map<string, Rule>();
|
|
|
|
for (const rule of rules) {
|
|
engine.addRule({
|
|
conditions: rule.conditions as any,
|
|
event: { type: rule.action.type, params: { ruleId: rule.id, ...rule.action.params as any } },
|
|
priority: rule.priority,
|
|
});
|
|
ruleMap.set(rule.id, rule);
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(factContext)) {
|
|
engine.addFact(key, value);
|
|
}
|
|
|
|
const { events } = await engine.run();
|
|
const results: any[] = [];
|
|
const rulesApplied: string[] = [];
|
|
|
|
for (const event of events) {
|
|
const ruleId = event.params?.ruleId;
|
|
const rule = ruleMap.get(ruleId);
|
|
if (!rule) continue;
|
|
const handler = this.actionHandlers.get(event.type);
|
|
if (handler) {
|
|
const result = await handler.execute(rule.action, factContext);
|
|
results.push({ ruleId, ruleName: rule.name, ...result });
|
|
rulesApplied.push(rule.name);
|
|
}
|
|
}
|
|
|
|
return { rulesApplied, results };
|
|
}
|
|
|
|
async scoreWorklistItem(item: any, priorityConfig: PriorityConfig): Promise<ScoredItem> {
|
|
const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item);
|
|
const callFacts = await this.callFacts.resolveFacts(item, priorityConfig);
|
|
const taskType = callFacts['call.taskType'] as string;
|
|
|
|
// Inject priority config weights into context for the score action
|
|
const campaignWeight = item.campaignId ? (priorityConfig.campaignWeights[item.campaignId] ?? 5) : 5;
|
|
const sourceWeight = priorityConfig.sourceWeights[leadFacts['lead.source'] as string] ?? 5;
|
|
|
|
const allFacts: Record<string, any> = {
|
|
...leadFacts,
|
|
...callFacts,
|
|
'_campaignWeight': campaignWeight,
|
|
'_sourceWeight': sourceWeight,
|
|
};
|
|
|
|
const { rulesApplied, results } = await this.evaluate('on_request', 'worklist', allFacts);
|
|
|
|
let totalScore = 0;
|
|
let slaMultiplierVal = 1;
|
|
let campaignMultiplierVal = 1;
|
|
|
|
for (const result of results) {
|
|
if (result.success && result.data?.score != null) {
|
|
totalScore += result.data.score;
|
|
if (result.data.slaApplied) slaMultiplierVal = computeSlaMultiplier((allFacts['call.slaElapsedPercent'] as number) ?? 0);
|
|
if (result.data.campaignApplied) campaignMultiplierVal = (campaignWeight / 10) * (sourceWeight / 10);
|
|
}
|
|
}
|
|
|
|
const slaElapsedPercent = (allFacts['call.slaElapsedPercent'] as number) ?? 0;
|
|
|
|
return {
|
|
id: item.id,
|
|
score: Math.round(totalScore * 100) / 100,
|
|
scoreBreakdown: {
|
|
baseScore: totalScore,
|
|
slaMultiplier: Math.round(slaMultiplierVal * 100) / 100,
|
|
campaignMultiplier: Math.round(campaignMultiplierVal * 100) / 100,
|
|
rulesApplied,
|
|
},
|
|
slaStatus: computeSlaStatus(slaElapsedPercent),
|
|
slaElapsedPercent,
|
|
};
|
|
}
|
|
|
|
async scoreWorklist(items: any[]): Promise<(any & ScoredItem)[]> {
|
|
const priorityConfig = await this.storage.getPriorityConfig();
|
|
const scored = await Promise.all(
|
|
items.map(async (item) => {
|
|
const scoreData = await this.scoreWorklistItem(item, priorityConfig);
|
|
return { ...item, ...scoreData };
|
|
}),
|
|
);
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored;
|
|
}
|
|
|
|
async previewScoring(items: any[], config: PriorityConfig): Promise<(any & ScoredItem)[]> {
|
|
// Same as scoreWorklist but uses provided config (for live preview)
|
|
const scored = await Promise.all(
|
|
items.map(async (item) => {
|
|
const scoreData = await this.scoreWorklistItem(item, config);
|
|
return { ...item, ...scoreData };
|
|
}),
|
|
);
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored;
|
|
}
|
|
}
|