- Rules engine spec v2 (priority vs automation rules distinction) - Priority Rules settings page with weight sliders, SLA config, campaign/source weights - Collapsible config sections with dynamic headers - Live worklist preview panel with client-side scoring - AI assistant panel (collapsible) with rules-engine-specific system prompt - Worklist panel: score display with SLA status dots, sort by score - Scoring library (scoring.ts) for client-side preview computation - Sidebar: Rules Engine nav item under Configuration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
49 KiB
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
cd helix-engage-server && npm install json-rules-engine
- Step 2: Create directory structure
mkdir -p src/rules-engine/{types,facts,actions,consumers,templates}
- Step 3: Create rule.types.ts
// src/rules-engine/types/rule.types.ts
export type RuleType = 'priority' | 'automation';
export type RuleTrigger =
| { type: 'on_request'; request: 'worklist' | 'assignment' }
| { type: 'on_event'; event: string }
| { type: 'on_schedule'; interval: string }
| { type: 'always' };
export type RuleCategory = 'priority' | 'assignment' | 'escalation' | 'lifecycle' | 'qualification';
export type RuleOperator =
| 'equal' | 'notEqual'
| 'greaterThan' | 'greaterThanInclusive'
| 'lessThan' | 'lessThanInclusive'
| 'in' | 'notIn'
| 'contains' | 'doesNotContain'
| 'exists' | 'doesNotExist';
export type RuleCondition = {
fact: string;
operator: RuleOperator;
value: any;
path?: string;
};
export type RuleConditionGroup = {
all?: (RuleCondition | RuleConditionGroup)[];
any?: (RuleCondition | RuleConditionGroup)[];
};
export type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify';
export type ScoreActionParams = {
weight: number;
slaMultiplier?: boolean;
campaignMultiplier?: boolean;
};
export type AssignActionParams = {
agentId?: string;
agentPool?: string[];
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
};
export type EscalateActionParams = {
channel: 'toast' | 'notification' | 'sms' | 'email';
recipients: 'supervisor' | 'agent' | string[];
message: string;
severity: 'warning' | 'critical';
};
export type UpdateActionParams = {
entity: string;
field: string;
value: any;
};
export type RuleAction = {
type: RuleActionType;
params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
};
export type Rule = {
id: string;
ruleType: RuleType;
name: string;
description?: string;
enabled: boolean;
priority: number;
trigger: RuleTrigger;
conditions: RuleConditionGroup;
action: RuleAction;
status?: 'draft' | 'published';
metadata: {
createdAt: string;
updatedAt: string;
createdBy: string;
category: RuleCategory;
tags?: string[];
};
};
export type ScoreBreakdown = {
baseScore: number;
slaMultiplier: number;
campaignMultiplier: number;
rulesApplied: string[];
};
export type ScoredItem = {
id: string;
score: number;
scoreBreakdown: ScoreBreakdown;
slaStatus: 'low' | 'medium' | 'high' | 'critical';
slaElapsedPercent: number;
};
// Priority config — what the supervisor edits via sliders
export type TaskWeightConfig = {
weight: number; // 0-10
slaMinutes: number; // SLA in minutes
enabled: boolean;
};
export type PriorityConfig = {
taskWeights: Record<string, TaskWeightConfig>;
campaignWeights: Record<string, number>; // campaignId → 0-10
sourceWeights: Record<string, number>; // leadSource → 0-10
};
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
taskWeights: {
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
},
campaignWeights: {},
sourceWeights: {
WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7,
INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5,
},
};
- Step 4: Create fact.types.ts
// src/rules-engine/types/fact.types.ts
export type FactValue = string | number | boolean | string[] | null;
export type FactContext = {
lead?: Record<string, FactValue>;
call?: Record<string, FactValue>;
agent?: Record<string, FactValue>;
campaign?: Record<string, FactValue>;
};
export interface FactProvider {
name: string;
resolveFacts(entityData: any): Promise<Record<string, FactValue>>;
}
- Step 5: Create action.types.ts
// src/rules-engine/types/action.types.ts
import type { RuleAction } from './rule.types';
export interface ActionHandler {
type: string;
execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult>;
}
export type ActionResult = {
success: boolean;
data?: Record<string, any>;
error?: string;
};
- Step 6: Commit
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
// src/rules-engine/rules-storage.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
import type { Rule, PriorityConfig } from './types/rule.types';
import { DEFAULT_PRIORITY_CONFIG } from './types/rule.types';
const RULES_KEY = 'rules:config';
const PRIORITY_CONFIG_KEY = 'rules:priority-config';
const VERSION_KEY = 'rules:scores:version';
@Injectable()
export class RulesStorageService implements OnModuleInit {
private readonly logger = new Logger(RulesStorageService.name);
private readonly redis: Redis;
private readonly backupDir: string;
constructor(private config: ConfigService) {
this.redis = new Redis(config.get<string>('REDIS_URL') ?? 'redis://localhost:6379');
this.backupDir = config.get<string>('RULES_BACKUP_DIR') ?? join(process.cwd(), 'data');
}
async onModuleInit() {
// Restore rules from backup if Redis is empty
const existing = await this.redis.get(RULES_KEY);
if (!existing) {
const rulesBackup = join(this.backupDir, 'rules-config.json');
if (existsSync(rulesBackup)) {
const data = readFileSync(rulesBackup, 'utf8');
await this.redis.set(RULES_KEY, data);
this.logger.log(`Restored ${JSON.parse(data).length} rules from backup`);
} else {
await this.redis.set(RULES_KEY, '[]');
this.logger.log('Initialized empty rules config');
}
}
// Restore priority config from backup if Redis is empty
const existingConfig = await this.redis.get(PRIORITY_CONFIG_KEY);
if (!existingConfig) {
const configBackup = join(this.backupDir, 'priority-config.json');
if (existsSync(configBackup)) {
const data = readFileSync(configBackup, 'utf8');
await this.redis.set(PRIORITY_CONFIG_KEY, data);
this.logger.log('Restored priority config from backup');
} else {
await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(DEFAULT_PRIORITY_CONFIG));
this.logger.log('Initialized default priority config');
}
}
}
// --- Priority Config ---
async getPriorityConfig(): Promise<PriorityConfig> {
const data = await this.redis.get(PRIORITY_CONFIG_KEY);
return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG;
}
async updatePriorityConfig(config: PriorityConfig): Promise<PriorityConfig> {
await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(config));
await this.redis.incr(VERSION_KEY);
this.backupFile('priority-config.json', config);
return config;
}
// --- Rules CRUD ---
async getAll(): Promise<Rule[]> {
const data = await this.redis.get(RULES_KEY);
return data ? JSON.parse(data) : [];
}
async getById(id: string): Promise<Rule | null> {
const rules = await this.getAll();
return rules.find(r => r.id === id) ?? null;
}
async getByTrigger(triggerType: string, triggerValue?: string): Promise<Rule[]> {
const rules = await this.getAll();
return rules.filter(r => {
if (!r.enabled) return false;
if (r.trigger.type !== triggerType) return false;
if (triggerValue && 'request' in r.trigger && r.trigger.request !== triggerValue) return false;
if (triggerValue && 'event' in r.trigger && r.trigger.event !== triggerValue) return false;
return true;
}).sort((a, b) => a.priority - b.priority);
}
async create(rule: Omit<Rule, 'id' | 'metadata'> & { createdBy?: string }): Promise<Rule> {
const rules = await this.getAll();
const newRule: Rule = {
...rule,
id: randomUUID(),
metadata: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: rule.createdBy ?? 'system',
category: this.inferCategory(rule.action.type),
tags: [],
},
};
rules.push(newRule);
await this.saveRules(rules);
return newRule;
}
async update(id: string, updates: Partial<Rule>): Promise<Rule | null> {
const rules = await this.getAll();
const index = rules.findIndex(r => r.id === id);
if (index === -1) return null;
rules[index] = {
...rules[index],
...updates,
id,
metadata: { ...rules[index].metadata, updatedAt: new Date().toISOString(), ...(updates.metadata ?? {}) },
};
await this.saveRules(rules);
return rules[index];
}
async delete(id: string): Promise<boolean> {
const rules = await this.getAll();
const filtered = rules.filter(r => r.id !== id);
if (filtered.length === rules.length) return false;
await this.saveRules(filtered);
return true;
}
async toggle(id: string): Promise<Rule | null> {
const rule = await this.getById(id);
if (!rule) return null;
return this.update(id, { enabled: !rule.enabled });
}
async reorder(ids: string[]): Promise<Rule[]> {
const rules = await this.getAll();
const reorderedIds = new Set(ids);
const reordered = ids.map((id, i) => {
const rule = rules.find(r => r.id === id);
if (rule) rule.priority = i;
return rule;
}).filter(Boolean) as Rule[];
const remaining = rules.filter(r => !reorderedIds.has(r.id));
const final = [...reordered, ...remaining];
await this.saveRules(final);
return final;
}
async getVersion(): Promise<number> {
const v = await this.redis.get(VERSION_KEY);
return v ? parseInt(v, 10) : 0;
}
// --- Internal ---
private async saveRules(rules: Rule[]) {
const json = JSON.stringify(rules, null, 2);
await this.redis.set(RULES_KEY, json);
await this.redis.incr(VERSION_KEY);
this.backupFile('rules-config.json', rules);
}
private backupFile(filename: string, data: any) {
try {
if (!existsSync(this.backupDir)) mkdirSync(this.backupDir, { recursive: true });
writeFileSync(join(this.backupDir, filename), JSON.stringify(data, null, 2), 'utf8');
} catch (err) {
this.logger.warn(`Failed to write backup ${filename}: ${err}`);
}
}
private inferCategory(actionType: string): Rule['metadata']['category'] {
switch (actionType) {
case 'score': return 'priority';
case 'assign': return 'assignment';
case 'escalate': return 'escalation';
case 'update': return 'lifecycle';
default: return 'priority';
}
}
}
- Step 2: Commit
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
// src/rules-engine/facts/lead-facts.provider.ts
import type { FactProvider, FactValue } from '../types/fact.types';
export class LeadFactsProvider implements FactProvider {
name = 'lead';
async resolveFacts(lead: any): Promise<Record<string, FactValue>> {
const createdAt = lead.createdAt ? new Date(lead.createdAt).getTime() : Date.now();
const lastContacted = lead.lastContacted ? new Date(lead.lastContacted).getTime() : null;
return {
'lead.source': lead.leadSource ?? lead.source ?? null,
'lead.status': lead.leadStatus ?? lead.status ?? null,
'lead.priority': lead.priority ?? 'NORMAL',
'lead.campaignId': lead.campaignId ?? null,
'lead.campaignName': lead.campaignName ?? null,
'lead.interestedService': lead.interestedService ?? null,
'lead.contactAttempts': lead.contactAttempts ?? 0,
'lead.ageMinutes': Math.round((Date.now() - createdAt) / 60000),
'lead.ageDays': Math.round((Date.now() - createdAt) / 86400000),
'lead.lastContactedMinutes': lastContacted ? Math.round((Date.now() - lastContacted) / 60000) : null,
'lead.hasPatient': !!lead.patientId,
'lead.isDuplicate': lead.isDuplicate ?? false,
'lead.isSpam': lead.isSpam ?? false,
'lead.spamScore': lead.spamScore ?? 0,
'lead.leadScore': lead.leadScore ?? 0,
};
}
}
- Step 2: Create call-facts.provider.ts
// src/rules-engine/facts/call-facts.provider.ts
import type { FactProvider, FactValue } from '../types/fact.types';
import type { PriorityConfig } from '../types/rule.types';
export class CallFactsProvider implements FactProvider {
name = 'call';
async resolveFacts(call: any, priorityConfig?: PriorityConfig): Promise<Record<string, FactValue>> {
const taskType = this.inferTaskType(call);
const slaMinutes = priorityConfig?.taskWeights[taskType]?.slaMinutes ?? 1440;
const createdAt = call.createdAt ? new Date(call.createdAt).getTime() : Date.now();
const elapsedMinutes = Math.round((Date.now() - createdAt) / 60000);
const slaElapsedPercent = Math.round((elapsedMinutes / slaMinutes) * 100);
return {
'call.direction': call.callDirection ?? call.direction ?? null,
'call.status': call.callStatus ?? null,
'call.disposition': call.disposition ?? null,
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
'call.callbackStatus': call.callbackstatus ?? call.callbackStatus ?? null,
'call.slaElapsedPercent': slaElapsedPercent,
'call.slaBreached': slaElapsedPercent > 100,
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
'call.taskType': taskType,
};
}
private inferTaskType(call: any): string {
if (call.callStatus === 'MISSED' || call.type === 'missed') return 'missed_call';
if (call.followUpType === 'CALLBACK' || call.type === 'callback') return 'follow_up';
if (call.type === 'follow-up') return 'follow_up';
if (call.contactAttempts >= 3) return 'attempt_3';
if (call.contactAttempts >= 2) return 'attempt_2';
if (call.campaignId || call.type === 'lead') return 'campaign_lead';
return 'campaign_lead';
}
}
// Exported scoring functions — used by both sidecar and frontend (via scoring.ts)
export function computeSlaMultiplier(slaElapsedPercent: number): number {
const elapsed = slaElapsedPercent / 100;
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
return Math.pow(elapsed, 1.6);
}
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
if (slaElapsedPercent > 100) return 'critical';
if (slaElapsedPercent >= 80) return 'high';
if (slaElapsedPercent >= 50) return 'medium';
return 'low';
}
- Step 3: Create agent-facts.provider.ts
// src/rules-engine/facts/agent-facts.provider.ts
import type { FactProvider, FactValue } from '../types/fact.types';
export class AgentFactsProvider implements FactProvider {
name = 'agent';
async resolveFacts(agent: any): Promise<Record<string, FactValue>> {
return {
'agent.status': agent.status ?? 'OFFLINE',
'agent.activeCallCount': agent.activeCallCount ?? 0,
'agent.todayCallCount': agent.todayCallCount ?? 0,
'agent.skills': agent.skills ?? [],
'agent.campaigns': agent.campaigns ?? [],
'agent.idleMinutes': agent.idleMinutes ?? 0,
};
}
}
- Step 4: Commit
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
// src/rules-engine/actions/score.action.ts
import type { ActionHandler, ActionResult } from '../types/action.types';
import type { RuleAction, ScoreActionParams } from '../types/rule.types';
import { computeSlaMultiplier } from '../facts/call-facts.provider';
export class ScoreActionHandler implements ActionHandler {
type = 'score';
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
const params = action.params as ScoreActionParams;
let score = params.weight;
let slaApplied = false;
let campaignApplied = false;
if (params.slaMultiplier && context['call.slaElapsedPercent'] != null) {
score *= computeSlaMultiplier(context['call.slaElapsedPercent']);
slaApplied = true;
}
if (params.campaignMultiplier) {
const campaignWeight = (context['_campaignWeight'] ?? 5) / 10;
const sourceWeight = (context['_sourceWeight'] ?? 5) / 10;
score *= campaignWeight * sourceWeight;
campaignApplied = true;
}
return {
success: true,
data: { score, weight: params.weight, slaApplied, campaignApplied },
};
}
}
- Step 2: Create stub handlers
// src/rules-engine/actions/assign.action.ts
import type { ActionHandler, ActionResult } from '../types/action.types';
import type { RuleAction } from '../types/rule.types';
export class AssignActionHandler implements ActionHandler {
type = 'assign';
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
return { success: true, data: { stub: true, action: 'assign' } };
}
}
// src/rules-engine/actions/escalate.action.ts
import type { ActionHandler, ActionResult } from '../types/action.types';
import type { RuleAction } from '../types/rule.types';
export class EscalateActionHandler implements ActionHandler {
type = 'escalate';
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
return { success: true, data: { stub: true, action: 'escalate' } };
}
}
- Step 3: Create hospital-starter.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
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
// src/rules-engine/rules-engine.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Engine } from 'json-rules-engine';
import { RulesStorageService } from './rules-storage.service';
import { LeadFactsProvider } from './facts/lead-facts.provider';
import { CallFactsProvider, computeSlaMultiplier, computeSlaStatus } from './facts/call-facts.provider';
import { AgentFactsProvider } from './facts/agent-facts.provider';
import { ScoreActionHandler } from './actions/score.action';
import { AssignActionHandler } from './actions/assign.action';
import { EscalateActionHandler } from './actions/escalate.action';
import type { Rule, ScoredItem, ScoreBreakdown, PriorityConfig } from './types/rule.types';
import type { ActionHandler } from './types/action.types';
@Injectable()
export class RulesEngineService {
private readonly logger = new Logger(RulesEngineService.name);
private readonly leadFacts = new LeadFactsProvider();
private readonly callFacts = new CallFactsProvider();
private readonly agentFacts = new AgentFactsProvider();
private readonly actionHandlers: Map<string, ActionHandler>;
constructor(private readonly storage: RulesStorageService) {
this.actionHandlers = new Map([
['score', new ScoreActionHandler()],
['assign', new AssignActionHandler()],
['escalate', new EscalateActionHandler()],
]);
}
async evaluate(triggerType: string, triggerValue: string, factContext: Record<string, any>): Promise<{ rulesApplied: string[]; results: any[] }> {
const rules = await this.storage.getByTrigger(triggerType, triggerValue);
if (rules.length === 0) return { rulesApplied: [], results: [] };
const engine = new Engine();
const ruleMap = new Map<string, Rule>();
for (const rule of rules) {
engine.addRule({
conditions: rule.conditions,
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<ScoredItem> {
const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item);
const callFacts = await this.callFacts.resolveFacts(item, priorityConfig);
const taskType = callFacts['call.taskType'] as string;
// Inject priority config weights into context for the score action
const campaignWeight = item.campaignId ? (priorityConfig.campaignWeights[item.campaignId] ?? 5) : 5;
const sourceWeight = priorityConfig.sourceWeights[leadFacts['lead.source'] as string] ?? 5;
const allFacts = {
...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
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
// src/rules-engine/rules-engine.controller.ts
import { Controller, Get, Post, Put, Delete, Patch, Param, Body, HttpException, Logger } from '@nestjs/common';
import { RulesStorageService } from './rules-storage.service';
import { RulesEngineService } from './rules-engine.service';
import type { Rule, PriorityConfig } from './types/rule.types';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('api/rules')
export class RulesEngineController {
private readonly logger = new Logger(RulesEngineController.name);
constructor(
private readonly storage: RulesStorageService,
private readonly engine: RulesEngineService,
) {}
// --- Priority Config (slider UI) ---
@Get('priority-config')
async getPriorityConfig() {
return this.storage.getPriorityConfig();
}
@Put('priority-config')
async updatePriorityConfig(@Body() body: PriorityConfig) {
return this.storage.updatePriorityConfig(body);
}
// --- Rule CRUD ---
@Get()
async listRules() {
return this.storage.getAll();
}
@Get(':id')
async getRule(@Param('id') id: string) {
const rule = await this.storage.getById(id);
if (!rule) throw new HttpException('Rule not found', 404);
return rule;
}
@Post()
async createRule(@Body() body: any) {
if (!body.name || !body.trigger || !body.conditions || !body.action) {
throw new HttpException('name, trigger, conditions, and action are required', 400);
}
return this.storage.create({
...body,
ruleType: body.ruleType ?? 'priority',
enabled: body.enabled ?? true,
priority: body.priority ?? 99,
});
}
@Put(':id')
async updateRule(@Param('id') id: string, @Body() body: Partial<Rule>) {
const updated = await this.storage.update(id, body);
if (!updated) throw new HttpException('Rule not found', 404);
return updated;
}
@Delete(':id')
async deleteRule(@Param('id') id: string) {
const deleted = await this.storage.delete(id);
if (!deleted) throw new HttpException('Rule not found', 404);
return { status: 'ok' };
}
@Patch(':id/toggle')
async toggleRule(@Param('id') id: string) {
const toggled = await this.storage.toggle(id);
if (!toggled) throw new HttpException('Rule not found', 404);
return toggled;
}
@Post('reorder')
async reorderRules(@Body() body: { ids: string[] }) {
if (!body.ids?.length) throw new HttpException('ids array required', 400);
return this.storage.reorder(body.ids);
}
// --- Evaluation ---
@Post('evaluate')
async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record<string, any> }) {
return this.engine.evaluate(body.trigger, body.triggerValue, body.facts);
}
// --- Templates ---
@Get('templates/list')
async listTemplates() {
return [{ id: 'hospital-starter', name: 'Hospital Starter Pack', description: 'Default rules for a hospital call center', ruleCount: 7 }];
}
@Post('templates/:id/apply')
async applyTemplate(@Param('id') id: string) {
if (id !== 'hospital-starter') throw new HttpException('Template not found', 404);
let template: any;
try {
template = JSON.parse(readFileSync(join(__dirname, 'templates', 'hospital-starter.json'), 'utf8'));
} catch {
throw new HttpException('Failed to load template', 500);
}
// Apply priority config
await this.storage.updatePriorityConfig(template.priorityConfig);
// Create rules
const created: Rule[] = [];
for (const rule of template.rules) {
const newRule = await this.storage.create(rule);
created.push(newRule);
}
this.logger.log(`Applied hospital-starter template: ${created.length} rules + priority config`);
return { status: 'ok', rulesCreated: created.length, rules: created };
}
}
- Step 2: Create rules-engine.module.ts
// 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
// src/rules-engine/consumers/worklist.consumer.ts
import { Injectable, Logger } from '@nestjs/common';
import { RulesEngineService } from '../rules-engine.service';
import { RulesStorageService } from '../rules-storage.service';
@Injectable()
export class WorklistConsumer {
private readonly logger = new Logger(WorklistConsumer.name);
constructor(
private readonly engine: RulesEngineService,
private readonly storage: RulesStorageService,
) {}
async scoreAndRank(worklistItems: any[]): Promise<any[]> {
const rules = await this.storage.getByTrigger('on_request', 'worklist');
if (rules.length === 0) {
this.logger.debug('No scoring rules configured — returning unsorted');
return worklistItems;
}
this.logger.debug(`Scoring ${worklistItems.length} items with ${rules.length} rules`);
return this.engine.scoreWorklist(worklistItems);
}
}
- 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
cd helix-engage-server && npm run build
- Step 8: Commit
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
// src/lib/scoring.ts — client-side scoring for live preview
export type TaskWeightConfig = {
weight: number;
slaMinutes: number;
enabled: boolean;
};
export type PriorityConfig = {
taskWeights: Record<string, TaskWeightConfig>;
campaignWeights: Record<string, number>;
sourceWeights: Record<string, number>;
};
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<string, string> = {
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<string, string> = {
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
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
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
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