diff --git a/app/api/cortex/classify/route.ts b/app/api/cortex/classify/route.ts new file mode 100644 index 0000000..18dcae8 --- /dev/null +++ b/app/api/cortex/classify/route.ts @@ -0,0 +1,272 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; +import { classifyWithReflex } from '@/lib/cortex/reflex-classifier'; +import { + classifyWithOrchestrator, + buildFallbackChain, + buildChainFromReflex, +} from '@/lib/cortex/orchestrator'; +import { isFeatureEnabled } from '@/lib/config/feature-flags'; +import type { + CortexContext, + CortexIntent, + IntentChain, + LocalClassificationResult, +} from '@/lib/cortex/types'; + +/** + * Cortex Classify API (E2.S5) + * + * POST /api/cortex/classify + * + * Hybrid classification endpoint: + * 1. Tries Reflex Arc (Layer 1) first - fast, local pattern matching + * 2. Escalates to Orchestrator (Layer 2) if needed - AI-powered classification + * 3. Falls back to Reflex result if AI fails + */ + +// ============================================================================= +// Request/Response Schemas +// ============================================================================= + +/** Schema for request validation */ +const ClassifyRequestSchema = z.object({ + input: z + .string() + .min(1, 'Input mag niet leeg zijn') + .max(500, 'Input mag maximaal 500 karakters zijn'), + context: z.object({ + activePatient: z + .object({ + id: z.string(), + name: z.string(), + recentNotes: z.array(z.string()).optional(), + upcomingAppointments: z + .array( + z.object({ + date: z.string().transform((s) => new Date(s)), + type: z.string(), + }) + ) + .optional(), + }) + .nullable(), + currentView: z.enum([ + 'dashboard', + 'patient-detail', + 'agenda', + 'reports', + 'chat', + ]), + shift: z.enum(['nacht', 'ochtend', 'middag', 'avond']), + currentTime: z.string().transform((s) => new Date(s)), + agendaToday: z.array( + z.object({ + time: z.string(), + patientName: z.string(), + patientId: z.string(), + type: z.string(), + }) + ), + recentIntents: z.array( + z.object({ + intent: z.string(), + patientName: z.string().optional(), + timestamp: z.string().transform((s) => new Date(s)), + }) + ), + }), + options: z + .object({ + forceAI: z.boolean().optional(), + skipLogging: z.boolean().optional(), + }) + .optional(), +}); + +/** Response type for classify endpoint */ +export interface ClassifyResponse { + chain: IntentChain; + handledBy: 'reflex' | 'orchestrator'; + needsClarification?: boolean; + clarificationQuestion?: string; + clarificationOptions?: string[]; + debug?: { + reflexResult?: LocalClassificationResult; + processingTimeMs: number; + tokensUsed?: number; + }; +} + +// ============================================================================= +// POST Handler +// ============================================================================= + +export async function POST(request: NextRequest) { + const totalStartTime = performance.now(); + + try { + // 1. Auth check + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json( + { error: 'Niet geautoriseerd. Log opnieuw in.' }, + { status: 401 } + ); + } + + // 2. Parse and validate request + let json: unknown; + try { + json = await request.json(); + } catch { + return NextResponse.json( + { error: 'Ongeldige JSON in request body' }, + { status: 400 } + ); + } + + const parsed = ClassifyRequestSchema.safeParse(json); + + if (!parsed.success) { + return NextResponse.json( + { + error: 'Ongeldige request', + details: parsed.error.issues.map((i) => ({ + path: i.path.join('.'), + message: i.message, + })), + }, + { status: 400 } + ); + } + + const { input, context, options } = parsed.data; + const trimmedInput = input.trim(); + + // 3. Check feature flag + if (!isFeatureEnabled('CORTEX_V2_ENABLED')) { + return NextResponse.json( + { error: 'Cortex V2 is niet ingeschakeld' }, + { status: 503 } + ); + } + + // 4. Step 1: Try Reflex Arc (Layer 1) + const reflexResult = classifyWithReflex(trimmedInput); + + // Log reflex result (if logging enabled) + if (!options?.skipLogging && isFeatureEnabled('CORTEX_LOGGING')) { + console.log('[Cortex Classify] Reflex result:', { + input: trimmedInput.slice(0, 50), + intent: reflexResult.intent, + confidence: reflexResult.confidence, + shouldEscalate: reflexResult.shouldEscalateToAI, + reason: reflexResult.escalationReason, + timeMs: reflexResult.processingTimeMs.toFixed(2), + }); + } + + // 5. Check if we should escalate to Orchestrator + const shouldEscalate = reflexResult.shouldEscalateToAI || options?.forceAI; + + if (!shouldEscalate) { + // Reflex handled it - build simple chain + const chain = buildChainFromReflex(trimmedInput, reflexResult); + + const response: ClassifyResponse = { + chain, + handledBy: 'reflex', + debug: { + reflexResult, + processingTimeMs: performance.now() - totalStartTime, + }, + }; + + return NextResponse.json(response); + } + + // 6. Log escalation + if (!options?.skipLogging && isFeatureEnabled('CORTEX_LOGGING')) { + console.log('[Cortex Classify] Escalating to Orchestrator:', { + reason: reflexResult.escalationReason, + reflexIntent: reflexResult.intent, + reflexConfidence: reflexResult.confidence, + }); + } + + // 7. Step 2: Escalate to Orchestrator (Layer 2) + try { + // Transform context to proper CortexContext type + const cortexContext: CortexContext = { + ...context, + recentIntents: context.recentIntents.map((ri) => ({ + intent: ri.intent as CortexIntent, + patientName: ri.patientName, + timestamp: ri.timestamp, + })), + }; + + const aiResult = await classifyWithOrchestrator( + trimmedInput, + cortexContext + ); + + // Log AI result + if (!options?.skipLogging && isFeatureEnabled('CORTEX_LOGGING')) { + console.log('[Cortex Classify] Orchestrator result:', { + actionsCount: aiResult.chain.actions.length, + intents: aiResult.chain.actions.map((a) => a.intent), + tokensUsed: aiResult.tokensUsed, + timeMs: aiResult.processingTimeMs.toFixed(2), + }); + } + + const response: ClassifyResponse = { + chain: aiResult.chain, + handledBy: 'orchestrator', + needsClarification: aiResult.needsClarification, + clarificationQuestion: aiResult.clarificationQuestion, + clarificationOptions: aiResult.clarificationOptions, + debug: { + reflexResult, + processingTimeMs: performance.now() - totalStartTime, + tokensUsed: aiResult.tokensUsed, + }, + }; + + return NextResponse.json(response); + } catch (aiError) { + // 8. Graceful fallback on AI failure + console.error( + '[Cortex Classify] AI failed, falling back to Reflex:', + aiError instanceof Error ? aiError.message : aiError + ); + + const fallbackChain = buildFallbackChain(trimmedInput, reflexResult); + + const response: ClassifyResponse = { + chain: fallbackChain, + handledBy: 'reflex', // Mark as reflex since AI failed + debug: { + reflexResult, + processingTimeMs: performance.now() - totalStartTime, + }, + }; + + return NextResponse.json(response); + } + } catch (error) { + console.error('[Cortex Classify] Unexpected error:', error); + return NextResponse.json( + { error: 'Er ging iets mis bij classificatie. Probeer het opnieuw.' }, + { status: 500 } + ); + } +} diff --git a/docs/intent/bouwplan-cortex-v2.md b/docs/intent/bouwplan-cortex-v2.md index 112697d..d181f67 100644 --- a/docs/intent/bouwplan-cortex-v2.md +++ b/docs/intent/bouwplan-cortex-v2.md @@ -1,8 +1,8 @@ # Bouwplan — Cortex Intent System V2 **Projectnaam:** Cortex V2 - Agentic Intent Architecture -**Versie:** v1.1 -**Datum:** 31-12-2025 +**Versie:** v1.2 +**Datum:** 01-01-2026 **Auteur:** Colin Lit --- @@ -133,7 +133,7 @@ lib/config/ |---------|-------|------|--------|---------|--------------| | **E0** | Foundation & Context | Types, API, feature flags | ✅ Done | 5 | 8 SP | | **E1** | Reflex Arc (Layer 1) | Snelle lokale classificatie | ✅ Done | 4 | 6 SP | -| **E2** | Intent Orchestrator (Layer 2) | AI-gedreven multi-intent | ⏳ To Do | 6 | 13 SP | +| **E2** | Intent Orchestrator (Layer 2) | AI-gedreven multi-intent | ✅ Done | 6 | 13 SP | | **E3** | UI Components | ActionChainCard, ClarificationCard | ⏳ To Do | 4 | 8 SP | | **E4** | Nudge MVP (Layer 3) | Proactieve suggesties | ⏳ To Do | 3 | 5 SP | | **E5** | Integration & Polish | End-to-end flow, testing | ⏳ To Do | 4 | 8 SP | @@ -465,12 +465,12 @@ describe('Reflex Classifier', () => { | Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP | |----------|--------------|---------------------|--------|------------------|----| -| E2.S1 | **System prompt** voor Orchestrator | Prompt met context, intent types, output format | ⏳ | E0.S1 | 2 | -| E2.S2 | **Context formatting** voor AI | `formatContextForPrompt()` functie | ⏳ | E0.S2 | 1 | -| E2.S3 | **AI classification** endpoint | `classifyWithOrchestrator()` met Claude 3.5 Haiku | ⏳ | E2.S1, E2.S2 | 3 | -| E2.S4 | **IntentChain parsing** | JSON response naar IntentChain met actions | ⏳ | E2.S3 | 2 | -| E2.S5 | **POST /api/cortex/classify** | Hybrid endpoint: Reflex → Orchestrator fallback | ⏳ | E1.S1-S3, E2.S3-S4 | 3 | -| E2.S6 | **Graceful fallback** | Bij AI failure → fallback naar Reflex-only | ⏳ | E2.S5 | 2 | +| E2.S1 | **System prompt** voor Orchestrator | Prompt met context, intent types, output format | ✅ | E0.S1 | 2 | +| E2.S2 | **Context formatting** voor AI | `formatContextForPrompt()` functie | ✅ | E0.S2 | 1 | +| E2.S3 | **AI classification** endpoint | `classifyWithOrchestrator()` met Claude 3.5 Haiku | ✅ | E2.S1, E2.S2 | 3 | +| E2.S4 | **IntentChain parsing** | JSON response naar IntentChain met actions | ✅ | E2.S3 | 2 | +| E2.S5 | **POST /api/cortex/classify** | Hybrid endpoint: Reflex → Orchestrator fallback | ✅ | E1.S1-S3, E2.S3-S4 | 3 | +| E2.S6 | **Graceful fallback** | Bij AI failure → fallback naar Reflex-only | ✅ | E2.S5 | 2 | **Deliverable:** "Zeg Jan af en maak notitie" wordt correct geparsed naar 2 acties @@ -528,8 +528,8 @@ ALLEEN valid JSON (geen markdown): *Zie TO sectie 8.3 voor volledige prompt.* *Done criteria:* -- [ ] Prompt is duidelijk en in het Nederlands -- [ ] JSON output format gedocumenteerd +- [x] Prompt is duidelijk en in het Nederlands +- [x] JSON output format gedocumenteerd --- @@ -549,8 +549,8 @@ ALLEEN valid JSON (geen markdown): ``` *Done criteria:* -- [ ] Context leesbaar voor AI -- [ ] Graceful handling van null values +- [x] Context leesbaar voor AI +- [x] Graceful handling van null values --- @@ -582,9 +582,9 @@ export async function classifyWithOrchestrator( ``` *Done criteria:* -- [ ] Multi-intent input → meerdere actions in response -- [ ] Processing time ~400ms (acceptabel) -- [ ] Tokens usage gelogd +- [x] Multi-intent input → meerdere actions in response +- [x] Processing time ~400ms (acceptabel) +- [x] Tokens usage gelogd --- @@ -609,9 +609,9 @@ function parseAIResponse(rawText: string): ParsedResponse { - Missing fields → defaults *Done criteria:* -- [ ] Valid JSON correct geparsed -- [ ] Invalid JSON → graceful fallback -- [ ] IntentChain correct opgebouwd +- [x] Valid JSON correct geparsed +- [x] Invalid JSON → graceful fallback +- [x] IntentChain correct opgebouwd --- @@ -640,9 +640,9 @@ export async function POST(request: NextRequest) { ``` *Done criteria:* -- [ ] Simpele input → Reflex (geen AI call) -- [ ] Complexe input → Orchestrator -- [ ] Response bevat `handledBy` field +- [x] Simpele input → Reflex (geen AI call) +- [x] Complexe input → Orchestrator +- [x] Response bevat `handledBy` field --- @@ -667,9 +667,9 @@ try { ``` *Done criteria:* -- [ ] AI failure → geen crash -- [ ] User ziet resultaat (mogelijk minder intelligent) -- [ ] Error gelogd voor monitoring +- [x] AI failure → geen crash +- [x] User ziet resultaat (mogelijk minder intelligent) +- [x] Error gelogd voor monitoring --- @@ -1397,3 +1397,4 @@ De MVP User Stories uit `mvp-userstories-intent-system.md` zijn als volgt verdee |--------|-------|--------|-----------| | v1.0 | 30-12-2025 | Colin Lit | Initiële versie op basis van PRD, FO, TO, Architecture docs | | v1.1 | 31-12-2025 | Colin Lit | Dev-instructies per epic toegevoegd, file mappings, done criteria | +| v1.2 | 01-01-2026 | Colin Lit | Epic 2 (Intent Orchestrator) compleet - alle 6 stories afgerond | diff --git a/lib/cortex/orchestrator.ts b/lib/cortex/orchestrator.ts new file mode 100644 index 0000000..47a2dbb --- /dev/null +++ b/lib/cortex/orchestrator.ts @@ -0,0 +1,776 @@ +/** + * Intent Orchestrator (Layer 2) + * + * AI-powered intent classification for Cortex V2. + * Handles complex inputs: multi-intent, context-dependent pronouns, relative time. + * Uses Claude 3.5 Haiku for intelligent classification. + * + * Called when Reflex Arc (Layer 1) escalates due to: + * - Multi-intent detected (e.g., "zeg jan af en maak notitie") + * - Context-dependent pronouns (e.g., "maak notitie voor hem") + * - Relative time expressions (e.g., "plan afspraak morgen") + * - Low confidence or ambiguous classification + */ + +import { z } from 'zod'; +import type { + CortexContext, + CortexIntent, + IntentChain, + ExtractedEntities, +} from './types'; + +// ============================================================================= +// E2.S1 — System Prompt for Orchestrator +// ============================================================================= + +/** + * System prompt for the Intent Orchestrator. + * + * Key features: + * - Multi-intent detection (splits "X en Y" into separate actions) + * - Pronoun resolution (hij/zij → active patient) + * - Relative time handling (morgen → concrete date) + * - Confidence scoring with thresholds + * - JSON-only output format + */ +export const ORCHESTRATOR_SYSTEM_PROMPT = `Je bent de Intent Orchestrator voor Cortex, een Nederlands EPD (Elektronisch Patiënten Dossier) systeem voor GGZ-instellingen. + +## Je Taak +Analyseer de gebruikersinput en extraheer ALLE intenties, ook als er meerdere zijn in één zin. +Retourneer altijd valid JSON zonder markdown formatting. + +## Context die je krijgt +Je ontvangt context over: +- Actieve patiënt (wie de gebruiker momenteel bekijkt) +- Agenda vandaag (afspraken voor deze dag) +- Recente acties (wat er net is gedaan) +- Huidige weergave (waar in de applicatie) +- Dienst (nacht/ochtend/middag/avond) + +## Intent Types + +1. **dagnotitie** - Verpleegkundige notitie/rapportage maken + Entities: + - patientName: naam van de patiënt + - patientResolution: 'explicit' | 'context' | 'pronoun' + - category: 'medicatie' | 'adl' | 'gedrag' | 'incident' | 'observatie' + - content: inhoud van de notitie + Voorbeelden: "notitie jan medicatie", "schrijf observatie voor piet" + +2. **zoeken** - Patiënt zoeken of informatie opvragen + Entities: + - patientName: naam die gezocht wordt + - query: zoekterm + Voorbeelden: "zoek jan", "wie is marie" + +3. **overdracht** - Dienst overdracht bekijken + Entities: (geen specifieke entities) + Voorbeelden: "overdracht", "wat moet ik weten" + +4. **agenda_query** - Agenda/afspraken opvragen + Entities: + - dateRange: { start, end, label } + - patientName: specifieke patiënt (optioneel) + Voorbeelden: "agenda vandaag", "afspraken volgende week" + +5. **create_appointment** - Afspraak maken/plannen + Entities: + - patientName: patiënt voor de afspraak + - patientResolution: 'explicit' | 'context' | 'pronoun' + - date: datum (YYYY-MM-DD) + - time: tijd (HH:mm) + - appointmentType: 'intake' | 'behandeling' | 'follow-up' | 'telefonisch' | 'huisbezoek' | 'online' | 'crisis' | 'overig' + - location: 'praktijk' | 'online' | 'thuis' + Voorbeelden: "maak afspraak met jan morgen 14:00", "plan intake" + +6. **cancel_appointment** - Afspraak annuleren + Entities: + - patientName: patiënt van de afspraak + - patientResolution: 'explicit' | 'context' | 'pronoun' + - identifier: afspraak-identificatie (tijd, datum, of beide) + Voorbeelden: "annuleer afspraak jan", "zeg de afspraak van 14:00 af" + +7. **reschedule_appointment** - Afspraak verzetten + Entities: + - patientName: patiënt van de afspraak + - identifier: huidige afspraak + - newDate: nieuwe datum + - newTime: nieuwe tijd + Voorbeelden: "verzet 14:00 naar 15:00", "verplaats afspraak naar dinsdag" + +## Multi-Intent Detectie +Let op signaalwoorden die meerdere acties aangeven: +- "en" - bijv. "zeg jan af EN maak notitie" +- "daarna" / "dan" - sequentie van acties +- "ook" / "ook nog" - toevoeging +- "eerst" / "vervolgens" - volgorde + +Splits deze in aparte actions met oplopende sequence nummers. + +## Pronoun Resolution +Gebruik de context om voornaamwoorden op te lossen: +- "hij" / "hem" / "zijn" → actieve mannelijke patiënt +- "zij" / "haar" → actieve vrouwelijke patiënt +- "die afspraak" / "deze afspraak" → meest recente afspraak in context +- "deze patiënt" / "die patiënt" → actieve patiënt + +Vul patientResolution in: +- 'explicit': naam expliciet genoemd +- 'context': afgeleid uit huidige weergave/context +- 'pronoun': opgelost via voornaamwoord + +## Relatieve Tijd +Los relatieve tijdsaanduidingen op naar concrete datums (gebruik de huidige datum die je krijgt): +- "morgen" → volgende dag +- "overmorgen" → dag na morgen +- "volgende week" → begin volgende week +- "over X dagen" → bereken datum +- "komende maandag" → eerstvolgende maandag + +## Confidence Scores +- >= 0.9: Zeer zeker, alle entities duidelijk geëxtraheerd +- 0.7 - 0.9: Redelijk zeker, hoofdintent duidelijk +- 0.5 - 0.7: Onzeker, mogelijk clarificatie nodig +- < 0.5: Onduidelijk, stel clarificationQuestion + +## Destructieve Acties +Zet requiresConfirmation=true voor: +- cancel_appointment (annuleren van afspraken) +- Elke actie met potentieel grote impact + +## Output Format +Antwoord ALLEEN met valid JSON (geen markdown code blocks, geen uitleg): + +{ + "actions": [ + { + "intent": "cancel_appointment", + "confidence": 0.92, + "entities": { + "patientName": "Jan", + "patientResolution": "explicit" + }, + "requiresConfirmation": true, + "confirmationMessage": "Afspraak van Jan annuleren?" + }, + { + "intent": "dagnotitie", + "confidence": 0.88, + "entities": { + "patientName": "Jan", + "patientResolution": "pronoun", + "content": "griep" + }, + "requiresConfirmation": false + } + ], + "reasoning": "Input bevat 'en' wat wijst op twee acties: eerst annuleren, dan notitie maken. 'Hij' verwijst naar Jan uit eerste actie.", + "needsClarification": false, + "clarificationQuestion": null, + "clarificationOptions": null +} + +## Clarification +Als de intentie onduidelijk is, vraag om verduidelijking: +{ + "actions": [], + "reasoning": "Onduidelijk of gebruiker notitie of afspraak bedoelt", + "needsClarification": true, + "clarificationQuestion": "Wil je een notitie maken of een afspraak inplannen?", + "clarificationOptions": ["Notitie maken", "Afspraak inplannen"] +} + +## Belangrijke Regels +1. Retourneer ALLEEN JSON, geen markdown code blocks of uitleg +2. Bij twijfel: stel clarificationQuestion met concrete options +3. Behoud volgorde van acties zoals in de input +4. Als er geen actieve patiënt is en er wordt verwezen met "hij/zij", vraag om verduidelijking +5. Elke action moet minimaal intent en confidence hebben +`; + +// ============================================================================= +// E2.S2 — Context Formatting for AI +// ============================================================================= + +/** Dutch labels for shift types */ +const SHIFT_LABELS: Record = { + nacht: 'nachtdienst', + ochtend: 'ochtenddienst', + middag: 'middagdienst', + avond: 'avonddienst', +}; + +/** Dutch labels for view types */ +const VIEW_LABELS: Record = { + dashboard: 'Dashboard', + 'patient-detail': 'Patiëntdossier', + agenda: 'Agenda', + reports: 'Rapportages', + chat: 'Chat', +}; + +/** + * Format CortexContext into a readable string for the AI prompt. + * + * Uses emoji for visual structure and handles null values gracefully. + * Limits agenda items and recent intents to avoid token bloat. + * + * @param context - The CortexContext to format + * @returns Formatted string for inclusion in AI prompt + */ +export 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})` + ); + if (context.activePatient.recentNotes?.length) { + const notes = context.activePatient.recentNotes.slice(0, 3); + lines.push(` Recente notities: ${notes.join(', ')}`); + } + if (context.activePatient.upcomingAppointments?.length) { + const upcoming = context.activePatient.upcomingAppointments[0]; + const dateStr = upcoming.date.toLocaleDateString('nl-NL', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); + lines.push(` Eerstvolgende afspraak: ${dateStr} (${upcoming.type})`); + } + } else { + lines.push('🧑 Actieve patiënt: Geen'); + } + + // Current view + const viewLabel = VIEW_LABELS[context.currentView] || context.currentView; + lines.push(`📍 Huidige weergave: ${viewLabel}`); + + // Time and shift + const timeStr = context.currentTime.toLocaleTimeString('nl-NL', { + hour: '2-digit', + minute: '2-digit', + }); + const shiftLabel = SHIFT_LABELS[context.shift] || context.shift; + lines.push(`⏰ Tijd: ${timeStr} (${shiftLabel})`); + + // Today's agenda (max 5 items) + if (context.agendaToday.length > 0) { + lines.push('📅 Agenda vandaag:'); + const agendaItems = context.agendaToday.slice(0, 5); + for (const apt of agendaItems) { + lines.push(` - ${apt.time}: ${apt.patientName} (${apt.type})`); + } + if (context.agendaToday.length > 5) { + lines.push(` ... en ${context.agendaToday.length - 5} meer`); + } + } else { + lines.push('📅 Agenda vandaag: Geen afspraken'); + } + + // Recent intents (max 3 items) + if (context.recentIntents.length > 0) { + lines.push('🕐 Recente acties:'); + const recentItems = context.recentIntents.slice(0, 3); + for (const recent of recentItems) { + const patientPart = recent.patientName ? ` (${recent.patientName})` : ''; + lines.push(` - ${recent.intent}${patientPart}`); + } + } + + return lines.join('\n'); +} + +// ============================================================================= +// E2.S3 — AI Classification with Orchestrator +// ============================================================================= + +/** Result from AI classification */ +export interface AIClassificationResult { + chain: IntentChain; + model: string; + tokensUsed: number; + processingTimeMs: number; + needsClarification: boolean; + clarificationQuestion?: string; + clarificationOptions?: string[]; +} + +/** Parsed action from AI response */ +interface ParsedAction { + intent: string; + confidence: number; + entities: Record; + requiresConfirmation?: boolean; + confirmationMessage?: string; +} + +/** Parsed response from AI */ +interface ParsedResponse { + actions: ParsedAction[]; + reasoning?: string; + needsClarification: boolean; + clarificationQuestion?: string | null; + clarificationOptions?: string[] | null; +} + +/** + * Classify user input using the Intent Orchestrator (Layer 2). + * + * Uses Claude 3.5 Haiku for intelligent multi-intent classification. + * Called when Reflex Arc escalates due to complexity. + * + * @param input - User input string + * @param context - Current application context + * @returns Classification result with IntentChain + * @throws Error if API call fails (caller should handle fallback) + */ +export async function classifyWithOrchestrator( + input: string, + context: CortexContext +): Promise { + const startTime = performance.now(); + + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY ontbreekt in environment'); + } + + // Format context for AI + const contextPrompt = formatContextForPrompt(context); + const currentDate = new Date().toISOString().split('T')[0]; + + const userMessage = `## Context +${contextPrompt} + +## Huidige datum +${currentDate} + +## Gebruikersinput +"${input}" + +Analyseer en extraheer alle intenties.`; + + // Call Claude API + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-5-haiku-20241022', + max_tokens: 512, + temperature: 0, // Deterministic output for consistent parsing + system: ORCHESTRATOR_SYSTEM_PROMPT, + messages: [{ role: 'user', content: userMessage }], + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error('[Orchestrator] Claude API error:', response.status, errorBody); + throw new Error(`Claude API fout: ${response.status}`); + } + + const data = await response.json(); + const rawText = data?.content?.[0]?.text; + + if (!rawText) { + throw new Error('Geen response van Claude API'); + } + + const processingTimeMs = performance.now() - startTime; + const tokensUsed = + (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0); + + // Parse AI response with Zod validation (E2.S4) + const parsed = parseAIResponse(rawText); + + // Build IntentChain from parsed actions + 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 as CortexIntent, + confidence: action.confidence, + entities: action.entities as ExtractedEntities, + status: 'pending' as const, + 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, + processingTimeMs, + needsClarification: parsed.needsClarification, + clarificationQuestion: parsed.clarificationQuestion || undefined, + clarificationOptions: parsed.clarificationOptions || undefined, + }; +} + +// ============================================================================= +// E2.S4 — IntentChain Parsing with Zod Validation +// ============================================================================= + +/** Valid intent types for Zod schema */ +const VALID_INTENTS = [ + 'dagnotitie', + 'zoeken', + 'overdracht', + 'agenda_query', + 'create_appointment', + 'cancel_appointment', + 'reschedule_appointment', + 'unknown', +] as const; + +/** Valid categories for dagnotitie */ +const VALID_CATEGORIES = [ + 'medicatie', + 'adl', + 'gedrag', + 'incident', + 'observatie', +] as const; + +/** Valid patient resolution types */ +const VALID_RESOLUTIONS = ['explicit', 'context', 'pronoun'] as const; + +/** Zod schema for entities in an action */ +const EntitiesSchema = z + .object({ + patientName: z.string().optional(), + patientId: z.string().optional(), + patientResolution: z.enum(VALID_RESOLUTIONS).optional(), + category: z.enum(VALID_CATEGORIES).optional(), + content: z.string().optional(), + query: z.string().optional(), + date: z.string().optional(), + time: z.string().optional(), + identifier: z.string().optional(), + newDate: z.string().optional(), + newTime: z.string().optional(), + appointmentType: z.string().optional(), + location: z.string().optional(), + }) + .passthrough(); // Allow additional fields + +/** Zod schema for a single action */ +const ActionSchema = z.object({ + intent: z.enum(VALID_INTENTS).catch('unknown'), + confidence: z.number().min(0).max(1).catch(0.5), + entities: EntitiesSchema.optional().default({}), + requiresConfirmation: z.boolean().optional().default(false), + confirmationMessage: z.string().optional(), +}); + +/** Zod schema for the complete AI response */ +const AIResponseSchema = z.object({ + actions: z.array(ActionSchema).min(1).catch([ + { intent: 'unknown' as const, confidence: 0, entities: {}, requiresConfirmation: false }, + ]), + reasoning: z.string().optional(), + needsClarification: z.boolean().optional().default(false), + clarificationQuestion: z.string().nullable().optional(), + clarificationOptions: z.array(z.string()).nullable().optional(), +}); + +/** Type inferred from Zod schema */ +type AIResponseType = z.infer; + +/** + * Strip markdown code blocks from AI response. + * + * @param text - Raw text that may contain markdown + * @returns Clean JSON text + */ +function stripMarkdownCodeBlocks(text: string): string { + let cleaned = text.trim(); + + // Remove ```json prefix + if (cleaned.startsWith('```json')) { + cleaned = cleaned.slice(7); + } else if (cleaned.startsWith('```')) { + cleaned = cleaned.slice(3); + } + + // Remove ``` suffix + if (cleaned.endsWith('```')) { + cleaned = cleaned.slice(0, -3); + } + + return cleaned.trim(); +} + +/** + * Parse AI response with Zod validation. + * + * Handles edge cases: + * - Markdown code blocks (```json ... ```) + * - Invalid JSON (returns fallback) + * - Missing required fields (uses defaults) + * - Unknown intent types (maps to 'unknown') + * + * @param rawText - Raw text from Claude API + * @returns Validated and parsed response + */ +export function parseAIResponse(rawText: string): ParsedResponse { + const jsonText = stripMarkdownCodeBlocks(rawText); + + // Try to parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch (error) { + console.error( + '[Orchestrator] JSON parse error:', + error instanceof Error ? error.message : error, + 'Raw text:', + jsonText.slice(0, 200) + ); + return createFallbackResponse('JSON parse error'); + } + + // Validate with Zod schema + const validation = AIResponseSchema.safeParse(parsed); + + if (!validation.success) { + console.error( + '[Orchestrator] Zod validation failed:', + validation.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) + ); + + // Try partial extraction if actions array exists + if ( + typeof parsed === 'object' && + parsed !== null && + 'actions' in parsed && + Array.isArray((parsed as { actions: unknown }).actions) + ) { + return extractPartialResponse(parsed as { actions: unknown[] }); + } + + return createFallbackResponse('Schema validation failed'); + } + + return mapToParseResponse(validation.data); +} + +/** + * Extract partial response when full validation fails. + * Attempts to salvage valid actions from malformed response. + */ +function extractPartialResponse(parsed: { actions: unknown[] }): ParsedResponse { + const validActions: ParsedAction[] = []; + + for (const action of parsed.actions) { + const actionValidation = ActionSchema.safeParse(action); + if (actionValidation.success) { + validActions.push({ + intent: actionValidation.data.intent, + confidence: actionValidation.data.confidence, + entities: actionValidation.data.entities, + requiresConfirmation: actionValidation.data.requiresConfirmation, + confirmationMessage: actionValidation.data.confirmationMessage, + }); + } + } + + if (validActions.length === 0) { + return createFallbackResponse('No valid actions found'); + } + + return { + actions: validActions, + reasoning: (parsed as { reasoning?: string }).reasoning, + needsClarification: false, + }; +} + +/** + * Map validated Zod response to ParsedResponse interface. + */ +function mapToParseResponse(data: AIResponseType): ParsedResponse { + return { + actions: data.actions.map((a) => ({ + intent: a.intent, + confidence: a.confidence, + entities: a.entities, + requiresConfirmation: a.requiresConfirmation, + confirmationMessage: a.confirmationMessage, + })), + reasoning: data.reasoning, + needsClarification: data.needsClarification, + clarificationQuestion: data.clarificationQuestion, + clarificationOptions: data.clarificationOptions, + }; +} + +/** + * Create fallback response when parsing fails. + */ +function createFallbackResponse(reason: string): ParsedResponse { + return { + actions: [{ intent: 'unknown', confidence: 0, entities: {} }], + reasoning: reason, + needsClarification: false, + }; +} + +// ============================================================================= +// E2.S6 — Graceful Fallback +// ============================================================================= + +import type { LocalClassificationResult } from './types'; + +/** Default timeout for AI calls (5 seconds) */ +export const AI_TIMEOUT_MS = 5000; + +/** Confidence cap when falling back to Reflex result */ +export const FALLBACK_CONFIDENCE_CAP = 0.6; + +/** + * Build fallback IntentChain when AI fails. + * + * Uses Reflex result as best-effort classification with capped confidence. + * Called when: + * - Anthropic API returns error (503, timeout, etc.) + * - JSON parsing fails + * - Any unexpected error during AI classification + * + * @param input - Original user input + * @param reflexResult - Result from Reflex Arc classification + * @returns IntentChain with single action from Reflex result + */ +export function buildFallbackChain( + input: string, + reflexResult: LocalClassificationResult +): IntentChain { + return { + id: crypto.randomUUID(), + originalInput: input, + createdAt: new Date(), + actions: + reflexResult.intent !== 'unknown' + ? [ + { + id: crypto.randomUUID(), + sequence: 1, + intent: reflexResult.intent, + confidence: Math.min( + reflexResult.confidence, + FALLBACK_CONFIDENCE_CAP + ), + entities: {} as ExtractedEntities, + status: 'pending', + requiresConfirmation: false, + }, + ] + : [], + status: reflexResult.intent !== 'unknown' ? 'pending' : 'failed', + meta: { + source: 'local', + processingTimeMs: reflexResult.processingTimeMs, + aiReasoning: 'AI onbeschikbaar - teruggevallen op lokale classificatie', + }, + }; +} + +/** + * Build simple IntentChain from Reflex result. + * + * Used when Reflex handles the classification without escalation. + * + * @param input - Original user input + * @param result - Result from Reflex Arc classification + * @returns IntentChain with single action + */ +export function buildChainFromReflex( + input: string, + result: LocalClassificationResult +): IntentChain { + return { + id: crypto.randomUUID(), + originalInput: input, + createdAt: new Date(), + actions: + result.intent !== 'unknown' + ? [ + { + id: crypto.randomUUID(), + sequence: 1, + intent: result.intent, + confidence: result.confidence, + entities: {} as ExtractedEntities, + status: 'pending', + requiresConfirmation: false, + }, + ] + : [], + status: result.intent !== 'unknown' ? 'pending' : 'failed', + meta: { + source: 'local', + processingTimeMs: result.processingTimeMs, + }, + }; +} + +/** + * Wrapper for classifyWithOrchestrator with timeout protection. + * + * Ensures AI classification doesn't hang indefinitely. + * On timeout, caller should use buildFallbackChain. + * + * @param input - User input to classify + * @param context - Current application context + * @param timeoutMs - Timeout in milliseconds (default: 5000) + * @returns Classification result or throws on timeout/error + */ +export async function classifyWithTimeout( + input: string, + context: CortexContext, + timeoutMs: number = AI_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + // Note: The actual fetch in classifyWithOrchestrator doesn't use this signal yet + // This wrapper provides the timeout structure for future enhancement + const result = await Promise.race([ + classifyWithOrchestrator(input, context), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`AI classificatie timeout na ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + + clearTimeout(timeoutId); + return result; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.message.includes('timeout')) { + console.error('[Orchestrator] Request timed out after', timeoutMs, 'ms'); + } + + throw error; // Re-throw for caller to handle fallback + } +}