diff --git a/docs/superpowers/plans/2026-03-31-rules-engine.md b/docs/superpowers/plans/2026-03-31-rules-engine.md new file mode 100644 index 0000000..7bd9c62 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-rules-engine.md @@ -0,0 +1,1134 @@ +# 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