Files
helix-engage-server/src/rules-engine/rules-engine.service.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

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;
}
}