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:
172
app/api/intent/classify/route.ts
Normal file
172
app/api/intent/classify/route.ts
Normal file
@@ -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<ReturnType<typeof createClient>>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,12 +131,12 @@ lib/
|
|||||||
|---------|-------|------|--------|---------|--------|
|
|---------|-------|------|--------|---------|--------|
|
||||||
| E0 | Setup & Foundation | Zustand, routing, base layout | ✅ Done | 4 | 8 SP |
|
| E0 | Setup & Foundation | Zustand, routing, base layout | ✅ Done | 4 | 8 SP |
|
||||||
| E1 | Command Center | Input, voice, context bar | ✅ Done | 5 | 13 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 |
|
| E3 | P1 Blocks | Dagnotitie, Zoeken, Overdracht | ⏳ To Do | 6 | 21 SP |
|
||||||
| E4 | Navigation & Auth | Login keuze, routing, preferences | ⏳ To Do | 4 | 8 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 |
|
| 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:**
|
**Belangrijk:**
|
||||||
- Bouw per epic en per story, niet alles tegelijk
|
- Bouw per epic en per story, niet alles tegelijk
|
||||||
@@ -221,10 +221,10 @@ Command Center Layout:
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
||||||
|----------|--------------|---------------------|--------|------|----|
|
|----------|--------------|---------------------|--------|------|----|
|
||||||
| E2.S1 | Local classifier | Regex patterns voor P1 intents, <50ms | ⏳ | E0.S4 | 3 |
|
| 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.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.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.S4 | Intent API route | POST /api/intent/classify | ✅ | E2.S3 | 2 |
|
||||||
|
|
||||||
**Technical Notes:**
|
**Technical Notes:**
|
||||||
```typescript
|
```typescript
|
||||||
@@ -531,3 +531,6 @@ Een epic is **Done** wanneer:
|
|||||||
|--------|-------|--------|-----------|
|
|--------|-------|--------|-----------|
|
||||||
| v1.0 | 23-12-2024 | Colin Lit | Initiële versie |
|
| v1.0 | 23-12-2024 | Colin Lit | Initiële versie |
|
||||||
| v1.1 | 23-12-2024 | Claude | E0 + E1 voltooid (21 SP) |
|
| 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) |
|
||||||
|
|||||||
233
lib/swift/entity-extractor.ts
Normal file
233
lib/swift/entity-extractor.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -4,3 +4,6 @@
|
|||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './use-swift-voice';
|
export * from './use-swift-voice';
|
||||||
|
export * from './intent-classifier';
|
||||||
|
export * from './intent-classifier-ai';
|
||||||
|
export * from './entity-extractor';
|
||||||
|
|||||||
167
lib/swift/intent-classifier-ai.ts
Normal file
167
lib/swift/intent-classifier-ai.ts
Normal 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;
|
||||||
|
}
|
||||||
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