Files
helix-engage/docs/superpowers/plans/2026-03-31-rules-engine.md
2026-03-31 18:18:09 +05:30

1135 lines
40 KiB
Markdown

# 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<string, FactValue>;
call?: Record<string, FactValue>;
agent?: Record<string, FactValue>;
campaign?: Record<string, FactValue>;
};
export interface FactProvider {
name: string;
resolveFacts(entityId: string, authHeader?: string): Promise<Record<string, FactValue>>;
}
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<ActionResult>;
}
export type ActionResult = {
success: boolean;
data?: Record<string, any>;
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<string>('REDIS_URL') ?? 'redis://localhost:6379');
this.backupPath = config.get<string>('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<Rule[]> {
const data = await this.redis.get(REDIS_KEY);
return data ? JSON.parse(data) : [];
}
async getById(id: string): Promise<Rule | null> {
const rules = await this.getAll();
return rules.find(r => r.id === id) ?? null;
}
async getByTrigger(triggerType: string, triggerValue?: string): Promise<Rule[]> {
const rules = await this.getAll();
return rules.filter(r => {
if (!r.enabled) return false;
if (r.trigger.type !== triggerType) return false;
if (triggerValue && 'request' in r.trigger && r.trigger.request !== triggerValue) return false;
if (triggerValue && 'event' in r.trigger && r.trigger.event !== triggerValue) return false;
return true;
}).sort((a, b) => a.priority - b.priority);
}
async create(rule: Omit<Rule, 'id' | 'metadata'> & { createdBy?: string }): Promise<Rule> {
const rules = await this.getAll();
const newRule: Rule = {
...rule,
id: 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<Rule>): Promise<Rule | null> {
const rules = await this.getAll();
const index = rules.findIndex(r => r.id === id);
if (index === -1) return null;
rules[index] = {
...rules[index],
...updates,
id,
metadata: {
...rules[index].metadata,
updatedAt: new Date().toISOString(),
...(updates.metadata ?? {}),
},
};
await this.save(rules);
return rules[index];
}
async delete(id: string): Promise<boolean> {
const rules = await this.getAll();
const filtered = rules.filter(r => r.id !== id);
if (filtered.length === rules.length) return false;
await this.save(filtered);
return true;
}
async toggle(id: string): Promise<Rule | null> {
const rule = await this.getById(id);
if (!rule) return null;
return this.update(id, { enabled: !rule.enabled });
}
async reorder(ids: string[]): Promise<Rule[]> {
const rules = await this.getAll();
const 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<number> {
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<Record<string, FactValue>> {
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<string, number>; // 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<Record<string, FactValue>> {
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<Record<string, FactValue>> {
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<ActionResult> {
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<ActionResult> {
// 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<ActionResult> {
// 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<string, ActionHandler>;
constructor(private readonly storage: RulesStorageService) {
this.actionHandlers = new Map([
['score', new ScoreActionHandler()],
['assign', new AssignActionHandler()],
['escalate', new EscalateActionHandler()],
]);
}
async evaluate(triggerType: string, triggerValue: string, factContext: Record<string, any>): Promise<{ rulesApplied: string[]; results: any[] }> {
const rules = await this.storage.getByTrigger(triggerType, triggerValue);
if (rules.length === 0) return { rulesApplied: [], results: [] };
const engine = new Engine();
const ruleMap = new Map<string, Rule>();
for (const rule of rules) {
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<ScoredItem> {
// 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<string, any>; 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<Rule>) {
const updated = await this.storage.update(id, body);
if (!updated) throw new HttpException('Rule not found', 404);
return updated;
}
@Delete(':id')
async deleteRule(@Param('id') id: string) {
const deleted = await this.storage.delete(id);
if (!deleted) throw new HttpException('Rule not found', 404);
return { status: 'ok' };
}
@Patch(':id/toggle')
async toggleRule(@Param('id') id: string) {
const toggled = await this.storage.toggle(id);
if (!toggled) throw new HttpException('Rule not found', 404);
return toggled;
}
@Post('reorder')
async reorderRules(@Body() body: { ids: string[] }) {
if (!body.ids?.length) throw new HttpException('ids array required', 400);
return this.storage.reorder(body.ids);
}
@Post('evaluate')
async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record<string, any> }) {
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<any[]> {
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