Files
helix-engage/docs/superpowers/plans/2026-03-31-rules-engine.md
saridsa2 b90740e009 feat: rules engine — priority config UI + worklist scoring
- 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>
2026-04-01 17:20:59 +05:30

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