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:
2026-04-01 16:59:10 +05:30
parent 7b59543d36
commit b8556cf440
20 changed files with 959 additions and 3 deletions

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