From db036a3d926bd438d506e81c81e738ff31d9833e Mon Sep 17 00:00:00 2001 From: colinislit Date: Tue, 23 Dec 2025 22:59:30 +0100 Subject: [PATCH] feat(swift): E2 Intent Classification voltooid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic 2 - Intent Classification (10 SP): - E2.S1: Local classifier met regex patterns (<50ms) - E2.S2: Entity extraction (patientName, category, content) - E2.S3: AI fallback met Claude Haiku bij confidence <0.8 - E2.S4: POST /api/intent/classify API route Two-tier systeem: 1. Lokale regex classificatie (snel, offline) 2. AI fallback voor lage confidence of complexe input Bouwplan bijgewerkt naar v1.4 (31/68 SP, 46% done) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Colin Lit --- app/api/intent/classify/route.ts | 172 ++++++++++++++++++++++ docs/swift/bouwplan-swift-v1.md | 15 +- lib/swift/entity-extractor.ts | 233 ++++++++++++++++++++++++++++++ lib/swift/index.ts | 3 + lib/swift/intent-classifier-ai.ts | 167 +++++++++++++++++++++ lib/swift/intent-classifier.ts | 189 ++++++++++++++++++++++++ 6 files changed, 773 insertions(+), 6 deletions(-) create mode 100644 app/api/intent/classify/route.ts create mode 100644 lib/swift/entity-extractor.ts create mode 100644 lib/swift/intent-classifier-ai.ts create mode 100644 lib/swift/intent-classifier.ts diff --git a/app/api/intent/classify/route.ts b/app/api/intent/classify/route.ts new file mode 100644 index 0000000..8342b06 --- /dev/null +++ b/app/api/intent/classify/route.ts @@ -0,0 +1,172 @@ +/** + * Intent Classify API + * + * POST /api/intent/classify + * Two-tier intent classification: local first, AI fallback if confidence < 0.8 + */ + +import { createClient } from '@/lib/auth/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { classifyIntent, isHighConfidence } from '@/lib/swift/intent-classifier'; +import { classifyIntentWithAI } from '@/lib/swift/intent-classifier-ai'; +import { extractEntities } from '@/lib/swift/entity-extractor'; +import type { IntentClassificationResult } from '@/lib/swift/types'; + +// Request schema +const ClassifyRequestSchema = z.object({ + input: z.string().min(1, 'Input is verplicht').max(500, 'Input te lang (max 500 tekens)'), + forceAI: z.boolean().optional(), // For testing: force AI classification +}); + +// Response type +interface ClassifyResponse { + intent: IntentClassificationResult['intent']; + confidence: number; + entities: IntentClassificationResult['entities']; + source: 'local' | 'ai'; + processingTimeMs: number; + localResult?: { + intent: string; + confidence: number; + matchedPattern?: string; + }; +} + +/** + * POST /api/intent/classify + * + * Classifies user input into an intent with entity extraction. + * Uses local regex patterns first, falls back to AI if confidence < 0.8. + */ +export async function POST(request: NextRequest) { + const startTime = performance.now(); + + try { + const body = await request.json(); + + // Validate input + const result = ClassifyRequestSchema.safeParse(body); + if (!result.success) { + return NextResponse.json( + { + error: 'Validatiefout', + details: result.error.issues.map((e) => ({ + field: e.path.join('.'), + message: e.message, + })), + }, + { status: 400 } + ); + } + + const { input, forceAI } = result.data; + const supabase = await createClient(); + + // Check auth + const { data: authData } = await supabase.auth.getUser(); + if (!authData?.user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + // Step 1: Local classification (fast, <50ms) + const localResult = classifyIntent(input); + + // Step 2: Decide if we need AI fallback + const useAI = forceAI || !isHighConfidence(localResult); + + let finalResult: ClassifyResponse; + + if (useAI) { + // AI fallback for low confidence or forced + const aiResult = await classifyIntentWithAI(input); + + finalResult = { + intent: aiResult.intent, + confidence: aiResult.confidence, + entities: aiResult.entities, + source: 'ai', + processingTimeMs: performance.now() - startTime, + localResult: { + intent: localResult.intent, + confidence: localResult.confidence, + matchedPattern: localResult.matchedPattern, + }, + }; + } else { + // High confidence local result - extract entities locally + const entities = extractEntities(input, localResult.intent); + + finalResult = { + intent: localResult.intent, + confidence: localResult.confidence, + entities, + source: 'local', + processingTimeMs: performance.now() - startTime, + }; + } + + // Log classification event (non-blocking) + logClassificationEvent(supabase, input, finalResult).catch((err) => { + console.error('Failed to log classification event:', err); + }); + + return NextResponse.json(finalResult); + } catch (error) { + console.error('Error classifying intent:', error); + + const errorMessage = error instanceof Error ? error.message : 'Onbekende fout'; + + if (errorMessage.includes('ANTHROPIC_API_KEY')) { + return NextResponse.json( + { error: 'AI service niet geconfigureerd' }, + { status: 503 } + ); + } + + if (errorMessage.includes('Claude API')) { + return NextResponse.json( + { error: 'AI service tijdelijk niet beschikbaar', details: errorMessage }, + { status: 503 } + ); + } + + return NextResponse.json( + { + error: 'Fout bij classificeren', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined, + }, + { status: 500 } + ); + } +} + +/** + * Log classification event to ai_events table + */ +async function logClassificationEvent( + supabase: Awaited>, + input: string, + result: ClassifyResponse +) { + try { + await supabase.from('ai_events').insert({ + kind: 'intent_classify', + input_data: { + input: input.slice(0, 200), // Truncate for storage + inputLength: input.length, + }, + output_data: { + intent: result.intent, + confidence: result.confidence, + source: result.source, + hasEntities: Object.keys(result.entities).length > 0, + localConfidence: result.localResult?.confidence, + }, + duration_ms: Math.round(result.processingTimeMs), + }); + } catch (error) { + // Don't throw, just log + console.error('Failed to log AI event:', error); + } +} diff --git a/docs/swift/bouwplan-swift-v1.md b/docs/swift/bouwplan-swift-v1.md index 4768ad7..0e86c9d 100644 --- a/docs/swift/bouwplan-swift-v1.md +++ b/docs/swift/bouwplan-swift-v1.md @@ -131,12 +131,12 @@ lib/ |---------|-------|------|--------|---------|--------| | E0 | Setup & Foundation | Zustand, routing, base layout | ✅ Done | 4 | 8 SP | | E1 | Command Center | Input, voice, context bar | ✅ Done | 5 | 13 SP | -| E2 | Intent Classification | Local + AI fallback | ⏳ To Do | 4 | 10 SP | +| E2 | Intent Classification | Local + AI fallback | ✅ Done | 4 | 10 SP | | E3 | P1 Blocks | Dagnotitie, Zoeken, Overdracht | ⏳ To Do | 6 | 21 SP | | E4 | Navigation & Auth | Login keuze, routing, preferences | ⏳ To Do | 4 | 8 SP | | E5 | Polish & Testing | Animaties, error handling, tests | ⏳ To Do | 4 | 8 SP | -**Totaal: 27 stories, 68 story points (21 SP done, 47 SP remaining)** +**Totaal: 27 stories, 68 story points (31 SP done, 37 SP remaining)** **Belangrijk:** - Bouw per epic en per story, niet alles tegelijk @@ -221,10 +221,10 @@ Command Center Layout: | Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP | |----------|--------------|---------------------|--------|------|----| -| E2.S1 | Local classifier | Regex patterns voor P1 intents, <50ms | ⏳ | E0.S4 | 3 | -| E2.S2 | Entity extraction | Patient naam, categorie uit input | ⏳ | E2.S1 | 3 | -| E2.S3 | AI fallback | Claude Haiku bij confidence <0.8 | ⏳ | E2.S2 | 2 | -| E2.S4 | Intent API route | POST /api/intent/classify | ⏳ | E2.S3 | 2 | +| E2.S1 | Local classifier | Regex patterns voor P1 intents, <50ms | ✅ | E0.S4 | 3 | +| E2.S2 | Entity extraction | Patient naam, categorie uit input | ✅ | E2.S1 | 3 | +| E2.S3 | AI fallback | Claude Haiku bij confidence <0.8 | ✅ | E2.S2 | 2 | +| E2.S4 | Intent API route | POST /api/intent/classify | ✅ | E2.S3 | 2 | **Technical Notes:** ```typescript @@ -531,3 +531,6 @@ Een epic is **Done** wanneer: |--------|-------|--------|-----------| | v1.0 | 23-12-2024 | Colin Lit | Initiële versie | | v1.1 | 23-12-2024 | Claude | E0 + E1 voltooid (21 SP) | +| v1.2 | 23-12-2024 | Claude | E2.S1 + E2.S2 voltooid (27 SP) | +| v1.3 | 23-12-2024 | Claude | E2.S3 AI fallback voltooid (29 SP) | +| v1.4 | 23-12-2024 | Claude | E2 Intent Classification voltooid (31 SP) | diff --git a/lib/swift/entity-extractor.ts b/lib/swift/entity-extractor.ts new file mode 100644 index 0000000..2ff4a28 --- /dev/null +++ b/lib/swift/entity-extractor.ts @@ -0,0 +1,233 @@ +/** + * Entity Extractor + * + * Extracts entities (patient name, category, content) from user input. + */ + +import type { VerpleegkundigCategory } from '@/lib/types/report'; +import type { ExtractedEntities, SwiftIntent } from './types'; + +// Category aliases mapping to canonical values +const CATEGORY_ALIASES: Record = { + // Medicatie + medicatie: 'medicatie', + medicijn: 'medicatie', + medicijnen: 'medicatie', + med: 'medicatie', + meds: 'medicatie', + + // ADL + adl: 'adl', + verzorging: 'adl', + zorg: 'adl', + wassen: 'adl', + eten: 'adl', + douchen: 'adl', + + // Gedrag + gedrag: 'gedrag', + gedrags: 'gedrag', + stemming: 'gedrag', + mood: 'gedrag', + emotie: 'gedrag', + + // Incident + incident: 'incident', + val: 'incident', + gevallen: 'incident', + ongeluk: 'incident', + agressie: 'incident', + + // Observatie + observatie: 'observatie', + obs: 'observatie', + waarneming: 'observatie', + opmerking: 'observatie', +}; + +// Command words to strip from input +const COMMAND_WORDS = [ + 'notitie', + 'dagnotitie', + 'nieuwe', + 'schrijf', + 'rapporteer', + 'registreer', + 'zoek', + 'zoeken', + 'vind', + 'wie', + 'is', + 'waar', + 'info', + 'gegevens', + 'dossier', + 'overdracht', + 'dienst', + 'klaar', + 'afronden', + 'einde', + 'start', + 'begin', +]; + +// Common Dutch first names for better name detection +const COMMON_NAMES = new Set([ + 'jan', 'piet', 'klaas', 'marie', 'anna', 'lisa', 'eva', 'emma', 'sophie', + 'thomas', 'lucas', 'daan', 'sem', 'liam', 'noah', 'julia', 'sara', 'lotte', + 'willem', 'johannes', 'cornelis', 'hendrik', 'maria', 'johanna', 'elisabeth', + 'peter', 'hans', 'henk', 'johan', 'bert', 'dick', 'kees', 'jaap', 'wim', + 'annie', 'bep', 'corrie', 'dinie', 'els', 'gerda', 'hanneke', 'ineke', 'joke', +]); + +/** + * Extract entities from user input based on the detected intent. + */ +export function extractEntities(input: string, intent: SwiftIntent): ExtractedEntities { + const trimmedInput = input.trim().toLowerCase(); + const entities: ExtractedEntities = {}; + + switch (intent) { + case 'dagnotitie': + return extractDagnotatieEntities(trimmedInput, input); + case 'zoeken': + return extractZoekenEntities(trimmedInput, input); + case 'overdracht': + // Overdracht doesn't need entity extraction + return entities; + default: + return entities; + } +} + +/** + * Extract entities for dagnotitie intent. + * Patterns: + * - "notitie jan medicatie" → name: jan, category: medicatie + * - "jan medicatie gegeven" → name: jan, category: medicatie, content: gegeven + * - "notitie medicatie jan" → name: jan, category: medicatie + */ +function extractDagnotatieEntities(lowerInput: string, originalInput: string): ExtractedEntities { + const entities: ExtractedEntities = {}; + const words = lowerInput.split(/\s+/); + + // Remove command words + const filteredWords = words.filter(w => !COMMAND_WORDS.includes(w)); + + // Find category + let categoryIndex = -1; + for (let i = 0; i < filteredWords.length; i++) { + const category = CATEGORY_ALIASES[filteredWords[i]]; + if (category) { + entities.category = category; + categoryIndex = i; + break; + } + } + + // Find name (word that's not a category and looks like a name) + for (let i = 0; i < filteredWords.length; i++) { + if (i === categoryIndex) continue; + + const word = filteredWords[i]; + // Check if it's a known name or starts with uppercase in original + if (isLikelyName(word, originalInput)) { + entities.patientName = capitalizeFirst(word); + break; + } + } + + // Extract remaining content + const contentWords = filteredWords.filter((w, i) => { + if (i === categoryIndex) return false; + if (entities.patientName && w === entities.patientName.toLowerCase()) return false; + return true; + }); + + if (contentWords.length > 0) { + entities.content = contentWords.join(' '); + } + + return entities; +} + +/** + * Extract entities for zoeken intent. + * Patterns: + * - "zoek jan" → name: jan + * - "wie is marie" → name: marie + * - "dossier piet" → name: piet + */ +function extractZoekenEntities(lowerInput: string, originalInput: string): ExtractedEntities { + const entities: ExtractedEntities = {}; + const words = lowerInput.split(/\s+/); + + // Remove command words + const filteredWords = words.filter(w => !COMMAND_WORDS.includes(w)); + + // The remaining word(s) should be the name + for (const word of filteredWords) { + if (isLikelyName(word, originalInput)) { + entities.patientName = capitalizeFirst(word); + break; + } + } + + // If no name found but there are remaining words, use the first one + if (!entities.patientName && filteredWords.length > 0) { + entities.patientName = capitalizeFirst(filteredWords[0]); + } + + return entities; +} + +/** + * Check if a word is likely a patient name. + */ +function isLikelyName(word: string, originalInput: string): boolean { + // Check if it's a common name + if (COMMON_NAMES.has(word.toLowerCase())) { + return true; + } + + // Check if the word starts with uppercase in the original input + const regex = new RegExp(`\\b${escapeRegex(word)}\\b`, 'i'); + const match = originalInput.match(regex); + if (match && match[0][0] === match[0][0].toUpperCase()) { + return true; + } + + // Single word that's not a category or command + if ( + word.length >= 2 && + !CATEGORY_ALIASES[word] && + !COMMAND_WORDS.includes(word) && + /^[a-z]+$/i.test(word) + ) { + return true; + } + + return false; +} + +/** + * Capitalize the first letter of a string. + */ +function capitalizeFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +/** + * Escape special regex characters. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Parse category from a string (with alias support). + */ +export function parseCategory(input: string): VerpleegkundigCategory | undefined { + const lower = input.toLowerCase().trim(); + return CATEGORY_ALIASES[lower]; +} diff --git a/lib/swift/index.ts b/lib/swift/index.ts index 37db350..a4b3bc9 100644 --- a/lib/swift/index.ts +++ b/lib/swift/index.ts @@ -4,3 +4,6 @@ export * from './types'; export * from './use-swift-voice'; +export * from './intent-classifier'; +export * from './intent-classifier-ai'; +export * from './entity-extractor'; diff --git a/lib/swift/intent-classifier-ai.ts b/lib/swift/intent-classifier-ai.ts new file mode 100644 index 0000000..2859622 --- /dev/null +++ b/lib/swift/intent-classifier-ai.ts @@ -0,0 +1,167 @@ +/** + * AI Intent Classifier (Fallback) + * + * Uses Claude Haiku for intent classification when local classifier + * has confidence < 0.8. Server-side only. + */ + +import { z } from 'zod'; +import type { SwiftIntent, ExtractedEntities } from './types'; +import type { VerpleegkundigCategory } from '@/lib/types/report'; + +// Zod schema for AI response validation +const AIIntentResponseSchema = z.object({ + intent: z.enum(['dagnotitie', 'zoeken', 'overdracht', 'unknown']), + confidence: z.number().min(0).max(1), + entities: z.object({ + patientName: z.string().optional(), + category: z.enum(['medicatie', 'adl', 'gedrag', 'incident', 'observatie']).optional(), + content: z.string().optional(), + }).optional(), + reasoning: z.string().optional(), +}); + +type AIIntentResponse = z.infer; + +export interface AIClassificationResult { + intent: SwiftIntent; + confidence: number; + entities: ExtractedEntities; + source: 'ai'; + processingTimeMs: number; + reasoning?: string; +} + +const INTENT_CLASSIFIER_SYSTEM_PROMPT = `Je bent een intent classifier voor een Nederlands EPD (Elektronisch Patiënten Dossier) systeem genaamd Swift. + +Je taak is om de intentie van een zorgmedewerker te classificeren in één van deze categorieën: + +1. **dagnotitie** - Gebruiker wil een notitie/rapportage maken over een patiënt + Voorbeelden: "notitie jan medicatie", "marie had een rustige nacht", "schrijf observatie voor piet" + +2. **zoeken** - Gebruiker wil een patiënt zoeken of informatie opvragen + Voorbeelden: "zoek jan", "wie is marie", "dossier van piet" + +3. **overdracht** - Gebruiker wil een overdracht/samenvatting van de dienst + Voorbeelden: "overdracht", "wat moet ik weten", "dienst afronden" + +4. **unknown** - Intentie is onduidelijk of past niet in bovenstaande categorieën + +Voor dagnotitie, extraheer ook: +- patientName: de naam van de patiënt (indien genoemd) +- category: de categorie (medicatie, adl, gedrag, incident, observatie) +- content: eventuele inhoud van de notitie + +Voor zoeken, extraheer: +- patientName: de naam die gezocht wordt + +Antwoord ALLEEN met een JSON object in dit formaat: +{ + "intent": "dagnotitie" | "zoeken" | "overdracht" | "unknown", + "confidence": 0.0-1.0, + "entities": { + "patientName": "naam" (optioneel), + "category": "medicatie" | "adl" | "gedrag" | "incident" | "observatie" (optioneel), + "content": "inhoud" (optioneel) + }, + "reasoning": "korte uitleg" (optioneel) +}`; + +/** + * Classify user input using Claude Haiku AI. + * Should only be called server-side (API routes). + */ +export async function classifyIntentWithAI(input: string): Promise { + const startTime = performance.now(); + + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY ontbreekt in environment'); + } + + try { + 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: 256, + temperature: 0, + system: INTENT_CLASSIFIER_SYSTEM_PROMPT, + messages: [ + { + role: 'user', + content: `Classificeer deze input: "${input}"`, + }, + ], + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error('Claude API error:', 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'); + } + + // Parse JSON from response (handle potential 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); + } + + const parsed = JSON.parse(jsonText.trim()); + const validated = AIIntentResponseSchema.parse(parsed); + + const processingTimeMs = performance.now() - startTime; + + return { + intent: validated.intent as SwiftIntent, + confidence: validated.confidence, + entities: { + patientName: validated.entities?.patientName, + category: validated.entities?.category as VerpleegkundigCategory | undefined, + content: validated.entities?.content, + }, + source: 'ai', + processingTimeMs, + reasoning: validated.reasoning, + }; + } catch (error) { + const processingTimeMs = performance.now() - startTime; + + // If AI fails, return unknown with low confidence + console.error('AI classification error:', error); + + return { + intent: 'unknown', + confidence: 0, + entities: {}, + source: 'ai', + processingTimeMs, + }; + } +} + +/** + * Check if we should use AI fallback based on local classification result. + */ +export function shouldUseAIFallback(localConfidence: number): boolean { + return localConfidence < 0.8; +} diff --git a/lib/swift/intent-classifier.ts b/lib/swift/intent-classifier.ts new file mode 100644 index 0000000..bea2e5b --- /dev/null +++ b/lib/swift/intent-classifier.ts @@ -0,0 +1,189 @@ +/** + * Local Intent Classifier + * + * Fast regex-based intent classification for Swift. + * Target: <50ms response time. + */ + +import type { SwiftIntent } from './types'; + +export interface ClassificationResult { + intent: SwiftIntent; + confidence: number; + matchedPattern?: string; + processingTimeMs: number; +} + +interface PatternConfig { + pattern: RegExp; + weight: number; // Higher weight = higher confidence +} + +// Intent patterns with weights +// Weight 1.0 = exact match, 0.8 = strong match, 0.6 = partial match +const INTENT_PATTERNS: Record, PatternConfig[]> = { + dagnotitie: [ + // Exact commands + { pattern: /^dagnotitie\b/i, weight: 1.0 }, + { pattern: /^notitie\b/i, weight: 1.0 }, + { pattern: /^nieuwe?\s+notitie\b/i, weight: 1.0 }, + + // Pattern: "notitie [naam]" or "[naam] notitie" + { pattern: /^notitie\s+\w+/i, weight: 0.95 }, + { pattern: /^\w+\s+notitie\b/i, weight: 0.85 }, + + // Pattern: "[naam] [categorie]" (e.g., "jan medicatie") + { pattern: /^\w+\s+(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 0.9 }, + + // Pattern: "notitie [naam] [categorie]" + { pattern: /^notitie\s+\w+\s+(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 1.0 }, + + // Categorie-first patterns + { pattern: /^(medicatie|adl|gedrag|incident|observatie)\s+\w+/i, weight: 0.85 }, + { pattern: /^(medicatie|adl|gedrag|incident|observatie)\b/i, weight: 0.7 }, + + // Schrijf patterns + { pattern: /^schrijf\b/i, weight: 0.8 }, + { pattern: /^rapporteer\b/i, weight: 0.8 }, + { pattern: /^registreer\b/i, weight: 0.8 }, + ], + + zoeken: [ + // Exact commands + { pattern: /^zoek\b/i, weight: 1.0 }, + { pattern: /^vind\b/i, weight: 1.0 }, + { pattern: /^zoeken\b/i, weight: 1.0 }, + + // Question patterns + { pattern: /^wie\s+is\b/i, weight: 1.0 }, + { pattern: /^waar\s+is\b/i, weight: 0.9 }, + { pattern: /^welke\s+pati[eë]nt/i, weight: 0.9 }, + + // Pattern: "zoek [naam]" + { pattern: /^zoek\s+\w+/i, weight: 1.0 }, + { pattern: /^vind\s+\w+/i, weight: 1.0 }, + + // Info requests + { pattern: /^info\s+\w+/i, weight: 0.8 }, + { pattern: /^gegevens\s+\w+/i, weight: 0.8 }, + { pattern: /^dossier\s+\w+/i, weight: 0.85 }, + + // Partial name lookups (single word that could be a name) + { pattern: /^[A-Z][a-z]+$/i, weight: 0.5 }, // Single capitalized word + ], + + overdracht: [ + // Exact commands + { pattern: /^overdracht\b/i, weight: 1.0 }, + { pattern: /^dienst\s*overdracht\b/i, weight: 1.0 }, + + // Dienst patterns + { pattern: /^dienst\s+(klaar|afronden|be[eë]indigen)\b/i, weight: 1.0 }, + { pattern: /^einde?\s+dienst\b/i, weight: 1.0 }, + { pattern: /^dienst\s+einde?\b/i, weight: 1.0 }, + + // Question patterns + { pattern: /^wat\s+moet\s+ik\s+weten\b/i, weight: 1.0 }, + { pattern: /^wat\s+is\s+er\s+gebeurd\b/i, weight: 0.9 }, + { pattern: /^updates?\b/i, weight: 0.7 }, + { pattern: /^samenvatting\b/i, weight: 0.85 }, + + // Start dienst + { pattern: /^start\s+dienst\b/i, weight: 0.9 }, + { pattern: /^begin\s+dienst\b/i, weight: 0.9 }, + { pattern: /^nieuwe?\s+dienst\b/i, weight: 0.85 }, + ], +}; + +// Help patterns (separate, always check) +const HELP_PATTERNS: PatternConfig[] = [ + { pattern: /^help\b/i, weight: 1.0 }, + { pattern: /^hulp\b/i, weight: 1.0 }, + { pattern: /^\?\s*$/i, weight: 1.0 }, + { pattern: /^wat\s+kan\s+(ik|je|swift)\b/i, weight: 1.0 }, + { pattern: /^hoe\s+werkt\b/i, weight: 0.9 }, + { pattern: /^voorbeelden?\b/i, weight: 0.9 }, +]; + +/** + * Classify user input into an intent using local regex patterns. + * Fast, runs entirely client-side. + */ +export function classifyIntent(input: string): ClassificationResult { + const startTime = performance.now(); + const trimmedInput = input.trim(); + + // Empty input + if (!trimmedInput) { + return { + intent: 'unknown', + confidence: 0, + processingTimeMs: performance.now() - startTime, + }; + } + + // Check for help first + for (const { pattern, weight } of HELP_PATTERNS) { + if (pattern.test(trimmedInput)) { + return { + intent: 'unknown', // Help is handled separately, return unknown to trigger help UI + confidence: weight, + matchedPattern: pattern.toString(), + processingTimeMs: performance.now() - startTime, + }; + } + } + + // Find best matching intent + let bestMatch: { intent: SwiftIntent; confidence: number; pattern: string } | null = null; + + for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) { + for (const { pattern, weight } of patterns) { + if (pattern.test(trimmedInput)) { + if (!bestMatch || weight > bestMatch.confidence) { + bestMatch = { + intent: intent as SwiftIntent, + confidence: weight, + pattern: pattern.toString(), + }; + } + // If we found a perfect match, we can stop + if (weight === 1.0) break; + } + } + // Early exit on perfect match + if (bestMatch?.confidence === 1.0) break; + } + + const processingTimeMs = performance.now() - startTime; + + if (bestMatch) { + return { + intent: bestMatch.intent, + confidence: bestMatch.confidence, + matchedPattern: bestMatch.pattern, + processingTimeMs, + }; + } + + // No match found + return { + intent: 'unknown', + confidence: 0, + processingTimeMs, + }; +} + +/** + * Check if classification confidence is high enough to proceed without AI fallback. + */ +export function isHighConfidence(result: ClassificationResult): boolean { + return result.confidence >= 0.8; +} + +/** + * Check if we should show the fallback picker. + */ +export function shouldShowFallback(result: ClassificationResult): boolean { + return result.intent === 'unknown' || result.confidence < 0.5; +}