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:
272
app/api/cortex/classify/route.ts
Normal file
272
app/api/cortex/classify/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user