mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +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:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
12
src/rules-engine/actions/assign.action.ts
Normal file
12
src/rules-engine/actions/assign.action.ts
Normal file
@@ -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<string, any>): Promise<ActionResult> {
|
||||
return { success: true, data: { stub: true, action: 'assign' } };
|
||||
}
|
||||
}
|
||||
12
src/rules-engine/actions/escalate.action.ts
Normal file
12
src/rules-engine/actions/escalate.action.ts
Normal file
@@ -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<string, any>): Promise<ActionResult> {
|
||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
||||
}
|
||||
}
|
||||
33
src/rules-engine/actions/score.action.ts
Normal file
33
src/rules-engine/actions/score.action.ts
Normal file
@@ -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<string, any>): Promise<ActionResult> {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/rules-engine/consumers/worklist.consumer.ts
Normal file
25
src/rules-engine/consumers/worklist.consumer.ts
Normal file
@@ -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<any[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
18
src/rules-engine/facts/agent-facts.provider.ts
Normal file
18
src/rules-engine/facts/agent-facts.provider.ts
Normal file
@@ -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<Record<string, FactValue>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
52
src/rules-engine/facts/call-facts.provider.ts
Normal file
52
src/rules-engine/facts/call-facts.provider.ts
Normal file
@@ -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<Record<string, FactValue>> {
|
||||
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';
|
||||
}
|
||||
30
src/rules-engine/facts/lead-facts.provider.ts
Normal file
30
src/rules-engine/facts/lead-facts.provider.ts
Normal file
@@ -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<Record<string, FactValue>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
123
src/rules-engine/rules-engine.controller.ts
Normal file
123
src/rules-engine/rules-engine.controller.ts
Normal file
@@ -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<Rule>) {
|
||||
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<string, any> }) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
14
src/rules-engine/rules-engine.module.ts
Normal file
14
src/rules-engine/rules-engine.module.ts
Normal file
@@ -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 {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
186
src/rules-engine/rules-storage.service.ts
Normal file
186
src/rules-engine/rules-storage.service.ts
Normal file
@@ -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<string>('REDIS_URL') ?? 'redis://localhost:6379');
|
||||
this.backupDir = config.get<string>('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<PriorityConfig> {
|
||||
const data = await this.redis.get(PRIORITY_CONFIG_KEY);
|
||||
return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG;
|
||||
}
|
||||
|
||||
async updatePriorityConfig(config: PriorityConfig): Promise<PriorityConfig> {
|
||||
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<Rule[]> {
|
||||
const data = await this.redis.get(RULES_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Rule | null> {
|
||||
const rules = await this.getAll();
|
||||
return rules.find(r => r.id === id) ?? null;
|
||||
}
|
||||
|
||||
async getByTrigger(triggerType: string, triggerValue?: string): Promise<Rule[]> {
|
||||
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<Rule, 'id' | 'metadata'> & { createdBy?: string }): Promise<Rule> {
|
||||
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<Rule>): Promise<Rule | null> {
|
||||
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<boolean> {
|
||||
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<Rule | null> {
|
||||
const rule = await this.getById(id);
|
||||
if (!rule) return null;
|
||||
return this.update(id, { enabled: !rule.enabled });
|
||||
}
|
||||
|
||||
async reorder(ids: string[]): Promise<Rule[]> {
|
||||
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<number> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/rules-engine/templates/hospital-starter.json
Normal file
89
src/rules-engine/templates/hospital-starter.json
Normal file
@@ -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" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
14
src/rules-engine/types/action.types.ts
Normal file
14
src/rules-engine/types/action.types.ts
Normal file
@@ -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<string, any>): Promise<ActionResult>;
|
||||
}
|
||||
|
||||
export type ActionResult = {
|
||||
success: boolean;
|
||||
data?: Record<string, any>;
|
||||
error?: string;
|
||||
};
|
||||
15
src/rules-engine/types/fact.types.ts
Normal file
15
src/rules-engine/types/fact.types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// src/rules-engine/types/fact.types.ts
|
||||
|
||||
export type FactValue = string | number | boolean | string[] | null;
|
||||
|
||||
export type FactContext = {
|
||||
lead?: Record<string, FactValue>;
|
||||
call?: Record<string, FactValue>;
|
||||
agent?: Record<string, FactValue>;
|
||||
campaign?: Record<string, FactValue>;
|
||||
};
|
||||
|
||||
export interface FactProvider {
|
||||
name: string;
|
||||
resolveFacts(entityData: any): Promise<Record<string, FactValue>>;
|
||||
}
|
||||
126
src/rules-engine/types/rule.types.ts
Normal file
126
src/rules-engine/types/rule.types.ts
Normal file
@@ -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<string, TaskWeightConfig>;
|
||||
campaignWeights: Record<string, number>; // campaignId → 0-10
|
||||
sourceWeights: Record<string, number>; // 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,
|
||||
},
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
@@ -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<WorklistResponse> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user