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:
189
lib/swift/intent-classifier.ts
Normal file
189
lib/swift/intent-classifier.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user