From b90740e009baaad4f1e30cdc8bb70ec226fb9333 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 1 Apr 2026 16:51:29 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20rules=20engine=20=E2=80=94=20priority?= =?UTF-8?q?=20config=20UI=20+=20worklist=20scoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../plans/2026-03-31-rules-engine.md | 912 +++++++++++------- .../specs/2026-03-31-rules-engine-design.md | 381 ++++---- src/components/call-desk/worklist-panel.tsx | 29 +- src/components/layout/sidebar.tsx | 5 + .../rules/campaign-weights-panel.tsx | 55 ++ src/components/rules/collapsible-section.tsx | 56 ++ .../rules/priority-config-panel.tsx | 80 ++ src/components/rules/rules-ai-assistant.tsx | 142 +++ src/components/rules/source-weights-panel.tsx | 48 + src/components/rules/weight-slider-row.tsx | 78 ++ src/components/rules/worklist-preview.tsx | 118 +++ src/lib/scoring.ts | 128 +++ src/main.tsx | 2 + src/pages/rules-settings.tsx | 160 +++ 14 files changed, 1680 insertions(+), 514 deletions(-) create mode 100644 src/components/rules/campaign-weights-panel.tsx create mode 100644 src/components/rules/collapsible-section.tsx create mode 100644 src/components/rules/priority-config-panel.tsx create mode 100644 src/components/rules/rules-ai-assistant.tsx create mode 100644 src/components/rules/source-weights-panel.tsx create mode 100644 src/components/rules/weight-slider-row.tsx create mode 100644 src/components/rules/worklist-preview.tsx create mode 100644 src/lib/scoring.ts create mode 100644 src/pages/rules-settings.tsx diff --git a/docs/superpowers/plans/2026-03-31-rules-engine.md b/docs/superpowers/plans/2026-03-31-rules-engine.md index 7bd9c62..a89839e 100644 --- a/docs/superpowers/plans/2026-03-31-rules-engine.md +++ b/docs/superpowers/plans/2026-03-31-rules-engine.md @@ -1,14 +1,15 @@ -# Rules Engine — Implementation Plan +# 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 that scores worklist items based on hospital-defined rules, replacing the hardcoded priority sort. +**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. Fact providers fetch data from platform GraphQL. First consumer is the worklist endpoint. +**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 +**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` @@ -16,26 +17,41 @@ ## File Map +### Backend (helix-engage-server) | 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 | +| `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 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 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/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 rules scoring | -| `package.json` | Modify | Add json-rules-engine dependency | +| `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) | --- @@ -53,11 +69,19 @@ cd helix-engage-server && npm install json-rules-engine ``` -- [ ] **Step 2: Create rule.types.ts** +- [ ] **Step 2: Create directory structure** + +```bash +mkdir -p src/rules-engine/{types,facts,actions,consumers,templates} +``` + +- [ ] **Step 3: Create rule.types.ts** ```typescript // 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 } @@ -108,24 +132,19 @@ export type EscalateActionParams = { }; export type UpdateActionParams = { - entity: 'lead' | 'call' | 'followUp'; + entity: string; 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; + params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams; }; export type Rule = { id: string; + ruleType: RuleType; name: string; description?: string; enabled: boolean; @@ -133,6 +152,7 @@ export type Rule = { trigger: RuleTrigger; conditions: RuleConditionGroup; action: RuleAction; + status?: 'draft' | 'published'; metadata: { createdAt: string; updatedAt: string; @@ -156,9 +176,37 @@ export type ScoredItem = { 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; + campaignWeights: Record; // campaignId → 0-10 + sourceWeights: Record; // 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 3: Create fact.types.ts** +- [ ] **Step 4: Create fact.types.ts** ```typescript // src/rules-engine/types/fact.types.ts @@ -174,54 +222,20 @@ export type FactContext = { export interface FactProvider { name: string; - resolveFacts(entityId: string, authHeader?: string): Promise>; + resolveFacts(entityData: any): Promise>; } - -export const FACT_REGISTRY = { - // Lead facts - 'lead.source': { type: 'string', description: 'Lead source (FACEBOOK_AD, GOOGLE_AD, PHONE, etc.)' }, - 'lead.status': { type: 'string', description: 'Lead status (NEW, CONTACTED, QUALIFIED, etc.)' }, - 'lead.priority': { type: 'string', description: 'Manual priority (LOW, NORMAL, HIGH, URGENT)' }, - 'lead.campaignId': { type: 'string', description: 'Associated campaign ID' }, - 'lead.campaignName': { type: 'string', description: 'Campaign name' }, - 'lead.interestedService': { type: 'string', description: 'Service interest' }, - 'lead.contactAttempts': { type: 'number', description: 'Number of contact attempts' }, - 'lead.ageMinutes': { type: 'number', description: 'Minutes since lead created' }, - 'lead.hasPatient': { type: 'boolean', description: 'Whether linked to a patient' }, - 'lead.isDuplicate': { type: 'boolean', description: 'Whether marked as duplicate' }, - 'lead.isSpam': { type: 'boolean', description: 'Whether marked as spam' }, - 'lead.spamScore': { type: 'number', description: 'Spam prediction score' }, - 'lead.leadScore': { type: 'number', description: 'Lead quality score' }, - - // Call facts - 'call.direction': { type: 'string', description: 'INBOUND or OUTBOUND' }, - 'call.status': { type: 'string', description: 'MISSED, COMPLETED, etc.' }, - 'call.disposition': { type: 'string', description: 'Call outcome' }, - 'call.callbackStatus': { type: 'string', description: 'PENDING_CALLBACK, ATTEMPTED, etc.' }, - 'call.slaElapsedPercent': { type: 'number', description: '% of SLA time elapsed' }, - 'call.slaBreached': { type: 'boolean', description: 'Whether SLA is breached' }, - 'call.missedCount': { type: 'number', description: 'Times this number was missed' }, - 'call.taskType': { type: 'string', description: 'missed_call, follow_up, campaign_lead, etc.' }, - - // Agent facts - 'agent.status': { type: 'string', description: 'READY, ON_CALL, BREAK, OFFLINE' }, - 'agent.activeCallCount': { type: 'number', description: 'Current active calls' }, - 'agent.todayCallCount': { type: 'number', description: 'Calls handled today' }, - 'agent.skills': { type: 'array', description: 'Agent skill tags' }, - 'agent.idleMinutes': { type: 'number', description: 'Minutes idle' }, -} as const; ``` -- [ ] **Step 4: Create action.types.ts** +- [ ] **Step 5: Create action.types.ts** ```typescript // src/rules-engine/types/action.types.ts -import type { RuleAction, FactContext } from './rule.types'; +import type { RuleAction } from './rule.types'; export interface ActionHandler { type: string; - execute(action: RuleAction, context: FactContext): Promise; + execute(action: RuleAction, context: Record): Promise; } export type ActionResult = { @@ -231,11 +245,12 @@ export type ActionResult = { }; ``` -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash +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 schemas" +git commit -m "feat: rules engine types — Rule, Fact, Action, PriorityConfig schemas" ``` --- @@ -254,44 +269,74 @@ 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'; +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 REDIS_KEY = 'rules:config'; -const REDIS_VERSION_KEY = 'rules:scores:version'; +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 backupPath: string; + private readonly backupDir: string; constructor(private config: ConfigService) { this.redis = new Redis(config.get('REDIS_URL') ?? 'redis://localhost:6379'); - this.backupPath = config.get('RULES_BACKUP_PATH') ?? join(process.cwd(), 'data', 'rules-config.json'); + this.backupDir = config.get('RULES_BACKUP_DIR') ?? join(process.cwd(), 'data'); } async onModuleInit() { - const existing = await this.redis.get(REDIS_KEY); + // Restore rules from backup if Redis is empty + const existing = await this.redis.get(RULES_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`); + 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(REDIS_KEY, '[]'); + await this.redis.set(RULES_KEY, '[]'); this.logger.log('Initialized empty rules config'); } - } else { - const rules = JSON.parse(existing); - this.logger.log(`Rules loaded: ${rules.length} rules in Redis`); + } + + // 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 { + const data = await this.redis.get(PRIORITY_CONFIG_KEY); + return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG; + } + + async updatePriorityConfig(config: PriorityConfig): Promise { + 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 { - const data = await this.redis.get(REDIS_KEY); + const data = await this.redis.get(RULES_KEY); return data ? JSON.parse(data) : []; } @@ -315,7 +360,7 @@ export class RulesStorageService implements OnModuleInit { const rules = await this.getAll(); const newRule: Rule = { ...rule, - id: uuidv4(), + id: randomUUID(), metadata: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -325,7 +370,7 @@ export class RulesStorageService implements OnModuleInit { }, }; rules.push(newRule); - await this.save(rules); + await this.saveRules(rules); return newRule; } @@ -333,18 +378,13 @@ export class RulesStorageService implements OnModuleInit { 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 ?? {}), - }, + metadata: { ...rules[index].metadata, updatedAt: new Date().toISOString(), ...(updates.metadata ?? {}) }, }; - await this.save(rules); + await this.saveRules(rules); return rules[index]; } @@ -352,7 +392,7 @@ export class RulesStorageService implements OnModuleInit { const rules = await this.getAll(); const filtered = rules.filter(r => r.id !== id); if (filtered.length === rules.length) return false; - await this.save(filtered); + await this.saveRules(filtered); return true; } @@ -364,38 +404,38 @@ export class RulesStorageService implements OnModuleInit { async reorder(ids: string[]): Promise { 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[]; - - // 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); + await this.saveRules(final); return final; } async getVersion(): Promise { - const v = await this.redis.get(REDIS_VERSION_KEY); + const v = await this.redis.get(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); + // --- Internal --- - // Backup to file + 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 { - const dir = dirname(this.backupPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(this.backupPath, json, 'utf8'); + 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: ${err}`); + this.logger.warn(`Failed to write backup ${filename}: ${err}`); } } @@ -415,7 +455,7 @@ export class RulesStorageService implements OnModuleInit { ```bash git add src/rules-engine/rules-storage.service.ts -git commit -m "feat: rules storage service — Redis + JSON file backup" +git commit -m "feat: rules storage service — Redis + JSON backup + PriorityConfig" ``` --- @@ -437,8 +477,7 @@ import type { FactProvider, FactValue } from '../types/fact.types'; export class LeadFactsProvider implements FactProvider { name = 'lead'; - async resolveFacts(entityData: any): Promise> { - const lead = entityData; + async resolveFacts(lead: any): Promise> { const createdAt = lead.createdAt ? new Date(lead.createdAt).getTime() : Date.now(); const lastContacted = lead.lastContacted ? new Date(lead.lastContacted).getTime() : null; @@ -469,24 +508,14 @@ export class LeadFactsProvider implements FactProvider { // src/rules-engine/facts/call-facts.provider.ts import type { FactProvider, FactValue } from '../types/fact.types'; - -type SlaConfig = Record; // taskType → SLA in minutes - -const DEFAULT_SLA: SlaConfig = { - missed_call: 720, // 12 hours - follow_up: 1440, // 1 day - attempt_2: 1440, // 24 hours - attempt_3: 2880, // 48 hours - campaign_lead: 2880, // 2 days -}; +import type { PriorityConfig } from '../types/rule.types'; export class CallFactsProvider implements FactProvider { name = 'call'; - async resolveFacts(entityData: any, slaConfig?: SlaConfig): Promise> { - const call = entityData; + async resolveFacts(call: any, priorityConfig?: PriorityConfig): Promise> { const taskType = this.inferTaskType(call); - const slaMinutes = (slaConfig ?? DEFAULT_SLA)[taskType] ?? 1440; + 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); @@ -515,6 +544,7 @@ export class CallFactsProvider implements FactProvider { } } +// 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; @@ -539,8 +569,7 @@ import type { FactProvider, FactValue } from '../types/fact.types'; export class AgentFactsProvider implements FactProvider { name = 'agent'; - async resolveFacts(entityData: any): Promise> { - const agent = entityData; + async resolveFacts(agent: any): Promise> { return { 'agent.status': agent.status ?? 'OFFLINE', 'agent.activeCallCount': agent.activeCallCount ?? 0, @@ -557,12 +586,12 @@ export class AgentFactsProvider implements FactProvider { ```bash git add src/rules-engine/facts/ -git commit -m "feat: fact providers — lead, call, agent data resolvers" +git commit -m "feat: fact providers — lead, call (with SLA), agent resolvers" ``` --- -### Task 4: Score Action Handler + Hospital Starter Template +### Task 4: Action Handlers + Hospital Starter Template **Files:** - Create: `helix-engage-server/src/rules-engine/actions/score.action.ts` @@ -576,36 +605,39 @@ git commit -m "feat: fact providers — lead, call, agent data resolvers" // 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 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: any): Promise { + async execute(action: RuleAction, context: Record): Promise { 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 && context['campaign.weight'] != null) { - const campaignWeight = (context['campaign.weight'] ?? 5) / 10; - const sourceWeight = (context['source.weight'] ?? 5) / 10; + 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: !!params.slaMultiplier, campaignApplied: !!params.campaignMultiplier }, + data: { score, weight: params.weight, slaApplied, campaignApplied }, }; } } ``` -- [ ] **Step 2: Create stub action handlers** +- [ ] **Step 2: Create stub handlers** ```typescript // src/rules-engine/actions/assign.action.ts @@ -616,8 +648,7 @@ import type { RuleAction } from '../types/rule.types'; export class AssignActionHandler implements ActionHandler { type = 'assign'; - async execute(action: RuleAction, context: any): Promise { - // Stub — will be implemented in Phase 2 + async execute(_action: RuleAction, _context: Record): Promise { return { success: true, data: { stub: true, action: 'assign' } }; } } @@ -632,8 +663,7 @@ import type { RuleAction } from '../types/rule.types'; export class EscalateActionHandler implements ActionHandler { type = 'escalate'; - async execute(action: RuleAction, context: any): Promise { - // Stub — will be implemented in Phase 2 + async execute(_action: RuleAction, _context: Record): Promise { return { success: true, data: { stub: true, action: 'escalate' } }; } } @@ -642,71 +672,95 @@ export class EscalateActionHandler implements ActionHandler { - [ ] **Step 3: Create hospital-starter.json** ```json -[ - { - "name": "Missed calls — high urgency", - "description": "Missed calls get highest priority with SLA-based urgency", - "enabled": true, - "priority": 1, - "trigger": { "type": "on_request", "request": "worklist" }, - "conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "missed_call" }] }, - "action": { "type": "score", "params": { "weight": 9, "slaMultiplier": true } } +{ + "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 + } }, - { - "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" } } - } -] + "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** @@ -737,7 +791,7 @@ 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 { Rule, ScoredItem, ScoreBreakdown, PriorityConfig } from './types/rule.types'; import type { ActionHandler } from './types/action.types'; @Injectable() @@ -764,22 +818,19 @@ export class RulesEngineService { const ruleMap = new Map(); for (const rule of rules) { - const engineRule = { + engine.addRule({ 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[] = []; @@ -787,7 +838,6 @@ export class RulesEngineService { 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); @@ -799,25 +849,33 @@ export class RulesEngineService { return { rulesApplied, results }; } - async scoreWorklistItem(item: any): Promise { - // Resolve facts from item data + async scoreWorklistItem(item: any, priorityConfig: PriorityConfig): Promise { const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item); - const callFacts = await this.callFacts.resolveFacts(item); - const allFacts = { ...leadFacts, ...callFacts }; + 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, + }; - // 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; + 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) totalSlaMultiplier = computeSlaMultiplier(allFacts['call.slaElapsedPercent'] as number ?? 0); - if (result.data.campaignApplied) totalCampaignMultiplier = (allFacts['campaign.weight'] as number ?? 5) / 10; + if (result.data.slaApplied) slaMultiplierVal = computeSlaMultiplier((allFacts['call.slaElapsedPercent'] as number) ?? 0); + if (result.data.campaignApplied) campaignMultiplierVal = (campaignWeight / 10) * (sourceWeight / 10); } } @@ -828,8 +886,8 @@ export class RulesEngineService { score: Math.round(totalScore * 100) / 100, scoreBreakdown: { baseScore: totalScore, - slaMultiplier: totalSlaMultiplier, - campaignMultiplier: totalCampaignMultiplier, + slaMultiplier: Math.round(slaMultiplierVal * 100) / 100, + campaignMultiplier: Math.round(campaignMultiplierVal * 100) / 100, rulesApplied, }, slaStatus: computeSlaStatus(slaElapsedPercent), @@ -838,30 +896,27 @@ export class RulesEngineService { } 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); + const scoreData = await this.scoreWorklistItem(item, priorityConfig); return { ...item, ...scoreData }; }), ); - - // Sort by score descending scored.sort((a, b) => b.score - a.score); return scored; } - async explain(itemId: string, item: any): Promise<{ facts: Record; rulesEvaluated: string[]; scoreBreakdown: ScoreBreakdown }> { - const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item); - const callFacts = await this.callFacts.resolveFacts(item); - const allFacts = { ...leadFacts, ...callFacts }; - - const scoreData = await this.scoreWorklistItem(item); - - return { - facts: allFacts, - rulesEvaluated: scoreData.scoreBreakdown.rulesApplied, - scoreBreakdown: scoreData.scoreBreakdown, - }; + 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; } } ``` @@ -870,17 +925,20 @@ export class RulesEngineService { ```bash git add src/rules-engine/rules-engine.service.ts -git commit -m "feat: rules engine service — json-rules-engine wrapper with scoring" +git commit -m "feat: rules engine service — scoring, evaluation, live preview" ``` --- -### Task 6: Rules Controller + Module + App Integration +### 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** @@ -890,7 +948,7 @@ git commit -m "feat: rules engine service — json-rules-engine wrapper with sco 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 type { Rule, PriorityConfig } from './types/rule.types'; import { readFileSync } from 'fs'; import { join } from 'path'; @@ -903,6 +961,20 @@ export class RulesEngineController { 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(); @@ -920,7 +992,12 @@ export class RulesEngineController { 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 }); + return this.storage.create({ + ...body, + ruleType: body.ruleType ?? 'priority', + enabled: body.enabled ?? true, + priority: body.priority ?? 99, + }); } @Put(':id') @@ -950,11 +1027,15 @@ export class RulesEngineController { return this.storage.reorder(body.ids); } + // --- Evaluation --- + @Post('evaluate') async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record }) { 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 }]; @@ -964,21 +1045,24 @@ export class RulesEngineController { 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[]; + let template: any; try { - templateRules = JSON.parse(readFileSync(templatePath, 'utf8')); + 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 templateRules) { + for (const rule of template.rules) { const newRule = await this.storage.create(rule); created.push(newRule); } - this.logger.log(`Applied template: ${created.length} rules created`); + this.logger.log(`Applied hospital-starter template: ${created.length} rules + priority config`); return { status: 'ok', rulesCreated: created.length, rules: created }; } } @@ -993,50 +1077,17 @@ 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], - exports: [RulesEngineService, RulesStorageService], + providers: [RulesEngineService, RulesStorageService, WorklistConsumer], + exports: [RulesEngineService, RulesStorageService, WorklistConsumer], }) export class RulesEngineModule {} ``` -- [ ] **Step 3: Register in app.module.ts** - -Add import at top: -```typescript -import { RulesEngineModule } from './rules-engine/rules-engine.module'; -``` - -Add to imports array: -```typescript -RulesEngineModule, -``` - -- [ ] **Step 4: Build and verify** - -```bash -cd helix-engage-server && npm run build -``` - -- [ ] **Step 5: Commit** - -```bash -git add src/rules-engine/rules-engine.controller.ts src/rules-engine/rules-engine.module.ts src/app.module.ts -git commit -m "feat: rules engine controller + module + app registration" -``` - ---- - -### Task 7: Worklist Integration - -**Files:** -- Create: `helix-engage-server/src/rules-engine/consumers/worklist.consumer.ts` -- Modify: `helix-engage-server/src/worklist/worklist.service.ts` -- Modify: `helix-engage-server/src/worklist/worklist.module.ts` - -- [ ] **Step 1: Create worklist.consumer.ts** +- [ ] **Step 3: Create worklist.consumer.ts** ```typescript // src/rules-engine/consumers/worklist.consumer.ts @@ -1044,7 +1095,6 @@ git commit -m "feat: rules engine controller + module + app registration" 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 { @@ -1057,78 +1107,268 @@ export class WorklistConsumer { async scoreAndRank(worklistItems: any[]): Promise { const rules = await this.storage.getByTrigger('on_request', 'worklist'); - if (rules.length === 0) { - this.logger.debug('No scoring rules configured — returning unsorted worklist'); + 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 2: Export WorklistConsumer from RulesEngineModule** +- [ ] **Step 4: Register in app.module.ts** -Update `rules-engine.module.ts` to include WorklistConsumer: +Add import and register `RulesEngineModule` in the imports array of `src/app.module.ts`. -```typescript -import { WorklistConsumer } from './consumers/worklist.consumer'; +- [ ] **Step 5: Integrate into WorklistService** -@Module({ - controllers: [RulesEngineController], - providers: [RulesEngineService, RulesStorageService, WorklistConsumer], - exports: [RulesEngineService, RulesStorageService, WorklistConsumer], -}) -``` +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 3: Integrate into WorklistService** +- [ ] **Step 6: Update WorklistModule imports** -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: +Add `RulesEngineModule` to `WorklistModule` imports. -```typescript -// At the end of getWorklist method, before return: -// const scored = await this.worklistConsumer.scoreAndRank(worklistItems); -// return { ...result, items: scored }; -``` - -Note: The exact integration depends on the current `getWorklist` return shape. Read the file and integrate accordingly. The consumer wraps the existing array and adds `score`, `scoreBreakdown`, `slaStatus`, `slaElapsedPercent` to each item. - -- [ ] **Step 4: Update WorklistModule imports** - -Import `RulesEngineModule` in `worklist.module.ts`: - -```typescript -import { RulesEngineModule } from '../rules-engine/rules-engine.module'; - -@Module({ - imports: [RulesEngineModule, ...], - ... -}) -``` - -- [ ] **Step 5: Build and verify** +- [ ] **Step 7: Build and verify** ```bash -npm run build +cd helix-engage-server && npm run build ``` -- [ ] **Step 6: Commit** +- [ ] **Step 8: Commit** ```bash git add src/rules-engine/ src/worklist/ src/app.module.ts -git commit -m "feat: worklist scoring integration — rules engine scores and ranks worklist items" +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** + +```typescript +// src/lib/scoring.ts — client-side scoring for live preview + +export type TaskWeightConfig = { + weight: number; + slaMinutes: number; + enabled: boolean; +}; + +export type PriorityConfig = { + taskWeights: Record; + campaignWeights: Record; + sourceWeights: Record; +}; + +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 = { + 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 = { + 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** + +```bash +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** + +```bash +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** + +```bash +git add src/components/call-desk/worklist-panel.tsx +git commit -m "feat: worklist score display — SLA dots + score badges + breakdown tooltip" ``` --- ## 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 +- 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` diff --git a/docs/superpowers/specs/2026-03-31-rules-engine-design.md b/docs/superpowers/specs/2026-03-31-rules-engine-design.md index ad9646d..5959f28 100644 --- a/docs/superpowers/specs/2026-03-31-rules-engine-design.md +++ b/docs/superpowers/specs/2026-03-31-rules-engine-design.md @@ -1,8 +1,8 @@ -# Rules Engine — Design Spec +# Rules Engine — Design Spec (v2) -**Date**: 2026-03-31 -**Status**: Draft -**Phase**: 1 (Engine + Storage + API + Worklist Integration) +**Date**: 2026-03-31 (revised 2026-04-01) +**Status**: Approved +**Phase**: 1 (Engine + Storage + API + Priority Rules UI + Worklist Integration) --- @@ -14,6 +14,35 @@ A configurable rules engine that governs how leads flow through the hospital's c --- +## Two Rule Types + +The engine supports two categories of rules, each with different behavior and UI: + +### Priority Rules — "Who gets called first?" +- Configures worklist ranking via weights, SLA curves, campaign modifiers +- **Computed at request time** — scores are ephemeral, not persisted to entities +- Time-sensitive (SLA elapsed changes every minute — can't be persisted) +- Supervisor sees: weight sliders, SLA thresholds, campaign weights, live worklist preview +- No draft/publish needed — changes affect ranking immediately + +### Automation Rules — "What should happen automatically?" +- Triggers durable actions when conditions are met: field updates, assignments, notifications +- **Writes back to entities** via platform GraphQL mutations (e.g., set lead.priority = HIGH) +- Event-driven (fires on lead.created, call.missed, etc.) or scheduled (every 5m) +- Supervisor sees: if-this-then-that condition builder with entity/field selectors +- **Draft/publish workflow** — rules don't affect live data until published +- Sub-types: Assignment, Escalation, Lifecycle + +| Aspect | Priority Rules | Automation Rules | +|---|---|---| +| When | On worklist request | On entity event / on schedule | +| Effect | Ephemeral score for ranking | Durable entity mutation | +| Persisted? | No (recomputed each request) | Yes (writes to platform) | +| Draft/publish? | No (immediate) | Yes | +| UI | Sliders + live preview | Condition builder + draft/publish | + +--- + ## Architecture Self-contained NestJS module inside helix-engage-server (sidecar). Designed for extraction into a standalone microservice when needed. @@ -22,22 +51,21 @@ Self-contained NestJS module inside helix-engage-server (sidecar). Designed for helix-engage-server/src/rules-engine/ ├── rules-engine.module.ts # NestJS module (self-contained) ├── rules-engine.service.ts # Core: json-rules-engine wrapper -├── rules-engine.controller.ts # REST API: CRUD + evaluate +├── rules-engine.controller.ts # REST API: CRUD + evaluate + config ├── rules-storage.service.ts # Redis (hot) + JSON file (backup) ├── types/ -│ ├── rule.types.ts # Rule schema -│ ├── fact.types.ts # Fact definitions -│ └── action.types.ts # Action definitions +│ ├── rule.types.ts # Rule schema (priority + automation) +│ ├── fact.types.ts # Fact definitions + computed facts +│ └── action.types.ts # Action handler interface ├── facts/ │ ├── lead-facts.provider.ts # Lead/campaign data facts -│ ├── call-facts.provider.ts # Call/SLA data facts +│ ├── call-facts.provider.ts # Call/SLA data facts (+ computed: ageMinutes, slaElapsed) │ └── agent-facts.provider.ts # Agent availability facts ├── actions/ │ ├── score.action.ts # Priority scoring action -│ ├── assign.action.ts # Lead-to-agent assignment -│ ├── escalate.action.ts # SLA breach alerts -│ ├── update.action.ts # Update entity field -│ └── notify.action.ts # Send notification +│ ├── assign.action.ts # Lead-to-agent assignment (stub) +│ ├── escalate.action.ts # SLA breach alerts (stub) +│ └── update.action.ts # Update entity field (stub) ├── consumers/ │ └── worklist.consumer.ts # Applies scoring rules to worklist └── templates/ @@ -57,12 +85,43 @@ helix-engage-server/src/rules-engine/ --- +## Fact System + +### Design Principle: Entity-Driven Facts +Facts should ultimately be driven by entity metadata from the platform — adding a field to an entity automatically makes it available as a fact. This is the long-term goal. + +### Phase 1: Curated Facts + Computed Facts +For Phase 1, facts are curated (hardcoded providers) with two categories: + +**Entity field facts** — direct field values from platform entities: +- `lead.source`, `lead.status`, `lead.campaignId`, etc. +- `call.direction`, `call.status`, `call.callbackStatus`, etc. +- `agent.status`, `agent.skills`, etc. + +**Computed facts** — derived values that don't exist as entity fields: +- `lead.ageMinutes` — computed from `createdAt` +- `call.slaElapsedPercent` — computed from `createdAt` + task type SLA +- `call.slaBreached` — computed from slaElapsedPercent > 100 +- `call.taskType` — inferred from call data (missed_call, follow_up, campaign_lead, etc.) + +### Phase 2: Metadata-Driven Discovery +- Query platform metadata API to discover entities and fields dynamically +- Each field's type (NUMBER, TEXT, SELECT, BOOLEAN) drives: + - Available operators in the condition builder UI + - Input type (slider, dropdown with enum values, text, toggle) +- Computed facts remain registered in code alongside metadata-driven facts + +--- + ## Rule Schema ```typescript +type RuleType = 'priority' | 'automation'; + type Rule = { id: string; // UUID - name: string; // Human-readable: "High priority for IVF missed calls" + ruleType: RuleType; // Priority or Automation + name: string; // Human-readable description?: string; // BA-friendly explanation enabled: boolean; // Toggle on/off without deleting priority: number; // Evaluation order (lower = first) @@ -71,39 +130,42 @@ type Rule = { conditions: RuleConditionGroup; // What to check action: RuleAction; // What to do + // Automation rules only + status?: 'draft' | 'published'; // Draft/publish workflow + metadata: { createdAt: string; updatedAt: string; - createdBy: string; // User who created - category: RuleCategory; // For UI grouping - tags?: string[]; // Optional tags for filtering + createdBy: string; + category: RuleCategory; + tags?: string[]; }; }; type RuleTrigger = | { type: 'on_request'; request: 'worklist' | 'assignment' } - | { type: 'on_event'; event: 'lead.created' | 'lead.updated' | 'call.created' | 'call.ended' | 'call.missed' | 'disposition.submitted' } - | { type: 'on_schedule'; interval: string } // cron expression or "5m", "1h" - | { type: 'always' }; // evaluated in all contexts + | { type: 'on_event'; event: string } + | { type: 'on_schedule'; interval: string } + | { type: 'always' }; type RuleCategory = - | 'priority' // Worklist scoring - | 'assignment' // Lead/call routing to agent - | 'escalation' // SLA breach handling - | 'lifecycle' // Lead status transitions - | 'qualification'; // Lead quality scoring + | 'priority' // Worklist scoring (Priority Rules) + | 'assignment' // Lead/call routing to agent (Automation) + | 'escalation' // SLA breach handling (Automation) + | 'lifecycle' // Lead status transitions (Automation) + | 'qualification'; // Lead quality scoring (Automation) type RuleConditionGroup = { - all?: RuleCondition[]; // AND - any?: RuleCondition[]; // OR + all?: (RuleCondition | RuleConditionGroup)[]; + any?: (RuleCondition | RuleConditionGroup)[]; }; type RuleCondition = { - fact: string; // Fact name (see Fact Registry below) + fact: string; // Fact name operator: RuleOperator; value: any; path?: string; // JSON path for nested facts -} | RuleConditionGroup; // Nested group for complex logic +}; type RuleOperator = | 'equal' | 'notEqual' @@ -114,102 +176,47 @@ type RuleOperator = | 'exists' | 'doesNotExist'; type RuleAction = { - type: 'score' | 'assign' | 'escalate' | 'update' | 'notify'; - params: Record; + type: RuleActionType; + params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams; }; -// Score action params +type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify'; + +// Score action params (Priority Rules) type ScoreActionParams = { weight: number; // 0-10 base weight slaMultiplier?: boolean; // Apply SLA urgency curve campaignMultiplier?: boolean; // Apply campaign weight }; -// Assign action params +// Assign action params (Automation Rules — stub) type AssignActionParams = { - agentId?: string; // Specific agent - agentPool?: string[]; // Round-robin from pool + agentId?: string; + agentPool?: string[]; strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based'; }; -// Escalate action params +// Escalate action params (Automation Rules — stub) type EscalateActionParams = { channel: 'toast' | 'notification' | 'sms' | 'email'; - recipients: 'supervisor' | 'agent' | string[]; // Specific user IDs - message: string; // Template with {{variables}} + recipients: 'supervisor' | 'agent' | string[]; + message: string; severity: 'warning' | 'critical'; }; -// Update action params +// Update action params (Automation Rules — stub) type UpdateActionParams = { - entity: 'lead' | 'call' | 'followUp'; + entity: string; field: string; value: any; }; - -// Notify action params -type NotifyActionParams = { - channel: 'toast' | 'bell' | 'sms'; - message: string; - target: 'agent' | 'supervisor' | 'all'; -}; ``` --- -## Fact Registry - -Facts are the data points rules can check against. Each fact has a provider that fetches/computes the value. - -### Lead Facts (`lead-facts.provider.ts`) -| Fact Name | Type | Description | -|---|---|---| -| `lead.source` | string | Lead source (FACEBOOK_AD, GOOGLE_AD, PHONE, etc.) | -| `lead.status` | string | Lead status (NEW, CONTACTED, QUALIFIED, etc.) | -| `lead.priority` | string | Manual priority (LOW, NORMAL, HIGH, URGENT) | -| `lead.campaignId` | string | Associated campaign ID | -| `lead.campaignName` | string | Campaign name (resolved) | -| `lead.campaignPlatform` | string | Campaign platform (FACEBOOK, GOOGLE, etc.) | -| `lead.interestedService` | string | Service interest | -| `lead.contactAttempts` | number | Number of contact attempts | -| `lead.ageMinutes` | number | Minutes since lead created | -| `lead.ageDays` | number | Days since lead created | -| `lead.lastContactedMinutes` | number | Minutes since last contact | -| `lead.hasPatient` | boolean | Whether linked to a patient | -| `lead.isDuplicate` | boolean | Whether marked as duplicate | -| `lead.isSpam` | boolean | Whether marked as spam | -| `lead.spamScore` | number | Spam prediction score | -| `lead.leadScore` | number | Lead quality score | - -### Call Facts (`call-facts.provider.ts`) -| Fact Name | Type | Description | -|---|---|---| -| `call.direction` | string | INBOUND or OUTBOUND | -| `call.status` | string | MISSED, COMPLETED, etc. | -| `call.disposition` | string | Call outcome | -| `call.durationSeconds` | number | Call duration | -| `call.callbackStatus` | string | PENDING_CALLBACK, ATTEMPTED, etc. | -| `call.slaElapsedPercent` | number | % of SLA time elapsed (0-100+) | -| `call.slaBreached` | boolean | Whether SLA is breached | -| `call.missedCount` | number | Times this number was missed | -| `call.taskType` | string | missed_call, follow_up, campaign_lead, attempt_2, attempt_3 | - -### Agent Facts (`agent-facts.provider.ts`) -| Fact Name | Type | Description | -|---|---|---| -| `agent.status` | string | READY, ON_CALL, BREAK, OFFLINE | -| `agent.activeCallCount` | number | Current active calls | -| `agent.todayCallCount` | number | Calls handled today | -| `agent.skills` | string[] | Agent skill tags | -| `agent.campaigns` | string[] | Assigned campaign IDs | -| `agent.idleMinutes` | number | Minutes idle | - ---- - -## Scoring System - -The worklist consumer uses scoring rules to rank items. The formula: +## Priority Rules — Scoring System +### Formula ``` finalScore = baseScore × slaMultiplier × campaignMultiplier ``` @@ -230,10 +237,73 @@ campaignWeight (0-10) / 10 × sourceWeight (0-10) / 10 ``` IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35. -### Score Caching -- Base scores cached in Redis on data change events (`rules:scores:{itemId}`) -- SLA multiplier computed at request time (changes every minute) -- Cache TTL: 5 minutes (safety — events should invalidate earlier) +### Priority Config (supervisor-editable) +```typescript +type PriorityConfig = { + taskWeights: Record; + campaignWeights: Record; // campaignId → 0-10 + sourceWeights: Record; // leadSource → 0-10 +}; + +// Default config (from hospital starter template) +const DEFAULT_PRIORITY_CONFIG = { + taskWeights: { + missed_call: { weight: 9, slaMinutes: 720 }, // 12 hours + follow_up: { weight: 8, slaMinutes: 1440 }, // 1 day + campaign_lead: { weight: 7, slaMinutes: 2880 }, // 2 days + attempt_2: { weight: 6, slaMinutes: 1440 }, + attempt_3: { weight: 4, slaMinutes: 2880 }, + }, + campaignWeights: {}, // Empty = no campaign multiplier + sourceWeights: { + WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7, + INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5, + }, +}; +``` + +This config is what the **Priority Rules UI** edits via sliders. Under the hood, each entry generates a json-rules-engine rule. + +--- + +## Priority Rules UI (Supervisor Settings) + +### Layout +Settings page → "Priority" tab with three sections: + +**Section 1: Task Type Weights** +| Task Type | Weight (slider 0-10) | SLA (input) | +|---|---|---| +| Missed Calls | ████████░░ 9 | 12h | +| Follow-ups | ███████░░░ 8 | 1d | +| Campaign Leads | ██████░░░░ 7 | 2d | +| 2nd Attempt | █████░░░░░ 6 | 1d | +| 3rd Attempt | ███░░░░░░░ 4 | 2d | + +**Section 2: Campaign Weights** +Shows existing campaigns with weight sliders. Default 5. +| Campaign | Weight | +|---|---| +| IVF Awareness | ████████░░ 9 | +| Health Checkup | ██████░░░░ 7 | +| Cancer Screening | ███████░░░ 8 | + +**Section 3: Source Weights** +| Source | Weight | +|---|---| +| WhatsApp | ████████░░ 9 | +| Phone | ███████░░░ 8 | +| Facebook Ad | ██████░░░░ 7 | +| ... | ... | + +**Section 4: Live Preview** +Shows the current worklist re-ranked with the configured weights. As supervisor adjusts sliders, preview updates in real-time (client-side computation using the same scoring formula). + +### Components +- Untitled UI Slider (if available) or custom range input +- Untitled UI Toggle for enable/disable per task type +- Untitled UI Tabs for Priority / Automations +- Score badges showing computed values in preview --- @@ -242,6 +312,7 @@ IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35. ### Redis Keys ``` rules:config # JSON array of all Rule objects +rules:priority-config # PriorityConfig JSON (slider values) rules:config:backup_path # Path to JSON backup file rules:scores:{itemId} # Cached base score per worklist item rules:scores:version # Incremented on rule change (invalidates all scores) @@ -249,16 +320,23 @@ rules:eval:log:{ruleId} # Last evaluation result (debug) ``` ### JSON File Backup -On every rule change: +On every rule/config change: 1. Write to Redis -2. Persist `rules:config` to `data/rules-config.json` in sidecar working directory -3. On sidecar startup: if Redis is empty, load from JSON file +2. Persist to `data/rules-config.json` + `data/priority-config.json` in sidecar working directory +3. On sidecar startup: if Redis is empty, load from JSON files --- ## API Endpoints -### Rule CRUD +### Priority Config (used by UI sliders) +``` +GET /api/rules/priority-config # Get current priority config +PUT /api/rules/priority-config # Update priority config (slider values) +POST /api/rules/priority-config/preview # Preview scoring with modified config +``` + +### Rule CRUD (for automation rules) ``` GET /api/rules # List all rules GET /api/rules/:id # Get single rule @@ -272,27 +350,26 @@ POST /api/rules/reorder # Change evaluation order ### Evaluation ``` POST /api/rules/evaluate # Evaluate rules against provided facts -GET /api/rules/explain/:itemId # Why is this item scored this way? ``` ### Templates ``` GET /api/rules/templates # List available rule templates -POST /api/rules/templates/:id/apply # Apply a template (creates rules) +POST /api/rules/templates/:id/apply # Apply a template (creates rules + config) ``` --- -## Worklist Integration (First Consumer) +## Worklist Integration ### Current Flow ``` -GET /api/worklist → returns leads + missed calls + follow-ups → frontend sorts by priority + createdAt +GET /api/worklist → returns { missedCalls, followUps, marketingLeads } → frontend sorts by priority + createdAt ``` ### New Flow ``` -GET /api/worklist → fetch items → RulesEngineService.scoreWorklist(items) → return items with scores → frontend displays by score +GET /api/worklist → fetch 3 arrays → score each item via RulesEngineService → return with scores → frontend sorts by score ``` ### Response Change @@ -321,79 +398,35 @@ Each worklist item gains: ## Hospital Starter Template -Pre-configured rules for a typical hospital. Applied on first setup. +Pre-configured priority config + automation rules for a typical hospital. Applied on first setup via `POST /api/rules/templates/hospital-starter/apply`. -```json -[ - { - "name": "Missed calls — high urgency", - "category": "priority", - "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", - "category": "priority", - "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", - "category": "priority", - "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": "SLA breach — escalate to supervisor", - "category": "escalation", - "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 for {{lead.name}} — no callback attempted", "severity": "critical" } } - }, - { - "name": "Spam leads — deprioritize", - "category": "priority", - "trigger": { "type": "on_request", "request": "worklist" }, - "conditions": { "all": [{ "fact": "lead.spamScore", "operator": "greaterThan", "value": 60 }] }, - "action": { "type": "score", "params": { "weight": -3 } } - } -] -``` - ---- - -## Phase 2 (Future — UI) - -Not in this spec, but the engine is designed for: -- Supervisor settings page with visual rule builder -- Untitled UI components: Slider (weights), Toggle (enable/disable), Select (conditions), Tabs (categories) -- Live preview — change a weight, watch worklist re-rank in real-time -- Rule templates — "Hospital starter pack" one-click apply -- Explainability — agent sees why a lead is ranked where it is +Creates: +1. `PriorityConfig` with default task/campaign/source weights +2. Scoring rules in `rules:config` matching the config +3. One escalation rule stub (SLA breach → supervisor notification) --- ## Scope Boundaries -**In scope (Phase 1):** +**In scope (Phase 1 — Friday):** - `json-rules-engine` integration in sidecar -- Complete rule schema (scoring, assignment, escalation, lifecycle, qualification) -- Fact providers (lead, call, agent) -- Action handlers (score only — others are stubs) +- Rule schema with `ruleType: 'priority' | 'automation'` distinction +- Curated fact providers (lead, call, agent) with computed facts +- Score action handler (full) + assign/escalate/update stubs - Redis storage + JSON backup -- CRUD API endpoints +- PriorityConfig CRUD + preview endpoints +- Rule CRUD API endpoints - Worklist consumer (scoring integration) - Hospital starter template -- Score explainability on API response +- **Priority Rules UI** — supervisor settings page with weight sliders, SLA config, live preview +- Frontend worklist changes (score display, SLA dots, breakdown tooltip) **Out of scope (Phase 2+):** -- Configuration UI -- Assignment action handler (stub only) -- Escalation action handler (stub only) +- Automation Rules UI (condition builder with entity/field selectors) +- Metadata-driven fact discovery from platform API +- Assignment/escalation/update action handlers (stubs in Phase 1) - Event-driven rule evaluation (on_event triggers) - Scheduled rule evaluation (on_schedule triggers) -- Frontend live preview -- Multi-tenant rule isolation (currently single workspace) +- Draft/publish workflow for automation rules +- Multi-tenant rule isolation diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index 1377e81..e12e4bf 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -85,6 +85,11 @@ type WorklistRow = { source: string | null; lastDisposition: string | null; missedCallId: string | null; + // Rules engine scoring (from sidecar) + score?: number; + scoreBreakdown?: { baseScore: number; slaMultiplier: number; campaignMultiplier: number; rulesApplied: string[] }; + slaStatus?: 'low' | 'medium' | 'high' | 'critical'; + slaElapsedPercent?: number; }; const priorityConfig: Record = { @@ -228,7 +233,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea // Remove rows without a phone number — agent can't act on them const actionableRows = rows.filter(r => r.phoneRaw); + // Sort by rules engine score if available, otherwise by priority + createdAt actionableRows.sort((a, b) => { + if (a.score != null && b.score != null) return b.score - a.score; const pa = priorityConfig[a.priority]?.sort ?? 2; const pb = priorityConfig[b.priority]?.sort ?? 2; if (pa !== pb) return pa - pb; @@ -280,6 +287,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect rows = [...rows].sort((a, b) => { switch (sortDescriptor.column) { case 'priority': { + if (a.score != null && b.score != null) return (a.score - b.score) * dir; const pa = priorityConfig[a.priority]?.sort ?? 2; const pb = priorityConfig[b.priority]?.sort ?? 2; return (pa - pb) * dir; @@ -404,7 +412,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
- + @@ -433,9 +441,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect }} > - - {priority.label} - + {row.score != null ? ( +
+ + {row.score.toFixed(1)} +
+ ) : ( + + {priority.label} + + )}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index f711fc3..e4a83ea 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -18,6 +18,7 @@ import { faChartLine, faFileAudio, faPhoneMissed, + faSlidersUp, } from "@fortawesome/pro-duotone-svg-icons"; import { faIcon } from "@/lib/icon-wrapper"; import { useAtom } from "jotai"; @@ -51,6 +52,7 @@ const IconTowerBroadcast = faIcon(faTowerBroadcast); const IconChartLine = faIcon(faChartLine); const IconFileAudio = faIcon(faFileAudio); const IconPhoneMissed = faIcon(faPhoneMissed); +const IconSlidersUp = faIcon(faSlidersUp); type NavSection = { label: string; @@ -76,6 +78,9 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Marketing', items: [ { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, ]}, + { label: 'Configuration', items: [ + { label: 'Rules Engine', href: '/rules', icon: IconSlidersUp }, + ]}, { label: 'Admin', items: [ { label: 'Settings', href: '/settings', icon: IconGear }, ]}, diff --git a/src/components/rules/campaign-weights-panel.tsx b/src/components/rules/campaign-weights-panel.tsx new file mode 100644 index 0000000..7a0e92e --- /dev/null +++ b/src/components/rules/campaign-weights-panel.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { WeightSliderRow } from './weight-slider-row'; +import { CollapsibleSection } from './collapsible-section'; +import { useData } from '@/providers/data-provider'; +import type { PriorityConfig } from '@/lib/scoring'; + +interface CampaignWeightsPanelProps { + config: PriorityConfig; + onChange: (config: PriorityConfig) => void; +} + +export const CampaignWeightsPanel = ({ config, onChange }: CampaignWeightsPanelProps) => { + const { campaigns } = useData(); + + const updateCampaign = (campaignId: string, weight: number) => { + onChange({ + ...config, + campaignWeights: { ...config.campaignWeights, [campaignId]: weight }, + }); + }; + + const badge = useMemo(() => { + if (!campaigns || campaigns.length === 0) return 'No campaigns'; + const configured = campaigns.filter(c => config.campaignWeights[c.id] != null).length; + return `${campaigns.length} campaigns · ${configured} configured`; + }, [campaigns, config.campaignWeights]); + + if (!campaigns || campaigns.length === 0) { + return ( + +

Campaign weights will apply once campaigns are created.

+
+ ); + } + + return ( + +
+ {campaigns.map(campaign => ( + updateCampaign(campaign.id, w)} + /> + ))} +
+
+ ); +}; diff --git a/src/components/rules/collapsible-section.tsx b/src/components/rules/collapsible-section.tsx new file mode 100644 index 0000000..ff1bcac --- /dev/null +++ b/src/components/rules/collapsible-section.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronDown, faChevronRight } from '@fortawesome/pro-duotone-svg-icons'; +import { cx } from '@/utils/cx'; + +interface CollapsibleSectionProps { + title: string; + subtitle?: string; + badge?: string; + badgeColor?: string; + defaultOpen?: boolean; + children: React.ReactNode; +} + +export const CollapsibleSection = ({ + title, + subtitle, + badge, + badgeColor = 'text-brand-secondary', + defaultOpen = true, + children, +}: CollapsibleSectionProps) => { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/components/rules/priority-config-panel.tsx b/src/components/rules/priority-config-panel.tsx new file mode 100644 index 0000000..31f7b6f --- /dev/null +++ b/src/components/rules/priority-config-panel.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { WeightSliderRow } from './weight-slider-row'; +import { CollapsibleSection } from './collapsible-section'; +import { TASK_TYPE_LABELS } from '@/lib/scoring'; +import type { PriorityConfig } from '@/lib/scoring'; + +interface PriorityConfigPanelProps { + config: PriorityConfig; + onChange: (config: PriorityConfig) => void; +} + +const TASK_TYPE_ORDER = ['missed_call', 'follow_up', 'campaign_lead', 'attempt_2', 'attempt_3']; + +export const PriorityConfigPanel = ({ config, onChange }: PriorityConfigPanelProps) => { + const updateTaskWeight = (taskType: string, weight: number) => { + onChange({ + ...config, + taskWeights: { + ...config.taskWeights, + [taskType]: { ...config.taskWeights[taskType], weight }, + }, + }); + }; + + const updateTaskSla = (taskType: string, slaMinutes: number) => { + onChange({ + ...config, + taskWeights: { + ...config.taskWeights, + [taskType]: { ...config.taskWeights[taskType], slaMinutes }, + }, + }); + }; + + const toggleTask = (taskType: string, enabled: boolean) => { + onChange({ + ...config, + taskWeights: { + ...config.taskWeights, + [taskType]: { ...config.taskWeights[taskType], enabled }, + }, + }); + }; + + const badge = useMemo(() => { + const entries = Object.values(config.taskWeights).filter(t => t.enabled); + if (entries.length === 0) return 'All disabled'; + const avg = entries.reduce((s, t) => s + t.weight, 0) / entries.length; + return `${entries.length} active · Avg ${avg.toFixed(1)}`; + }, [config.taskWeights]); + + return ( + +
+ {TASK_TYPE_ORDER.map(taskType => { + const taskConfig = config.taskWeights[taskType]; + if (!taskConfig) return null; + return ( + updateTaskWeight(taskType, w)} + enabled={taskConfig.enabled} + onToggle={(e) => toggleTask(taskType, e)} + slaMinutes={taskConfig.slaMinutes} + onSlaChange={(m) => updateTaskSla(taskType, m)} + showSla + /> + ); + })} +
+
+ ); +}; diff --git a/src/components/rules/rules-ai-assistant.tsx b/src/components/rules/rules-ai-assistant.tsx new file mode 100644 index 0000000..b7cd3e5 --- /dev/null +++ b/src/components/rules/rules-ai-assistant.tsx @@ -0,0 +1,142 @@ +import { useState, useRef, useEffect } from 'react'; +import { useChat } from '@ai-sdk/react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPaperPlaneTop, faSparkles, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons'; +import type { PriorityConfig } from '@/lib/scoring'; +import { cx } from '@/utils/cx'; + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + +interface RulesAiAssistantProps { + config: PriorityConfig; +} + +const QUICK_ACTIONS = [ + { label: 'Explain scoring', prompt: 'How does the priority scoring formula work?' }, + { label: 'Optimize weights', prompt: 'What would you recommend changing to better prioritize urgent cases?' }, + { label: 'SLA best practices', prompt: 'What SLA thresholds are recommended for a hospital call center?' }, + { label: 'Campaign strategy', prompt: 'How should I weight campaigns for IVF vs general health checkups?' }, +]; + +export const RulesAiAssistant = ({ config }: RulesAiAssistantProps) => { + const [expanded, setExpanded] = useState(false); + const messagesEndRef = useRef(null); + const token = localStorage.getItem('helix_access_token') ?? ''; + + const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({ + api: `${API_URL}/api/ai/stream`, + streamProtocol: 'text', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: { + context: { + type: 'rules-engine', + currentConfig: config, + }, + }, + }); + + // Auto-expand when messages arrive + useEffect(() => { + if (messages.length > 0) setExpanded(true); + }, [messages.length]); + + useEffect(() => { + const el = messagesEndRef.current; + if (el?.parentElement) { + el.parentElement.scrollTop = el.parentElement.scrollHeight; + } + }, [messages]); + + return ( +
+ {/* Collapsible header */} + + + {/* Expandable content */} + {expanded && ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+

+ Ask about rule configuration, scoring, or best practices. +

+
+ {QUICK_ACTIONS.map((action) => ( + + ))} +
+
+ )} + {messages.map((msg) => ( +
+
{msg.content}
+
+ ))} + {isLoading && ( +
+
+ + + +
+
+ )} +
+
+ + {/* Input */} +
+ + + +
+ )} +
+ ); +}; diff --git a/src/components/rules/source-weights-panel.tsx b/src/components/rules/source-weights-panel.tsx new file mode 100644 index 0000000..f803e26 --- /dev/null +++ b/src/components/rules/source-weights-panel.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { WeightSliderRow } from './weight-slider-row'; +import { CollapsibleSection } from './collapsible-section'; +import { SOURCE_LABELS } from '@/lib/scoring'; +import type { PriorityConfig } from '@/lib/scoring'; + +interface SourceWeightsPanelProps { + config: PriorityConfig; + onChange: (config: PriorityConfig) => void; +} + +const SOURCE_ORDER = ['WHATSAPP', 'PHONE', 'FACEBOOK_AD', 'GOOGLE_AD', 'INSTAGRAM', 'WEBSITE', 'REFERRAL', 'WALK_IN', 'OTHER']; + +export const SourceWeightsPanel = ({ config, onChange }: SourceWeightsPanelProps) => { + const updateSource = (source: string, weight: number) => { + onChange({ + ...config, + sourceWeights: { ...config.sourceWeights, [source]: weight }, + }); + }; + + const badge = useMemo(() => { + const weights = SOURCE_ORDER.map(s => config.sourceWeights[s] ?? 5); + const avg = weights.reduce((a, b) => a + b, 0) / weights.length; + const highest = SOURCE_ORDER.reduce((best, s) => (config.sourceWeights[s] ?? 5) > (config.sourceWeights[best] ?? 5) ? s : best, SOURCE_ORDER[0]); + return `Avg ${avg.toFixed(1)} · Top: ${SOURCE_LABELS[highest]}`; + }, [config.sourceWeights]); + + return ( + +
+ {SOURCE_ORDER.map(source => ( + updateSource(source, w)} + /> + ))} +
+
+ ); +}; diff --git a/src/components/rules/weight-slider-row.tsx b/src/components/rules/weight-slider-row.tsx new file mode 100644 index 0000000..900a31b --- /dev/null +++ b/src/components/rules/weight-slider-row.tsx @@ -0,0 +1,78 @@ +import { Slider } from '@/components/base/slider/slider'; +import { Select } from '@/components/base/select/select'; +import { Toggle } from '@/components/base/toggle/toggle'; +import { cx } from '@/utils/cx'; + +interface WeightSliderRowProps { + label: string; + weight: number; + onWeightChange: (value: number) => void; + enabled?: boolean; + onToggle?: (enabled: boolean) => void; + slaMinutes?: number; + onSlaChange?: (minutes: number) => void; + showSla?: boolean; + className?: string; +} + +const SLA_OPTIONS = [ + { id: '60', label: '1h' }, + { id: '240', label: '4h' }, + { id: '720', label: '12h' }, + { id: '1440', label: '1d' }, + { id: '2880', label: '2d' }, + { id: '4320', label: '3d' }, +]; + +export const WeightSliderRow = ({ + label, + weight, + onWeightChange, + enabled = true, + onToggle, + slaMinutes, + onSlaChange, + showSla = false, + className, +}: WeightSliderRowProps) => { + return ( +
+ {onToggle && ( + + )} +
+ {label} +
+
+ onWeightChange(v as number)} + isDisabled={!enabled} + formatOptions={{ style: 'decimal', maximumFractionDigits: 0 }} + /> +
+
+ = 8 ? 'text-error-primary' : weight >= 5 ? 'text-warning-primary' : 'text-tertiary')}> + {weight} + +
+ {showSla && slaMinutes != null && onSlaChange && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/rules/worklist-preview.tsx b/src/components/rules/worklist-preview.tsx new file mode 100644 index 0000000..596a70b --- /dev/null +++ b/src/components/rules/worklist-preview.tsx @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import { useData } from '@/providers/data-provider'; +import { scoreAndRankItems } from '@/lib/scoring'; +import type { PriorityConfig, ScoreResult } from '@/lib/scoring'; +import { cx } from '@/utils/cx'; + +interface WorklistPreviewProps { + config: PriorityConfig; +} + +const slaColors: Record = { + low: 'bg-success-solid', + medium: 'bg-warning-solid', + high: 'bg-error-solid', + critical: 'bg-error-solid animate-pulse', +}; + +const slaTextColor: Record = { + low: 'text-success-primary', + medium: 'text-warning-primary', + high: 'text-error-primary', + critical: 'text-error-primary', +}; + +const shortType: Record = { + missed_call: 'Missed', + follow_up: 'Follow-up', + campaign_lead: 'Campaign', + attempt_2: '2nd Att.', + attempt_3: '3rd Att.', +}; + +export const WorklistPreview = ({ config }: WorklistPreviewProps) => { + const { calls, leads, followUps } = useData(); + + const previewItems = useMemo(() => { + const items: any[] = []; + + if (calls) { + calls + .filter((c: any) => c.callStatus === 'MISSED') + .slice(0, 5) + .forEach((c: any) => items.push({ ...c, type: 'missed', _label: c.callerNumber?.primaryPhoneNumber ?? c.name ?? 'Unknown' })); + } + + if (followUps) { + followUps + .slice(0, 5) + .forEach((f: any) => items.push({ ...f, type: 'follow-up', _label: f.name ?? 'Follow-up' })); + } + + if (leads) { + leads + .filter((l: any) => l.campaignId) + .slice(0, 5) + .forEach((l: any) => items.push({ + ...l, + type: 'lead', + _label: l.contactName ? `${l.contactName.firstName ?? ''} ${l.contactName.lastName ?? ''}`.trim() : l.contactPhone?.primaryPhoneNumber ?? 'Unknown', + })); + } + + return items; + }, [calls, leads, followUps]); + + const scored = useMemo(() => scoreAndRankItems(previewItems, config), [previewItems, config]); + + return ( +
+
+

Live Preview

+ {scored.length} items +
+
+ {/* Header */} +
+ + Name + Score +
+ {/* Rows */} +
+ {scored.map((item: any & ScoreResult, index: number) => ( +
+ +
+
+ {item._label ?? item.name ?? 'Item'} +
+
+ + {shortType[item.taskType] ?? item.taskType} + + · + + {item.slaElapsedPercent}% SLA + +
+
+ + {item.score.toFixed(1)} + +
+ ))} + {scored.length === 0 && ( +
+ No worklist items to preview +
+ )} +
+
+
+ ); +}; + diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts new file mode 100644 index 0000000..ce435e6 --- /dev/null +++ b/src/lib/scoring.ts @@ -0,0 +1,128 @@ +// Client-side scoring library — mirrors sidecar computation for live preview + +export type TaskWeightConfig = { + weight: number; + slaMinutes: number; + enabled: boolean; +}; + +export type PriorityConfig = { + taskWeights: Record; + campaignWeights: Record; + sourceWeights: Record; +}; + +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 = { + 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 = { + 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, + }, +}; diff --git a/src/main.tsx b/src/main.tsx index acde424..03d7d5f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -28,6 +28,7 @@ import { CallRecordingsPage } from "@/pages/call-recordings"; import { MissedCallsPage } from "@/pages/missed-calls"; import { ProfilePage } from "@/pages/profile"; import { AccountSettingsPage } from "@/pages/account-settings"; +import { RulesSettingsPage } from "@/pages/rules-settings"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; import { RouteProvider } from "@/providers/router-provider"; @@ -75,6 +76,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> diff --git a/src/pages/rules-settings.tsx b/src/pages/rules-settings.tsx new file mode 100644 index 0000000..6bd297a --- /dev/null +++ b/src/pages/rules-settings.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +import { Button } from '@/components/base/buttons/button'; +import { PriorityConfigPanel } from '@/components/rules/priority-config-panel'; +import { CampaignWeightsPanel } from '@/components/rules/campaign-weights-panel'; +import { SourceWeightsPanel } from '@/components/rules/source-weights-panel'; +import { WorklistPreview } from '@/components/rules/worklist-preview'; +import { RulesAiAssistant } from '@/components/rules/rules-ai-assistant'; +import { DEFAULT_PRIORITY_CONFIG } from '@/lib/scoring'; +import type { PriorityConfig } from '@/lib/scoring'; +const API_BASE = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100'; +const getToken = () => localStorage.getItem('helix_access_token'); + +export const RulesSettingsPage = () => { + const token = getToken(); + const [config, setConfig] = useState(DEFAULT_PRIORITY_CONFIG); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const saveTimerRef = useRef | null>(null); + + useEffect(() => { + const load = async () => { + try { + const res = await fetch(`${API_BASE}/api/rules/priority-config`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (res.ok) { + const data = await res.json(); + setConfig(data); + } + } catch { + // Fallback to defaults + } finally { + setLoading(false); + } + }; + load(); + }, [token]); + + const saveConfig = useCallback(async (newConfig: PriorityConfig) => { + try { + setSaving(true); + await fetch(`${API_BASE}/api/rules/priority-config`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(newConfig), + }); + setDirty(false); + } catch { + // Silent fail + } finally { + setSaving(false); + } + }, [token]); + + const handleConfigChange = (newConfig: PriorityConfig) => { + setConfig(newConfig); + setDirty(true); + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => saveConfig(newConfig), 1000); + }; + + const applyTemplate = async () => { + try { + const res = await fetch(`${API_BASE}/api/rules/templates/hospital-starter/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (res.ok) { + const configRes = await fetch(`${API_BASE}/api/rules/priority-config`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (configRes.ok) { + setConfig(await configRes.json()); + setDirty(false); + } + } + } catch { + // Silent fail + } + }; + + if (loading) { + return ( +
+
Loading rules configuration...
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Rules Engine

+

Configure how leads are prioritized and routed in the worklist

+
+
+ {dirty && {saving ? 'Saving...' : 'Unsaved changes'}} + +
+
+ + {/* Tabs + Content — fills remaining height */} +
+ +
+ + {(item) => } + +
+ + +
+ {/* Left — config panels, scrollable */} +
+ + + +
+ + {/* Right — preview + collapsible AI */} +
+ {/* Preview — takes available space */} +
+ +
+ + {/* AI Assistant — collapsible at bottom */} + +
+
+
+ + +
+
+

Automation Rules

+

+ Configure rules that automatically assign leads, escalate SLA breaches, and manage lead lifecycle. + This feature is coming soon. +

+
+
+
+
+
+
+ ); +};