feat(swift): E2 Intent Classification voltooid

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 <colin@example.com>
This commit is contained in:
colinislit
2025-12-23 22:59:30 +01:00
parent 7f91e048f3
commit db036a3d92
6 changed files with 773 additions and 6 deletions

View File

@@ -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<string, VerpleegkundigCategory> = {
// 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];
}

View File

@@ -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';

View File

@@ -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<typeof AIIntentResponseSchema>;
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<AIClassificationResult> {
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;
}

View File

@@ -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<Exclude<SwiftIntent, 'unknown'>, 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;
}