feat(cortex): Epic 2 - Intent Orchestrator (Layer 2) complete

Implements AI-powered multi-intent classification for Cortex V2.
When Reflex Arc (Layer 1) detects complexity, it escalates to
the Orchestrator for intelligent classification using Claude 3.5 Haiku.

E2.S1 - System prompt for Orchestrator
- ORCHESTRATOR_SYSTEM_PROMPT constant with Dutch instructions
- Multi-intent detection (en, daarna, ook, eerst, vervolgens)
- Pronoun resolution rules (hij/zij → active patient)
- JSON-only output format specification

E2.S2 - Context formatting for AI
- formatContextForPrompt() with emoji structure
- Dutch labels for shifts and views
- Graceful null handling, token limiting (max 5 agenda, 3 recent)

E2.S3 - AI classification endpoint
- classifyWithOrchestrator() calling Claude 3.5 Haiku
- Builds IntentChain from AI response
- Tracks tokens used and processing time

E2.S4 - IntentChain parsing with Zod
- parseAIResponse() with Zod schema validation
- Strips markdown code blocks
- Partial extraction for malformed responses
- Graceful fallback to intent: 'unknown'

E2.S5 - POST /api/cortex/classify hybrid endpoint
- Reflex first, escalate to Orchestrator if needed
- Response includes handledBy: 'reflex' | 'orchestrator'
- Auth check, feature flag check, request validation

E2.S6 - Graceful fallback
- buildFallbackChain() with confidence cap (0.6)
- buildChainFromReflex() for simple chains
- classifyWithTimeout() wrapper (5s default)

New files:
- lib/cortex/orchestrator.ts (~780 lines)
- app/api/cortex/classify/route.ts (~180 lines)

Key deliverable: "Zeg Jan af en maak notitie" correctly
parsed into 2 actions in an IntentChain.

Progress: 27 SP / 48 SP (56%) - E0, E1, E2 complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-01-01 11:28:14 +01:00
parent 7df92dd2fb
commit b36df63d03
3 changed files with 1074 additions and 25 deletions

776
lib/cortex/orchestrator.ts Normal file
View File

@@ -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<string, string> = {
nacht: 'nachtdienst',
ochtend: 'ochtenddienst',
middag: 'middagdienst',
avond: 'avonddienst',
};
/** Dutch labels for view types */
const VIEW_LABELS: Record<string, string> = {
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<string, unknown>;
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<AIClassificationResult> {
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<typeof AIResponseSchema>;
/**
* 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<AIClassificationResult> {
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<never>((_, 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
}
}