# Rules Engine — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a configurable rules engine in the sidecar that scores worklist items based on hospital-defined rules, replacing the hardcoded priority sort. **Architecture:** Self-contained NestJS module using `json-rules-engine` with Redis storage + JSON file backup. Fact providers fetch data from platform GraphQL. First consumer is the worklist endpoint. **Tech Stack:** json-rules-engine, NestJS, Redis (ioredis), TypeScript **Sidecar path:** `helix-engage-server/src/rules-engine/` **Spec:** `helix-engage/docs/superpowers/specs/2026-03-31-rules-engine-design.md` --- ## File Map | File | Action | Responsibility | |---|---|---| | `src/rules-engine/types/rule.types.ts` | Create | Rule, RuleTrigger, RuleCondition, RuleAction types | | `src/rules-engine/types/fact.types.ts` | Create | Fact registry type, FactProvider interface | | `src/rules-engine/types/action.types.ts` | Create | Action handler interface, action param types | | `src/rules-engine/rules-storage.service.ts` | Create | Redis CRUD + JSON file backup | | `src/rules-engine/rules-engine.service.ts` | Create | json-rules-engine wrapper, evaluate, scoreWorklist | | `src/rules-engine/rules-engine.controller.ts` | Create | REST API: CRUD + evaluate + explain + templates | | `src/rules-engine/rules-engine.module.ts` | Create | NestJS module registration | | `src/rules-engine/facts/lead-facts.provider.ts` | Create | Lead fact resolver | | `src/rules-engine/facts/call-facts.provider.ts` | Create | Call/SLA fact resolver | | `src/rules-engine/facts/agent-facts.provider.ts` | Create | Agent fact resolver | | `src/rules-engine/actions/score.action.ts` | Create | Priority scoring action handler | | `src/rules-engine/actions/assign.action.ts` | Create | Assignment action handler (stub) | | `src/rules-engine/actions/escalate.action.ts` | Create | Escalation action handler (stub) | | `src/rules-engine/templates/hospital-starter.json` | Create | Default rule set | | `src/rules-engine/consumers/worklist.consumer.ts` | Create | Applies scoring to worklist items | | `src/app.module.ts` | Modify | Import RulesEngineModule | | `src/worklist/worklist.service.ts` | Modify | Integrate rules scoring | | `package.json` | Modify | Add json-rules-engine dependency | --- ### Task 1: Install dependency + create types **Files:** - Modify: `helix-engage-server/package.json` - Create: `helix-engage-server/src/rules-engine/types/rule.types.ts` - Create: `helix-engage-server/src/rules-engine/types/fact.types.ts` - Create: `helix-engage-server/src/rules-engine/types/action.types.ts` - [ ] **Step 1: Install json-rules-engine** ```bash cd helix-engage-server && npm install json-rules-engine ``` - [ ] **Step 2: Create rule.types.ts** ```typescript // src/rules-engine/types/rule.types.ts 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: 'lead' | 'call' | 'followUp'; field: string; value: any; }; export type NotifyActionParams = { channel: 'toast' | 'bell' | 'sms'; message: string; target: 'agent' | 'supervisor' | 'all'; }; export type RuleAction = { type: RuleActionType; params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams | NotifyActionParams; }; export type Rule = { id: string; name: string; description?: string; enabled: boolean; priority: number; trigger: RuleTrigger; conditions: RuleConditionGroup; action: RuleAction; 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; }; ``` - [ ] **Step 3: Create fact.types.ts** ```typescript // 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(entityId: string, authHeader?: string): Promise>; } export const FACT_REGISTRY = { // Lead facts 'lead.source': { type: 'string', description: 'Lead source (FACEBOOK_AD, GOOGLE_AD, PHONE, etc.)' }, 'lead.status': { type: 'string', description: 'Lead status (NEW, CONTACTED, QUALIFIED, etc.)' }, 'lead.priority': { type: 'string', description: 'Manual priority (LOW, NORMAL, HIGH, URGENT)' }, 'lead.campaignId': { type: 'string', description: 'Associated campaign ID' }, 'lead.campaignName': { type: 'string', description: 'Campaign name' }, 'lead.interestedService': { type: 'string', description: 'Service interest' }, 'lead.contactAttempts': { type: 'number', description: 'Number of contact attempts' }, 'lead.ageMinutes': { type: 'number', description: 'Minutes since lead created' }, 'lead.hasPatient': { type: 'boolean', description: 'Whether linked to a patient' }, 'lead.isDuplicate': { type: 'boolean', description: 'Whether marked as duplicate' }, 'lead.isSpam': { type: 'boolean', description: 'Whether marked as spam' }, 'lead.spamScore': { type: 'number', description: 'Spam prediction score' }, 'lead.leadScore': { type: 'number', description: 'Lead quality score' }, // Call facts 'call.direction': { type: 'string', description: 'INBOUND or OUTBOUND' }, 'call.status': { type: 'string', description: 'MISSED, COMPLETED, etc.' }, 'call.disposition': { type: 'string', description: 'Call outcome' }, 'call.callbackStatus': { type: 'string', description: 'PENDING_CALLBACK, ATTEMPTED, etc.' }, 'call.slaElapsedPercent': { type: 'number', description: '% of SLA time elapsed' }, 'call.slaBreached': { type: 'boolean', description: 'Whether SLA is breached' }, 'call.missedCount': { type: 'number', description: 'Times this number was missed' }, 'call.taskType': { type: 'string', description: 'missed_call, follow_up, campaign_lead, etc.' }, // Agent facts 'agent.status': { type: 'string', description: 'READY, ON_CALL, BREAK, OFFLINE' }, 'agent.activeCallCount': { type: 'number', description: 'Current active calls' }, 'agent.todayCallCount': { type: 'number', description: 'Calls handled today' }, 'agent.skills': { type: 'array', description: 'Agent skill tags' }, 'agent.idleMinutes': { type: 'number', description: 'Minutes idle' }, } as const; ``` - [ ] **Step 4: Create action.types.ts** ```typescript // src/rules-engine/types/action.types.ts import type { RuleAction, FactContext } from './rule.types'; export interface ActionHandler { type: string; execute(action: RuleAction, context: FactContext): Promise; } export type ActionResult = { success: boolean; data?: Record; error?: string; }; ``` - [ ] **Step 5: Commit** ```bash git add package.json package-lock.json src/rules-engine/types/ git commit -m "feat: rules engine types — Rule, Fact, Action schemas" ``` --- ### Task 2: Rules Storage Service **Files:** - Create: `helix-engage-server/src/rules-engine/rules-storage.service.ts` - [ ] **Step 1: Create rules-storage.service.ts** ```typescript // 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 { join, dirname } from 'path'; import { v4 as uuidv4 } from 'uuid'; import type { Rule } from './types/rule.types'; const REDIS_KEY = 'rules:config'; const REDIS_VERSION_KEY = 'rules:scores:version'; @Injectable() export class RulesStorageService implements OnModuleInit { private readonly logger = new Logger(RulesStorageService.name); private readonly redis: Redis; private readonly backupPath: string; constructor(private config: ConfigService) { this.redis = new Redis(config.get('REDIS_URL') ?? 'redis://localhost:6379'); this.backupPath = config.get('RULES_BACKUP_PATH') ?? join(process.cwd(), 'data', 'rules-config.json'); } async onModuleInit() { const existing = await this.redis.get(REDIS_KEY); if (!existing) { this.logger.log('No rules in Redis — checking backup file'); if (existsSync(this.backupPath)) { const backup = readFileSync(this.backupPath, 'utf8'); await this.redis.set(REDIS_KEY, backup); this.logger.log(`Restored ${JSON.parse(backup).length} rules from backup`); } else { await this.redis.set(REDIS_KEY, '[]'); this.logger.log('Initialized empty rules config'); } } else { const rules = JSON.parse(existing); this.logger.log(`Rules loaded: ${rules.length} rules in Redis`); } } async getAll(): Promise { const data = await this.redis.get(REDIS_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: uuidv4(), 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.save(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.save(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.save(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 reordered = ids.map((id, i) => { const rule = rules.find(r => r.id === id); if (rule) rule.priority = i; return rule; }).filter(Boolean) as Rule[]; // Append rules not in the reorder list const reorderedIds = new Set(ids); const remaining = rules.filter(r => !reorderedIds.has(r.id)); const final = [...reordered, ...remaining]; await this.save(final); return final; } async getVersion(): Promise { const v = await this.redis.get(REDIS_VERSION_KEY); return v ? parseInt(v, 10) : 0; } private async save(rules: Rule[]) { const json = JSON.stringify(rules, null, 2); await this.redis.set(REDIS_KEY, json); await this.redis.incr(REDIS_VERSION_KEY); // Backup to file try { const dir = dirname(this.backupPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(this.backupPath, json, 'utf8'); } catch (err) { this.logger.warn(`Failed to write backup: ${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'; } } } ``` - [ ] **Step 2: Commit** ```bash git add src/rules-engine/rules-storage.service.ts git commit -m "feat: rules storage service — Redis + JSON file backup" ``` --- ### Task 3: Fact Providers **Files:** - Create: `helix-engage-server/src/rules-engine/facts/lead-facts.provider.ts` - Create: `helix-engage-server/src/rules-engine/facts/call-facts.provider.ts` - Create: `helix-engage-server/src/rules-engine/facts/agent-facts.provider.ts` - [ ] **Step 1: Create lead-facts.provider.ts** ```typescript // 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(entityData: any): Promise> { const lead = entityData; 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, }; } } ``` - [ ] **Step 2: Create call-facts.provider.ts** ```typescript // src/rules-engine/facts/call-facts.provider.ts import type { FactProvider, FactValue } from '../types/fact.types'; type SlaConfig = Record; // taskType → SLA in minutes const DEFAULT_SLA: SlaConfig = { missed_call: 720, // 12 hours follow_up: 1440, // 1 day attempt_2: 1440, // 24 hours attempt_3: 2880, // 48 hours campaign_lead: 2880, // 2 days }; export class CallFactsProvider implements FactProvider { name = 'call'; async resolveFacts(entityData: any, slaConfig?: SlaConfig): Promise> { const call = entityData; const taskType = this.inferTaskType(call); const slaMinutes = (slaConfig ?? DEFAULT_SLA)[taskType] ?? 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'; } } 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'; } ``` - [ ] **Step 3: Create agent-facts.provider.ts** ```typescript // 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(entityData: any): Promise> { const agent = entityData; 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, }; } } ``` - [ ] **Step 4: Commit** ```bash git add src/rules-engine/facts/ git commit -m "feat: fact providers — lead, call, agent data resolvers" ``` --- ### Task 4: Score Action Handler + Hospital Starter Template **Files:** - Create: `helix-engage-server/src/rules-engine/actions/score.action.ts` - Create: `helix-engage-server/src/rules-engine/actions/assign.action.ts` - Create: `helix-engage-server/src/rules-engine/actions/escalate.action.ts` - Create: `helix-engage-server/src/rules-engine/templates/hospital-starter.json` - [ ] **Step 1: Create score.action.ts** ```typescript // src/rules-engine/actions/score.action.ts import type { ActionHandler, ActionResult } from '../types/action.types'; import type { RuleAction } from '../types/rule.types'; import type { ScoreActionParams } from '../types/rule.types'; import { computeSlaMultiplier } from '../facts/call-facts.provider'; export class ScoreActionHandler implements ActionHandler { type = 'score'; async execute(action: RuleAction, context: any): Promise { const params = action.params as ScoreActionParams; let score = params.weight; if (params.slaMultiplier && context['call.slaElapsedPercent'] != null) { score *= computeSlaMultiplier(context['call.slaElapsedPercent']); } if (params.campaignMultiplier && context['campaign.weight'] != null) { const campaignWeight = (context['campaign.weight'] ?? 5) / 10; const sourceWeight = (context['source.weight'] ?? 5) / 10; score *= campaignWeight * sourceWeight; } return { success: true, data: { score, weight: params.weight, slaApplied: !!params.slaMultiplier, campaignApplied: !!params.campaignMultiplier }, }; } } ``` - [ ] **Step 2: Create stub action handlers** ```typescript // 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: any): Promise { // Stub — will be implemented in Phase 2 return { success: true, data: { stub: true, action: 'assign' } }; } } ``` ```typescript // 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: any): Promise { // Stub — will be implemented in Phase 2 return { success: true, data: { stub: true, action: 'escalate' } }; } } ``` - [ ] **Step 3: Create hospital-starter.json** ```json [ { "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 } } }, { "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 } } }, { "name": "Campaign leads — weighted by campaign", "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 } } }, { "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 } } }, { "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 } } }, { "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 } } }, { "name": "SLA breach — escalate to supervisor", "description": "Alert supervisor when callback SLA is breached", "enabled": true, "priority": 1, "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" } } } ] ``` - [ ] **Step 4: Commit** ```bash git add src/rules-engine/actions/ src/rules-engine/templates/ git commit -m "feat: action handlers (score + stubs) + hospital starter template" ``` --- ### Task 5: Rules Engine Service (Core) **Files:** - Create: `helix-engage-server/src/rules-engine/rules-engine.service.ts` - [ ] **Step 1: Create rules-engine.service.ts** ```typescript // src/rules-engine/rules-engine.service.ts import { Injectable, Logger } from '@nestjs/common'; import { Engine } from 'json-rules-engine'; import { RulesStorageService } from './rules-storage.service'; import { LeadFactsProvider } from './facts/lead-facts.provider'; import { CallFactsProvider, computeSlaMultiplier, computeSlaStatus } from './facts/call-facts.provider'; import { AgentFactsProvider } from './facts/agent-facts.provider'; import { ScoreActionHandler } from './actions/score.action'; import { AssignActionHandler } from './actions/assign.action'; import { EscalateActionHandler } from './actions/escalate.action'; import type { Rule, ScoredItem, ScoreBreakdown } 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) { const engineRule = { conditions: rule.conditions, event: { type: rule.action.type, params: { ruleId: rule.id, ...rule.action.params } }, priority: rule.priority, }; engine.addRule(engineRule); ruleMap.set(rule.id, rule); } // Add facts 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): Promise { // Resolve facts from item data const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item); const callFacts = await this.callFacts.resolveFacts(item); const allFacts = { ...leadFacts, ...callFacts }; // Evaluate scoring rules const { rulesApplied, results } = await this.evaluate('on_request', 'worklist', allFacts); // Sum scores from all fired rules let totalScore = 0; let totalSlaMultiplier = 1; let totalCampaignMultiplier = 1; for (const result of results) { if (result.success && result.data?.score != null) { totalScore += result.data.score; if (result.data.slaApplied) totalSlaMultiplier = computeSlaMultiplier(allFacts['call.slaElapsedPercent'] as number ?? 0); if (result.data.campaignApplied) totalCampaignMultiplier = (allFacts['campaign.weight'] as number ?? 5) / 10; } } const slaElapsedPercent = (allFacts['call.slaElapsedPercent'] as number) ?? 0; return { id: item.id, score: Math.round(totalScore * 100) / 100, scoreBreakdown: { baseScore: totalScore, slaMultiplier: totalSlaMultiplier, campaignMultiplier: totalCampaignMultiplier, rulesApplied, }, slaStatus: computeSlaStatus(slaElapsedPercent), slaElapsedPercent, }; } async scoreWorklist(items: any[]): Promise<(any & ScoredItem)[]> { const scored = await Promise.all( items.map(async (item) => { const scoreData = await this.scoreWorklistItem(item); return { ...item, ...scoreData }; }), ); // Sort by score descending scored.sort((a, b) => b.score - a.score); return scored; } async explain(itemId: string, item: any): Promise<{ facts: Record; rulesEvaluated: string[]; scoreBreakdown: ScoreBreakdown }> { const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item); const callFacts = await this.callFacts.resolveFacts(item); const allFacts = { ...leadFacts, ...callFacts }; const scoreData = await this.scoreWorklistItem(item); return { facts: allFacts, rulesEvaluated: scoreData.scoreBreakdown.rulesApplied, scoreBreakdown: scoreData.scoreBreakdown, }; } } ``` - [ ] **Step 2: Commit** ```bash git add src/rules-engine/rules-engine.service.ts git commit -m "feat: rules engine service — json-rules-engine wrapper with scoring" ``` --- ### Task 6: Rules Controller + Module + App Integration **Files:** - Create: `helix-engage-server/src/rules-engine/rules-engine.controller.ts` - Create: `helix-engage-server/src/rules-engine/rules-engine.module.ts` - Modify: `helix-engage-server/src/app.module.ts` - [ ] **Step 1: Create rules-engine.controller.ts** ```typescript // 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 } 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, ) {} @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, 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); } @Post('evaluate') async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record }) { return this.engine.evaluate(body.trigger, body.triggerValue, body.facts); } @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); const templatePath = join(__dirname, 'templates', 'hospital-starter.json'); let templateRules: any[]; try { templateRules = JSON.parse(readFileSync(templatePath, 'utf8')); } catch { throw new HttpException('Failed to load template', 500); } const created: Rule[] = []; for (const rule of templateRules) { const newRule = await this.storage.create(rule); created.push(newRule); } this.logger.log(`Applied template: ${created.length} rules created`); return { status: 'ok', rulesCreated: created.length, rules: created }; } } ``` - [ ] **Step 2: Create rules-engine.module.ts** ```typescript // 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'; @Module({ controllers: [RulesEngineController], providers: [RulesEngineService, RulesStorageService], exports: [RulesEngineService, RulesStorageService], }) export class RulesEngineModule {} ``` - [ ] **Step 3: Register in app.module.ts** Add import at top: ```typescript import { RulesEngineModule } from './rules-engine/rules-engine.module'; ``` Add to imports array: ```typescript RulesEngineModule, ``` - [ ] **Step 4: Build and verify** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 5: Commit** ```bash git add src/rules-engine/rules-engine.controller.ts src/rules-engine/rules-engine.module.ts src/app.module.ts git commit -m "feat: rules engine controller + module + app registration" ``` --- ### Task 7: Worklist Integration **Files:** - Create: `helix-engage-server/src/rules-engine/consumers/worklist.consumer.ts` - Modify: `helix-engage-server/src/worklist/worklist.service.ts` - Modify: `helix-engage-server/src/worklist/worklist.module.ts` - [ ] **Step 1: Create worklist.consumer.ts** ```typescript // 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'; import type { ScoredItem } from '../types/rule.types'; @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 worklist'); return worklistItems; } this.logger.debug(`Scoring ${worklistItems.length} items with ${rules.length} rules`); return this.engine.scoreWorklist(worklistItems); } } ``` - [ ] **Step 2: Export WorklistConsumer from RulesEngineModule** Update `rules-engine.module.ts` to include WorklistConsumer: ```typescript import { WorklistConsumer } from './consumers/worklist.consumer'; @Module({ controllers: [RulesEngineController], providers: [RulesEngineService, RulesStorageService, WorklistConsumer], exports: [RulesEngineService, RulesStorageService, WorklistConsumer], }) ``` - [ ] **Step 3: Integrate into WorklistService** In `src/worklist/worklist.service.ts`, inject `WorklistConsumer` and call `scoreAndRank` before returning results. Add to the `getWorklist` method's return — after fetching and building the worklist array, pass it through the consumer: ```typescript // At the end of getWorklist method, before return: // const scored = await this.worklistConsumer.scoreAndRank(worklistItems); // return { ...result, items: scored }; ``` Note: The exact integration depends on the current `getWorklist` return shape. Read the file and integrate accordingly. The consumer wraps the existing array and adds `score`, `scoreBreakdown`, `slaStatus`, `slaElapsedPercent` to each item. - [ ] **Step 4: Update WorklistModule imports** Import `RulesEngineModule` in `worklist.module.ts`: ```typescript import { RulesEngineModule } from '../rules-engine/rules-engine.module'; @Module({ imports: [RulesEngineModule, ...], ... }) ``` - [ ] **Step 5: Build and verify** ```bash npm run build ``` - [ ] **Step 6: Commit** ```bash git add src/rules-engine/ src/worklist/ src/app.module.ts git commit -m "feat: worklist scoring integration — rules engine scores and ranks worklist items" ``` --- ## Execution Notes - All work is in `helix-engage-server/` (the sidecar), not the frontend - `json-rules-engine` evaluates rules against facts — conditions match, events fire - The hospital starter template is applied via `POST /api/rules/templates/hospital-starter/apply` - Scoring is additive — multiple rules can fire for the same item, scores sum - SLA multiplier is computed at request time (not cached) since it changes every minute - Action handlers for assign/escalate are stubs — skeleton is there for Phase 2 - The `explain` endpoint returns the full fact context + which rules fired — useful for debugging and future UI