# Rules Engine — Implementation Plan (v2) > **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 with a Priority Rules UI that lets supervisors configure worklist scoring via sliders, replacing the hardcoded priority sort. **Architecture:** Self-contained NestJS module using `json-rules-engine` with Redis storage + JSON file backup. Priority config (weights/SLA) edited via supervisor UI. Automation rules schema defined but stubs only. **Tech Stack:** json-rules-engine, NestJS, Redis (ioredis), TypeScript, React + Untitled UI Slider **Sidecar path:** `helix-engage-server/src/rules-engine/` **Frontend path:** `helix-engage/src/pages/rules-settings.tsx` + `src/components/rules/` **Spec:** `helix-engage/docs/superpowers/specs/2026-03-31-rules-engine-design.md` --- ## File Map ### Backend (helix-engage-server) | File | Action | Responsibility | |---|---|---| | `package.json` | Modify | Add json-rules-engine | | `src/rules-engine/types/rule.types.ts` | Create | Rule, RuleTrigger, RuleCondition, RuleAction, PriorityConfig | | `src/rules-engine/types/fact.types.ts` | Create | FactProvider interface, computed facts | | `src/rules-engine/types/action.types.ts` | Create | ActionHandler interface | | `src/rules-engine/rules-storage.service.ts` | Create | Redis CRUD + JSON backup + PriorityConfig | | `src/rules-engine/facts/lead-facts.provider.ts` | Create | Lead fact resolver | | `src/rules-engine/facts/call-facts.provider.ts` | Create | Call/SLA facts + computeSlaMultiplier + computeSlaStatus | | `src/rules-engine/facts/agent-facts.provider.ts` | Create | Agent fact resolver | | `src/rules-engine/actions/score.action.ts` | Create | Priority scoring action | | `src/rules-engine/actions/assign.action.ts` | Create | Stub | | `src/rules-engine/actions/escalate.action.ts` | Create | Stub | | `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 + priority-config + evaluate + templates | | `src/rules-engine/rules-engine.module.ts` | Create | NestJS module | | `src/rules-engine/consumers/worklist.consumer.ts` | Create | Scoring integration | | `src/rules-engine/templates/hospital-starter.json` | Create | Default rules + priority config | | `src/app.module.ts` | Modify | Import RulesEngineModule | | `src/worklist/worklist.service.ts` | Modify | Integrate scoring | | `src/worklist/worklist.module.ts` | Modify | Import RulesEngineModule | ### Frontend (helix-engage) | File | Action | Responsibility | |---|---|---| | `src/pages/rules-settings.tsx` | Create | Supervisor rules settings page | | `src/components/rules/priority-config-panel.tsx` | Create | Task weights + SLA sliders | | `src/components/rules/campaign-weights-panel.tsx` | Create | Campaign weight sliders | | `src/components/rules/source-weights-panel.tsx` | Create | Source weight sliders | | `src/components/rules/worklist-preview.tsx` | Create | Live preview of scored worklist | | `src/components/rules/weight-slider-row.tsx` | Create | Reusable slider row (label + slider + value) | | `src/components/layout/sidebar.tsx` | Modify | Add Rules Settings nav item | | `src/components/call-desk/worklist-panel.tsx` | Modify | Display scores + SLA dots | | `src/lib/scoring.ts` | Create | Client-side scoring formula (shared for preview) | --- ### 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 directory structure** ```bash mkdir -p src/rules-engine/{types,facts,actions,consumers,templates} ``` - [ ] **Step 3: Create rule.types.ts** ```typescript // 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, }, }; ``` - [ ] **Step 4: 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(entityData: any): Promise>; } ``` - [ ] **Step 5: Create action.types.ts** ```typescript // 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; }; ``` - [ ] **Step 6: Commit** ```bash cd helix-engage-server git add package.json package-lock.json src/rules-engine/types/ git commit -m "feat: rules engine types — Rule, Fact, Action, PriorityConfig 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 { 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'; } } } ``` - [ ] **Step 2: Commit** ```bash git add src/rules-engine/rules-storage.service.ts git commit -m "feat: rules storage service — Redis + JSON backup + PriorityConfig" ``` --- ### 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(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, }; } } ``` - [ ] **Step 2: Create call-facts.provider.ts** ```typescript // 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'; } ``` - [ ] **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(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, }; } } ``` - [ ] **Step 4: Commit** ```bash git add src/rules-engine/facts/ git commit -m "feat: fact providers — lead, call (with SLA), agent resolvers" ``` --- ### Task 4: Action Handlers + 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, 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 }, }; } } ``` - [ ] **Step 2: Create stub 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: Record): Promise { 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: Record): Promise { return { success: true, data: { stub: true, action: 'escalate' } }; } } ``` - [ ] **Step 3: Create hospital-starter.json** ```json { "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" } } } ] } ``` - [ ] **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, 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, event: { type: rule.action.type, params: { ruleId: rule.id, ...rule.action.params } }, 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 = { ...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; } } ``` - [ ] **Step 2: Commit** ```bash git add src/rules-engine/rules-engine.service.ts git commit -m "feat: rules engine service — scoring, evaluation, live preview" ``` --- ### Task 6: Controller + Module + Worklist Integration **Files:** - Create: `helix-engage-server/src/rules-engine/rules-engine.controller.ts` - Create: `helix-engage-server/src/rules-engine/rules-engine.module.ts` - Create: `helix-engage-server/src/rules-engine/consumers/worklist.consumer.ts` - Modify: `helix-engage-server/src/app.module.ts` - Modify: `helix-engage-server/src/worklist/worklist.service.ts` - Modify: `helix-engage-server/src/worklist/worklist.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, 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 }; } } ``` - [ ] **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'; import { WorklistConsumer } from './consumers/worklist.consumer'; @Module({ controllers: [RulesEngineController], providers: [RulesEngineService, RulesStorageService, WorklistConsumer], exports: [RulesEngineService, RulesStorageService, WorklistConsumer], }) export class RulesEngineModule {} ``` - [ ] **Step 3: 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'; @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); } } ``` - [ ] **Step 4: Register in app.module.ts** Add import and register `RulesEngineModule` in the imports array of `src/app.module.ts`. - [ ] **Step 5: Integrate into WorklistService** Inject `WorklistConsumer` into `WorklistService`. After fetching the 3 arrays (missedCalls, followUps, marketingLeads), combine into a flat array with a `type` field, score via the consumer, then split back into the 3 categories for the response. Add scoring fields to each item. - [ ] **Step 6: Update WorklistModule imports** Add `RulesEngineModule` to `WorklistModule` imports. - [ ] **Step 7: Build and verify** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 8: Commit** ```bash git add src/rules-engine/ src/worklist/ src/app.module.ts git commit -m "feat: rules engine module + controller + worklist scoring integration" ``` --- ### Task 7: Client-Side Scoring Library **Files:** - Create: `helix-engage/src/lib/scoring.ts` Shared scoring formula for the frontend live preview. Must match the sidecar computation exactly. - [ ] **Step 1: Create scoring.ts** ```typescript // src/lib/scoring.ts — client-side scoring for live preview export type TaskWeightConfig = { weight: number; slaMinutes: number; enabled: boolean; }; export type PriorityConfig = { taskWeights: Record; campaignWeights: Record; sourceWeights: Record; }; export type ScoreResult = { score: number; baseScore: number; slaMultiplier: number; campaignMultiplier: number; slaElapsedPercent: number; slaStatus: 'low' | 'medium' | 'high' | 'critical'; taskType: string; }; 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'; } export function inferTaskType(item: any): string { if (item.callStatus === 'MISSED' || item.type === 'missed') return 'missed_call'; if (item.followUpType === 'CALLBACK' || item.type === 'callback' || item.type === 'follow-up') return 'follow_up'; if (item.contactAttempts >= 3) return 'attempt_3'; if (item.contactAttempts >= 2) return 'attempt_2'; return 'campaign_lead'; } export function scoreItem(item: any, config: PriorityConfig): ScoreResult { const taskType = inferTaskType(item); const taskConfig = config.taskWeights[taskType]; if (!taskConfig?.enabled) { return { score: 0, baseScore: 0, slaMultiplier: 1, campaignMultiplier: 1, slaElapsedPercent: 0, slaStatus: 'low', taskType }; } const createdAt = item.createdAt ? new Date(item.createdAt).getTime() : Date.now(); const elapsedMinutes = (Date.now() - createdAt) / 60000; const slaElapsedPercent = Math.round((elapsedMinutes / taskConfig.slaMinutes) * 100); const baseScore = taskConfig.weight; const slaMultiplier = computeSlaMultiplier(slaElapsedPercent); let campaignMultiplier = 1; if (item.campaignId && config.campaignWeights[item.campaignId]) { const cw = (config.campaignWeights[item.campaignId] ?? 5) / 10; const source = item.leadSource ?? item.source ?? 'OTHER'; const sw = (config.sourceWeights[source] ?? 5) / 10; campaignMultiplier = cw * sw; } const score = Math.round(baseScore * slaMultiplier * campaignMultiplier * 100) / 100; return { score, baseScore, slaMultiplier: Math.round(slaMultiplier * 100) / 100, campaignMultiplier: Math.round(campaignMultiplier * 100) / 100, slaElapsedPercent, slaStatus: computeSlaStatus(slaElapsedPercent), taskType, }; } export function scoreAndRankItems(items: any[], config: PriorityConfig): (any & ScoreResult)[] { return items .map(item => ({ ...item, ...scoreItem(item, config) })) .sort((a, b) => b.score - a.score); } export const TASK_TYPE_LABELS: Record = { missed_call: 'Missed Calls', follow_up: 'Follow-ups', campaign_lead: 'Campaign Leads', attempt_2: '2nd Attempt', attempt_3: '3rd Attempt', }; export const SOURCE_LABELS: Record = { WHATSAPP: 'WhatsApp', PHONE: 'Phone', FACEBOOK_AD: 'Facebook Ad', GOOGLE_AD: 'Google Ad', INSTAGRAM: 'Instagram', WEBSITE: 'Website', REFERRAL: 'Referral', WALK_IN: 'Walk-in', OTHER: 'Other', }; 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, }, }; ``` - [ ] **Step 2: Commit** ```bash cd helix-engage git add src/lib/scoring.ts git commit -m "feat: client-side scoring library for live preview" ``` --- ### Task 8: Priority Rules Settings Page **Files:** - Create: `helix-engage/src/pages/rules-settings.tsx` - Create: `helix-engage/src/components/rules/weight-slider-row.tsx` - Create: `helix-engage/src/components/rules/priority-config-panel.tsx` - Create: `helix-engage/src/components/rules/campaign-weights-panel.tsx` - Create: `helix-engage/src/components/rules/source-weights-panel.tsx` - Create: `helix-engage/src/components/rules/worklist-preview.tsx` - Modify: `helix-engage/src/components/layout/sidebar.tsx` — add nav item - [ ] **Step 1: Create weight-slider-row.tsx** Reusable row component: label + Untitled UI Slider (0-10) + current value display + optional SLA input + optional toggle. - [ ] **Step 2: Create priority-config-panel.tsx** Section with task type weight sliders and SLA inputs. Uses `weight-slider-row.tsx` for each task type. Fetches initial config from `GET /api/rules/priority-config`, calls `PUT` on change (debounced). - [ ] **Step 3: Create campaign-weights-panel.tsx** Lists campaigns from DataProvider with weight sliders. Default weight 5 for campaigns without a configured weight. - [ ] **Step 4: Create source-weights-panel.tsx** Lists lead sources with weight sliders. - [ ] **Step 5: Create worklist-preview.tsx** Shows a mini worklist table re-ranked using the client-side `scoreAndRankItems()` function. Uses current worklist data from DataProvider + the current slider config. Updates in real-time as sliders change. - [ ] **Step 6: Create rules-settings.tsx** Page layout with Untitled UI Tabs: "Priority" (active) and "Automations" (coming soon). Priority tab renders the 3 config panels + preview panel in a 2-column layout (config left, preview right). - [ ] **Step 7: Add to sidebar** Add "Rules" nav item under Supervisor section in `sidebar.tsx` for admin role. - [ ] **Step 8: Add route** Add `/rules` route in the router configuration. - [ ] **Step 9: Commit** ```bash git add src/pages/rules-settings.tsx src/components/rules/ src/components/layout/sidebar.tsx git commit -m "feat: priority rules settings page — weight sliders + live preview" ``` --- ### Task 9: Worklist Score Display **Files:** - Modify: `helix-engage/src/components/call-desk/worklist-panel.tsx` - [ ] **Step 1: Add SLA status dot** Replace or augment the priority Badge with an SLA status indicator: - `low` → green dot - `medium` → amber dot - `high` → red dot - `critical` → dark-red pulsing dot - [ ] **Step 2: Add score display** Show the computed score as a small badge or number next to the item. Tooltip on hover shows scoreBreakdown: which rules fired, SLA multiplier, campaign multiplier. - [ ] **Step 3: Sort by score** When worklist items have a `score` field (from the sidecar), sort by score descending instead of the hardcoded priority + createdAt. - [ ] **Step 4: Commit** ```bash git add src/components/call-desk/worklist-panel.tsx git commit -m "feat: worklist score display — SLA dots + score badges + breakdown tooltip" ``` --- ## Execution Notes - Tasks 1-4 are independent (backend) — can be parallelized - Task 5 depends on 1-4 - Task 6 depends on 5 - Task 7 is independent (frontend) — can run in parallel with backend tasks - Tasks 8-9 depend on Task 7 - Build sidecar after Task 6 to verify compilation - Hospital starter template is applied via `POST /api/rules/templates/hospital-starter/apply`