From b8556cf4407ac02955d03740369a40d314c3c595 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 1 Apr 2026 16:59:10 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20rules=20engine=20=E2=80=94=20json-rules?= =?UTF-8?q?-engine=20integration=20with=20worklist=20scoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package-lock.json | 43 ++++ package.json | 1 + src/app.module.ts | 4 + src/rules-engine/actions/assign.action.ts | 12 ++ src/rules-engine/actions/escalate.action.ts | 12 ++ src/rules-engine/actions/score.action.ts | 33 ++++ .../consumers/worklist.consumer.ts | 25 +++ .../facts/agent-facts.provider.ts | 18 ++ src/rules-engine/facts/call-facts.provider.ts | 52 +++++ src/rules-engine/facts/lead-facts.provider.ts | 30 +++ src/rules-engine/rules-engine.controller.ts | 123 ++++++++++++ src/rules-engine/rules-engine.module.ts | 14 ++ src/rules-engine/rules-engine.service.ts | 139 +++++++++++++ src/rules-engine/rules-storage.service.ts | 186 ++++++++++++++++++ .../templates/hospital-starter.json | 89 +++++++++ src/rules-engine/types/action.types.ts | 14 ++ src/rules-engine/types/fact.types.ts | 15 ++ src/rules-engine/types/rule.types.ts | 126 ++++++++++++ src/worklist/worklist.module.ts | 3 +- src/worklist/worklist.service.ts | 23 ++- 20 files changed, 959 insertions(+), 3 deletions(-) create mode 100644 src/rules-engine/actions/assign.action.ts create mode 100644 src/rules-engine/actions/escalate.action.ts create mode 100644 src/rules-engine/actions/score.action.ts create mode 100644 src/rules-engine/consumers/worklist.consumer.ts create mode 100644 src/rules-engine/facts/agent-facts.provider.ts create mode 100644 src/rules-engine/facts/call-facts.provider.ts create mode 100644 src/rules-engine/facts/lead-facts.provider.ts create mode 100644 src/rules-engine/rules-engine.controller.ts create mode 100644 src/rules-engine/rules-engine.module.ts create mode 100644 src/rules-engine/rules-engine.service.ts create mode 100644 src/rules-engine/rules-storage.service.ts create mode 100644 src/rules-engine/templates/hospital-starter.json create mode 100644 src/rules-engine/types/action.types.ts create mode 100644 src/rules-engine/types/fact.types.ts create mode 100644 src/rules-engine/types/rule.types.ts diff --git a/package-lock.json b/package-lock.json index 5d99507..10e6e87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "ai": "^6.0.116", "axios": "^1.13.6", "ioredis": "^5.10.1", + "json-rules-engine": "^6.6.0", "kafkajs": "^2.2.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -8234,6 +8235,12 @@ "node": ">=6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "http://localhost:4873/events/-/events-3.3.0.tgz", @@ -9175,6 +9182,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-it": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hash-it/-/hash-it-6.0.1.tgz", + "integrity": "sha512-qhl8+l4Zwi1eLlL3lja5ywmDQnBzLEJxd0QJoAVIgZpgQbdtVZrN5ypB0y3VHwBlvAalpcbM2/A6x7oUks5zNg==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "http://localhost:4873/hasown/-/hasown-2.0.2.tgz", @@ -10490,6 +10503,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-rules-engine": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/json-rules-engine/-/json-rules-engine-6.6.0.tgz", + "integrity": "sha512-jJ4eVCPnItetPiU3fTIzrrl3d2zeIXCcCy11dwWhN72YXBR2mByV1Vfbrvt6y2n+VFmxc6rtL/XhDqLKIwBx6g==", + "license": "ISC", + "dependencies": { + "clone": "^2.1.2", + "eventemitter2": "^6.4.4", + "hash-it": "^6.0.0", + "jsonpath-plus": "^7.2.0" + } + }, + "node_modules/json-rules-engine/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "http://localhost:4873/json-schema/-/json-schema-0.4.0.tgz", @@ -10549,6 +10583,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpath-plus": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", + "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "http://localhost:4873/jwa/-/jwa-2.0.1.tgz", diff --git a/package.json b/package.json index 50b5bcd..b85cedc 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "ai": "^6.0.116", "axios": "^1.13.6", "ioredis": "^5.10.1", + "json-rules-engine": "^6.6.0", "kafkajs": "^2.2.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/src/app.module.ts b/src/app.module.ts index 6e7bc18..97a4b81 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,8 @@ import { SupervisorModule } from './supervisor/supervisor.module'; import { MaintModule } from './maint/maint.module'; import { RecordingsModule } from './recordings/recordings.module'; import { EventsModule } from './events/events.module'; +import { CallerResolutionModule } from './caller/caller-resolution.module'; +import { RulesEngineModule } from './rules-engine/rules-engine.module'; @Module({ imports: [ @@ -38,6 +40,8 @@ import { EventsModule } from './events/events.module'; MaintModule, RecordingsModule, EventsModule, + CallerResolutionModule, + RulesEngineModule, ], }) export class AppModule {} diff --git a/src/rules-engine/actions/assign.action.ts b/src/rules-engine/actions/assign.action.ts new file mode 100644 index 0000000..79d1b02 --- /dev/null +++ b/src/rules-engine/actions/assign.action.ts @@ -0,0 +1,12 @@ +// src/rules-engine/actions/assign.action.ts + +import type { ActionHandler, ActionResult } from '../types/action.types'; +import type { RuleAction } from '../types/rule.types'; + +export class AssignActionHandler implements ActionHandler { + type = 'assign'; + + async execute(_action: RuleAction, _context: Record): Promise { + return { success: true, data: { stub: true, action: 'assign' } }; + } +} diff --git a/src/rules-engine/actions/escalate.action.ts b/src/rules-engine/actions/escalate.action.ts new file mode 100644 index 0000000..f562172 --- /dev/null +++ b/src/rules-engine/actions/escalate.action.ts @@ -0,0 +1,12 @@ +// src/rules-engine/actions/escalate.action.ts + +import type { ActionHandler, ActionResult } from '../types/action.types'; +import type { RuleAction } from '../types/rule.types'; + +export class EscalateActionHandler implements ActionHandler { + type = 'escalate'; + + async execute(_action: RuleAction, _context: Record): Promise { + return { success: true, data: { stub: true, action: 'escalate' } }; + } +} diff --git a/src/rules-engine/actions/score.action.ts b/src/rules-engine/actions/score.action.ts new file mode 100644 index 0000000..cc1b553 --- /dev/null +++ b/src/rules-engine/actions/score.action.ts @@ -0,0 +1,33 @@ +// src/rules-engine/actions/score.action.ts + +import type { ActionHandler, ActionResult } from '../types/action.types'; +import type { RuleAction, ScoreActionParams } from '../types/rule.types'; +import { computeSlaMultiplier } from '../facts/call-facts.provider'; + +export class ScoreActionHandler implements ActionHandler { + type = 'score'; + + async execute(action: RuleAction, context: Record): Promise { + const params = action.params as ScoreActionParams; + let score = params.weight; + let slaApplied = false; + let campaignApplied = false; + + if (params.slaMultiplier && context['call.slaElapsedPercent'] != null) { + score *= computeSlaMultiplier(context['call.slaElapsedPercent']); + slaApplied = true; + } + + if (params.campaignMultiplier) { + const campaignWeight = (context['_campaignWeight'] ?? 5) / 10; + const sourceWeight = (context['_sourceWeight'] ?? 5) / 10; + score *= campaignWeight * sourceWeight; + campaignApplied = true; + } + + return { + success: true, + data: { score, weight: params.weight, slaApplied, campaignApplied }, + }; + } +} diff --git a/src/rules-engine/consumers/worklist.consumer.ts b/src/rules-engine/consumers/worklist.consumer.ts new file mode 100644 index 0000000..29bc660 --- /dev/null +++ b/src/rules-engine/consumers/worklist.consumer.ts @@ -0,0 +1,25 @@ +// src/rules-engine/consumers/worklist.consumer.ts + +import { Injectable, Logger } from '@nestjs/common'; +import { RulesEngineService } from '../rules-engine.service'; +import { RulesStorageService } from '../rules-storage.service'; + +@Injectable() +export class WorklistConsumer { + private readonly logger = new Logger(WorklistConsumer.name); + + constructor( + private readonly engine: RulesEngineService, + private readonly storage: RulesStorageService, + ) {} + + async scoreAndRank(worklistItems: any[]): Promise { + const rules = await this.storage.getByTrigger('on_request', 'worklist'); + if (rules.length === 0) { + this.logger.debug('No scoring rules configured — returning unsorted'); + return worklistItems; + } + this.logger.debug(`Scoring ${worklistItems.length} items with ${rules.length} rules`); + return this.engine.scoreWorklist(worklistItems); + } +} diff --git a/src/rules-engine/facts/agent-facts.provider.ts b/src/rules-engine/facts/agent-facts.provider.ts new file mode 100644 index 0000000..72b0d37 --- /dev/null +++ b/src/rules-engine/facts/agent-facts.provider.ts @@ -0,0 +1,18 @@ +// src/rules-engine/facts/agent-facts.provider.ts + +import type { FactProvider, FactValue } from '../types/fact.types'; + +export class AgentFactsProvider implements FactProvider { + name = 'agent'; + + async resolveFacts(agent: any): Promise> { + return { + 'agent.status': agent.status ?? 'OFFLINE', + 'agent.activeCallCount': agent.activeCallCount ?? 0, + 'agent.todayCallCount': agent.todayCallCount ?? 0, + 'agent.skills': agent.skills ?? [], + 'agent.campaigns': agent.campaigns ?? [], + 'agent.idleMinutes': agent.idleMinutes ?? 0, + }; + } +} diff --git a/src/rules-engine/facts/call-facts.provider.ts b/src/rules-engine/facts/call-facts.provider.ts new file mode 100644 index 0000000..3248740 --- /dev/null +++ b/src/rules-engine/facts/call-facts.provider.ts @@ -0,0 +1,52 @@ +// src/rules-engine/facts/call-facts.provider.ts + +import type { FactProvider, FactValue } from '../types/fact.types'; +import type { PriorityConfig } from '../types/rule.types'; + +export class CallFactsProvider implements FactProvider { + name = 'call'; + + async resolveFacts(call: any, priorityConfig?: PriorityConfig): Promise> { + const taskType = this.inferTaskType(call); + const slaMinutes = priorityConfig?.taskWeights[taskType]?.slaMinutes ?? 1440; + const createdAt = call.createdAt ? new Date(call.createdAt).getTime() : Date.now(); + const elapsedMinutes = Math.round((Date.now() - createdAt) / 60000); + const slaElapsedPercent = Math.round((elapsedMinutes / slaMinutes) * 100); + + return { + 'call.direction': call.callDirection ?? call.direction ?? null, + 'call.status': call.callStatus ?? null, + 'call.disposition': call.disposition ?? null, + 'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0, + 'call.callbackStatus': call.callbackstatus ?? call.callbackStatus ?? null, + 'call.slaElapsedPercent': slaElapsedPercent, + 'call.slaBreached': slaElapsedPercent > 100, + 'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0, + 'call.taskType': taskType, + }; + } + + private inferTaskType(call: any): string { + if (call.callStatus === 'MISSED' || call.type === 'missed') return 'missed_call'; + if (call.followUpType === 'CALLBACK' || call.type === 'callback') return 'follow_up'; + if (call.type === 'follow-up') return 'follow_up'; + if (call.contactAttempts >= 3) return 'attempt_3'; + if (call.contactAttempts >= 2) return 'attempt_2'; + if (call.campaignId || call.type === 'lead') return 'campaign_lead'; + return 'campaign_lead'; + } +} + +// Exported scoring functions — used by both sidecar and frontend (via scoring.ts) +export function computeSlaMultiplier(slaElapsedPercent: number): number { + const elapsed = slaElapsedPercent / 100; + if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5; + return Math.pow(elapsed, 1.6); +} + +export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' { + if (slaElapsedPercent > 100) return 'critical'; + if (slaElapsedPercent >= 80) return 'high'; + if (slaElapsedPercent >= 50) return 'medium'; + return 'low'; +} diff --git a/src/rules-engine/facts/lead-facts.provider.ts b/src/rules-engine/facts/lead-facts.provider.ts new file mode 100644 index 0000000..58d05d5 --- /dev/null +++ b/src/rules-engine/facts/lead-facts.provider.ts @@ -0,0 +1,30 @@ +// src/rules-engine/facts/lead-facts.provider.ts + +import type { FactProvider, FactValue } from '../types/fact.types'; + +export class LeadFactsProvider implements FactProvider { + name = 'lead'; + + async resolveFacts(lead: any): Promise> { + const createdAt = lead.createdAt ? new Date(lead.createdAt).getTime() : Date.now(); + const lastContacted = lead.lastContacted ? new Date(lead.lastContacted).getTime() : null; + + return { + 'lead.source': lead.leadSource ?? lead.source ?? null, + 'lead.status': lead.leadStatus ?? lead.status ?? null, + 'lead.priority': lead.priority ?? 'NORMAL', + 'lead.campaignId': lead.campaignId ?? null, + 'lead.campaignName': lead.campaignName ?? null, + 'lead.interestedService': lead.interestedService ?? null, + 'lead.contactAttempts': lead.contactAttempts ?? 0, + 'lead.ageMinutes': Math.round((Date.now() - createdAt) / 60000), + 'lead.ageDays': Math.round((Date.now() - createdAt) / 86400000), + 'lead.lastContactedMinutes': lastContacted ? Math.round((Date.now() - lastContacted) / 60000) : null, + 'lead.hasPatient': !!lead.patientId, + 'lead.isDuplicate': lead.isDuplicate ?? false, + 'lead.isSpam': lead.isSpam ?? false, + 'lead.spamScore': lead.spamScore ?? 0, + 'lead.leadScore': lead.leadScore ?? 0, + }; + } +} diff --git a/src/rules-engine/rules-engine.controller.ts b/src/rules-engine/rules-engine.controller.ts new file mode 100644 index 0000000..a437ce8 --- /dev/null +++ b/src/rules-engine/rules-engine.controller.ts @@ -0,0 +1,123 @@ +// src/rules-engine/rules-engine.controller.ts + +import { Controller, Get, Post, Put, Delete, Patch, Param, Body, HttpException, Logger } from '@nestjs/common'; +import { RulesStorageService } from './rules-storage.service'; +import { RulesEngineService } from './rules-engine.service'; +import type { Rule, PriorityConfig } from './types/rule.types'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +@Controller('api/rules') +export class RulesEngineController { + private readonly logger = new Logger(RulesEngineController.name); + + constructor( + private readonly storage: RulesStorageService, + private readonly engine: RulesEngineService, + ) {} + + // --- Priority Config (slider UI) --- + + @Get('priority-config') + async getPriorityConfig() { + return this.storage.getPriorityConfig(); + } + + @Put('priority-config') + async updatePriorityConfig(@Body() body: PriorityConfig) { + return this.storage.updatePriorityConfig(body); + } + + // --- Rule CRUD --- + + @Get() + async listRules() { + return this.storage.getAll(); + } + + @Get(':id') + async getRule(@Param('id') id: string) { + const rule = await this.storage.getById(id); + if (!rule) throw new HttpException('Rule not found', 404); + return rule; + } + + @Post() + async createRule(@Body() body: any) { + if (!body.name || !body.trigger || !body.conditions || !body.action) { + throw new HttpException('name, trigger, conditions, and action are required', 400); + } + return this.storage.create({ + ...body, + ruleType: body.ruleType ?? 'priority', + enabled: body.enabled ?? true, + priority: body.priority ?? 99, + }); + } + + @Put(':id') + async updateRule(@Param('id') id: string, @Body() body: Partial) { + const updated = await this.storage.update(id, body); + if (!updated) throw new HttpException('Rule not found', 404); + return updated; + } + + @Delete(':id') + async deleteRule(@Param('id') id: string) { + const deleted = await this.storage.delete(id); + if (!deleted) throw new HttpException('Rule not found', 404); + return { status: 'ok' }; + } + + @Patch(':id/toggle') + async toggleRule(@Param('id') id: string) { + const toggled = await this.storage.toggle(id); + if (!toggled) throw new HttpException('Rule not found', 404); + return toggled; + } + + @Post('reorder') + async reorderRules(@Body() body: { ids: string[] }) { + if (!body.ids?.length) throw new HttpException('ids array required', 400); + return this.storage.reorder(body.ids); + } + + // --- Evaluation --- + + @Post('evaluate') + async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record }) { + return this.engine.evaluate(body.trigger, body.triggerValue, body.facts); + } + + // --- Templates --- + + @Get('templates/list') + async listTemplates() { + return [{ id: 'hospital-starter', name: 'Hospital Starter Pack', description: 'Default rules for a hospital call center', ruleCount: 7 }]; + } + + @Post('templates/:id/apply') + async applyTemplate(@Param('id') id: string) { + if (id !== 'hospital-starter') throw new HttpException('Template not found', 404); + + let template: any; + try { + template = JSON.parse(readFileSync(join(__dirname, 'templates', 'hospital-starter.json'), 'utf8')); + } catch { + throw new HttpException('Failed to load template', 500); + } + + // Apply priority config + await this.storage.updatePriorityConfig(template.priorityConfig); + + // Create rules + const created: Rule[] = []; + for (const rule of template.rules) { + const newRule = await this.storage.create(rule); + created.push(newRule); + } + + this.logger.log(`Applied hospital-starter template: ${created.length} rules + priority config`); + return { status: 'ok', rulesCreated: created.length, rules: created }; + } +} diff --git a/src/rules-engine/rules-engine.module.ts b/src/rules-engine/rules-engine.module.ts new file mode 100644 index 0000000..9228289 --- /dev/null +++ b/src/rules-engine/rules-engine.module.ts @@ -0,0 +1,14 @@ +// src/rules-engine/rules-engine.module.ts + +import { Module } from '@nestjs/common'; +import { RulesEngineController } from './rules-engine.controller'; +import { RulesEngineService } from './rules-engine.service'; +import { RulesStorageService } from './rules-storage.service'; +import { WorklistConsumer } from './consumers/worklist.consumer'; + +@Module({ + controllers: [RulesEngineController], + providers: [RulesEngineService, RulesStorageService, WorklistConsumer], + exports: [RulesEngineService, RulesStorageService, WorklistConsumer], +}) +export class RulesEngineModule {} diff --git a/src/rules-engine/rules-engine.service.ts b/src/rules-engine/rules-engine.service.ts new file mode 100644 index 0000000..6b3df32 --- /dev/null +++ b/src/rules-engine/rules-engine.service.ts @@ -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; + + 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): 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(); + + 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 { + 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 = { + ...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; + } +} diff --git a/src/rules-engine/rules-storage.service.ts b/src/rules-engine/rules-storage.service.ts new file mode 100644 index 0000000..4dae457 --- /dev/null +++ b/src/rules-engine/rules-storage.service.ts @@ -0,0 +1,186 @@ +// src/rules-engine/rules-storage.service.ts + +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; +import type { Rule, PriorityConfig } from './types/rule.types'; +import { DEFAULT_PRIORITY_CONFIG } from './types/rule.types'; + +const RULES_KEY = 'rules:config'; +const PRIORITY_CONFIG_KEY = 'rules:priority-config'; +const VERSION_KEY = 'rules:scores:version'; + +@Injectable() +export class RulesStorageService implements OnModuleInit { + private readonly logger = new Logger(RulesStorageService.name); + private readonly redis: Redis; + private readonly backupDir: string; + + constructor(private config: ConfigService) { + this.redis = new Redis(config.get('REDIS_URL') ?? 'redis://localhost:6379'); + this.backupDir = config.get('RULES_BACKUP_DIR') ?? join(process.cwd(), 'data'); + } + + async onModuleInit() { + // Restore rules from backup if Redis is empty + const existing = await this.redis.get(RULES_KEY); + if (!existing) { + const rulesBackup = join(this.backupDir, 'rules-config.json'); + if (existsSync(rulesBackup)) { + const data = readFileSync(rulesBackup, 'utf8'); + await this.redis.set(RULES_KEY, data); + this.logger.log(`Restored ${JSON.parse(data).length} rules from backup`); + } else { + await this.redis.set(RULES_KEY, '[]'); + this.logger.log('Initialized empty rules config'); + } + } + + // Restore priority config from backup if Redis is empty + const existingConfig = await this.redis.get(PRIORITY_CONFIG_KEY); + if (!existingConfig) { + const configBackup = join(this.backupDir, 'priority-config.json'); + if (existsSync(configBackup)) { + const data = readFileSync(configBackup, 'utf8'); + await this.redis.set(PRIORITY_CONFIG_KEY, data); + this.logger.log('Restored priority config from backup'); + } else { + await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(DEFAULT_PRIORITY_CONFIG)); + this.logger.log('Initialized default priority config'); + } + } + } + + // --- Priority Config --- + + async getPriorityConfig(): Promise { + const data = await this.redis.get(PRIORITY_CONFIG_KEY); + return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG; + } + + async updatePriorityConfig(config: PriorityConfig): Promise { + await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(config)); + await this.redis.incr(VERSION_KEY); + this.backupFile('priority-config.json', config); + return config; + } + + // --- Rules CRUD --- + + async getAll(): Promise { + const data = await this.redis.get(RULES_KEY); + return data ? JSON.parse(data) : []; + } + + async getById(id: string): Promise { + const rules = await this.getAll(); + return rules.find(r => r.id === id) ?? null; + } + + async getByTrigger(triggerType: string, triggerValue?: string): Promise { + const rules = await this.getAll(); + return rules.filter(r => { + if (!r.enabled) return false; + if (r.trigger.type !== triggerType) return false; + if (triggerValue && 'request' in r.trigger && r.trigger.request !== triggerValue) return false; + if (triggerValue && 'event' in r.trigger && r.trigger.event !== triggerValue) return false; + return true; + }).sort((a, b) => a.priority - b.priority); + } + + async create(rule: Omit & { createdBy?: string }): Promise { + const rules = await this.getAll(); + const newRule: Rule = { + ...rule, + id: randomUUID(), + metadata: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: rule.createdBy ?? 'system', + category: this.inferCategory(rule.action.type), + tags: [], + }, + }; + rules.push(newRule); + await this.saveRules(rules); + return newRule; + } + + async update(id: string, updates: Partial): Promise { + const rules = await this.getAll(); + const index = rules.findIndex(r => r.id === id); + if (index === -1) return null; + rules[index] = { + ...rules[index], + ...updates, + id, + metadata: { ...rules[index].metadata, updatedAt: new Date().toISOString(), ...(updates.metadata ?? {}) }, + }; + await this.saveRules(rules); + return rules[index]; + } + + async delete(id: string): Promise { + const rules = await this.getAll(); + const filtered = rules.filter(r => r.id !== id); + if (filtered.length === rules.length) return false; + await this.saveRules(filtered); + return true; + } + + async toggle(id: string): Promise { + const rule = await this.getById(id); + if (!rule) return null; + return this.update(id, { enabled: !rule.enabled }); + } + + async reorder(ids: string[]): Promise { + const rules = await this.getAll(); + const reorderedIds = new Set(ids); + const reordered = ids.map((id, i) => { + const rule = rules.find(r => r.id === id); + if (rule) rule.priority = i; + return rule; + }).filter(Boolean) as Rule[]; + const remaining = rules.filter(r => !reorderedIds.has(r.id)); + const final = [...reordered, ...remaining]; + await this.saveRules(final); + return final; + } + + async getVersion(): Promise { + const v = await this.redis.get(VERSION_KEY); + return v ? parseInt(v, 10) : 0; + } + + // --- Internal --- + + private async saveRules(rules: Rule[]) { + const json = JSON.stringify(rules, null, 2); + await this.redis.set(RULES_KEY, json); + await this.redis.incr(VERSION_KEY); + this.backupFile('rules-config.json', rules); + } + + private backupFile(filename: string, data: any) { + try { + if (!existsSync(this.backupDir)) mkdirSync(this.backupDir, { recursive: true }); + writeFileSync(join(this.backupDir, filename), JSON.stringify(data, null, 2), 'utf8'); + } catch (err) { + this.logger.warn(`Failed to write backup ${filename}: ${err}`); + } + } + + private inferCategory(actionType: string): Rule['metadata']['category'] { + switch (actionType) { + case 'score': return 'priority'; + case 'assign': return 'assignment'; + case 'escalate': return 'escalation'; + case 'update': return 'lifecycle'; + default: return 'priority'; + } + } +} diff --git a/src/rules-engine/templates/hospital-starter.json b/src/rules-engine/templates/hospital-starter.json new file mode 100644 index 0000000..f7b15f6 --- /dev/null +++ b/src/rules-engine/templates/hospital-starter.json @@ -0,0 +1,89 @@ +{ + "priorityConfig": { + "taskWeights": { + "missed_call": { "weight": 9, "slaMinutes": 720, "enabled": true }, + "follow_up": { "weight": 8, "slaMinutes": 1440, "enabled": true }, + "campaign_lead": { "weight": 7, "slaMinutes": 2880, "enabled": true }, + "attempt_2": { "weight": 6, "slaMinutes": 1440, "enabled": true }, + "attempt_3": { "weight": 4, "slaMinutes": 2880, "enabled": true } + }, + "campaignWeights": {}, + "sourceWeights": { + "WHATSAPP": 9, "PHONE": 8, "FACEBOOK_AD": 7, "GOOGLE_AD": 7, + "INSTAGRAM": 5, "WEBSITE": 7, "REFERRAL": 6, "WALK_IN": 5, "OTHER": 5 + } + }, + "rules": [ + { + "ruleType": "priority", + "name": "Missed calls — high urgency", + "description": "Missed calls get highest priority with SLA-based urgency", + "enabled": true, + "priority": 1, + "trigger": { "type": "on_request", "request": "worklist" }, + "conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "missed_call" }] }, + "action": { "type": "score", "params": { "weight": 9, "slaMultiplier": true } } + }, + { + "ruleType": "priority", + "name": "Scheduled follow-ups", + "description": "Committed callbacks from prior calls", + "enabled": true, + "priority": 2, + "trigger": { "type": "on_request", "request": "worklist" }, + "conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "follow_up" }] }, + "action": { "type": "score", "params": { "weight": 8, "slaMultiplier": true } } + }, + { + "ruleType": "priority", + "name": "Campaign leads — weighted", + "description": "Outbound campaign calls, weighted by campaign importance", + "enabled": true, + "priority": 3, + "trigger": { "type": "on_request", "request": "worklist" }, + "conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "campaign_lead" }] }, + "action": { "type": "score", "params": { "weight": 7, "slaMultiplier": true, "campaignMultiplier": true } } + }, + { + "ruleType": "priority", + "name": "2nd attempt — medium urgency", + "description": "First call went unanswered, try again", + "enabled": true, + "priority": 4, + "trigger": { "type": "on_request", "request": "worklist" }, + "conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "attempt_2" }] }, + "action": { "type": "score", "params": { "weight": 6, "slaMultiplier": true } } + }, + { + "ruleType": "priority", + "name": "3rd attempt — lower urgency", + "description": "Two prior unanswered attempts", + "enabled": true, + "priority": 5, + "trigger": { "type": "on_request", "request": "worklist" }, + "conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "attempt_3" }] }, + "action": { "type": "score", "params": { "weight": 4, "slaMultiplier": true } } + }, + { + "ruleType": "priority", + "name": "Spam leads — deprioritize", + "description": "High spam score leads get pushed down", + "enabled": true, + "priority": 10, + "trigger": { "type": "on_request", "request": "worklist" }, + "conditions": { "all": [{ "fact": "lead.spamScore", "operator": "greaterThan", "value": 60 }] }, + "action": { "type": "score", "params": { "weight": -3 } } + }, + { + "ruleType": "automation", + "name": "SLA breach — escalate to supervisor", + "description": "Alert supervisor when callback SLA is breached", + "enabled": true, + "priority": 1, + "status": "draft", + "trigger": { "type": "on_schedule", "interval": "5m" }, + "conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] }, + "action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } } + } + ] +} diff --git a/src/rules-engine/types/action.types.ts b/src/rules-engine/types/action.types.ts new file mode 100644 index 0000000..9a8f5ea --- /dev/null +++ b/src/rules-engine/types/action.types.ts @@ -0,0 +1,14 @@ +// src/rules-engine/types/action.types.ts + +import type { RuleAction } from './rule.types'; + +export interface ActionHandler { + type: string; + execute(action: RuleAction, context: Record): Promise; +} + +export type ActionResult = { + success: boolean; + data?: Record; + error?: string; +}; diff --git a/src/rules-engine/types/fact.types.ts b/src/rules-engine/types/fact.types.ts new file mode 100644 index 0000000..dd1e371 --- /dev/null +++ b/src/rules-engine/types/fact.types.ts @@ -0,0 +1,15 @@ +// src/rules-engine/types/fact.types.ts + +export type FactValue = string | number | boolean | string[] | null; + +export type FactContext = { + lead?: Record; + call?: Record; + agent?: Record; + campaign?: Record; +}; + +export interface FactProvider { + name: string; + resolveFacts(entityData: any): Promise>; +} diff --git a/src/rules-engine/types/rule.types.ts b/src/rules-engine/types/rule.types.ts new file mode 100644 index 0000000..9ec7275 --- /dev/null +++ b/src/rules-engine/types/rule.types.ts @@ -0,0 +1,126 @@ +// src/rules-engine/types/rule.types.ts + +export type RuleType = 'priority' | 'automation'; + +export type RuleTrigger = + | { type: 'on_request'; request: 'worklist' | 'assignment' } + | { type: 'on_event'; event: string } + | { type: 'on_schedule'; interval: string } + | { type: 'always' }; + +export type RuleCategory = 'priority' | 'assignment' | 'escalation' | 'lifecycle' | 'qualification'; + +export type RuleOperator = + | 'equal' | 'notEqual' + | 'greaterThan' | 'greaterThanInclusive' + | 'lessThan' | 'lessThanInclusive' + | 'in' | 'notIn' + | 'contains' | 'doesNotContain' + | 'exists' | 'doesNotExist'; + +export type RuleCondition = { + fact: string; + operator: RuleOperator; + value: any; + path?: string; +}; + +export type RuleConditionGroup = { + all?: (RuleCondition | RuleConditionGroup)[]; + any?: (RuleCondition | RuleConditionGroup)[]; +}; + +export type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify'; + +export type ScoreActionParams = { + weight: number; + slaMultiplier?: boolean; + campaignMultiplier?: boolean; +}; + +export type AssignActionParams = { + agentId?: string; + agentPool?: string[]; + strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based'; +}; + +export type EscalateActionParams = { + channel: 'toast' | 'notification' | 'sms' | 'email'; + recipients: 'supervisor' | 'agent' | string[]; + message: string; + severity: 'warning' | 'critical'; +}; + +export type UpdateActionParams = { + entity: string; + field: string; + value: any; +}; + +export type RuleAction = { + type: RuleActionType; + params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams; +}; + +export type Rule = { + id: string; + ruleType: RuleType; + name: string; + description?: string; + enabled: boolean; + priority: number; + trigger: RuleTrigger; + conditions: RuleConditionGroup; + action: RuleAction; + status?: 'draft' | 'published'; + metadata: { + createdAt: string; + updatedAt: string; + createdBy: string; + category: RuleCategory; + tags?: string[]; + }; +}; + +export type ScoreBreakdown = { + baseScore: number; + slaMultiplier: number; + campaignMultiplier: number; + rulesApplied: string[]; +}; + +export type ScoredItem = { + id: string; + score: number; + scoreBreakdown: ScoreBreakdown; + slaStatus: 'low' | 'medium' | 'high' | 'critical'; + slaElapsedPercent: number; +}; + +// Priority config — what the supervisor edits via sliders +export type TaskWeightConfig = { + weight: number; // 0-10 + slaMinutes: number; // SLA in minutes + enabled: boolean; +}; + +export type PriorityConfig = { + taskWeights: Record; + campaignWeights: Record; // campaignId → 0-10 + sourceWeights: Record; // leadSource → 0-10 +}; + +export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = { + taskWeights: { + missed_call: { weight: 9, slaMinutes: 720, enabled: true }, + follow_up: { weight: 8, slaMinutes: 1440, enabled: true }, + campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true }, + attempt_2: { weight: 6, slaMinutes: 1440, enabled: true }, + attempt_3: { weight: 4, slaMinutes: 2880, enabled: true }, + }, + campaignWeights: {}, + sourceWeights: { + WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7, + INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5, + }, +}; diff --git a/src/worklist/worklist.module.ts b/src/worklist/worklist.module.ts index cfc64c6..036bc0b 100644 --- a/src/worklist/worklist.module.ts +++ b/src/worklist/worklist.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; +import { RulesEngineModule } from '../rules-engine/rules-engine.module'; import { WorklistController } from './worklist.controller'; import { WorklistService } from './worklist.service'; import { MissedQueueService } from './missed-queue.service'; @@ -8,7 +9,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller'; import { KookooCallbackController } from './kookoo-callback.controller'; @Module({ - imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)], + imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), RulesEngineModule], controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController], providers: [WorklistService, MissedQueueService], exports: [MissedQueueService], diff --git a/src/worklist/worklist.service.ts b/src/worklist/worklist.service.ts index a93a323..00ededc 100644 --- a/src/worklist/worklist.service.ts +++ b/src/worklist/worklist.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer'; export type WorklistResponse = { missedCalls: any[]; @@ -12,15 +13,33 @@ export type WorklistResponse = { export class WorklistService { private readonly logger = new Logger(WorklistService.name); - constructor(private readonly platform: PlatformGraphqlService) {} + constructor( + private readonly platform: PlatformGraphqlService, + private readonly worklistConsumer: WorklistConsumer, + ) {} async getWorklist(agentName: string, authHeader: string): Promise { - const [missedCalls, followUps, marketingLeads] = await Promise.all([ + const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([ this.getMissedCalls(agentName, authHeader), this.getPendingFollowUps(agentName, authHeader), this.getAssignedLeads(agentName, authHeader), ]); + // Tag each item with a type field for the scoring engine + const combined = [ + ...rawMissedCalls.map((item: any) => ({ ...item, type: 'missed' })), + ...rawFollowUps.map((item: any) => ({ ...item, type: 'follow-up' })), + ...rawMarketingLeads.map((item: any) => ({ ...item, type: 'lead' })), + ]; + + // Score and rank via rules engine + const scored = await this.worklistConsumer.scoreAndRank(combined); + + // Split back into the 3 categories + const missedCalls = scored.filter((item: any) => item.type === 'missed'); + const followUps = scored.filter((item: any) => item.type === 'follow-up'); + const marketingLeads = scored.filter((item: any) => item.type === 'lead'); + return { missedCalls, followUps,