Files
triqura-ecd/lib/cortex/intent-classifier.ts
colinislit a8b02c1ef3 chore(strip): verwijder kapotte intake-tabs anamnese, onderzoeken en ROM
Opslaan faalde altijd door label/code-mismatch met de database-constraints
(reviewbevinding). Tabs komen terug bij de rebuild op het nieuwe datamodel.

- tab-mappen anamnese/, examination/, rom/ verwijderd
- tabs uit intake-tabs.tsx, server actions uit actions.ts
- intake-status API telt de secties niet meer mee (voortgangsring klopt)
- Cortex-navigatiedoelen (regex, entity-mapping, prompt) bijgewerkt zodat
  'ga naar anamnese' geen 404 meer geeft

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:44:09 +02:00

313 lines
12 KiB
TypeScript

/**
* Local Intent Classifier
*
* Fast regex-based intent classification for Cortex.
* Target: <50ms response time.
*/
import type { CortexIntent } from './types';
export interface ClassificationResult {
intent: CortexIntent;
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<CortexIntent, '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 },
],
// Agenda intents
agenda_query: [
// Exact commands
{ pattern: /^agenda\b/i, weight: 1.0 },
{ pattern: /^mijn\s+agenda\b/i, weight: 0.95 },
{ pattern: /^volgende\s+afspraak\b/i, weight: 0.95 },
// Question patterns
{ pattern: /^wat\s+zijn\s+(mijn\s+)?afspraken\b/i, weight: 1.0 },
{ pattern: /^(wat|wanneer)\s+is\s+(mijn\s+)?volgende\s+afspraak\b/i, weight: 1.0 },
// Date scoped queries
{ pattern: /^afspraken\s+(vandaag|morgen|deze\s+week|volgende\s+week)\b/i, weight: 0.95 },
{ pattern: /^(vandaag|morgen|deze\s+week|volgende\s+week)\s+afspraken\b/i, weight: 0.9 },
{ pattern: /^agenda\s+(vandaag|morgen|deze\s+week|volgende\s+week)\b/i, weight: 0.9 },
{ pattern: /^planning\s+(vandaag|morgen|deze\s+week|volgende\s+week)\b/i, weight: 0.85 },
{ pattern: /^afspraken\b(?!\s+(maken|plannen|inplannen|annuleren|verzetten|verplaatsen|verschuiven))\b/i, weight: 0.85 },
// Verb patterns
{ pattern: /^toon\s+agenda\b/i, weight: 0.9 },
{ pattern: /^laat\s+(mijn\s+)?agenda\s+zien\b/i, weight: 0.85 },
],
create_appointment: [
// Exact commands
{ pattern: /^maak\s+afspraak\b/i, weight: 1.0 },
{ pattern: /^plan\s+(een\s+)?(afspraak|intake|gesprek)\b/i, weight: 1.0 },
{ pattern: /^afspraak\s+(maken|plannen|inplannen)\b/i, weight: 0.95 },
{ pattern: /^afspraken\s+(maken|plannen|inplannen)\b/i, weight: 0.95 },
{ pattern: /^nieuwe?\s+afspraak\b/i, weight: 0.95 },
// Type-first patterns
{ pattern: /^maak\s+(een\s+)?(intake|behandeling|gesprek)\b/i, weight: 0.9 },
{ pattern: /^(intake|behandeling|gesprek)\s+(met\s+)?\w+/i, weight: 0.85 },
{ pattern: /^afspraak\s+met\s+\w+/i, weight: 0.9 },
{ pattern: /^plan\s+afspraak\s+met\s+\w+/i, weight: 0.9 },
],
cancel_appointment: [
// Exact commands
{ pattern: /^annuleer\s+(de\s+)?afspraak\b/i, weight: 1.0 },
{ pattern: /^cancel\s+(de\s+)?afspraak\b/i, weight: 1.0 },
{ pattern: /^verwijder\s+afspraak\b/i, weight: 0.95 },
{ pattern: /^afspraak\s+annuleren\b/i, weight: 0.95 },
{ pattern: /^zeg\s+afspraak\s+af\b/i, weight: 0.95 },
// Short forms
{ pattern: /^annuleer\s+\w+/i, weight: 0.7 }, // "annuleer jan"
],
reschedule_appointment: [
// Exact commands
{ pattern: /^verzet\s+(de\s+)?afspraak\b/i, weight: 1.0 },
{ pattern: /^verplaats\s+(de\s+)?afspraak\b/i, weight: 1.0 },
{ pattern: /^verschuif\s+afspraak\b/i, weight: 0.95 },
{ pattern: /^afspraak\s+verzetten\b/i, weight: 0.95 },
// Time shifts
{ pattern: /^\d{1,2}[:.]\d{2}\s+naar\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 },
{ pattern: /^(verzet|verplaats)\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 },
{ pattern: /^verzet\s+\w+\s+naar\b/i, weight: 0.85 }, // "verzet jan naar dinsdag"
],
// Intake intents (MVP)
intake_status: [
// Exact commands - voortgang/status check
{ pattern: /^(wat\s+)?moet\s+ik\s+nog\s+(doen|invullen)/i, weight: 1.0 },
{ pattern: /^is\s+(de\s+)?intake\s+compleet/i, weight: 0.95 },
{ pattern: /^intake\s+status\b/i, weight: 1.0 },
{ pattern: /^voortgang\s+(intake|invullen)\b/i, weight: 0.95 },
{ pattern: /^welke\s+(velden|onderdelen)\s+(missen|ontbreken)/i, weight: 0.9 },
{ pattern: /^(hoeveel|wat)\s+(is|staat)\s+er\s+(nog\s+)?open\b/i, weight: 0.9 },
{ pattern: /^status\s+(intake|invullen)\b/i, weight: 0.95 },
{ pattern: /^checklist\s+intake\b/i, weight: 0.85 },
],
intake_navigeer: [
// Navigation to specific intake tabs/sections
{ pattern: /^ga\s+naar\s+(de\s+)?(risico|risicotaxatie)/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?diagnose[ns]?/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?behandelgeschiedenis/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?medicatie/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(het\s+)?netwerk/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?doelen/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?samenvatting/i, weight: 1.0 },
// Open patterns
{ pattern: /^open\s+(de\s+)?(risico|diagnose|medicatie|netwerk|doelen|samenvatting)/i, weight: 0.95 },
// Show patterns
{ pattern: /^(toon|laat\s+zien)\s+(de\s+)?(risico|diagnose|medicatie|netwerk|doelen|samenvatting)/i, weight: 0.9 },
],
risico_query: [
// Exact commands - risk information
{ pattern: /^(wat\s+zijn\s+)?(de\s+)?risico'?s/i, weight: 1.0 },
{ pattern: /^risicotaxatie\b/i, weight: 1.0 },
{ pattern: /^welke\s+risico'?s\s+(zijn|hebben)/i, weight: 0.95 },
{ pattern: /^(is\s+er\s+)?(suïcide|suicide)\s*risico/i, weight: 1.0 },
{ pattern: /^(wat\s+is\s+)?(de\s+)?(sui[cï]daliteit|zelfbeschadiging)/i, weight: 0.95 },
{ pattern: /^risico'?s\s+(van|bij|voor)\s+\w+/i, weight: 0.9 },
{ pattern: /^toon\s+(de\s+)?risico'?s/i, weight: 0.9 },
{ pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?risico'?s/i, weight: 0.85 },
],
diagnose_query: [
// Exact commands - diagnosis information
{ pattern: /^(welke\s+)?diagnose[ns]?(\s+heeft)?/i, weight: 1.0 },
{ pattern: /^dsm[- ]?(5|iv|diagnose)/i, weight: 1.0 },
{ pattern: /^(wat\s+is\s+)?(de\s+)?hoofddiagnose/i, weight: 1.0 },
{ pattern: /^(wat\s+zijn\s+)?(de\s+)?nevendiagnose[ns]?/i, weight: 0.95 },
{ pattern: /^toon\s+(de\s+)?diagnose[ns]?/i, weight: 0.9 },
{ pattern: /^diagnose[ns]?\s+(van|bij|voor)\s+\w+/i, weight: 0.9 },
{ pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?diagnose[ns]?/i, weight: 0.85 },
{ pattern: /^(is\s+er\s+)?(een\s+)?persoonlijkheidsstoornis/i, weight: 0.9 },
],
// No Show casus
register_no_show: [
{ pattern: /no.?show/i, weight: 1.0 },
{ pattern: /niet\s+verschenen/i, weight: 0.95 },
{ pattern: /niet\s+gekomen/i, weight: 0.95 },
{ pattern: /niet\s+op\s+komen\s+dagen/i, weight: 0.95 },
{ pattern: /afwezig\s+(bij|voor)\s+(de\s+)?afspraak/i, weight: 0.9 },
{ pattern: /pati[eë]nt\s+afwezig/i, weight: 0.85 },
{ pattern: /komt?\s+niet\s+(op|naar)/i, weight: 0.85 },
{ pattern: /is\s+er\s+niet\s+(geweest)?/i, weight: 0.7 },
],
};
// 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|cortex)\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: CortexIntent; 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 CortexIntent,
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;
}