Complete rename of "swift" terminology to "cortex" across the codebase: Code Changes: - Rename directories: lib/swift → lib/cortex, components/swift → components/cortex - Rename API routes: /api/swift/* → /api/cortex/* - Rename page routes: /epd/swift → /epd/cortex - Rename store: swift-store.ts → cortex-store.ts Type Renames: - SwiftIntent → CortexIntent - SwiftStore → CortexStore - useSwiftStore → useCortexStore - SwiftContext → CortexContext - useSwiftVoice → useCortexVoice Documentation Updates (docs/intent/): - Safety Net → Nudge (Layer 3 rename) - SafetySuggestion → NudgeSuggestion - evaluateSafetyNet → evaluateNudge - SWIFT_* feature flags → CORTEX_* - All path references updated to match new structure Files affected: 47 files, ~700 lines changed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2232 lines
66 KiB
Markdown
2232 lines
66 KiB
Markdown
# 🧠 Swift Cortex V2 - Uitgebreid Architectuurplan
|
|
|
|
**Project:** Swift Intent System V2
|
|
**Versie:** 2.0
|
|
**Datum:** 29-12-2025
|
|
**Status:** Technisch Ontwerp
|
|
|
|
---
|
|
|
|
## Inhoudsopgave
|
|
|
|
1. [Executive Summary](#1-executive-summary)
|
|
2. [Architectuur Overview](#2-architectuur-overview)
|
|
3. [Data Models & Types](#3-data-models--types)
|
|
4. [Layer 1: Reflex Arc](#4-layer-1-reflex-arc)
|
|
5. [Layer 2: Intent Orchestrator](#5-layer-2-intent-orchestrator)
|
|
6. [Layer 3: Nudge](#6-layer-3-nudge)
|
|
7. [API Design](#7-api-design)
|
|
8. [Frontend Components](#8-frontend-components)
|
|
9. [State Management](#9-state-management)
|
|
10. [Implementatie Roadmap](#10-implementatie-roadmap)
|
|
11. [Testing Strategy](#11-testing-strategy)
|
|
12. [Migratie Plan](#12-migratie-plan)
|
|
|
|
---
|
|
|
|
## 1. Executive Summary
|
|
|
|
### Huidige Situatie (V1)
|
|
Het huidige Swift systeem is **reactief**: gebruiker geeft commando → systeem voert uit. Het werkt met single-intent classificatie en heeft een two-tier architectuur (lokaal regex + AI fallback).
|
|
|
|
### Doel V2: "The Swift Cortex"
|
|
Transformatie naar een **agentic systeem** dat:
|
|
- **Multi-intents** begrijpt ("Zeg Jan af **en** maak notitie")
|
|
- **Context-aware** is (snapt wie "hij" is, wat "morgen" betekent)
|
|
- **Proactief** suggesties geeft (na wondzorg → "Controle inplannen?")
|
|
- **Nooit** "Ik snap het niet" zegt (altijd een poging tot begrip)
|
|
|
|
### Kernprincipe
|
|
> "We stoppen met optimaliseren voor milliseconden en starten met optimaliseren voor intelligentie."
|
|
|
|
---
|
|
|
|
## 2. Architectuur Overview
|
|
|
|
### 2.1 The Three-Layer Cortex Model
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ USER INPUT │
|
|
│ "Zeg Jan af en maak notitie: grieperig" │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 1: REFLEX ARC [<20ms] │
|
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ • Local Regex Pattern Matching │ │
|
|
│ │ • High-confidence simple commands only (>0.9) │ │
|
|
│ │ • Examples: "agenda", "zoek jan", "notitie" │ │
|
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ Decision: Confidence > 0.9 AND single intent? ──► EXECUTE DIRECTLY │
|
|
│ Otherwise ──► Pass to Layer 2 │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
[Complex/Multi-intent/Low confidence]
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 2: INTENT ORCHESTRATOR (AI CORTEX) [~400ms] │
|
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ • Claude 3.5 Haiku (fast, cheap, smart) │ │
|
|
│ │ • Context injection: ActivePatient, CurrentView, Agenda, History │ │
|
|
│ │ • Multi-intent parsing: splits "X en Y" into action chain │ │
|
|
│ │ • Entity disambiguation: "hij" → active patient │ │
|
|
│ │ • Clarification questions if truly ambiguous │ │
|
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ Output: IntentChain { actions: [Action1, Action2, ...] } │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ EXECUTION ENGINE │
|
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ • Sequential or parallel action execution │ │
|
|
│ │ • Confirmation dialogs for destructive actions │ │
|
|
│ │ • Rollback support for failed chains │ │
|
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
[Action Completed]
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ LAYER 3: NUDGE (POST-ACTION INTELLIGENCE) [async] │
|
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ • Protocol Rules Engine: medical domain knowledge │ │
|
|
│ │ • Trigger evaluation: "Does this action warrant a follow-up?" │ │
|
|
│ │ • Suggestion generation: "Wondcontrole inplannen?" │ │
|
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ Output: Suggestion Toast / Follow-up Card │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 2.2 Data Flow Diagram
|
|
|
|
```
|
|
┌──────────┐ ┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌───────────┐
|
|
│ Speech │───►│ Input │───►│ Classify │───►│ Execute │───►│ Safety │
|
|
│ /Text │ │ Buffer │ │ (L1/L2) │ │ Chain │ │ Nudge │
|
|
└──────────┘ └─────────┘ └─────────────┘ └──────────┘ └───────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌─────────────┐ ┌──────────┐ ┌───────────┐
|
|
│ Swift │ │ Artifact │ │ Suggestion│
|
|
│ Store │◄───│ Updates │ │ Toast │
|
|
└─────────────┘ └──────────┘ └───────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Data Models & Types
|
|
|
|
### 3.1 Core Types (lib/cortex/types.ts)
|
|
|
|
```typescript
|
|
// ============================================================================
|
|
// INTENT TYPES
|
|
// ============================================================================
|
|
|
|
export type CortexIntent =
|
|
| 'dagnotitie'
|
|
| 'zoeken'
|
|
| 'overdracht'
|
|
| 'agenda_query'
|
|
| 'create_appointment'
|
|
| 'cancel_appointment'
|
|
| 'reschedule_appointment'
|
|
| 'unknown';
|
|
|
|
// ============================================================================
|
|
// CONTEXT TYPES (NEW in V2)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Full context passed to AI for intelligent classification
|
|
*/
|
|
export interface CortexContext {
|
|
// Active patient (if any)
|
|
activePatient: {
|
|
id: string;
|
|
name: string;
|
|
recentNotes?: string[]; // Last 3 note summaries
|
|
upcomingAppointments?: {
|
|
date: Date;
|
|
type: string;
|
|
}[];
|
|
} | null;
|
|
|
|
// Current UI state
|
|
currentView: 'dashboard' | 'patient-detail' | 'agenda' | 'reports' | 'chat';
|
|
|
|
// Time context
|
|
shift: ShiftType;
|
|
currentTime: Date;
|
|
|
|
// Today's agenda (for disambiguation)
|
|
agendaToday: {
|
|
time: string;
|
|
patientName: string;
|
|
patientId: string;
|
|
type: string;
|
|
}[];
|
|
|
|
// Recent intents (for continuity)
|
|
recentIntents: {
|
|
intent: CortexIntent;
|
|
patientName?: string;
|
|
timestamp: Date;
|
|
}[];
|
|
|
|
// User preferences (adaptive confidence)
|
|
userPreferences?: {
|
|
confirmationLevel: 'always' | 'destructive' | 'never';
|
|
frequentIntents: CortexIntent[];
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// INTENT CHAIN (Multi-Intent Support - NEW in V2)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* A chain of actions derived from a single user input
|
|
*/
|
|
export interface IntentChain {
|
|
id: string;
|
|
originalInput: string;
|
|
createdAt: Date;
|
|
|
|
// Array of actions to execute
|
|
actions: IntentAction[];
|
|
|
|
// Overall chain status
|
|
status: 'pending' | 'executing' | 'completed' | 'partial' | 'failed';
|
|
|
|
// Processing metadata
|
|
meta: {
|
|
source: 'local' | 'ai';
|
|
processingTimeMs: number;
|
|
aiReasoning?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A single action within an intent chain
|
|
*/
|
|
export interface IntentAction {
|
|
id: string;
|
|
sequence: number; // Order in chain (1, 2, 3...)
|
|
|
|
// Classification
|
|
intent: CortexIntent;
|
|
confidence: number;
|
|
|
|
// Extracted data
|
|
entities: ExtractedEntities;
|
|
|
|
// Execution state
|
|
status: 'pending' | 'confirming' | 'executing' | 'success' | 'failed' | 'skipped';
|
|
|
|
// Confirmation handling
|
|
requiresConfirmation: boolean;
|
|
confirmationMessage?: string;
|
|
|
|
// Error handling
|
|
error?: {
|
|
code: string;
|
|
message: string;
|
|
recoverable: boolean;
|
|
};
|
|
|
|
// Timing
|
|
startedAt?: Date;
|
|
completedAt?: Date;
|
|
}
|
|
|
|
// ============================================================================
|
|
// ENTITY TYPES (Enhanced)
|
|
// ============================================================================
|
|
|
|
export interface ExtractedEntities {
|
|
// Patient reference
|
|
patientName?: string;
|
|
patientId?: string;
|
|
patientResolution?: 'explicit' | 'context' | 'pronoun'; // How we found the patient
|
|
|
|
// Dagnotitie
|
|
category?: VerpleegkundigCategory;
|
|
content?: string;
|
|
severity?: 'low' | 'medium' | 'high'; // Sentiment analysis
|
|
|
|
// Search
|
|
query?: string;
|
|
|
|
// Agenda
|
|
dateRange?: DateRange;
|
|
datetime?: {
|
|
date: Date;
|
|
time: string;
|
|
isRelative: boolean; // "morgen" vs "15 januari"
|
|
};
|
|
appointmentType?: AppointmentType;
|
|
location?: 'praktijk' | 'online' | 'thuis';
|
|
|
|
// Reschedule
|
|
identifier?: AppointmentIdentifier;
|
|
newDatetime?: {
|
|
date: Date;
|
|
time: string;
|
|
};
|
|
|
|
// Raw AI extraction (for debugging)
|
|
_raw?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface DateRange {
|
|
start: Date;
|
|
end: Date;
|
|
label: 'vandaag' | 'morgen' | 'deze week' | 'volgende week' | 'custom';
|
|
}
|
|
|
|
export interface AppointmentIdentifier {
|
|
type: 'patient' | 'time' | 'both' | 'id';
|
|
patientName?: string;
|
|
patientId?: string;
|
|
time?: string;
|
|
date?: Date;
|
|
encounterId?: string;
|
|
}
|
|
|
|
export type AppointmentType =
|
|
| 'intake'
|
|
| 'behandeling'
|
|
| 'follow-up'
|
|
| 'telefonisch'
|
|
| 'huisbezoek'
|
|
| 'online'
|
|
| 'crisis'
|
|
| 'overig';
|
|
|
|
// ============================================================================
|
|
// NUDGE TYPES (NEW in V2)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* A suggestion generated by the Nudge layer
|
|
*/
|
|
export interface NudgeSuggestion {
|
|
id: string;
|
|
|
|
// What triggered this suggestion
|
|
trigger: {
|
|
actionId: string;
|
|
intent: CortexIntent;
|
|
entities: ExtractedEntities;
|
|
};
|
|
|
|
// The suggestion itself
|
|
suggestion: {
|
|
intent: CortexIntent;
|
|
entities: Partial<ExtractedEntities>;
|
|
message: string; // "Wondcontrole inplannen over 3 dagen?"
|
|
rationale: string; // "Bij wondzorg hoort standaard een controle"
|
|
};
|
|
|
|
// UI state
|
|
status: 'pending' | 'accepted' | 'dismissed' | 'expired';
|
|
|
|
// Priority and timing
|
|
priority: 'low' | 'medium' | 'high';
|
|
expiresAt?: Date;
|
|
|
|
createdAt: Date;
|
|
}
|
|
|
|
/**
|
|
* Protocol rule for generating suggestions
|
|
*/
|
|
export interface ProtocolRule {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
|
|
// When to trigger
|
|
trigger: {
|
|
intent: CortexIntent;
|
|
conditions?: ProtocolCondition[];
|
|
};
|
|
|
|
// What to suggest
|
|
suggestion: {
|
|
intent: CortexIntent;
|
|
message: string;
|
|
prefillFrom: (source: ExtractedEntities) => Partial<ExtractedEntities>;
|
|
};
|
|
|
|
// Rule metadata
|
|
priority: 'low' | 'medium' | 'high';
|
|
category: 'medicatie' | 'wondzorg' | 'veiligheid' | 'administratief';
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface ProtocolCondition {
|
|
field: keyof ExtractedEntities;
|
|
operator: 'equals' | 'contains' | 'exists' | 'matches';
|
|
value?: string | RegExp;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLASSIFICATION RESULT TYPES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Result from Layer 1 (Local Reflex)
|
|
*/
|
|
export interface LocalClassificationResult {
|
|
intent: CortexIntent;
|
|
confidence: number;
|
|
matchedPattern?: string;
|
|
processingTimeMs: number;
|
|
|
|
// Decision
|
|
shouldEscalateToAI: boolean;
|
|
escalationReason?: 'low_confidence' | 'multi_intent_detected' | 'needs_context';
|
|
}
|
|
|
|
/**
|
|
* Result from Layer 2 (AI Orchestrator)
|
|
*/
|
|
export interface AIClassificationResult {
|
|
chain: IntentChain;
|
|
|
|
// AI metadata
|
|
model: string;
|
|
tokensUsed: number;
|
|
processingTimeMs: number;
|
|
|
|
// Clarification (if needed)
|
|
needsClarification: boolean;
|
|
clarificationQuestion?: string;
|
|
clarificationOptions?: string[];
|
|
}
|
|
|
|
/**
|
|
* Combined classification result
|
|
*/
|
|
export interface ClassificationResult {
|
|
// The final intent chain
|
|
chain: IntentChain;
|
|
|
|
// Which layer handled it
|
|
handledBy: 'reflex' | 'orchestrator';
|
|
|
|
// Timing
|
|
totalProcessingTimeMs: number;
|
|
|
|
// Debug info
|
|
debug?: {
|
|
localResult?: LocalClassificationResult;
|
|
aiResult?: AIClassificationResult;
|
|
};
|
|
}
|
|
```
|
|
|
|
### 3.2 Store Types (stores/cortex-store.ts additions)
|
|
|
|
```typescript
|
|
// Add to existing CortexStore interface
|
|
|
|
interface CortexStoreV2 extends CortexStore {
|
|
// Context (enhanced)
|
|
context: CortexContext;
|
|
|
|
// Intent Chain state
|
|
activeChain: IntentChain | null;
|
|
chainHistory: IntentChain[];
|
|
|
|
// Nudge state
|
|
pendingSuggestions: NudgeSuggestion[];
|
|
suggestionHistory: NudgeSuggestion[];
|
|
|
|
// Actions
|
|
setContext: (context: Partial<CortexContext>) => void;
|
|
|
|
// Chain actions
|
|
startChain: (chain: IntentChain) => void;
|
|
updateActionStatus: (chainId: string, actionId: string, status: IntentAction['status']) => void;
|
|
completeChain: (chainId: string) => void;
|
|
|
|
// Nudge actions
|
|
addSuggestion: (suggestion: NudgeSuggestion) => void;
|
|
acceptSuggestion: (suggestionId: string) => void;
|
|
dismissSuggestion: (suggestionId: string) => void;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Layer 1: Reflex Arc
|
|
|
|
### 4.1 Doel
|
|
Razendsnelle (<20ms) afhandeling van **simpele, eenduidige commando's** met hoge confidence.
|
|
|
|
### 4.2 Wanneer Layer 1 afhandelt
|
|
- Confidence > 0.9
|
|
- Geen "en", "daarna", "ook" (multi-intent signals)
|
|
- Geen context-afhankelijke woorden ("hij", "haar", "die afspraak")
|
|
- Geen tijdsrelaties die interpretatie nodig hebben
|
|
|
|
### 4.3 Implementatie (lib/cortex/reflex-classifier.ts)
|
|
|
|
```typescript
|
|
/**
|
|
* Layer 1: Reflex Arc
|
|
*
|
|
* Fast, local pattern matching for simple commands.
|
|
* Escalates to Layer 2 when complexity is detected.
|
|
*/
|
|
|
|
import type { LocalClassificationResult, CortexIntent } from './types';
|
|
|
|
// Multi-intent signal words
|
|
const MULTI_INTENT_SIGNALS = [
|
|
/\ben\b/i, // "X en Y"
|
|
/\bdaarna\b/i, // "X daarna Y"
|
|
/\book\b/i, // "X ook Y"
|
|
/\beerst\b/i, // "eerst X dan Y"
|
|
/\bdan\b/i, // "X dan Y"
|
|
/\bvervolgens\b/i,
|
|
];
|
|
|
|
// Context-dependent words (need AI to resolve)
|
|
const CONTEXT_SIGNALS = [
|
|
/\bhij\b/i,
|
|
/\bzij\b/i,
|
|
/\bhaar\b/i,
|
|
/\bhem\b/i,
|
|
/\bdie\b/i, // "die afspraak"
|
|
/\bdeze\b/i, // "deze patiënt"
|
|
/\bdezelfde\b/i,
|
|
];
|
|
|
|
// Intent patterns with weights
|
|
const REFLEX_PATTERNS: Record<CortexIntent, Array<{ pattern: RegExp; weight: number }>> = {
|
|
dagnotitie: [
|
|
{ pattern: /^dagnotitie\b/i, weight: 1.0 },
|
|
{ pattern: /^notitie\s+\w+\s+(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 0.95 },
|
|
{ pattern: /^notitie\s+\w+/i, weight: 0.9 },
|
|
{ pattern: /^(medicatie|adl|gedrag|incident|observatie)\s+\w+/i, weight: 0.85 },
|
|
],
|
|
|
|
zoeken: [
|
|
{ pattern: /^zoek\s+\w+$/i, weight: 1.0 },
|
|
{ pattern: /^vind\s+\w+$/i, weight: 1.0 },
|
|
{ pattern: /^wie\s+is\s+\w+/i, weight: 0.95 },
|
|
{ pattern: /^dossier\s+\w+$/i, weight: 0.9 },
|
|
],
|
|
|
|
agenda_query: [
|
|
{ pattern: /^agenda\s*(vandaag|morgen)?$/i, weight: 1.0 },
|
|
{ pattern: /^afspraken\s*(vandaag|morgen|deze week)?$/i, weight: 0.95 },
|
|
{ pattern: /^wat\s+staat\s+er\s+(vandaag|morgen)/i, weight: 0.9 },
|
|
],
|
|
|
|
overdracht: [
|
|
{ pattern: /^overdracht$/i, weight: 1.0 },
|
|
{ pattern: /^dienst\s+afronden$/i, weight: 0.95 },
|
|
],
|
|
|
|
create_appointment: [
|
|
{ pattern: /^maak\s+afspraak\b/i, weight: 0.85 },
|
|
{ pattern: /^plan\s+(intake|afspraak)\b/i, weight: 0.85 },
|
|
],
|
|
|
|
cancel_appointment: [
|
|
{ pattern: /^annuleer\s+afspraak\b/i, weight: 0.9 },
|
|
{ pattern: /^zeg\s+\w+\s+af\b/i, weight: 0.85 },
|
|
],
|
|
|
|
reschedule_appointment: [
|
|
{ pattern: /^verzet\s+afspraak\b/i, weight: 0.85 },
|
|
{ pattern: /^verplaats\s+\w+\s+naar\b/i, weight: 0.8 },
|
|
],
|
|
|
|
unknown: [],
|
|
};
|
|
|
|
/**
|
|
* Check if input contains signals that require AI processing
|
|
*/
|
|
function detectComplexity(input: string): {
|
|
hasMultiIntent: boolean;
|
|
hasContextDependency: boolean;
|
|
signals: string[];
|
|
} {
|
|
const signals: string[] = [];
|
|
|
|
const hasMultiIntent = MULTI_INTENT_SIGNALS.some(pattern => {
|
|
if (pattern.test(input)) {
|
|
signals.push(`multi-intent: ${pattern.source}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const hasContextDependency = CONTEXT_SIGNALS.some(pattern => {
|
|
if (pattern.test(input)) {
|
|
signals.push(`context: ${pattern.source}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
return { hasMultiIntent, hasContextDependency, signals };
|
|
}
|
|
|
|
/**
|
|
* Classify input using local patterns
|
|
*/
|
|
export function classifyWithReflex(input: string): LocalClassificationResult {
|
|
const startTime = performance.now();
|
|
const trimmedInput = input.trim();
|
|
|
|
// Step 1: Check for complexity signals
|
|
const complexity = detectComplexity(trimmedInput);
|
|
|
|
if (complexity.hasMultiIntent) {
|
|
return {
|
|
intent: 'unknown',
|
|
confidence: 0,
|
|
processingTimeMs: performance.now() - startTime,
|
|
shouldEscalateToAI: true,
|
|
escalationReason: 'multi_intent_detected',
|
|
};
|
|
}
|
|
|
|
if (complexity.hasContextDependency) {
|
|
return {
|
|
intent: 'unknown',
|
|
confidence: 0,
|
|
processingTimeMs: performance.now() - startTime,
|
|
shouldEscalateToAI: true,
|
|
escalationReason: 'needs_context',
|
|
};
|
|
}
|
|
|
|
// Step 2: Pattern matching
|
|
let bestMatch: { intent: CortexIntent; confidence: number; pattern: string } | null = null;
|
|
|
|
for (const [intent, patterns] of Object.entries(REFLEX_PATTERNS)) {
|
|
for (const { pattern, weight } of patterns) {
|
|
if (pattern.test(trimmedInput)) {
|
|
if (!bestMatch || weight > bestMatch.confidence) {
|
|
bestMatch = {
|
|
intent: intent as CortexIntent,
|
|
confidence: weight,
|
|
pattern: pattern.source,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const processingTimeMs = performance.now() - startTime;
|
|
|
|
// Step 3: Decide on escalation
|
|
if (!bestMatch || bestMatch.confidence < 0.9) {
|
|
return {
|
|
intent: bestMatch?.intent || 'unknown',
|
|
confidence: bestMatch?.confidence || 0,
|
|
matchedPattern: bestMatch?.pattern,
|
|
processingTimeMs,
|
|
shouldEscalateToAI: true,
|
|
escalationReason: 'low_confidence',
|
|
};
|
|
}
|
|
|
|
// High confidence match - handle locally
|
|
return {
|
|
intent: bestMatch.intent,
|
|
confidence: bestMatch.confidence,
|
|
matchedPattern: bestMatch.pattern,
|
|
processingTimeMs,
|
|
shouldEscalateToAI: false,
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Layer 2: Intent Orchestrator
|
|
|
|
### 5.1 Doel
|
|
AI-gedreven analyse voor:
|
|
- **Complexe zinnen** met lage confidence
|
|
- **Multi-intents** ("X en Y")
|
|
- **Context-afhankelijke** verwijzingen ("hij", "die afspraak")
|
|
- **Disambiguation** bij twijfel
|
|
|
|
### 5.2 Context Injection
|
|
|
|
De AI krijgt altijd volledige context mee:
|
|
|
|
```typescript
|
|
/**
|
|
* Build context for AI classification
|
|
*/
|
|
export function buildCortexContext(store: CortexStore): CortexContext {
|
|
return {
|
|
activePatient: store.activePatient ? {
|
|
id: store.activePatient.id,
|
|
name: `${store.activePatient.name_given[0]} ${store.activePatient.name_family}`,
|
|
} : null,
|
|
|
|
currentView: determineCurrentView(),
|
|
shift: store.shift,
|
|
currentTime: new Date(),
|
|
|
|
agendaToday: [], // Populated from API
|
|
|
|
recentIntents: store.recentActions.slice(0, 5).map(a => ({
|
|
intent: a.intent,
|
|
patientName: a.patientName,
|
|
timestamp: a.timestamp,
|
|
})),
|
|
};
|
|
}
|
|
```
|
|
|
|
### 5.3 Implementatie (lib/cortex/orchestrator.ts)
|
|
|
|
```typescript
|
|
/**
|
|
* Layer 2: Intent Orchestrator
|
|
*
|
|
* AI-powered classification with context awareness and multi-intent support.
|
|
*/
|
|
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import type {
|
|
CortexContext,
|
|
IntentChain,
|
|
IntentAction,
|
|
AIClassificationResult
|
|
} from './types';
|
|
|
|
const ORCHESTRATOR_SYSTEM_PROMPT = `Je bent de Intent Orchestrator voor Swift, een Nederlands EPD systeem.
|
|
|
|
## Je Taak
|
|
Analyseer de gebruikersinput en extraheer ALLE intenties, ook als er meerdere zijn.
|
|
|
|
## Context die je krijgt
|
|
- Actieve patiënt: wie de gebruiker momenteel bekijkt
|
|
- Agenda vandaag: afspraken voor vandaag
|
|
- Recente acties: wat de gebruiker net deed
|
|
- Huidige weergave: waar in de app de gebruiker is
|
|
|
|
## Intent Types
|
|
1. **dagnotitie** - Notitie/rapportage maken
|
|
2. **zoeken** - Patiënt zoeken
|
|
3. **overdracht** - Dienst overdracht
|
|
4. **agenda_query** - Agenda bekijken
|
|
5. **create_appointment** - Afspraak maken
|
|
6. **cancel_appointment** - Afspraak annuleren
|
|
7. **reschedule_appointment** - Afspraak verzetten
|
|
|
|
## Multi-Intent Detectie
|
|
Let op woorden als: "en", "daarna", "ook", "eerst", "dan", "vervolgens"
|
|
|
|
Voorbeeld:
|
|
- "Zeg Jan af en maak notitie: ziek" → 2 intents
|
|
- "Plan intake marie morgen 14:00" → 1 intent
|
|
|
|
## Pronoun Resolution
|
|
Gebruik de context om "hij/zij/die" op te lossen:
|
|
- Als er een actieve patiënt is, verwijst "hij/zij" daar waarschijnlijk naar
|
|
- "Die afspraak" verwijst naar de meest recente genoemde afspraak
|
|
|
|
## Output Format
|
|
Antwoord ALLEEN met valid JSON:
|
|
|
|
{
|
|
"actions": [
|
|
{
|
|
"intent": "cancel_appointment",
|
|
"confidence": 0.95,
|
|
"entities": {
|
|
"patientName": "Jan",
|
|
"patientResolution": "explicit"
|
|
},
|
|
"requiresConfirmation": true,
|
|
"confirmationMessage": "Afspraak van Jan annuleren?"
|
|
},
|
|
{
|
|
"intent": "dagnotitie",
|
|
"confidence": 0.9,
|
|
"entities": {
|
|
"patientName": "Jan",
|
|
"patientResolution": "context",
|
|
"content": "ziek"
|
|
},
|
|
"requiresConfirmation": false
|
|
}
|
|
],
|
|
"reasoning": "Gebruiker wil twee dingen: afspraak annuleren EN notitie maken. 'Jan' expliciet genoemd voor annulering, impliciet voor notitie.",
|
|
"needsClarification": false
|
|
}
|
|
|
|
Als je twijfelt, stel een verduidelijkingsvraag:
|
|
{
|
|
"actions": [],
|
|
"needsClarification": true,
|
|
"clarificationQuestion": "Bedoel je een notitie maken of een afspraak inplannen?",
|
|
"clarificationOptions": ["Notitie maken", "Afspraak inplannen"]
|
|
}`;
|
|
|
|
/**
|
|
* Format context for AI prompt
|
|
*/
|
|
function formatContextForPrompt(context: CortexContext): string {
|
|
const lines: string[] = [];
|
|
|
|
// Active patient
|
|
if (context.activePatient) {
|
|
lines.push(`🧑 Actieve patiënt: ${context.activePatient.name} (ID: ${context.activePatient.id})`);
|
|
} else {
|
|
lines.push(`🧑 Actieve patiënt: Geen`);
|
|
}
|
|
|
|
// Current view
|
|
lines.push(`📍 Huidige weergave: ${context.currentView}`);
|
|
|
|
// Time
|
|
lines.push(`⏰ Tijd: ${context.currentTime.toLocaleTimeString('nl-NL')} (${context.shift}dienst)`);
|
|
|
|
// Today's agenda
|
|
if (context.agendaToday.length > 0) {
|
|
lines.push(`📅 Agenda vandaag:`);
|
|
context.agendaToday.slice(0, 5).forEach(apt => {
|
|
lines.push(` - ${apt.time}: ${apt.patientName} (${apt.type})`);
|
|
});
|
|
}
|
|
|
|
// Recent intents
|
|
if (context.recentIntents.length > 0) {
|
|
lines.push(`🕐 Recente acties:`);
|
|
context.recentIntents.slice(0, 3).forEach(ri => {
|
|
lines.push(` - ${ri.intent}${ri.patientName ? ` (${ri.patientName})` : ''}`);
|
|
});
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Classify with AI Orchestrator
|
|
*/
|
|
export async function classifyWithOrchestrator(
|
|
input: string,
|
|
context: CortexContext
|
|
): Promise<AIClassificationResult> {
|
|
const startTime = performance.now();
|
|
|
|
const anthropic = new Anthropic({
|
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
});
|
|
|
|
const contextPrompt = formatContextForPrompt(context);
|
|
|
|
const response = await anthropic.messages.create({
|
|
model: 'claude-3-5-haiku-20241022',
|
|
max_tokens: 512,
|
|
temperature: 0,
|
|
system: ORCHESTRATOR_SYSTEM_PROMPT,
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: `## Context
|
|
${contextPrompt}
|
|
|
|
## Input
|
|
"${input}"
|
|
|
|
Analyseer en extraheer alle intenties.`,
|
|
},
|
|
],
|
|
});
|
|
|
|
const processingTimeMs = performance.now() - startTime;
|
|
const rawText = response.content[0].type === 'text' ? response.content[0].text : '';
|
|
|
|
// Parse JSON response
|
|
const parsed = parseAIResponse(rawText);
|
|
|
|
// Build IntentChain
|
|
const chain: IntentChain = {
|
|
id: crypto.randomUUID(),
|
|
originalInput: input,
|
|
createdAt: new Date(),
|
|
actions: parsed.actions.map((action, index) => ({
|
|
id: crypto.randomUUID(),
|
|
sequence: index + 1,
|
|
intent: action.intent,
|
|
confidence: action.confidence,
|
|
entities: action.entities,
|
|
status: 'pending',
|
|
requiresConfirmation: action.requiresConfirmation ?? false,
|
|
confirmationMessage: action.confirmationMessage,
|
|
})),
|
|
status: 'pending',
|
|
meta: {
|
|
source: 'ai',
|
|
processingTimeMs,
|
|
aiReasoning: parsed.reasoning,
|
|
},
|
|
};
|
|
|
|
return {
|
|
chain,
|
|
model: 'claude-3-5-haiku-20241022',
|
|
tokensUsed: response.usage.input_tokens + response.usage.output_tokens,
|
|
processingTimeMs,
|
|
needsClarification: parsed.needsClarification ?? false,
|
|
clarificationQuestion: parsed.clarificationQuestion,
|
|
clarificationOptions: parsed.clarificationOptions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse AI JSON response with error handling
|
|
*/
|
|
function parseAIResponse(rawText: string): {
|
|
actions: Array<{
|
|
intent: CortexIntent;
|
|
confidence: number;
|
|
entities: ExtractedEntities;
|
|
requiresConfirmation?: boolean;
|
|
confirmationMessage?: string;
|
|
}>;
|
|
reasoning?: string;
|
|
needsClarification?: boolean;
|
|
clarificationQuestion?: string;
|
|
clarificationOptions?: string[];
|
|
} {
|
|
// Strip markdown code blocks
|
|
let jsonText = rawText.trim();
|
|
if (jsonText.startsWith('```json')) {
|
|
jsonText = jsonText.slice(7);
|
|
}
|
|
if (jsonText.startsWith('```')) {
|
|
jsonText = jsonText.slice(3);
|
|
}
|
|
if (jsonText.endsWith('```')) {
|
|
jsonText = jsonText.slice(0, -3);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(jsonText.trim());
|
|
} catch (error) {
|
|
console.error('Failed to parse AI response:', error, rawText);
|
|
return {
|
|
actions: [{
|
|
intent: 'unknown',
|
|
confidence: 0.3,
|
|
entities: {},
|
|
}],
|
|
reasoning: 'Failed to parse AI response',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Layer 3: Nudge
|
|
|
|
### 6.1 Doel
|
|
Proactieve suggesties na succesvolle acties op basis van medische protocollen en domeinkennis.
|
|
|
|
### 6.2 Protocol Rules
|
|
|
|
```typescript
|
|
/**
|
|
* Layer 3: Nudge
|
|
*
|
|
* Post-action intelligence that suggests follow-up actions
|
|
* based on medical protocols and domain knowledge.
|
|
*/
|
|
|
|
import type {
|
|
IntentAction,
|
|
NudgeSuggestion,
|
|
ProtocolRule,
|
|
ExtractedEntities
|
|
} from './types';
|
|
|
|
// ============================================================================
|
|
// PROTOCOL RULES DATABASE
|
|
// ============================================================================
|
|
|
|
export const PROTOCOL_RULES: ProtocolRule[] = [
|
|
// Wondzorg Protocol
|
|
{
|
|
id: 'wondzorg-controle',
|
|
name: 'Wondcontrole na verzorging',
|
|
description: 'Bij wondzorg hoort standaard een vervolgcontrole',
|
|
trigger: {
|
|
intent: 'dagnotitie',
|
|
conditions: [
|
|
{ field: 'category', operator: 'equals', value: 'adl' },
|
|
{ field: 'content', operator: 'contains', value: 'wond' },
|
|
],
|
|
},
|
|
suggestion: {
|
|
intent: 'create_appointment',
|
|
message: 'Wondcontrole inplannen over 3 dagen?',
|
|
prefillFrom: (source) => ({
|
|
patientName: source.patientName,
|
|
patientId: source.patientId,
|
|
appointmentType: 'follow-up',
|
|
content: 'Wondcontrole',
|
|
}),
|
|
},
|
|
priority: 'medium',
|
|
category: 'wondzorg',
|
|
enabled: true,
|
|
},
|
|
|
|
// Medicatie Protocol
|
|
{
|
|
id: 'medicatie-evaluatie',
|
|
name: 'Evaluatie na medicatiestart',
|
|
description: 'Nieuwe medicatie vereist evaluatie na 2 weken',
|
|
trigger: {
|
|
intent: 'dagnotitie',
|
|
conditions: [
|
|
{ field: 'category', operator: 'equals', value: 'medicatie' },
|
|
{ field: 'content', operator: 'matches', value: /start|gestart|nieuw/i },
|
|
],
|
|
},
|
|
suggestion: {
|
|
intent: 'create_appointment',
|
|
message: 'Medicatie-evaluatie inplannen over 2 weken?',
|
|
prefillFrom: (source) => ({
|
|
patientName: source.patientName,
|
|
patientId: source.patientId,
|
|
appointmentType: 'follow-up',
|
|
content: 'Medicatie-evaluatie',
|
|
}),
|
|
},
|
|
priority: 'medium',
|
|
category: 'medicatie',
|
|
enabled: true,
|
|
},
|
|
|
|
// Incident Protocol
|
|
{
|
|
id: 'incident-melding',
|
|
name: 'Incidentmelding',
|
|
description: 'Bij ernstige incidenten altijd MIC-melding overwegen',
|
|
trigger: {
|
|
intent: 'dagnotitie',
|
|
conditions: [
|
|
{ field: 'category', operator: 'equals', value: 'incident' },
|
|
{ field: 'severity', operator: 'equals', value: 'high' },
|
|
],
|
|
},
|
|
suggestion: {
|
|
intent: 'dagnotitie',
|
|
message: 'MIC-melding aanmaken voor dit incident?',
|
|
prefillFrom: (source) => ({
|
|
patientName: source.patientName,
|
|
patientId: source.patientId,
|
|
category: 'incident',
|
|
content: `MIC: ${source.content}`,
|
|
}),
|
|
},
|
|
priority: 'high',
|
|
category: 'veiligheid',
|
|
enabled: true,
|
|
},
|
|
|
|
// Crisis Protocol (van de UX simulatie)
|
|
{
|
|
id: 'crisis-signalering',
|
|
name: 'Suïcidaliteit signalering',
|
|
description: 'Bij signalen van suïcidaliteit crisisprotocol checken',
|
|
trigger: {
|
|
intent: 'dagnotitie',
|
|
conditions: [
|
|
{ field: 'content', operator: 'matches', value: /suïcid|zelfmoord|dood|uitzichtloos|geen zin/i },
|
|
],
|
|
},
|
|
suggestion: {
|
|
intent: 'unknown', // Special action: show crisis protocol
|
|
message: 'Crisisprotocol raadplegen? Signaleringsplan updaten?',
|
|
prefillFrom: (source) => ({
|
|
patientName: source.patientName,
|
|
patientId: source.patientId,
|
|
}),
|
|
},
|
|
priority: 'high',
|
|
category: 'veiligheid',
|
|
enabled: true,
|
|
},
|
|
|
|
// Afspraak verzet Protocol
|
|
{
|
|
id: 'afspraak-bellen',
|
|
name: 'Patiënt informeren',
|
|
description: 'Bij annulering patiënt informeren',
|
|
trigger: {
|
|
intent: 'cancel_appointment',
|
|
conditions: [],
|
|
},
|
|
suggestion: {
|
|
intent: 'dagnotitie',
|
|
message: 'Bellen genoteerd? Patiënt geïnformeerd over annulering?',
|
|
prefillFrom: (source) => ({
|
|
patientName: source.patientName,
|
|
patientId: source.patientId,
|
|
category: 'observatie',
|
|
content: 'Telefonisch geïnformeerd over geannuleerde afspraak',
|
|
}),
|
|
},
|
|
priority: 'low',
|
|
category: 'administratief',
|
|
enabled: true,
|
|
},
|
|
];
|
|
|
|
// ============================================================================
|
|
// NUDGE ENGINE
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check if a condition matches the entities
|
|
*/
|
|
function checkCondition(
|
|
condition: ProtocolCondition,
|
|
entities: ExtractedEntities
|
|
): boolean {
|
|
const value = entities[condition.field];
|
|
|
|
switch (condition.operator) {
|
|
case 'equals':
|
|
return value === condition.value;
|
|
|
|
case 'contains':
|
|
return typeof value === 'string' &&
|
|
typeof condition.value === 'string' &&
|
|
value.toLowerCase().includes(condition.value.toLowerCase());
|
|
|
|
case 'exists':
|
|
return value !== undefined && value !== null && value !== '';
|
|
|
|
case 'matches':
|
|
return typeof value === 'string' &&
|
|
condition.value instanceof RegExp &&
|
|
condition.value.test(value);
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate all protocol rules against a completed action
|
|
*/
|
|
export function evaluateNudge(
|
|
completedAction: IntentAction
|
|
): NudgeSuggestion[] {
|
|
const suggestions: NudgeSuggestion[] = [];
|
|
|
|
for (const rule of PROTOCOL_RULES) {
|
|
if (!rule.enabled) continue;
|
|
|
|
// Check if trigger intent matches
|
|
if (rule.trigger.intent !== completedAction.intent) continue;
|
|
|
|
// Check all conditions
|
|
const conditionsMet = !rule.trigger.conditions ||
|
|
rule.trigger.conditions.every(cond =>
|
|
checkCondition(cond, completedAction.entities)
|
|
);
|
|
|
|
if (!conditionsMet) continue;
|
|
|
|
// Generate suggestion
|
|
const suggestion: NudgeSuggestion = {
|
|
id: crypto.randomUUID(),
|
|
trigger: {
|
|
actionId: completedAction.id,
|
|
intent: completedAction.intent,
|
|
entities: completedAction.entities,
|
|
},
|
|
suggestion: {
|
|
intent: rule.suggestion.intent,
|
|
entities: rule.suggestion.prefillFrom(completedAction.entities),
|
|
message: rule.suggestion.message,
|
|
rationale: rule.description,
|
|
},
|
|
status: 'pending',
|
|
priority: rule.priority,
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
suggestions.push(suggestion);
|
|
}
|
|
|
|
// Sort by priority (high first)
|
|
return suggestions.sort((a, b) => {
|
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. API Design
|
|
|
|
### 7.1 Intent Classification API (Enhanced)
|
|
|
|
**Endpoint:** `POST /api/intent/classify`
|
|
|
|
```typescript
|
|
// app/api/intent/classify/route.ts (V2)
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { classifyWithReflex } from '@/lib/cortex/reflex-classifier';
|
|
import { classifyWithOrchestrator } from '@/lib/cortex/orchestrator';
|
|
import { evaluateNudge } from '@/lib/cortex/nudge';
|
|
import { extractEntities } from '@/lib/cortex/entity-extractor';
|
|
import type { ClassificationResult, CortexContext } from '@/lib/cortex/types';
|
|
|
|
// Request schema
|
|
interface ClassifyRequest {
|
|
input: string;
|
|
context: CortexContext;
|
|
options?: {
|
|
forceAI?: boolean;
|
|
skipSafetyNet?: boolean;
|
|
};
|
|
}
|
|
|
|
// Response schema
|
|
interface ClassifyResponse {
|
|
chain: IntentChain;
|
|
handledBy: 'reflex' | 'orchestrator';
|
|
|
|
// Clarification (if needed)
|
|
needsClarification?: boolean;
|
|
clarificationQuestion?: string;
|
|
clarificationOptions?: string[];
|
|
|
|
// Nudge suggestions (if any)
|
|
suggestions?: NudgeSuggestion[];
|
|
|
|
// Debug info (dev only)
|
|
debug?: object;
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
const body: ClassifyRequest = await request.json();
|
|
const { input, context, options } = body;
|
|
|
|
// Step 1: Try Reflex Arc (Layer 1)
|
|
const reflexResult = classifyWithReflex(input);
|
|
|
|
let result: ClassificationResult;
|
|
|
|
if (reflexResult.shouldEscalateToAI || options?.forceAI) {
|
|
// Step 2: Escalate to Orchestrator (Layer 2)
|
|
const aiResult = await classifyWithOrchestrator(input, context);
|
|
|
|
result = {
|
|
chain: aiResult.chain,
|
|
handledBy: 'orchestrator',
|
|
totalProcessingTimeMs: performance.now() - startTime,
|
|
debug: process.env.NODE_ENV === 'development' ? {
|
|
reflexResult,
|
|
aiResult: { ...aiResult, chain: undefined },
|
|
} : undefined,
|
|
};
|
|
|
|
// Handle clarification
|
|
if (aiResult.needsClarification) {
|
|
return NextResponse.json({
|
|
...result,
|
|
needsClarification: true,
|
|
clarificationQuestion: aiResult.clarificationQuestion,
|
|
clarificationOptions: aiResult.clarificationOptions,
|
|
});
|
|
}
|
|
} else {
|
|
// Handle locally with Reflex Arc
|
|
const entities = extractEntities(input, reflexResult.intent);
|
|
|
|
result = {
|
|
chain: {
|
|
id: crypto.randomUUID(),
|
|
originalInput: input,
|
|
createdAt: new Date(),
|
|
actions: [{
|
|
id: crypto.randomUUID(),
|
|
sequence: 1,
|
|
intent: reflexResult.intent,
|
|
confidence: reflexResult.confidence,
|
|
entities,
|
|
status: 'pending',
|
|
requiresConfirmation: false,
|
|
}],
|
|
status: 'pending',
|
|
meta: {
|
|
source: 'local',
|
|
processingTimeMs: reflexResult.processingTimeMs,
|
|
},
|
|
},
|
|
handledBy: 'reflex',
|
|
totalProcessingTimeMs: performance.now() - startTime,
|
|
};
|
|
}
|
|
|
|
return NextResponse.json(result);
|
|
|
|
} catch (error) {
|
|
console.error('Classification error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Classificatie mislukt' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.2 Action Execution API
|
|
|
|
**Endpoint:** `POST /api/intent/execute`
|
|
|
|
```typescript
|
|
// app/api/intent/execute/route.ts
|
|
|
|
interface ExecuteRequest {
|
|
chainId: string;
|
|
actionId: string;
|
|
confirmed?: boolean; // For actions requiring confirmation
|
|
}
|
|
|
|
interface ExecuteResponse {
|
|
success: boolean;
|
|
action: IntentAction;
|
|
suggestions?: NudgeSuggestion[]; // From Nudge
|
|
error?: string;
|
|
}
|
|
```
|
|
|
|
### 7.3 Context API
|
|
|
|
**Endpoint:** `GET /api/cortex/context`
|
|
|
|
Returns current context for AI classification:
|
|
- Active patient
|
|
- Today's agenda
|
|
- Recent actions
|
|
- Current shift
|
|
|
|
---
|
|
|
|
## 8. Frontend Components
|
|
|
|
### 8.1 Component Hierarchy
|
|
|
|
```
|
|
CommandCenter (v3.0)
|
|
├── ContextBar
|
|
├── ChatPanel
|
|
│ ├── ChatMessages
|
|
│ │ ├── UserMessage
|
|
│ │ ├── AssistantMessage
|
|
│ │ │ └── ActionChainCard (NEW)
|
|
│ │ │ ├── ActionItem
|
|
│ │ │ ├── ActionItem
|
|
│ │ │ └── ConfirmationDialog
|
|
│ │ └── ClarificationCard (NEW)
|
|
│ └── ChatInput
|
|
├── ArtifactArea
|
|
│ ├── ArtifactTabs
|
|
│ └── ArtifactContainer
|
|
└── NudgeToast (NEW - Layer 3)
|
|
```
|
|
|
|
### 8.2 ActionChainCard Component
|
|
|
|
```tsx
|
|
// components/cortex/chat/action-chain-card.tsx
|
|
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Check, X, Loader2, AlertCircle } from 'lucide-react';
|
|
import type { IntentChain, IntentAction } from '@/lib/cortex/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ActionChainCardProps {
|
|
chain: IntentChain;
|
|
onConfirm: (actionId: string) => void;
|
|
onSkip: (actionId: string) => void;
|
|
onRetry: (actionId: string) => void;
|
|
}
|
|
|
|
const STATUS_ICONS = {
|
|
pending: <div className="w-2 h-2 rounded-full bg-slate-300" />,
|
|
confirming: <AlertCircle className="w-4 h-4 text-amber-500" />,
|
|
executing: <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />,
|
|
success: <Check className="w-4 h-4 text-green-500" />,
|
|
failed: <X className="w-4 h-4 text-red-500" />,
|
|
skipped: <X className="w-4 h-4 text-slate-400" />,
|
|
};
|
|
|
|
const INTENT_LABELS: Record<string, string> = {
|
|
dagnotitie: 'Notitie',
|
|
zoeken: 'Zoeken',
|
|
overdracht: 'Overdracht',
|
|
agenda_query: 'Agenda',
|
|
create_appointment: 'Afspraak maken',
|
|
cancel_appointment: 'Afspraak annuleren',
|
|
reschedule_appointment: 'Afspraak verzetten',
|
|
};
|
|
|
|
export function ActionChainCard({
|
|
chain,
|
|
onConfirm,
|
|
onSkip,
|
|
onRetry
|
|
}: ActionChainCardProps) {
|
|
return (
|
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-4 py-2 bg-slate-50 border-b border-slate-200">
|
|
<span className="text-sm text-slate-600">
|
|
{chain.actions.length} {chain.actions.length === 1 ? 'actie' : 'acties'} gedetecteerd
|
|
</span>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="divide-y divide-slate-100">
|
|
{chain.actions.map((action, index) => (
|
|
<ActionItem
|
|
key={action.id}
|
|
action={action}
|
|
index={index}
|
|
onConfirm={() => onConfirm(action.id)}
|
|
onSkip={() => onSkip(action.id)}
|
|
onRetry={() => onRetry(action.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* AI Reasoning (collapsible) */}
|
|
{chain.meta.aiReasoning && (
|
|
<details className="px-4 py-2 bg-slate-50 border-t border-slate-200">
|
|
<summary className="text-xs text-slate-500 cursor-pointer">
|
|
AI Redenering
|
|
</summary>
|
|
<p className="mt-1 text-xs text-slate-600">
|
|
{chain.meta.aiReasoning}
|
|
</p>
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionItem({
|
|
action,
|
|
index,
|
|
onConfirm,
|
|
onSkip,
|
|
onRetry
|
|
}: {
|
|
action: IntentAction;
|
|
index: number;
|
|
onConfirm: () => void;
|
|
onSkip: () => void;
|
|
onRetry: () => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className={cn(
|
|
"px-4 py-3",
|
|
action.status === 'confirming' && "bg-amber-50",
|
|
action.status === 'failed' && "bg-red-50",
|
|
)}>
|
|
{/* Main row */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Sequence number */}
|
|
<span className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-xs font-medium text-slate-600">
|
|
{index + 1}
|
|
</span>
|
|
|
|
{/* Status icon */}
|
|
{STATUS_ICONS[action.status]}
|
|
|
|
{/* Intent label */}
|
|
<span className="font-medium text-slate-900">
|
|
{INTENT_LABELS[action.intent] || action.intent}
|
|
</span>
|
|
|
|
{/* Entity preview */}
|
|
{action.entities.patientName && (
|
|
<span className="text-slate-500">
|
|
• {action.entities.patientName}
|
|
</span>
|
|
)}
|
|
|
|
{/* Confidence badge */}
|
|
<span className={cn(
|
|
"ml-auto text-xs px-2 py-0.5 rounded-full",
|
|
action.confidence >= 0.9 && "bg-green-100 text-green-700",
|
|
action.confidence >= 0.7 && action.confidence < 0.9 && "bg-amber-100 text-amber-700",
|
|
action.confidence < 0.7 && "bg-red-100 text-red-700",
|
|
)}>
|
|
{Math.round(action.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Confirmation buttons */}
|
|
{action.status === 'confirming' && action.confirmationMessage && (
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<span className="text-sm text-amber-700">
|
|
{action.confirmationMessage}
|
|
</span>
|
|
<Button size="sm" onClick={onConfirm}>
|
|
Bevestig
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={onSkip}>
|
|
Overslaan
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{action.status === 'failed' && action.error && (
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<span className="text-sm text-red-700">
|
|
{action.error.message}
|
|
</span>
|
|
{action.error.recoverable && (
|
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
Opnieuw
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 8.3 NudgeToast Component (Layer 3 UI)
|
|
|
|
```tsx
|
|
// components/cortex/command-center/nudge-toast.tsx
|
|
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { X, Lightbulb, ArrowRight } from 'lucide-react';
|
|
import type { NudgeSuggestion } from '@/lib/cortex/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface NudgeToastProps {
|
|
suggestion: NudgeSuggestion;
|
|
onAccept: (suggestionId: string) => void;
|
|
onDismiss: (suggestionId: string) => void;
|
|
}
|
|
|
|
const PRIORITY_STYLES = {
|
|
high: 'border-red-200 bg-red-50',
|
|
medium: 'border-amber-200 bg-amber-50',
|
|
low: 'border-blue-200 bg-blue-50',
|
|
};
|
|
|
|
export function NudgeToast({
|
|
suggestion,
|
|
onAccept,
|
|
onDismiss
|
|
}: NudgeToastProps) {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [timeLeft, setTimeLeft] = useState(100);
|
|
|
|
// Animate in
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsVisible(true), 100);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
// Countdown timer
|
|
useEffect(() => {
|
|
if (!suggestion.expiresAt) return;
|
|
|
|
const interval = setInterval(() => {
|
|
const now = Date.now();
|
|
const expires = new Date(suggestion.expiresAt!).getTime();
|
|
const total = expires - new Date(suggestion.createdAt).getTime();
|
|
const remaining = expires - now;
|
|
|
|
if (remaining <= 0) {
|
|
onDismiss(suggestion.id);
|
|
} else {
|
|
setTimeLeft(Math.round((remaining / total) * 100));
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [suggestion, onDismiss]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"fixed bottom-4 right-4 max-w-sm rounded-lg border shadow-lg transition-all duration-300",
|
|
PRIORITY_STYLES[suggestion.priority],
|
|
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
|
)}
|
|
>
|
|
{/* Progress bar */}
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-slate-200 rounded-t-lg overflow-hidden">
|
|
<div
|
|
className="h-full bg-slate-400 transition-all duration-1000"
|
|
style={{ width: `${timeLeft}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4 pt-5">
|
|
<div className="flex items-start gap-3">
|
|
<Lightbulb className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
|
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-slate-900">
|
|
{suggestion.suggestion.message}
|
|
</p>
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
{suggestion.suggestion.rationale}
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => onDismiss(suggestion.id)}
|
|
className="text-slate-400 hover:text-slate-600"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => onAccept(suggestion.id)}
|
|
className="gap-1"
|
|
>
|
|
Ja, doe maar
|
|
<ArrowRight className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onDismiss(suggestion.id)}
|
|
>
|
|
Nee, bedankt
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 8.4 ClarificationCard Component
|
|
|
|
```tsx
|
|
// components/cortex/chat/clarification-card.tsx
|
|
|
|
'use client';
|
|
|
|
import { HelpCircle } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
interface ClarificationCardProps {
|
|
question: string;
|
|
options: string[];
|
|
onSelect: (option: string) => void;
|
|
}
|
|
|
|
export function ClarificationCard({
|
|
question,
|
|
options,
|
|
onSelect
|
|
}: ClarificationCardProps) {
|
|
return (
|
|
<div className="bg-blue-50 rounded-lg border border-blue-200 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<HelpCircle className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
|
|
|
<div>
|
|
<p className="text-sm text-slate-700">{question}</p>
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{options.map((option) => (
|
|
<Button
|
|
key={option}
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => onSelect(option)}
|
|
className="bg-white"
|
|
>
|
|
{option}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. State Management
|
|
|
|
### 9.1 Enhanced Cortex Store
|
|
|
|
```typescript
|
|
// stores/cortex-store.ts (V2 additions)
|
|
|
|
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import type {
|
|
CortexContext,
|
|
IntentChain,
|
|
IntentAction,
|
|
NudgeSuggestion,
|
|
} from '@/lib/cortex/types';
|
|
|
|
interface CortexStoreV2 {
|
|
// ============ CONTEXT ============
|
|
context: CortexContext;
|
|
setContext: (context: Partial<CortexContext>) => void;
|
|
|
|
// ============ INTENT CHAINS ============
|
|
activeChain: IntentChain | null;
|
|
chainHistory: IntentChain[];
|
|
|
|
// Chain actions
|
|
startChain: (chain: IntentChain) => void;
|
|
updateActionStatus: (
|
|
chainId: string,
|
|
actionId: string,
|
|
status: IntentAction['status'],
|
|
error?: IntentAction['error']
|
|
) => void;
|
|
confirmAction: (chainId: string, actionId: string) => void;
|
|
skipAction: (chainId: string, actionId: string) => void;
|
|
completeChain: (chainId: string) => void;
|
|
|
|
// ============ NUDGE ============
|
|
suggestions: NudgeSuggestion[];
|
|
|
|
addSuggestion: (suggestion: NudgeSuggestion) => void;
|
|
acceptSuggestion: (suggestionId: string) => void;
|
|
dismissSuggestion: (suggestionId: string) => void;
|
|
clearExpiredSuggestions: () => void;
|
|
|
|
// ============ CLARIFICATION ============
|
|
pendingClarification: {
|
|
question: string;
|
|
options: string[];
|
|
originalInput: string;
|
|
} | null;
|
|
|
|
setClarification: (clarification: CortexStoreV2['pendingClarification']) => void;
|
|
answerClarification: (answer: string) => void;
|
|
}
|
|
|
|
export const useCortexStoreV2 = create<CortexStoreV2>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
// Context
|
|
context: {
|
|
activePatient: null,
|
|
currentView: 'dashboard',
|
|
shift: 'ochtend',
|
|
currentTime: new Date(),
|
|
agendaToday: [],
|
|
recentIntents: [],
|
|
},
|
|
|
|
setContext: (partial) => set((state) => ({
|
|
context: { ...state.context, ...partial },
|
|
})),
|
|
|
|
// Chains
|
|
activeChain: null,
|
|
chainHistory: [],
|
|
|
|
startChain: (chain) => set({
|
|
activeChain: chain,
|
|
}),
|
|
|
|
updateActionStatus: (chainId, actionId, status, error) => set((state) => {
|
|
if (state.activeChain?.id !== chainId) return state;
|
|
|
|
const actions = state.activeChain.actions.map(action =>
|
|
action.id === actionId
|
|
? {
|
|
...action,
|
|
status,
|
|
error,
|
|
...(status === 'executing' ? { startedAt: new Date() } : {}),
|
|
...(status === 'success' || status === 'failed' ? { completedAt: new Date() } : {}),
|
|
}
|
|
: action
|
|
);
|
|
|
|
return {
|
|
activeChain: {
|
|
...state.activeChain,
|
|
actions,
|
|
},
|
|
};
|
|
}),
|
|
|
|
confirmAction: (chainId, actionId) => set((state) => {
|
|
if (state.activeChain?.id !== chainId) return state;
|
|
|
|
return {
|
|
activeChain: {
|
|
...state.activeChain,
|
|
actions: state.activeChain.actions.map(action =>
|
|
action.id === actionId
|
|
? { ...action, status: 'executing' as const }
|
|
: action
|
|
),
|
|
},
|
|
};
|
|
}),
|
|
|
|
skipAction: (chainId, actionId) => set((state) => {
|
|
if (state.activeChain?.id !== chainId) return state;
|
|
|
|
return {
|
|
activeChain: {
|
|
...state.activeChain,
|
|
actions: state.activeChain.actions.map(action =>
|
|
action.id === actionId
|
|
? { ...action, status: 'skipped' as const }
|
|
: action
|
|
),
|
|
},
|
|
};
|
|
}),
|
|
|
|
completeChain: (chainId) => set((state) => {
|
|
if (state.activeChain?.id !== chainId) return state;
|
|
|
|
const completedChain = {
|
|
...state.activeChain,
|
|
status: 'completed' as const,
|
|
};
|
|
|
|
return {
|
|
activeChain: null,
|
|
chainHistory: [completedChain, ...state.chainHistory].slice(0, 20),
|
|
};
|
|
}),
|
|
|
|
// Suggestions
|
|
suggestions: [],
|
|
|
|
addSuggestion: (suggestion) => set((state) => ({
|
|
suggestions: [...state.suggestions, suggestion],
|
|
})),
|
|
|
|
acceptSuggestion: (suggestionId) => set((state) => ({
|
|
suggestions: state.suggestions.map(s =>
|
|
s.id === suggestionId
|
|
? { ...s, status: 'accepted' as const }
|
|
: s
|
|
),
|
|
})),
|
|
|
|
dismissSuggestion: (suggestionId) => set((state) => ({
|
|
suggestions: state.suggestions.filter(s => s.id !== suggestionId),
|
|
})),
|
|
|
|
clearExpiredSuggestions: () => set((state) => ({
|
|
suggestions: state.suggestions.filter(s =>
|
|
!s.expiresAt || new Date(s.expiresAt) > new Date()
|
|
),
|
|
})),
|
|
|
|
// Clarification
|
|
pendingClarification: null,
|
|
|
|
setClarification: (clarification) => set({
|
|
pendingClarification: clarification,
|
|
}),
|
|
|
|
answerClarification: (answer) => {
|
|
const { pendingClarification } = get();
|
|
if (!pendingClarification) return;
|
|
|
|
// Re-classify with the clarified input
|
|
// This would trigger a new classification request
|
|
set({ pendingClarification: null });
|
|
},
|
|
}),
|
|
{
|
|
name: 'cortex-store-v2',
|
|
partialize: (state) => ({
|
|
chainHistory: state.chainHistory.slice(0, 10),
|
|
}),
|
|
}
|
|
),
|
|
{ name: 'cortex-v2' }
|
|
)
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Implementatie Roadmap
|
|
|
|
### Fase 1: Hybrid Foundation (Week 1-2)
|
|
|
|
| # | Task | Beschrijving | Effort |
|
|
|---|------|--------------|--------|
|
|
| 1.1 | Context Types | Nieuwe types voor CortexContext | S |
|
|
| 1.2 | Context API | GET /api/cortex/context endpoint | M |
|
|
| 1.3 | Context Injection | Update AI classifier om context te ontvangen | M |
|
|
| 1.4 | Reflex Complexity Detection | Multi-intent en context signals detectie | S |
|
|
| 1.5 | Clarification UI | ClarificationCard component | S |
|
|
|
|
**Deliverable:** AI begrijpt context en geeft verduidelijkingsvragen
|
|
|
|
### Fase 2: Multi-Intent Support (Week 3-4)
|
|
|
|
| # | Task | Beschrijving | Effort |
|
|
|---|------|--------------|--------|
|
|
| 2.1 | IntentChain Types | Nieuwe types voor chains en actions | M |
|
|
| 2.2 | Orchestrator Prompt | AI prompt voor multi-intent parsing | M |
|
|
| 2.3 | Chain Store | Zustand state voor chains | M |
|
|
| 2.4 | ActionChainCard | UI voor meerdere acties | L |
|
|
| 2.5 | Chain Execution | Sequential action execution met confirmations | L |
|
|
|
|
**Deliverable:** "Zeg Jan af en maak notitie" werkt
|
|
|
|
### Fase 3: Nudge (Week 5-6)
|
|
|
|
| # | Task | Beschrijving | Effort |
|
|
|---|------|--------------|--------|
|
|
| 3.1 | Protocol Rules | Rule definitions voor wondzorg, medicatie | M |
|
|
| 3.2 | Nudge Engine | evaluateNudge functie | M |
|
|
| 3.3 | NudgeToast | UI component met timer | M |
|
|
| 3.4 | Suggestion Flow | Accept/dismiss handling | S |
|
|
| 3.5 | Protocol Admin | Admin UI voor regels (optional) | L |
|
|
|
|
**Deliverable:** Proactieve suggesties na acties
|
|
|
|
### Fase 4: Polish & Optimization (Week 7-8)
|
|
|
|
| # | Task | Beschrijving | Effort |
|
|
|---|------|--------------|--------|
|
|
| 4.1 | Error Handling | Graceful degradation, rollbacks | M |
|
|
| 4.2 | Performance | Caching, parallel requests | M |
|
|
| 4.3 | Analytics | Telemetry voor classificatie success | S |
|
|
| 4.4 | Testing | E2E tests voor hele flow | L |
|
|
| 4.5 | Documentation | API docs, user guide | S |
|
|
|
|
---
|
|
|
|
## 11. Testing Strategy
|
|
|
|
### 11.1 Unit Tests
|
|
|
|
```typescript
|
|
// lib/cortex/__tests__/reflex-classifier.test.ts
|
|
|
|
import { classifyWithReflex } from '../reflex-classifier';
|
|
|
|
describe('Reflex Classifier', () => {
|
|
describe('Simple intents (should NOT escalate)', () => {
|
|
it('handles "notitie jan medicatie"', () => {
|
|
const result = classifyWithReflex('notitie jan medicatie');
|
|
expect(result.intent).toBe('dagnotitie');
|
|
expect(result.confidence).toBeGreaterThanOrEqual(0.9);
|
|
expect(result.shouldEscalateToAI).toBe(false);
|
|
});
|
|
|
|
it('handles "agenda vandaag"', () => {
|
|
const result = classifyWithReflex('agenda vandaag');
|
|
expect(result.intent).toBe('agenda_query');
|
|
expect(result.shouldEscalateToAI).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Complex intents (SHOULD escalate)', () => {
|
|
it('escalates multi-intent: "zeg jan af en maak notitie"', () => {
|
|
const result = classifyWithReflex('zeg jan af en maak notitie');
|
|
expect(result.shouldEscalateToAI).toBe(true);
|
|
expect(result.escalationReason).toBe('multi_intent_detected');
|
|
});
|
|
|
|
it('escalates context-dependent: "maak notitie voor hem"', () => {
|
|
const result = classifyWithReflex('maak notitie voor hem');
|
|
expect(result.shouldEscalateToAI).toBe(true);
|
|
expect(result.escalationReason).toBe('needs_context');
|
|
});
|
|
|
|
it('escalates low confidence', () => {
|
|
const result = classifyWithReflex('help jan met iets');
|
|
expect(result.shouldEscalateToAI).toBe(true);
|
|
expect(result.escalationReason).toBe('low_confidence');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### 11.2 Integration Tests
|
|
|
|
```typescript
|
|
// __tests__/api/intent-classify.test.ts
|
|
|
|
describe('POST /api/intent/classify', () => {
|
|
describe('Multi-intent handling', () => {
|
|
it('returns chain with multiple actions', async () => {
|
|
const response = await fetch('/api/intent/classify', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
input: 'Zeg de afspraak van Jan af en maak een notitie dat hij ziek is',
|
|
context: mockContext,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
expect(data.chain.actions).toHaveLength(2);
|
|
expect(data.chain.actions[0].intent).toBe('cancel_appointment');
|
|
expect(data.chain.actions[1].intent).toBe('dagnotitie');
|
|
});
|
|
});
|
|
|
|
describe('Context resolution', () => {
|
|
it('resolves "hij" to active patient', async () => {
|
|
const response = await fetch('/api/intent/classify', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
input: 'Maak notitie voor hem',
|
|
context: {
|
|
...mockContext,
|
|
activePatient: { id: '123', name: 'Jan de Vries' },
|
|
},
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
expect(data.chain.actions[0].entities.patientName).toBe('Jan de Vries');
|
|
expect(data.chain.actions[0].entities.patientResolution).toBe('pronoun');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### 11.3 Test Zinnen Dataset
|
|
|
|
```json
|
|
// lib/cortex/__tests__/test-sentences.json
|
|
{
|
|
"single_intent": [
|
|
{ "input": "notitie jan medicatie", "expected": ["dagnotitie"] },
|
|
{ "input": "zoek marie", "expected": ["zoeken"] },
|
|
{ "input": "agenda morgen", "expected": ["agenda_query"] }
|
|
],
|
|
"multi_intent": [
|
|
{
|
|
"input": "Zeg Jan af en maak notitie dat hij griep heeft",
|
|
"expected": ["cancel_appointment", "dagnotitie"],
|
|
"entities": [
|
|
{ "patientName": "Jan" },
|
|
{ "patientName": "Jan", "content": "griep" }
|
|
]
|
|
},
|
|
{
|
|
"input": "Plan intake marie morgen 14:00 en bel haar huisarts",
|
|
"expected": ["create_appointment", "dagnotitie"],
|
|
"entities": [
|
|
{ "patientName": "Marie", "time": "14:00" },
|
|
{ "patientName": "Marie" }
|
|
]
|
|
}
|
|
],
|
|
"context_dependent": [
|
|
{
|
|
"input": "Maak notitie voor hem",
|
|
"context": { "activePatient": { "name": "Piet" } },
|
|
"expected": ["dagnotitie"],
|
|
"entities": [{ "patientName": "Piet", "patientResolution": "pronoun" }]
|
|
}
|
|
],
|
|
"ambiguous": [
|
|
{
|
|
"input": "Plan wondzorg",
|
|
"shouldClarify": true,
|
|
"clarificationOptions": ["Notitie maken", "Afspraak inplannen"]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Migratie Plan
|
|
|
|
### 12.1 Backward Compatibility
|
|
|
|
Het V2 systeem moet naast V1 kunnen draaien tijdens de migratie:
|
|
|
|
```typescript
|
|
// lib/cortex/intent-classifier-adapter.ts
|
|
|
|
import { classifyIntent as classifyV1 } from './intent-classifier';
|
|
import { classifyWithReflex } from './reflex-classifier';
|
|
import { classifyWithOrchestrator } from './orchestrator';
|
|
|
|
const USE_V2 = process.env.NEXT_PUBLIC_CORTEX_V2 === 'true';
|
|
|
|
export async function classifyIntent(
|
|
input: string,
|
|
context?: CortexContext
|
|
): Promise<ClassificationResult> {
|
|
if (!USE_V2) {
|
|
// V1 path - single intent
|
|
const result = classifyV1(input);
|
|
return {
|
|
chain: {
|
|
id: crypto.randomUUID(),
|
|
originalInput: input,
|
|
actions: [{
|
|
id: crypto.randomUUID(),
|
|
sequence: 1,
|
|
intent: result.intent,
|
|
confidence: result.confidence,
|
|
entities: {},
|
|
status: 'pending',
|
|
requiresConfirmation: false,
|
|
}],
|
|
status: 'pending',
|
|
meta: { source: 'local', processingTimeMs: result.processingTimeMs },
|
|
createdAt: new Date(),
|
|
},
|
|
handledBy: 'reflex',
|
|
totalProcessingTimeMs: result.processingTimeMs,
|
|
};
|
|
}
|
|
|
|
// V2 path - full cortex
|
|
const reflex = classifyWithReflex(input);
|
|
|
|
if (!reflex.shouldEscalateToAI) {
|
|
// Handle locally
|
|
return buildLocalResult(input, reflex);
|
|
}
|
|
|
|
// Escalate to AI
|
|
return classifyWithOrchestrator(input, context!);
|
|
}
|
|
```
|
|
|
|
### 12.2 Feature Flags
|
|
|
|
```typescript
|
|
// lib/config/feature-flags.ts
|
|
|
|
export const FEATURE_FLAGS = {
|
|
// V2 Features
|
|
CORTEX_V2_ENABLED: process.env.NEXT_PUBLIC_CORTEX_V2 === 'true',
|
|
CORTEX_MULTI_INTENT: process.env.NEXT_PUBLIC_CORTEX_MULTI_INTENT === 'true',
|
|
CORTEX_NUDGE: process.env.NEXT_PUBLIC_CORTEX_NUDGE === 'true',
|
|
CORTEX_CONTEXT_INJECTION: process.env.NEXT_PUBLIC_CORTEX_CONTEXT === 'true',
|
|
|
|
// Rollout percentage (A/B testing)
|
|
CORTEX_V2_ROLLOUT: parseInt(process.env.NEXT_PUBLIC_CORTEX_V2_ROLLOUT || '0', 10),
|
|
};
|
|
|
|
export function isCortexV2Enabled(userId?: string): boolean {
|
|
if (!FEATURE_FLAGS.CORTEX_V2_ENABLED) return false;
|
|
|
|
// A/B test based on user ID hash
|
|
if (userId && FEATURE_FLAGS.CORTEX_V2_ROLLOUT < 100) {
|
|
const hash = simpleHash(userId);
|
|
return hash % 100 < FEATURE_FLAGS.CORTEX_V2_ROLLOUT;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### 12.3 Rollout Strategie
|
|
|
|
| Week | Rollout % | Features | Monitoring |
|
|
|------|-----------|----------|------------|
|
|
| 1 | 0% (dev only) | Context injection | Error rates |
|
|
| 2 | 10% | + Multi-intent | Classification accuracy |
|
|
| 3 | 25% | + Safety Net | Suggestion acceptance rate |
|
|
| 4 | 50% | Full V2 | User feedback |
|
|
| 5 | 75% | Full V2 | Performance metrics |
|
|
| 6 | 100% | Full V2 | Final review |
|
|
|
|
---
|
|
|
|
## Bijlagen
|
|
|
|
### A. Glossary
|
|
|
|
| Term | Definitie |
|
|
|------|-----------|
|
|
| **Intent** | De actie die de gebruiker wil uitvoeren (bijv. "dagnotitie") |
|
|
| **IntentChain** | Lijst van intents geëxtraheerd uit één uiting |
|
|
| **Reflex Arc** | Layer 1 - snelle lokale pattern matching |
|
|
| **Orchestrator** | Layer 2 - AI-gedreven classificatie |
|
|
| **Nudge** | Layer 3 - proactieve suggesties |
|
|
| **Entity** | Geëxtraheerde data (patiëntnaam, datum, etc.) |
|
|
| **Artifact** | UI component voor een specifieke taak |
|
|
|
|
### B. Referenties
|
|
|
|
- [FO Cortex Intent System V2](./fo-swift-intent-system-v2.md)
|
|
- [UX Simulatie Next Level](./ux-simulation-intent-next-level.md)
|
|
- [UX Evaluatie Schaalbaarheid](./ux-evaluation-intent-scalability.md)
|
|
- [Architecture Proposal V2](./intent-architecture-v2-proposal.md)
|
|
|
|
### C. Versie Historie
|
|
|
|
| Versie | Datum | Auteur | Wijzigingen |
|
|
|--------|-------|--------|-------------|
|
|
| 2.0 | 29-12-2025 | Colin Lit | Initieel document |
|
|
|