mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
docs: flow runtime design spec — config-driven WhatsApp conversation engine
Groups + Blocks model adapted from Typebot. Execution loop pauses at InputBlocks, resumes on next message. Tool registry bridges existing tools. Session state in Redis with 24h TTL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# WhatsApp Flow Runtime — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Config-driven conversation engine that reads flow definitions (JSON) and executes them at runtime. Replaces the hardcoded system prompt + tools in `messaging.service.ts`. Hospital admins define flows via API/file — no code changes needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Inbound WhatsApp message
|
||||
→ MessagingController (existing)
|
||||
→ FlowExecutionService (NEW — replaces MessagingService AI logic)
|
||||
→ Load/create FlowSession from Redis
|
||||
→ Match flow by trigger (or resume existing session)
|
||||
→ Walk forward through Groups → Blocks
|
||||
→ Pause at InputBlock, resume on next message
|
||||
→ Send messages via MessagingProvider (existing)
|
||||
→ Call tools via ToolRegistry (NEW)
|
||||
→ Reply sent to patient
|
||||
```
|
||||
|
||||
## Flow Definition Schema
|
||||
|
||||
```typescript
|
||||
type Flow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: FlowTrigger;
|
||||
groups: Group[];
|
||||
edges: Edge[];
|
||||
variables: VariableDefinition[];
|
||||
version: number;
|
||||
status: 'draft' | 'published';
|
||||
};
|
||||
|
||||
type FlowTrigger =
|
||||
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||
| { type: 'default' };
|
||||
|
||||
type VariableDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
defaultValue?: any;
|
||||
};
|
||||
```
|
||||
|
||||
## Groups and Edges
|
||||
|
||||
```typescript
|
||||
type Group = {
|
||||
id: string;
|
||||
title: string;
|
||||
blocks: Block[];
|
||||
};
|
||||
|
||||
type Edge = {
|
||||
id: string;
|
||||
from: { blockId: string; conditionId?: string };
|
||||
to: { groupId: string; blockId?: string };
|
||||
};
|
||||
```
|
||||
|
||||
## Block Types
|
||||
|
||||
```typescript
|
||||
type Block =
|
||||
| MessageBlock
|
||||
| InputBlock
|
||||
| ConditionBlock
|
||||
| SetVariableBlock
|
||||
| ToolCallBlock
|
||||
| AIBlock
|
||||
| JumpBlock;
|
||||
|
||||
// Send text/list/buttons to patient
|
||||
type MessageBlock = {
|
||||
id: string;
|
||||
type: 'message';
|
||||
content:
|
||||
| { format: 'text'; text: string }
|
||||
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||
};
|
||||
|
||||
// Wait for patient reply
|
||||
type InputBlock = {
|
||||
id: string;
|
||||
type: 'input';
|
||||
inputType: 'text' | 'interactive_reply' | 'any';
|
||||
variableId: string;
|
||||
validation?: { regex?: string; errorMessage?: string };
|
||||
};
|
||||
|
||||
// Branch based on variable value
|
||||
type ConditionBlock = {
|
||||
id: string;
|
||||
type: 'condition';
|
||||
conditions: {
|
||||
id: string;
|
||||
variableId: string;
|
||||
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||
value?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Assign/transform a variable
|
||||
type SetVariableBlock = {
|
||||
id: string;
|
||||
type: 'set_variable';
|
||||
variableId: string;
|
||||
value: string;
|
||||
expression?: 'extract_id';
|
||||
};
|
||||
|
||||
// Execute a registered tool
|
||||
type ToolCallBlock = {
|
||||
id: string;
|
||||
type: 'tool_call';
|
||||
toolName: string;
|
||||
inputs: Record<string, string>; // values support {{variables}}
|
||||
outputVariableId?: string;
|
||||
};
|
||||
|
||||
// Generate dynamic LLM response
|
||||
type AIBlock = {
|
||||
id: string;
|
||||
type: 'ai';
|
||||
prompt: string; // supports {{variables}}
|
||||
outputVariableId?: string;
|
||||
sendToPatient: boolean;
|
||||
};
|
||||
|
||||
// Jump to another group
|
||||
type JumpBlock = {
|
||||
id: string;
|
||||
type: 'jump';
|
||||
targetGroupId: string;
|
||||
};
|
||||
```
|
||||
|
||||
## Session State (Redis)
|
||||
|
||||
```typescript
|
||||
type FlowSession = {
|
||||
flowId: string;
|
||||
currentGroupId: string;
|
||||
currentBlockIndex: number;
|
||||
variables: Record<string, any>;
|
||||
history: ConversationEntry[];
|
||||
startedAt: number;
|
||||
lastActiveAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
Key: `wa:flow:{phone}`, TTL: 24 hours (WhatsApp session window).
|
||||
|
||||
## Execution Loop
|
||||
|
||||
```
|
||||
On inbound message:
|
||||
1. Load session from Redis (or create new → match flow by trigger)
|
||||
2. If paused at InputBlock → store reply in variable, advance
|
||||
3. Walk forward:
|
||||
- MessageBlock → send via provider, advance
|
||||
- InputBlock → save session, STOP (wait for next message)
|
||||
- ConditionBlock → evaluate, follow matching edge (or fall through)
|
||||
- SetVariableBlock → assign value, advance
|
||||
- ToolCallBlock → execute tool, store result, advance
|
||||
- AIBlock → call LLM, optionally send, advance
|
||||
- JumpBlock → jump to target group
|
||||
- End of group → follow outgoing edge to next group
|
||||
4. If no more blocks/edges → flow complete, clear session
|
||||
```
|
||||
|
||||
## Tool Registry
|
||||
|
||||
Existing tools from messaging.service.ts become registered tools:
|
||||
|
||||
| Tool Name | Description | Inputs | Output |
|
||||
|---|---|---|---|
|
||||
| resolve_caller | Phone → Lead + Patient | phone | { leadId, patientId, isNew, name } |
|
||||
| send_department_list | Send interactive department list | (none — reads from platform) | { departments[] } |
|
||||
| send_doctor_list | Send interactive doctor list | department | { doctors[] } |
|
||||
| send_slot_list | Send time slots for doctor+date | doctorId, doctorName, date | { slots[] } |
|
||||
| send_confirm_buttons | Send confirm/cancel buttons | summary | { sent: true } |
|
||||
| book_appointment | Book appointment (with conflict check) | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId } |
|
||||
| lookup_appointments | Check existing appointments | patientId? | { appointments[] } |
|
||||
| create_lead | Create lead + patient | name, phoneNumber, interest | { leadId } |
|
||||
|
||||
## Example Flow: Appointment Booking
|
||||
|
||||
```
|
||||
Group: "Greeting" (g1)
|
||||
→ AIBlock: greet using patient name + context
|
||||
→ MessageBlock: buttons ["Book Appointment", "Check Appointment", "Ask a Question"]
|
||||
→ InputBlock: store in {{intent}}
|
||||
Edges: g1 → ConditionBlock routes to g2 (book) / g7 (check) / g8 (question)
|
||||
|
||||
Group: "Department Selection" (g2)
|
||||
→ ToolCallBlock: send_department_list
|
||||
→ InputBlock: store in {{selectedDepartment}}
|
||||
Edge: g2 → g3
|
||||
|
||||
Group: "Doctor Selection" (g3)
|
||||
→ ToolCallBlock: send_doctor_list, input: department={{selectedDepartment}}
|
||||
→ InputBlock: store in {{selectedDoctor}}
|
||||
→ SetVariableBlock: extract doctorId from {{selectedDoctor}}
|
||||
Edge: g3 → g4
|
||||
|
||||
Group: "Date Selection" (g4)
|
||||
→ MessageBlock: "When would you like to visit?"
|
||||
→ MessageBlock: buttons ["Tomorrow", "Day After", "Choose Date"]
|
||||
→ InputBlock: store in {{dateChoice}}
|
||||
→ ConditionBlock: tomorrow → SetVariable, day_after → SetVariable, else → AI parse
|
||||
Edge: g4 → g5
|
||||
|
||||
Group: "Slot Selection" (g5)
|
||||
→ ToolCallBlock: send_slot_list, inputs: doctorId={{doctorId}}, date={{selectedDate}}
|
||||
→ InputBlock: store in {{selectedSlot}}
|
||||
Edge: g5 → g6
|
||||
|
||||
Group: "Confirmation" (g6)
|
||||
→ MessageBlock: buttons ["Confirm", "Cancel"], summary text
|
||||
→ InputBlock: store in {{confirmation}}
|
||||
→ ConditionBlock: confirm → g7, cancel → g8
|
||||
Edges: confirm → "Booking" group, cancel → "Cancelled" group
|
||||
|
||||
Group: "Booking" (g7)
|
||||
→ ToolCallBlock: book_appointment with all collected variables
|
||||
→ MessageBlock: confirmation with reference number
|
||||
|
||||
Group: "Cancelled" (g8)
|
||||
→ MessageBlock: "No problem! Let me know if you need anything else."
|
||||
```
|
||||
|
||||
## File Structure (Implementation)
|
||||
|
||||
```
|
||||
src/messaging/
|
||||
├── flow/
|
||||
│ ├── flow-types.ts — All types above
|
||||
│ ├── flow-execution.service.ts — Main execution loop
|
||||
│ ├── flow-session.service.ts — Redis session CRUD
|
||||
│ ├── flow-store.service.ts — Load/save flow definitions (file/Redis)
|
||||
│ ├── flow-variable.service.ts — Variable interpolation + expressions
|
||||
│ ├── tool-registry.ts — Tool name → handler mapping
|
||||
│ └── default-flows/
|
||||
│ └── appointment-booking.json — Seeded default flow
|
||||
├── providers/ (existing, unchanged)
|
||||
├── messaging.module.ts — Wire new services
|
||||
├── messaging.controller.ts — Unchanged (webhook still here)
|
||||
├── messaging.service.ts — Delegates to FlowExecutionService
|
||||
└── types.ts — Existing types (unchanged)
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Build FlowExecutionService alongside existing MessagingService
|
||||
2. Seed default appointment-booking.json (equivalent to current hardcoded flow)
|
||||
3. MessagingService checks: if flow config exists → delegate to FlowExecutionService, else → current AI behavior (backward compatible)
|
||||
4. Once validated, remove hardcoded AI flow from MessagingService
|
||||
|
||||
## Not in Scope
|
||||
|
||||
- Visual builder UI (future, maybe never)
|
||||
- Flow versioning/rollback (v2)
|
||||
- Flow analytics/metrics (v2)
|
||||
- Multi-flow routing (v2 — for now, one active flow per trigger type)
|
||||
Reference in New Issue
Block a user