mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
# Rules Engine — Design Spec
|
# Rules Engine — Design Spec (v2)
|
||||||
|
|
||||||
**Date**: 2026-03-31
|
**Date**: 2026-03-31 (revised 2026-04-01)
|
||||||
**Status**: Draft
|
**Status**: Approved
|
||||||
**Phase**: 1 (Engine + Storage + API + Worklist Integration)
|
**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
|
## Architecture
|
||||||
|
|
||||||
Self-contained NestJS module inside helix-engage-server (sidecar). Designed for extraction into a standalone microservice when needed.
|
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/
|
helix-engage-server/src/rules-engine/
|
||||||
├── rules-engine.module.ts # NestJS module (self-contained)
|
├── rules-engine.module.ts # NestJS module (self-contained)
|
||||||
├── rules-engine.service.ts # Core: json-rules-engine wrapper
|
├── 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)
|
├── rules-storage.service.ts # Redis (hot) + JSON file (backup)
|
||||||
├── types/
|
├── types/
|
||||||
│ ├── rule.types.ts # Rule schema
|
│ ├── rule.types.ts # Rule schema (priority + automation)
|
||||||
│ ├── fact.types.ts # Fact definitions
|
│ ├── fact.types.ts # Fact definitions + computed facts
|
||||||
│ └── action.types.ts # Action definitions
|
│ └── action.types.ts # Action handler interface
|
||||||
├── facts/
|
├── facts/
|
||||||
│ ├── lead-facts.provider.ts # Lead/campaign data 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
|
│ └── agent-facts.provider.ts # Agent availability facts
|
||||||
├── actions/
|
├── actions/
|
||||||
│ ├── score.action.ts # Priority scoring action
|
│ ├── score.action.ts # Priority scoring action
|
||||||
│ ├── assign.action.ts # Lead-to-agent assignment
|
│ ├── assign.action.ts # Lead-to-agent assignment (stub)
|
||||||
│ ├── escalate.action.ts # SLA breach alerts
|
│ ├── escalate.action.ts # SLA breach alerts (stub)
|
||||||
│ ├── update.action.ts # Update entity field
|
│ └── update.action.ts # Update entity field (stub)
|
||||||
│ └── notify.action.ts # Send notification
|
|
||||||
├── consumers/
|
├── consumers/
|
||||||
│ └── worklist.consumer.ts # Applies scoring rules to worklist
|
│ └── worklist.consumer.ts # Applies scoring rules to worklist
|
||||||
└── templates/
|
└── 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
|
## Rule Schema
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
type RuleType = 'priority' | 'automation';
|
||||||
|
|
||||||
type Rule = {
|
type Rule = {
|
||||||
id: string; // UUID
|
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
|
description?: string; // BA-friendly explanation
|
||||||
enabled: boolean; // Toggle on/off without deleting
|
enabled: boolean; // Toggle on/off without deleting
|
||||||
priority: number; // Evaluation order (lower = first)
|
priority: number; // Evaluation order (lower = first)
|
||||||
@@ -71,39 +130,42 @@ type Rule = {
|
|||||||
conditions: RuleConditionGroup; // What to check
|
conditions: RuleConditionGroup; // What to check
|
||||||
action: RuleAction; // What to do
|
action: RuleAction; // What to do
|
||||||
|
|
||||||
|
// Automation rules only
|
||||||
|
status?: 'draft' | 'published'; // Draft/publish workflow
|
||||||
|
|
||||||
metadata: {
|
metadata: {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdBy: string; // User who created
|
createdBy: string;
|
||||||
category: RuleCategory; // For UI grouping
|
category: RuleCategory;
|
||||||
tags?: string[]; // Optional tags for filtering
|
tags?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuleTrigger =
|
type RuleTrigger =
|
||||||
| { type: 'on_request'; request: 'worklist' | 'assignment' }
|
| { type: 'on_request'; request: 'worklist' | 'assignment' }
|
||||||
| { type: 'on_event'; event: 'lead.created' | 'lead.updated' | 'call.created' | 'call.ended' | 'call.missed' | 'disposition.submitted' }
|
| { type: 'on_event'; event: string }
|
||||||
| { type: 'on_schedule'; interval: string } // cron expression or "5m", "1h"
|
| { type: 'on_schedule'; interval: string }
|
||||||
| { type: 'always' }; // evaluated in all contexts
|
| { type: 'always' };
|
||||||
|
|
||||||
type RuleCategory =
|
type RuleCategory =
|
||||||
| 'priority' // Worklist scoring
|
| 'priority' // Worklist scoring (Priority Rules)
|
||||||
| 'assignment' // Lead/call routing to agent
|
| 'assignment' // Lead/call routing to agent (Automation)
|
||||||
| 'escalation' // SLA breach handling
|
| 'escalation' // SLA breach handling (Automation)
|
||||||
| 'lifecycle' // Lead status transitions
|
| 'lifecycle' // Lead status transitions (Automation)
|
||||||
| 'qualification'; // Lead quality scoring
|
| 'qualification'; // Lead quality scoring (Automation)
|
||||||
|
|
||||||
type RuleConditionGroup = {
|
type RuleConditionGroup = {
|
||||||
all?: RuleCondition[]; // AND
|
all?: (RuleCondition | RuleConditionGroup)[];
|
||||||
any?: RuleCondition[]; // OR
|
any?: (RuleCondition | RuleConditionGroup)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuleCondition = {
|
type RuleCondition = {
|
||||||
fact: string; // Fact name (see Fact Registry below)
|
fact: string; // Fact name
|
||||||
operator: RuleOperator;
|
operator: RuleOperator;
|
||||||
value: any;
|
value: any;
|
||||||
path?: string; // JSON path for nested facts
|
path?: string; // JSON path for nested facts
|
||||||
} | RuleConditionGroup; // Nested group for complex logic
|
};
|
||||||
|
|
||||||
type RuleOperator =
|
type RuleOperator =
|
||||||
| 'equal' | 'notEqual'
|
| 'equal' | 'notEqual'
|
||||||
@@ -114,102 +176,47 @@ type RuleOperator =
|
|||||||
| 'exists' | 'doesNotExist';
|
| 'exists' | 'doesNotExist';
|
||||||
|
|
||||||
type RuleAction = {
|
type RuleAction = {
|
||||||
type: 'score' | 'assign' | 'escalate' | 'update' | 'notify';
|
type: RuleActionType;
|
||||||
params: Record<string, any>;
|
params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Score action params
|
type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify';
|
||||||
|
|
||||||
|
// Score action params (Priority Rules)
|
||||||
type ScoreActionParams = {
|
type ScoreActionParams = {
|
||||||
weight: number; // 0-10 base weight
|
weight: number; // 0-10 base weight
|
||||||
slaMultiplier?: boolean; // Apply SLA urgency curve
|
slaMultiplier?: boolean; // Apply SLA urgency curve
|
||||||
campaignMultiplier?: boolean; // Apply campaign weight
|
campaignMultiplier?: boolean; // Apply campaign weight
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assign action params
|
// Assign action params (Automation Rules — stub)
|
||||||
type AssignActionParams = {
|
type AssignActionParams = {
|
||||||
agentId?: string; // Specific agent
|
agentId?: string;
|
||||||
agentPool?: string[]; // Round-robin from pool
|
agentPool?: string[];
|
||||||
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
|
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Escalate action params
|
// Escalate action params (Automation Rules — stub)
|
||||||
type EscalateActionParams = {
|
type EscalateActionParams = {
|
||||||
channel: 'toast' | 'notification' | 'sms' | 'email';
|
channel: 'toast' | 'notification' | 'sms' | 'email';
|
||||||
recipients: 'supervisor' | 'agent' | string[]; // Specific user IDs
|
recipients: 'supervisor' | 'agent' | string[];
|
||||||
message: string; // Template with {{variables}}
|
message: string;
|
||||||
severity: 'warning' | 'critical';
|
severity: 'warning' | 'critical';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update action params
|
// Update action params (Automation Rules — stub)
|
||||||
type UpdateActionParams = {
|
type UpdateActionParams = {
|
||||||
entity: 'lead' | 'call' | 'followUp';
|
entity: string;
|
||||||
field: string;
|
field: string;
|
||||||
value: any;
|
value: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notify action params
|
|
||||||
type NotifyActionParams = {
|
|
||||||
channel: 'toast' | 'bell' | 'sms';
|
|
||||||
message: string;
|
|
||||||
target: 'agent' | 'supervisor' | 'all';
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fact Registry
|
## Priority Rules — Scoring System
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
|
### Formula
|
||||||
```
|
```
|
||||||
finalScore = baseScore × slaMultiplier × campaignMultiplier
|
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.
|
IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.
|
||||||
|
|
||||||
### Score Caching
|
### Priority Config (supervisor-editable)
|
||||||
- Base scores cached in Redis on data change events (`rules:scores:{itemId}`)
|
```typescript
|
||||||
- SLA multiplier computed at request time (changes every minute)
|
type PriorityConfig = {
|
||||||
- Cache TTL: 5 minutes (safety — events should invalidate earlier)
|
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
|
### Redis Keys
|
||||||
```
|
```
|
||||||
rules:config # JSON array of all Rule objects
|
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:config:backup_path # Path to JSON backup file
|
||||||
rules:scores:{itemId} # Cached base score per worklist item
|
rules:scores:{itemId} # Cached base score per worklist item
|
||||||
rules:scores:version # Incremented on rule change (invalidates all scores)
|
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
|
### JSON File Backup
|
||||||
On every rule change:
|
On every rule/config change:
|
||||||
1. Write to Redis
|
1. Write to Redis
|
||||||
2. Persist `rules:config` to `data/rules-config.json` in sidecar working directory
|
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 file
|
3. On sidecar startup: if Redis is empty, load from JSON files
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## 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 # List all rules
|
||||||
GET /api/rules/:id # Get single rule
|
GET /api/rules/:id # Get single rule
|
||||||
@@ -272,27 +350,26 @@ POST /api/rules/reorder # Change evaluation order
|
|||||||
### Evaluation
|
### Evaluation
|
||||||
```
|
```
|
||||||
POST /api/rules/evaluate # Evaluate rules against provided facts
|
POST /api/rules/evaluate # Evaluate rules against provided facts
|
||||||
GET /api/rules/explain/:itemId # Why is this item scored this way?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Templates
|
### Templates
|
||||||
```
|
```
|
||||||
GET /api/rules/templates # List available rule 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
|
### 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
|
### 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
|
### Response Change
|
||||||
@@ -321,79 +398,35 @@ Each worklist item gains:
|
|||||||
|
|
||||||
## Hospital Starter Template
|
## 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
|
Creates:
|
||||||
[
|
1. `PriorityConfig` with default task/campaign/source weights
|
||||||
{
|
2. Scoring rules in `rules:config` matching the config
|
||||||
"name": "Missed calls — high urgency",
|
3. One escalation rule stub (SLA breach → supervisor notification)
|
||||||
"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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scope Boundaries
|
## Scope Boundaries
|
||||||
|
|
||||||
**In scope (Phase 1):**
|
**In scope (Phase 1 — Friday):**
|
||||||
- `json-rules-engine` integration in sidecar
|
- `json-rules-engine` integration in sidecar
|
||||||
- Complete rule schema (scoring, assignment, escalation, lifecycle, qualification)
|
- Rule schema with `ruleType: 'priority' | 'automation'` distinction
|
||||||
- Fact providers (lead, call, agent)
|
- Curated fact providers (lead, call, agent) with computed facts
|
||||||
- Action handlers (score only — others are stubs)
|
- Score action handler (full) + assign/escalate/update stubs
|
||||||
- Redis storage + JSON backup
|
- Redis storage + JSON backup
|
||||||
- CRUD API endpoints
|
- PriorityConfig CRUD + preview endpoints
|
||||||
|
- Rule CRUD API endpoints
|
||||||
- Worklist consumer (scoring integration)
|
- Worklist consumer (scoring integration)
|
||||||
- Hospital starter template
|
- 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+):**
|
**Out of scope (Phase 2+):**
|
||||||
- Configuration UI
|
- Automation Rules UI (condition builder with entity/field selectors)
|
||||||
- Assignment action handler (stub only)
|
- Metadata-driven fact discovery from platform API
|
||||||
- Escalation action handler (stub only)
|
- Assignment/escalation/update action handlers (stubs in Phase 1)
|
||||||
- Event-driven rule evaluation (on_event triggers)
|
- Event-driven rule evaluation (on_event triggers)
|
||||||
- Scheduled rule evaluation (on_schedule triggers)
|
- Scheduled rule evaluation (on_schedule triggers)
|
||||||
- Frontend live preview
|
- Draft/publish workflow for automation rules
|
||||||
- Multi-tenant rule isolation (currently single workspace)
|
- Multi-tenant rule isolation
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ type WorklistRow = {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
lastDisposition: string | null;
|
lastDisposition: string | null;
|
||||||
missedCallId: 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<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
||||||
@@ -228,7 +233,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
// Remove rows without a phone number — agent can't act on them
|
// Remove rows without a phone number — agent can't act on them
|
||||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
const actionableRows = rows.filter(r => r.phoneRaw);
|
||||||
|
|
||||||
|
// Sort by rules engine score if available, otherwise by priority + createdAt
|
||||||
actionableRows.sort((a, b) => {
|
actionableRows.sort((a, b) => {
|
||||||
|
if (a.score != null && b.score != null) return b.score - a.score;
|
||||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
if (pa !== pb) return pa - pb;
|
if (pa !== pb) return pa - pb;
|
||||||
@@ -280,6 +287,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
rows = [...rows].sort((a, b) => {
|
rows = [...rows].sort((a, b) => {
|
||||||
switch (sortDescriptor.column) {
|
switch (sortDescriptor.column) {
|
||||||
case 'priority': {
|
case 'priority': {
|
||||||
|
if (a.score != null && b.score != null) return (a.score - b.score) * dir;
|
||||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
return (pa - pb) * dir;
|
return (pa - pb) * dir;
|
||||||
@@ -404,7 +412,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
|
||||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head id="priority" label="PRIORITY" className="w-20" isRowHeader allowsSorting />
|
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
|
||||||
<Table.Head id="name" label="PATIENT" allowsSorting />
|
<Table.Head id="name" label="PATIENT" allowsSorting />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
||||||
@@ -433,9 +441,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
{row.score != null ? (
|
||||||
|
<div className="flex items-center gap-2" title={row.scoreBreakdown ? `${row.scoreBreakdown.rulesApplied.join(', ')}\nSLA: ×${row.scoreBreakdown.slaMultiplier}\nCampaign: ×${row.scoreBreakdown.campaignMultiplier}` : undefined}>
|
||||||
|
<span className={cx(
|
||||||
|
'size-2.5 rounded-full shrink-0',
|
||||||
|
row.slaStatus === 'low' && 'bg-success-solid',
|
||||||
|
row.slaStatus === 'medium' && 'bg-warning-solid',
|
||||||
|
row.slaStatus === 'high' && 'bg-error-solid',
|
||||||
|
row.slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-xs font-bold tabular-nums text-primary">{row.score.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Badge size="sm" color={priority.color} type="pill-color">
|
<Badge size="sm" color={priority.color} type="pill-color">
|
||||||
{priority.label}
|
{priority.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
faChartLine,
|
faChartLine,
|
||||||
faFileAudio,
|
faFileAudio,
|
||||||
faPhoneMissed,
|
faPhoneMissed,
|
||||||
|
faSlidersUp,
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
} from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
import { faIcon } from "@/lib/icon-wrapper";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -51,6 +52,7 @@ const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
|||||||
const IconChartLine = faIcon(faChartLine);
|
const IconChartLine = faIcon(faChartLine);
|
||||||
const IconFileAudio = faIcon(faFileAudio);
|
const IconFileAudio = faIcon(faFileAudio);
|
||||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||||
|
const IconSlidersUp = faIcon(faSlidersUp);
|
||||||
|
|
||||||
type NavSection = {
|
type NavSection = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -76,6 +78,9 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Marketing', items: [
|
{ label: 'Marketing', items: [
|
||||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
]},
|
]},
|
||||||
|
{ label: 'Configuration', items: [
|
||||||
|
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
|
||||||
|
]},
|
||||||
{ label: 'Admin', items: [
|
{ label: 'Admin', items: [
|
||||||
{ label: 'Settings', href: '/settings', icon: IconGear },
|
{ label: 'Settings', href: '/settings', icon: IconGear },
|
||||||
]},
|
]},
|
||||||
|
|||||||
55
src/components/rules/campaign-weights-panel.tsx
Normal file
55
src/components/rules/campaign-weights-panel.tsx
Normal file
@@ -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 (
|
||||||
|
<CollapsibleSection title="Campaign Weights" badge="No campaigns" defaultOpen={false}>
|
||||||
|
<p className="text-xs text-tertiary py-2">Campaign weights will apply once campaigns are created.</p>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Campaign Weights"
|
||||||
|
subtitle="Higher-weighted campaigns get their leads called first"
|
||||||
|
badge={badge}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-tertiary">
|
||||||
|
{campaigns.map(campaign => (
|
||||||
|
<WeightSliderRow
|
||||||
|
key={campaign.id}
|
||||||
|
label={campaign.campaignName ?? 'Untitled Campaign'}
|
||||||
|
weight={config.campaignWeights[campaign.id] ?? 5}
|
||||||
|
onWeightChange={(w) => updateCampaign(campaign.id, w)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
src/components/rules/collapsible-section.tsx
Normal file
56
src/components/rules/collapsible-section.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex w-full items-center justify-between px-5 py-3.5 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={open ? faChevronDown : faChevronRight}
|
||||||
|
className="size-3 text-fg-quaternary"
|
||||||
|
/>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">{title}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className={cx('text-xs font-medium tabular-nums', badgeColor)}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && <p className="text-xs text-tertiary mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-secondary px-5 pb-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
src/components/rules/priority-config-panel.tsx
Normal file
80
src/components/rules/priority-config-panel.tsx
Normal file
@@ -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 (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Task Type Weights"
|
||||||
|
subtitle="Higher weight = called first"
|
||||||
|
badge={badge}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-tertiary">
|
||||||
|
{TASK_TYPE_ORDER.map(taskType => {
|
||||||
|
const taskConfig = config.taskWeights[taskType];
|
||||||
|
if (!taskConfig) return null;
|
||||||
|
return (
|
||||||
|
<WeightSliderRow
|
||||||
|
key={taskType}
|
||||||
|
label={TASK_TYPE_LABELS[taskType] ?? taskType}
|
||||||
|
weight={taskConfig.weight}
|
||||||
|
onWeightChange={(w) => updateTaskWeight(taskType, w)}
|
||||||
|
enabled={taskConfig.enabled}
|
||||||
|
onToggle={(e) => toggleTask(taskType, e)}
|
||||||
|
slaMinutes={taskConfig.slaMinutes}
|
||||||
|
onSlaChange={(m) => updateTaskSla(taskType, m)}
|
||||||
|
showSla
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
142
src/components/rules/rules-ai-assistant.tsx
Normal file
142
src/components/rules/rules-ai-assistant.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className={cx('flex flex-col border-t border-secondary', expanded ? 'flex-1 min-h-0' : '')}>
|
||||||
|
{/* Collapsible header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center justify-between px-4 py-3 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold text-primary">AI Assistant</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<span className="rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-medium text-brand-secondary">
|
||||||
|
{messages.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={expanded ? faChevronDown : faChevronUp}
|
||||||
|
className="size-3 text-fg-quaternary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expandable content */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 px-4 pb-3">
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 space-y-2 mb-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<p className="text-[11px] text-tertiary mb-2">
|
||||||
|
Ask about rule configuration, scoring, or best practices.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-1">
|
||||||
|
{QUICK_ACTIONS.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.label}
|
||||||
|
onClick={() => append({ role: 'user', content: action.prompt })}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-md border border-secondary bg-primary px-2 py-1 text-[10px] font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={msg.role === 'user'
|
||||||
|
? 'ml-8 rounded-lg bg-brand-solid px-2.5 py-1.5 text-[11px] text-white'
|
||||||
|
: 'mr-4 rounded-lg bg-primary px-2.5 py-1.5 text-[11px] text-primary leading-relaxed'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="mr-4 rounded-lg bg-primary px-2.5 py-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Ask about rules..."
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 rounded-lg border border-secondary bg-primary px-3 py-2 text-xs text-primary placeholder:text-placeholder focus:border-brand focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !input.trim()}
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/rules/source-weights-panel.tsx
Normal file
48
src/components/rules/source-weights-panel.tsx
Normal file
@@ -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 (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Source Weights"
|
||||||
|
subtitle="Leads from higher-weighted sources get priority"
|
||||||
|
badge={badge}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-tertiary">
|
||||||
|
{SOURCE_ORDER.map(source => (
|
||||||
|
<WeightSliderRow
|
||||||
|
key={source}
|
||||||
|
label={SOURCE_LABELS[source] ?? source}
|
||||||
|
weight={config.sourceWeights[source] ?? 5}
|
||||||
|
onWeightChange={(w) => updateSource(source, w)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/components/rules/weight-slider-row.tsx
Normal file
78
src/components/rules/weight-slider-row.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={cx('flex items-center gap-4 py-3', !enabled && 'opacity-40', className)}>
|
||||||
|
{onToggle && (
|
||||||
|
<Toggle size="sm" isSelected={enabled} onChange={onToggle} />
|
||||||
|
)}
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<span className="text-sm font-medium text-primary">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[140px]">
|
||||||
|
<Slider
|
||||||
|
minValue={0}
|
||||||
|
maxValue={10}
|
||||||
|
step={1}
|
||||||
|
value={weight}
|
||||||
|
onChange={(v) => onWeightChange(v as number)}
|
||||||
|
isDisabled={!enabled}
|
||||||
|
formatOptions={{ style: 'decimal', maximumFractionDigits: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 text-center">
|
||||||
|
<span className={cx('text-sm font-bold tabular-nums', weight >= 8 ? 'text-error-primary' : weight >= 5 ? 'text-warning-primary' : 'text-tertiary')}>
|
||||||
|
{weight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showSla && slaMinutes != null && onSlaChange && (
|
||||||
|
<div className="w-20 shrink-0">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="SLA"
|
||||||
|
items={SLA_OPTIONS}
|
||||||
|
selectedKey={String(slaMinutes)}
|
||||||
|
onSelectionChange={(key) => onSlaChange(Number(key))}
|
||||||
|
isDisabled={!enabled}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
src/components/rules/worklist-preview.tsx
Normal file
118
src/components/rules/worklist-preview.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
low: 'bg-success-solid',
|
||||||
|
medium: 'bg-warning-solid',
|
||||||
|
high: 'bg-error-solid',
|
||||||
|
critical: 'bg-error-solid animate-pulse',
|
||||||
|
};
|
||||||
|
|
||||||
|
const slaTextColor: Record<string, string> = {
|
||||||
|
low: 'text-success-primary',
|
||||||
|
medium: 'text-warning-primary',
|
||||||
|
high: 'text-error-primary',
|
||||||
|
critical: 'text-error-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortType: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Live Preview</h3>
|
||||||
|
<span className="text-xs text-tertiary">{scored.length} items</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-secondary overflow-hidden bg-primary">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-secondary text-xs font-medium text-tertiary border-b border-secondary">
|
||||||
|
<span className="w-3" />
|
||||||
|
<span className="flex-1 min-w-0">Name</span>
|
||||||
|
<span className="w-16 text-right">Score</span>
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="divide-y divide-tertiary overflow-y-auto max-h-[320px]">
|
||||||
|
{scored.map((item: any & ScoreResult, index: number) => (
|
||||||
|
<div
|
||||||
|
key={item.id ?? index}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<span className={cx('size-2 rounded-full shrink-0', slaColors[item.slaStatus])} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-primary truncate">
|
||||||
|
{item._label ?? item.name ?? 'Item'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] text-tertiary">
|
||||||
|
{shortType[item.taskType] ?? item.taskType}
|
||||||
|
</span>
|
||||||
|
<span className="text-quaternary">·</span>
|
||||||
|
<span className={cx('text-[10px] font-medium', slaTextColor[item.slaStatus])}>
|
||||||
|
{item.slaElapsedPercent}% SLA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-16 text-right text-sm font-bold tabular-nums text-primary shrink-0">
|
||||||
|
{item.score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{scored.length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-tertiary">
|
||||||
|
No worklist items to preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
128
src/lib/scoring.ts
Normal file
128
src/lib/scoring.ts
Normal file
@@ -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<string, TaskWeightConfig>;
|
||||||
|
campaignWeights: Record<string, number>;
|
||||||
|
sourceWeights: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreResult = {
|
||||||
|
score: number;
|
||||||
|
baseScore: number;
|
||||||
|
slaMultiplier: number;
|
||||||
|
campaignMultiplier: number;
|
||||||
|
slaElapsedPercent: number;
|
||||||
|
slaStatus: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
taskType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeSlaMultiplier(slaElapsedPercent: number): number {
|
||||||
|
const elapsed = slaElapsedPercent / 100;
|
||||||
|
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
|
||||||
|
return Math.pow(elapsed, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
|
||||||
|
if (slaElapsedPercent > 100) return 'critical';
|
||||||
|
if (slaElapsedPercent >= 80) return 'high';
|
||||||
|
if (slaElapsedPercent >= 50) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferTaskType(item: any): string {
|
||||||
|
if (item.callStatus === 'MISSED' || item.type === 'missed') return 'missed_call';
|
||||||
|
if (item.followUpType === 'CALLBACK' || item.type === 'callback' || item.type === 'follow-up') return 'follow_up';
|
||||||
|
if (item.contactAttempts >= 3) return 'attempt_3';
|
||||||
|
if (item.contactAttempts >= 2) return 'attempt_2';
|
||||||
|
return 'campaign_lead';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreItem(item: any, config: PriorityConfig): ScoreResult {
|
||||||
|
const taskType = inferTaskType(item);
|
||||||
|
const taskConfig = config.taskWeights[taskType];
|
||||||
|
|
||||||
|
if (!taskConfig?.enabled) {
|
||||||
|
return { score: 0, baseScore: 0, slaMultiplier: 1, campaignMultiplier: 1, slaElapsedPercent: 0, slaStatus: 'low', taskType };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = item.createdAt ? new Date(item.createdAt).getTime() : Date.now();
|
||||||
|
const elapsedMinutes = (Date.now() - createdAt) / 60000;
|
||||||
|
const slaElapsedPercent = Math.round((elapsedMinutes / taskConfig.slaMinutes) * 100);
|
||||||
|
|
||||||
|
const baseScore = taskConfig.weight;
|
||||||
|
const slaMultiplier = computeSlaMultiplier(slaElapsedPercent);
|
||||||
|
|
||||||
|
let campaignMultiplier = 1;
|
||||||
|
if (item.campaignId && config.campaignWeights[item.campaignId]) {
|
||||||
|
const cw = (config.campaignWeights[item.campaignId] ?? 5) / 10;
|
||||||
|
const source = item.leadSource ?? item.source ?? 'OTHER';
|
||||||
|
const sw = (config.sourceWeights[source] ?? 5) / 10;
|
||||||
|
campaignMultiplier = cw * sw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.round(baseScore * slaMultiplier * campaignMultiplier * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
baseScore,
|
||||||
|
slaMultiplier: Math.round(slaMultiplier * 100) / 100,
|
||||||
|
campaignMultiplier: Math.round(campaignMultiplier * 100) / 100,
|
||||||
|
slaElapsedPercent,
|
||||||
|
slaStatus: computeSlaStatus(slaElapsedPercent),
|
||||||
|
taskType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreAndRankItems(items: any[], config: PriorityConfig): (any & ScoreResult)[] {
|
||||||
|
return items
|
||||||
|
.map(item => ({ ...item, ...scoreItem(item, config) }))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TASK_TYPE_LABELS: Record<string, string> = {
|
||||||
|
missed_call: 'Missed Calls',
|
||||||
|
follow_up: 'Follow-ups',
|
||||||
|
campaign_lead: 'Campaign Leads',
|
||||||
|
attempt_2: '2nd Attempt',
|
||||||
|
attempt_3: '3rd Attempt',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
WHATSAPP: 'WhatsApp',
|
||||||
|
PHONE: 'Phone',
|
||||||
|
FACEBOOK_AD: 'Facebook Ad',
|
||||||
|
GOOGLE_AD: 'Google Ad',
|
||||||
|
INSTAGRAM: 'Instagram',
|
||||||
|
WEBSITE: 'Website',
|
||||||
|
REFERRAL: 'Referral',
|
||||||
|
WALK_IN: 'Walk-in',
|
||||||
|
OTHER: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
|
||||||
|
taskWeights: {
|
||||||
|
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
|
||||||
|
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
|
||||||
|
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
|
||||||
|
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
|
||||||
|
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
|
||||||
|
},
|
||||||
|
campaignWeights: {},
|
||||||
|
sourceWeights: {
|
||||||
|
WHATSAPP: 9,
|
||||||
|
PHONE: 8,
|
||||||
|
FACEBOOK_AD: 7,
|
||||||
|
GOOGLE_AD: 7,
|
||||||
|
INSTAGRAM: 5,
|
||||||
|
WEBSITE: 7,
|
||||||
|
REFERRAL: 6,
|
||||||
|
WALK_IN: 5,
|
||||||
|
OTHER: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ import { CallRecordingsPage } from "@/pages/call-recordings";
|
|||||||
import { MissedCallsPage } from "@/pages/missed-calls";
|
import { MissedCallsPage } from "@/pages/missed-calls";
|
||||||
import { ProfilePage } from "@/pages/profile";
|
import { ProfilePage } from "@/pages/profile";
|
||||||
import { AccountSettingsPage } from "@/pages/account-settings";
|
import { AccountSettingsPage } from "@/pages/account-settings";
|
||||||
|
import { RulesSettingsPage } from "@/pages/rules-settings";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
import { RouteProvider } from "@/providers/router-provider";
|
import { RouteProvider } from "@/providers/router-provider";
|
||||||
@@ -75,6 +76,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/account-settings" element={<AccountSettingsPage />} />
|
<Route path="/account-settings" element={<AccountSettingsPage />} />
|
||||||
|
<Route path="/rules" element={<RulesSettingsPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
160
src/pages/rules-settings.tsx
Normal file
160
src/pages/rules-settings.tsx
Normal file
@@ -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<PriorityConfig>(DEFAULT_PRIORITY_CONFIG);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-sm text-tertiary">Loading rules configuration...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between border-b border-secondary px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-primary">Rules Engine</h1>
|
||||||
|
<p className="text-sm text-tertiary">Configure how leads are prioritized and routed in the worklist</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{dirty && <span className="text-xs text-warning-primary">{saving ? 'Saving...' : 'Unsaved changes'}</span>}
|
||||||
|
<Button size="sm" color="secondary" onClick={applyTemplate}>
|
||||||
|
Apply Starter Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs + Content — fills remaining height */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
|
<Tabs aria-label="Rules configuration" className="flex flex-1 flex-col min-h-0">
|
||||||
|
<div className="shrink-0 border-b border-secondary px-6 pt-2">
|
||||||
|
<TabList items={[{ id: 'priority', label: 'Priority Rules' }, { id: 'automations', label: 'Automations' }]} type="underline" size="sm">
|
||||||
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
||||||
|
</TabList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Panel id="priority" className="flex flex-1 min-h-0">
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
{/* Left — config panels, scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||||
|
<PriorityConfigPanel config={config} onChange={handleConfigChange} />
|
||||||
|
<CampaignWeightsPanel config={config} onChange={handleConfigChange} />
|
||||||
|
<SourceWeightsPanel config={config} onChange={handleConfigChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — preview + collapsible AI */}
|
||||||
|
<div className="w-[400px] shrink-0 border-l border-secondary flex flex-col min-h-0 bg-secondary">
|
||||||
|
{/* Preview — takes available space */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||||
|
<WorklistPreview config={config} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Assistant — collapsible at bottom */}
|
||||||
|
<RulesAiAssistant config={config} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel id="automations" className="flex flex-1 min-h-0">
|
||||||
|
<div className="flex items-center justify-center flex-1 p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-md font-semibold text-primary mb-2">Automation Rules</h3>
|
||||||
|
<p className="text-sm text-tertiary max-w-md">
|
||||||
|
Configure rules that automatically assign leads, escalate SLA breaches, and manage lead lifecycle.
|
||||||
|
This feature is coming soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user