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

40 KiB

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

cd helix-engage-server && npm install json-rules-engine
  • Step 2: Create rule.types.ts
// 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
// 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
// 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
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

// 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
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

// 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
// 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
// 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
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

// 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
// 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' } };
    }
}
// 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
[
    {
        "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
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 } 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
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

// 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
// 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:

import { RulesEngineModule } from './rules-engine/rules-engine.module';

Add to imports array:

RulesEngineModule,
  • Step 4: Build and verify
cd helix-engage-server && npm run build
  • Step 5: Commit
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

// 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:

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:

// 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:

import { RulesEngineModule } from '../rules-engine/rules-engine.module';

@Module({
    imports: [RulesEngineModule, ...],
    ...
})
  • Step 5: Build and verify
npm run build
  • Step 6: Commit
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