feat: rules engine — priority config UI + worklist scoring

- Rules engine spec v2 (priority vs automation rules distinction)
- Priority Rules settings page with weight sliders, SLA config, campaign/source weights
- Collapsible config sections with dynamic headers
- Live worklist preview panel with client-side scoring
- AI assistant panel (collapsible) with rules-engine-specific system prompt
- Worklist panel: score display with SLA status dots, sort by score
- Scoring library (scoring.ts) for client-side preview computation
- Sidebar: Rules Engine nav item under Configuration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 16:51:29 +05:30
parent 462601d0dc
commit b90740e009
14 changed files with 1680 additions and 514 deletions

View File

@@ -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<string, any>;
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<string, { weight: number; slaMinutes: number }>;
campaignWeights: Record<string, number>; // campaignId → 0-10
sourceWeights: Record<string, number>; // 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