# Rules Engine — Design Spec (v2)

**Date**: 2026-03-31 (revised 2026-04-01)
**Status**: Approved
**Phase**: 1 (Engine + Storage + API + Priority Rules UI + Worklist Integration)

---

## Overview

A configurable rules engine that governs how leads flow through the hospital's call center — which leads get called first, which agent handles them, when to escalate, and when to mark them lost. Each hospital defines its own rules. No code changes needed to change behavior.

**Product pitch**: "Your hospital defines the rules, the call center follows them automatically."

---

## 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.

```
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 + config
├── rules-storage.service.ts        # Redis (hot) + JSON file (backup)
├── types/
│   ├── 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 (+ computed: ageMinutes, slaElapsed)
│   └── agent-facts.provider.ts     # Agent availability facts
├── actions/
│   ├── score.action.ts             # Priority scoring action
│   ├── 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/
    └── hospital-starter.json       # Pre-built rule set for new hospitals
```

### Dependencies
- `json-rules-engine` (npm) — rule evaluation
- Redis — active rule storage, score cache
- Platform GraphQL — fact data (leads, calls, campaigns, agents)
- No imports from other sidecar modules except via constructor injection

### Communication
- Own Redis namespace: `rules:*`
- Own route prefix: `/api/rules/*`
- Other modules call `RulesEngineService.evaluate()` — they don't import internals

---

## 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
    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)

    trigger: RuleTrigger;                // When to evaluate
    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;
        category: RuleCategory;
        tags?: string[];
    };
};

type RuleTrigger =
    | { type: 'on_request'; request: 'worklist' | 'assignment' }
    | { type: 'on_event'; event: string }
    | { type: 'on_schedule'; interval: string }
    | { type: 'always' };

type RuleCategory =
    | '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 | RuleConditionGroup)[];
    any?: (RuleCondition | RuleConditionGroup)[];
};

type RuleCondition = {
    fact: string;                        // Fact name
    operator: RuleOperator;
    value: any;
    path?: string;                       // JSON path for nested facts
};

type RuleOperator =
    | 'equal' | 'notEqual'
    | 'greaterThan' | 'greaterThanInclusive'
    | 'lessThan' | 'lessThanInclusive'
    | 'in' | 'notIn'
    | 'contains' | 'doesNotContain'
    | 'exists' | 'doesNotExist';

type RuleAction = {
    type: RuleActionType;
    params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
};

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 (Automation Rules — stub)
type AssignActionParams = {
    agentId?: string;
    agentPool?: string[];
    strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
};

// Escalate action params (Automation Rules — stub)
type EscalateActionParams = {
    channel: 'toast' | 'notification' | 'sms' | 'email';
    recipients: 'supervisor' | 'agent' | string[];
    message: string;
    severity: 'warning' | 'critical';
};

// Update action params (Automation Rules — stub)
type UpdateActionParams = {
    entity: string;
    field: string;
    value: any;
};
```

---

## Priority Rules — Scoring System

### Formula
```
finalScore = baseScore × slaMultiplier × campaignMultiplier
```

### Base Score
Determined by the rule's `weight` param (0-10). Multiple rules can fire for the same item — scores are **summed**.

### SLA Multiplier (time-sensitive, computed at request time)
```
if slaElapsed <= 100%:  multiplier = (slaElapsed / 100) ^ 1.6
if slaElapsed > 100%:   multiplier = 1.0 + (excess × 0.05)
```
Non-linear curve — urgency accelerates as deadline approaches. Continues increasing past breach.

### Campaign Multiplier
```
campaignWeight (0-10) / 10 × sourceWeight (0-10) / 10
```
IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.

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

---

## Storage

### 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)
rules:eval:log:{ruleId}         # Last evaluation result (debug)
```

### JSON File Backup
On every rule/config change:
1. Write to Redis
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

### 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
POST   /api/rules                    # Create rule
PUT    /api/rules/:id                # Update rule
DELETE /api/rules/:id                # Delete rule
PATCH  /api/rules/:id/toggle         # Enable/disable
POST   /api/rules/reorder            # Change evaluation order
```

### Evaluation
```
POST   /api/rules/evaluate           # Evaluate rules against provided facts
```

### Templates
```
GET    /api/rules/templates           # List available rule templates
POST   /api/rules/templates/:id/apply # Apply a template (creates rules + config)
```

---

## Worklist Integration

### Current Flow
```
GET /api/worklist → returns { missedCalls, followUps, marketingLeads } → frontend sorts by priority + createdAt
```

### New Flow
```
GET /api/worklist → fetch 3 arrays → score each item via RulesEngineService → return with scores → frontend sorts by score
```

### Response Change
Each worklist item gains:
```typescript
{
    ...existingFields,
    score: number;              // Computed priority score
    scoreBreakdown: {           // Explainability
        baseScore: number;
        slaMultiplier: number;
        campaignMultiplier: number;
        rulesApplied: string[]; // Rule names that fired
    };
    slaStatus: 'low' | 'medium' | 'high' | 'critical';
    slaElapsedPercent: number;
}
```

### Frontend Changes
- Worklist sorts by `score` descending instead of hardcoded priority
- SLA status dot (green/amber/red/dark-red) replaces priority badge
- Tooltip on score shows breakdown ("IVF campaign ×0.81, Missed call weight 9, SLA 72% elapsed")

---

## Hospital Starter Template

Pre-configured priority config + automation rules for a typical hospital. Applied on first setup via `POST /api/rules/templates/hospital-starter/apply`.

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 — Friday):**
- `json-rules-engine` integration in sidecar
- 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
- PriorityConfig CRUD + preview endpoints
- Rule CRUD API endpoints
- Worklist consumer (scoring integration)
- Hospital starter template
- **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+):**
- 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)
- Draft/publish workflow for automation rules
- Multi-tenant rule isolation
