mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat: rules engine — json-rules-engine integration with worklist scoring
- Self-contained NestJS module: types, storage (Redis+JSON), fact providers, action handlers - PriorityConfig CRUD (slider values for task weights, campaign weights, source weights) - Score action handler with SLA multiplier + campaign multiplier formula - Worklist consumer: scores and ranks items before returning - Hospital starter template (7 rules) - REST API: /api/rules/* (CRUD, priority-config, evaluate, templates) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
139
src/rules-engine/rules-engine.service.ts
Normal file
139
src/rules-engine/rules-engine.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// 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) {
|
||||
this.actionHandlers = new Map([
|
||||
['score', new ScoreActionHandler()],
|
||||
['assign', new AssignActionHandler()],
|
||||
['escalate', new EscalateActionHandler()],
|
||||
]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user