refactor: rename swift → cortex in code and documentation
Complete rename of "swift" terminology to "cortex" across the codebase: Code Changes: - Rename directories: lib/swift → lib/cortex, components/swift → components/cortex - Rename API routes: /api/swift/* → /api/cortex/* - Rename page routes: /epd/swift → /epd/cortex - Rename store: swift-store.ts → cortex-store.ts Type Renames: - SwiftIntent → CortexIntent - SwiftStore → CortexStore - useSwiftStore → useCortexStore - SwiftContext → CortexContext - useSwiftVoice → useCortexVoice Documentation Updates (docs/intent/): - Safety Net → Nudge (Layer 3 rename) - SafetySuggestion → NudgeSuggestion - evaluateSafetyNet → evaluateNudge - SWIFT_* feature flags → CORTEX_* - All path references updated to match new structure Files affected: 47 files, ~700 lines changed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
208
lib/cortex/__tests__/date-time-parser.test.ts
Normal file
208
lib/cortex/__tests__/date-time-parser.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Date/Time Parser - Smoke Tests
|
||||
*
|
||||
* Quick verification that parser functions work correctly
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import {
|
||||
parseRelativeDate,
|
||||
parseTime,
|
||||
isDateRange,
|
||||
dateToRange,
|
||||
combineDatetime,
|
||||
isNotInPast,
|
||||
type DateRange,
|
||||
} from '../date-time-parser';
|
||||
import { addDays, startOfDay, endOfDay } from 'date-fns';
|
||||
|
||||
describe('parseRelativeDate', () => {
|
||||
it('should parse "vandaag" to today', () => {
|
||||
const result = parseRelativeDate('vandaag');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(startOfDay(result as Date).getTime()).toBe(startOfDay(new Date()).getTime());
|
||||
});
|
||||
|
||||
it('should parse "morgen" to tomorrow', () => {
|
||||
const result = parseRelativeDate('morgen');
|
||||
const tomorrow = addDays(new Date(), 1);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(startOfDay(result as Date).getTime()).toBe(startOfDay(tomorrow).getTime());
|
||||
});
|
||||
|
||||
it('should parse "overmorgen" to day after tomorrow', () => {
|
||||
const result = parseRelativeDate('overmorgen');
|
||||
const dayAfterTomorrow = addDays(new Date(), 2);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(startOfDay(result as Date).getTime()).toBe(
|
||||
startOfDay(dayAfterTomorrow).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse "deze week" to a DateRange', () => {
|
||||
const result = parseRelativeDate('deze week');
|
||||
expect(isDateRange(result)).toBe(true);
|
||||
if (isDateRange(result)) {
|
||||
expect(result.label).toBe('deze week');
|
||||
expect(result.start).toBeInstanceOf(Date);
|
||||
expect(result.end).toBeInstanceOf(Date);
|
||||
expect(result.end.getTime()).toBeGreaterThan(result.start.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse "volgende week" to a DateRange', () => {
|
||||
const result = parseRelativeDate('volgende week');
|
||||
expect(isDateRange(result)).toBe(true);
|
||||
if (isDateRange(result)) {
|
||||
expect(result.label).toBe('volgende week');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse weekday names', () => {
|
||||
const weekdays = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
||||
weekdays.forEach((day) => {
|
||||
const result = parseRelativeDate(day);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse absolute dates like "30 december"', () => {
|
||||
const result = parseRelativeDate('30 december');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
if (result instanceof Date) {
|
||||
expect(result.getDate()).toBe(30);
|
||||
expect(result.getMonth()).toBe(11); // December = 11 (0-indexed)
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse ISO date format "2024-12-28"', () => {
|
||||
const result = parseRelativeDate('2024-12-28');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
if (result instanceof Date) {
|
||||
expect(result.getFullYear()).toBe(2024);
|
||||
expect(result.getMonth()).toBe(11); // December
|
||||
expect(result.getDate()).toBe(28);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for unparseable input', () => {
|
||||
const result = parseRelativeDate('gibberish xyz');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTime', () => {
|
||||
it('should parse "14:00" format', () => {
|
||||
expect(parseTime('14:00')).toBe('14:00');
|
||||
});
|
||||
|
||||
it('should parse "14.00" format', () => {
|
||||
expect(parseTime('14.00')).toBe('14:00');
|
||||
});
|
||||
|
||||
it('should parse hour only "14" to "14:00"', () => {
|
||||
expect(parseTime('14')).toBe('14:00');
|
||||
});
|
||||
|
||||
it('should parse "twee uur" to "14:00"', () => {
|
||||
expect(parseTime('twee uur')).toBe('14:00');
|
||||
});
|
||||
|
||||
it('should parse "half drie" to "14:30"', () => {
|
||||
expect(parseTime('half drie')).toBe('14:30');
|
||||
});
|
||||
|
||||
it('should parse "kwart over twee" to "14:15"', () => {
|
||||
expect(parseTime('kwart over twee')).toBe('14:15');
|
||||
});
|
||||
|
||||
it('should parse "kwart voor drie" to "14:45"', () => {
|
||||
expect(parseTime('kwart voor drie')).toBe('14:45');
|
||||
});
|
||||
|
||||
it('should parse time of day words', () => {
|
||||
expect(parseTime('ochtend')).toBe('09:00');
|
||||
expect(parseTime('middag')).toBe('14:00');
|
||||
expect(parseTime('avond')).toBe('19:00');
|
||||
});
|
||||
|
||||
it('should return null for unparseable input', () => {
|
||||
expect(parseTime('xyz')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle invalid hour values', () => {
|
||||
expect(parseTime('25:00')).toBeNull();
|
||||
expect(parseTime('14:70')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDateRange', () => {
|
||||
it('should return true for DateRange objects', () => {
|
||||
const range: DateRange = {
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
label: 'vandaag',
|
||||
};
|
||||
expect(isDateRange(range)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for Date objects', () => {
|
||||
expect(isDateRange(new Date())).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isDateRange(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateToRange', () => {
|
||||
it('should convert Date to DateRange', () => {
|
||||
const date = new Date('2024-12-28');
|
||||
const range = dateToRange(date, 'vandaag');
|
||||
|
||||
expect(range.label).toBe('vandaag');
|
||||
expect(range.start).toBeInstanceOf(Date);
|
||||
expect(range.end).toBeInstanceOf(Date);
|
||||
expect(range.start.getHours()).toBe(0); // Start of day
|
||||
expect(range.end.getHours()).toBe(23); // End of day
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineDatetime', () => {
|
||||
it('should combine date and time into ISO string', () => {
|
||||
const date = new Date('2024-12-28');
|
||||
const time = '14:00';
|
||||
const result = combineDatetime(date, time);
|
||||
|
||||
expect(result).toContain('2024-12-28');
|
||||
expect(result).toContain('14:00');
|
||||
});
|
||||
|
||||
it('should handle date strings', () => {
|
||||
const result = combineDatetime('2024-12-28', '14:00');
|
||||
expect(result).toContain('2024-12-28');
|
||||
expect(result).toContain('14:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNotInPast', () => {
|
||||
it('should return true for future dates', () => {
|
||||
const futureDate = addDays(new Date(), 1);
|
||||
expect(isNotInPast(futureDate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for today by default', () => {
|
||||
const today = new Date();
|
||||
expect(isNotInPast(today)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for today when allowToday=false', () => {
|
||||
const today = new Date();
|
||||
expect(isNotInPast(today, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for past dates', () => {
|
||||
const pastDate = addDays(new Date(), -1);
|
||||
expect(isNotInPast(pastDate)).toBe(false);
|
||||
});
|
||||
});
|
||||
330
lib/cortex/action-parser.ts
Normal file
330
lib/cortex/action-parser.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Action Parser for Cortex Assistent
|
||||
*
|
||||
* Parses JSON action objects from AI responses and validates them.
|
||||
*
|
||||
* Epic: E3 (Chat API & Cortex Assistent)
|
||||
* Story: E3.S4 (Intent detection in response)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ChatAction, CortexIntent, BlockType } from '@/stores/cortex-store';
|
||||
|
||||
// Validation schema for action objects
|
||||
const ActionSchema = z.object({
|
||||
type: z.literal('action'),
|
||||
intent: z.enum([
|
||||
'dagnotitie',
|
||||
'zoeken',
|
||||
'overdracht',
|
||||
'agenda_query',
|
||||
'create_appointment',
|
||||
'cancel_appointment',
|
||||
'reschedule_appointment',
|
||||
'unknown',
|
||||
]),
|
||||
entities: z.object({
|
||||
patientName: z.string().optional(),
|
||||
patientId: z.string().optional(),
|
||||
category: z.enum(['medicatie', 'adl', 'gedrag', 'incident', 'observatie']).optional(),
|
||||
content: z.string().optional(),
|
||||
query: z.string().optional(), // For zoeken intent
|
||||
dateRange: z
|
||||
.object({
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
label: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
datetime: z
|
||||
.object({
|
||||
date: z.string(),
|
||||
time: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
appointmentType: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
identifier: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
encounterId: z.string().optional(),
|
||||
patientName: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
newDatetime: z
|
||||
.object({
|
||||
date: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
confidence: z.number().min(0).max(1),
|
||||
artifact: z
|
||||
.object({
|
||||
type: z.enum([
|
||||
'dagnotitie',
|
||||
'zoeken',
|
||||
'overdracht',
|
||||
'agenda_query',
|
||||
'create_appointment',
|
||||
'cancel_appointment',
|
||||
'reschedule_appointment',
|
||||
'fallback',
|
||||
'patient-dashboard',
|
||||
]),
|
||||
prefill: z.record(z.string(), z.any()),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export interface ParsedActionResult {
|
||||
action: ChatAction | null;
|
||||
textContent: string; // Text without JSON block
|
||||
rawJson?: string; // Raw JSON string if found
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON code block from markdown text
|
||||
* Looks for ```json ... ``` blocks
|
||||
*/
|
||||
function extractJsonBlock(text: string): string | null {
|
||||
// Match ```json ... ``` blocks (with newlines)
|
||||
const jsonBlockRegex = /```json\s*\n([\s\S]*?)\n```/;
|
||||
const match = text.match(jsonBlockRegex);
|
||||
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove JSON code blocks from text
|
||||
*/
|
||||
function removeJsonBlocks(text: string): string {
|
||||
return text.replace(/```json\s*\n[\s\S]*?\n```/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse action object from AI response
|
||||
*
|
||||
* Extracts and validates JSON action objects from markdown code blocks.
|
||||
*
|
||||
* @param responseText - Full AI response text
|
||||
* @returns Parsed action (if valid), cleaned text content, and raw JSON
|
||||
*/
|
||||
export function parseActionFromResponse(responseText: string): ParsedActionResult {
|
||||
// Extract JSON block from markdown
|
||||
const jsonString = extractJsonBlock(responseText);
|
||||
|
||||
if (!jsonString) {
|
||||
return {
|
||||
action: null,
|
||||
textContent: responseText,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse JSON
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JSON action:', error);
|
||||
return {
|
||||
action: null,
|
||||
textContent: responseText,
|
||||
rawJson: jsonString,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate against schema
|
||||
const validation = ActionSchema.safeParse(parsedJson);
|
||||
|
||||
if (!validation.success) {
|
||||
console.error('Action validation failed:', validation.error);
|
||||
return {
|
||||
action: null,
|
||||
textContent: removeJsonBlocks(responseText),
|
||||
rawJson: jsonString,
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize entities - convert complex identifier to string
|
||||
const normalizedEntities = {
|
||||
...validation.data.entities,
|
||||
// Convert object identifier to string if needed
|
||||
identifier: typeof validation.data.entities.identifier === 'object'
|
||||
? validation.data.entities.identifier.encounterId || JSON.stringify(validation.data.entities.identifier)
|
||||
: validation.data.entities.identifier,
|
||||
};
|
||||
|
||||
// Valid action found
|
||||
const action: ChatAction = {
|
||||
intent: validation.data.intent,
|
||||
entities: normalizedEntities,
|
||||
confidence: validation.data.confidence,
|
||||
artifact: validation.data.artifact,
|
||||
};
|
||||
|
||||
return {
|
||||
action,
|
||||
textContent: removeJsonBlocks(responseText),
|
||||
rawJson: jsonString,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if confidence is high enough to open artifact
|
||||
*/
|
||||
export function shouldOpenArtifact(confidence: number): boolean {
|
||||
return confidence >= 0.7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly confidence label
|
||||
*/
|
||||
export function getConfidenceLabel(confidence: number): string {
|
||||
if (confidence >= 0.9) return 'Zeer zeker';
|
||||
if (confidence >= 0.7) return 'Redelijk zeker';
|
||||
if (confidence >= 0.5) return 'Onzeker';
|
||||
return 'Zeer onzeker';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that artifact type matches intent
|
||||
*/
|
||||
export function validateArtifactType(intent: CortexIntent, artifactType?: BlockType): boolean {
|
||||
if (!artifactType) return true; // No artifact is valid
|
||||
if (artifactType === 'patient-dashboard') return true;
|
||||
|
||||
// Intent should match artifact type (except for 'unknown' and 'fallback')
|
||||
if (intent === 'unknown') return artifactType === 'fallback';
|
||||
|
||||
// For agenda intents, all map to agenda block types
|
||||
const agendaIntents: CortexIntent[] = [
|
||||
'agenda_query',
|
||||
'create_appointment',
|
||||
'cancel_appointment',
|
||||
'reschedule_appointment',
|
||||
];
|
||||
if (agendaIntents.includes(intent)) {
|
||||
return agendaIntents.includes(artifactType as CortexIntent);
|
||||
}
|
||||
|
||||
return intent === artifactType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route intent to appropriate artifact configuration
|
||||
*
|
||||
* Maps intents (especially agenda intents) to the correct artifact type with prefill data.
|
||||
* Implements Epic 5.S1 routing logic.
|
||||
*
|
||||
* @param intent - The classified intent
|
||||
* @param entities - Extracted entities from user input
|
||||
* @param confidence - Intent classification confidence (0-1)
|
||||
* @returns Artifact configuration or null if confidence too low or required data missing
|
||||
*/
|
||||
export function routeIntentToArtifact(
|
||||
intent: CortexIntent,
|
||||
entities: Record<string, any>,
|
||||
confidence: number
|
||||
): { type: BlockType; prefill: Record<string, any>; title: string } | null {
|
||||
// Confidence threshold: return null if too low
|
||||
// This triggers fallback/clarification question in UI
|
||||
if (confidence < 0.7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Route agenda intents to AgendaBlock with appropriate configuration
|
||||
switch (intent) {
|
||||
case 'agenda_query':
|
||||
return {
|
||||
type: 'agenda_query',
|
||||
title: 'Agenda',
|
||||
prefill: {
|
||||
dateRange: entities.dateRange,
|
||||
},
|
||||
};
|
||||
|
||||
case 'create_appointment':
|
||||
// Require patient for create
|
||||
if (!entities.patientName && !entities.patientId) {
|
||||
return null; // Missing required entity - trigger clarification
|
||||
}
|
||||
return {
|
||||
type: 'create_appointment',
|
||||
title: 'Nieuwe afspraak',
|
||||
prefill: {
|
||||
patientName: entities.patientName,
|
||||
patientId: entities.patientId,
|
||||
datetime: entities.datetime,
|
||||
appointmentType: entities.appointmentType,
|
||||
location: entities.location,
|
||||
},
|
||||
};
|
||||
|
||||
case 'cancel_appointment':
|
||||
return {
|
||||
type: 'cancel_appointment',
|
||||
title: 'Afspraak annuleren',
|
||||
prefill: {
|
||||
identifier: entities.identifier,
|
||||
},
|
||||
};
|
||||
|
||||
case 'reschedule_appointment':
|
||||
// Require identifier to know which appointment
|
||||
if (!entities.identifier) {
|
||||
return null; // Missing required entity - trigger clarification
|
||||
}
|
||||
return {
|
||||
type: 'reschedule_appointment',
|
||||
title: 'Afspraak verzetten',
|
||||
prefill: {
|
||||
identifier: entities.identifier,
|
||||
newDatetime: entities.newDatetime,
|
||||
},
|
||||
};
|
||||
|
||||
// Non-agenda intents - direct mapping
|
||||
case 'dagnotitie':
|
||||
return {
|
||||
type: 'dagnotitie',
|
||||
title: 'Dagnotitie',
|
||||
prefill: entities,
|
||||
};
|
||||
|
||||
case 'zoeken':
|
||||
return {
|
||||
type: 'zoeken',
|
||||
title: 'Patiënt zoeken',
|
||||
prefill: entities,
|
||||
};
|
||||
|
||||
case 'overdracht':
|
||||
return {
|
||||
type: 'overdracht',
|
||||
title: 'Overdracht',
|
||||
prefill: entities,
|
||||
};
|
||||
|
||||
case 'unknown':
|
||||
// Unknown intent - show fallback picker
|
||||
return {
|
||||
type: 'fallback',
|
||||
title: 'Keuze maken',
|
||||
prefill: entities,
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
109
lib/cortex/chat-api.ts
Normal file
109
lib/cortex/chat-api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Cortex Chat API Client
|
||||
*
|
||||
* Client-side helper voor het aanroepen van de Cortex chat API met streaming support.
|
||||
*
|
||||
* Epic: E3 (Chat API & Cortex Assistent)
|
||||
* Story: E3.S1 (Chat API endpoint skeleton)
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from '@/stores/cortex-store';
|
||||
|
||||
export interface ChatContext {
|
||||
activePatient?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
} | null;
|
||||
shift: 'nacht' | 'ochtend' | 'middag' | 'avond';
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
type: 'content' | 'done' | 'error';
|
||||
text?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message and receive streaming response via SSE
|
||||
*/
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
messages: ChatMessage[],
|
||||
context?: ChatContext,
|
||||
onChunk?: (text: string) => void,
|
||||
onDone?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/cortex/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
messages,
|
||||
context,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(errorData.error || 'API request failed');
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
// Read SSE stream
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete SSE events (separated by \n\n)
|
||||
const events = buffer.split('\n\n');
|
||||
|
||||
// Keep the last incomplete event in the buffer
|
||||
buffer = events.pop() || '';
|
||||
|
||||
// Process complete events
|
||||
for (const eventStr of events) {
|
||||
if (!eventStr.trim()) continue;
|
||||
|
||||
// Parse SSE event (format: "data: {...}")
|
||||
const dataMatch = eventStr.match(/^data: (.+)$/);
|
||||
if (!dataMatch) continue;
|
||||
|
||||
try {
|
||||
const event: StreamEvent = JSON.parse(dataMatch[1]);
|
||||
|
||||
if (event.type === 'content' && event.text) {
|
||||
onChunk?.(event.text);
|
||||
} else if (event.type === 'done') {
|
||||
onDone?.();
|
||||
} else if (event.type === 'error') {
|
||||
onError?.(event.error || 'Unknown error');
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse SSE event:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
onError?.(error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
308
lib/cortex/date-time-parser.ts
Normal file
308
lib/cortex/date-time-parser.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Date/Time Parser Utilities
|
||||
*
|
||||
* Parses natural language date and time expressions (Dutch)
|
||||
* for Cortex agenda functionality.
|
||||
*/
|
||||
|
||||
import {
|
||||
addDays,
|
||||
addWeeks,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
parse,
|
||||
isValid,
|
||||
} from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
|
||||
/**
|
||||
* Date range type for queries like "deze week"
|
||||
*/
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label: 'vandaag' | 'morgen' | 'deze week' | 'volgende week' | 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse relative date expressions (Dutch)
|
||||
*
|
||||
* Supports:
|
||||
* - "vandaag", "morgen", "overmorgen"
|
||||
* - "maandag", "dinsdag", etc. (next occurrence of weekday)
|
||||
* - "deze week", "volgende week" (returns DateRange)
|
||||
* - Absolute dates: "30 december", "28-12-2024"
|
||||
*
|
||||
* @param input - Natural language date expression
|
||||
* @returns Date object, DateRange, or null if unparseable
|
||||
*
|
||||
* @example
|
||||
* parseRelativeDate("morgen") // tomorrow's date
|
||||
* parseRelativeDate("deze week") // { start: Mon, end: Sun, label: "deze week" }
|
||||
* parseRelativeDate("dinsdag") // next Tuesday
|
||||
*/
|
||||
export function parseRelativeDate(input: string): Date | DateRange | null {
|
||||
const today = new Date();
|
||||
const normalized = input.toLowerCase().trim();
|
||||
|
||||
// Single day patterns (check longer patterns first to avoid "morgen" matching in "overmorgen")
|
||||
const singleDayPatterns: Record<string, () => Date> = {
|
||||
overmorgen: () => addDays(today, 2),
|
||||
eergisteren: () => addDays(today, -2), // Voor queries
|
||||
vandaag: () => today,
|
||||
morgen: () => addDays(today, 1),
|
||||
gisteren: () => addDays(today, -1), // Voor queries
|
||||
};
|
||||
|
||||
// Check single day patterns (exact match or word boundary)
|
||||
for (const [pattern, fn] of Object.entries(singleDayPatterns)) {
|
||||
// Use word boundaries to avoid partial matches
|
||||
const regex = new RegExp(`\\b${pattern}\\b`, 'i');
|
||||
if (regex.test(normalized)) {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
// Weekday patterns (next occurrence)
|
||||
const weekdayPatterns: Record<string, () => Date> = {
|
||||
maandag: () => nextMonday(today),
|
||||
dinsdag: () => nextTuesday(today),
|
||||
woensdag: () => nextWednesday(today),
|
||||
donderdag: () => nextThursday(today),
|
||||
vrijdag: () => nextFriday(today),
|
||||
zaterdag: () => nextSaturday(today),
|
||||
zondag: () => nextSunday(today),
|
||||
};
|
||||
|
||||
// Check weekday patterns
|
||||
for (const [pattern, fn] of Object.entries(weekdayPatterns)) {
|
||||
if (normalized === pattern || normalized.includes(pattern)) {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
// Week range patterns (returns DateRange)
|
||||
const weekRangePatterns: Record<
|
||||
string,
|
||||
() => DateRange
|
||||
> = {
|
||||
'deze week': () => ({
|
||||
start: startOfWeek(today, { locale: nl, weekStartsOn: 1 }), // Monday
|
||||
end: endOfWeek(today, { locale: nl, weekStartsOn: 1 }), // Sunday
|
||||
label: 'deze week',
|
||||
}),
|
||||
'volgende week': () => {
|
||||
const nextWeek = addWeeks(today, 1);
|
||||
return {
|
||||
start: startOfWeek(nextWeek, { locale: nl, weekStartsOn: 1 }),
|
||||
end: endOfWeek(nextWeek, { locale: nl, weekStartsOn: 1 }),
|
||||
label: 'volgende week',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Check week range patterns
|
||||
for (const [pattern, fn] of Object.entries(weekRangePatterns)) {
|
||||
if (normalized.includes(pattern)) {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing absolute dates
|
||||
// Format: "30 december", "30 dec", "28-12-2024", "28/12/2024"
|
||||
const absoluteDatePatterns = [
|
||||
'd MMMM', // "30 december"
|
||||
'd MMM', // "30 dec"
|
||||
'd-M-yyyy', // "28-12-2024"
|
||||
'dd-MM-yyyy', // "28-12-2024"
|
||||
'd/M/yyyy', // "28/12/2024"
|
||||
'dd/MM/yyyy', // "28/12/2024"
|
||||
'yyyy-MM-dd', // ISO format "2024-12-28"
|
||||
];
|
||||
|
||||
for (const pattern of absoluteDatePatterns) {
|
||||
try {
|
||||
const parsed = parse(normalized, pattern, today, { locale: nl });
|
||||
if (isValid(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next pattern
|
||||
}
|
||||
}
|
||||
|
||||
// Could not parse
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parsed result is a DateRange
|
||||
*/
|
||||
export function isDateRange(result: Date | DateRange | null): result is DateRange {
|
||||
return result !== null && typeof result === 'object' && 'start' in result && 'end' in result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert single date to DateRange (start of day to end of day)
|
||||
*/
|
||||
export function dateToRange(date: Date, label: DateRange['label'] = 'custom'): DateRange {
|
||||
return {
|
||||
start: startOfDay(date),
|
||||
end: endOfDay(date),
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time expressions (Dutch)
|
||||
*
|
||||
* Supports:
|
||||
* - "14:00" → "14:00"
|
||||
* - "14" → "14:00"
|
||||
* - "twee uur" → "14:00"
|
||||
* - "half drie" → "14:30"
|
||||
* - "kwart voor drie" → "14:45"
|
||||
* - "kwart over twee" → "14:15"
|
||||
*
|
||||
* @param input - Natural language time expression
|
||||
* @returns Time string in HH:mm format, or null if unparseable
|
||||
*
|
||||
* @example
|
||||
* parseTime("14:00") // "14:00"
|
||||
* parseTime("half drie") // "14:30"
|
||||
* parseTime("twee uur") // "14:00"
|
||||
*/
|
||||
export function parseTime(input: string): string | null {
|
||||
const normalized = input.toLowerCase().trim();
|
||||
|
||||
// Direct time format: "14:00" or "14.00"
|
||||
const timePattern = /^(\d{1,2})[:\.](\d{2})$/;
|
||||
const match = normalized.match(timePattern);
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = parseInt(match[2], 10);
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Just hour: "14" → "14:00"
|
||||
const hourPattern = /^(\d{1,2})$/;
|
||||
const hourMatch = normalized.match(hourPattern);
|
||||
if (hourMatch) {
|
||||
const hours = parseInt(hourMatch[1], 10);
|
||||
if (hours >= 0 && hours <= 23) {
|
||||
return `${hours.toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
}
|
||||
|
||||
// Dutch time words
|
||||
const timeWords: Record<string, string> = {
|
||||
// Full hours
|
||||
'een uur': '13:00',
|
||||
'twee uur': '14:00',
|
||||
'drie uur': '15:00',
|
||||
'vier uur': '16:00',
|
||||
'vijf uur': '17:00',
|
||||
'zes uur': '18:00',
|
||||
'zeven uur': '19:00',
|
||||
'acht uur': '20:00',
|
||||
'negen uur': '21:00',
|
||||
'tien uur': '22:00',
|
||||
'elf uur': '23:00',
|
||||
'twaalf uur': '12:00',
|
||||
|
||||
// Morning variants
|
||||
'ochtend': '09:00', // Default morning time
|
||||
's ochtends': '09:00',
|
||||
'ochtendje': '09:00',
|
||||
|
||||
// Afternoon/evening
|
||||
'middag': '14:00',
|
||||
's middags': '14:00',
|
||||
'namiddag': '14:00',
|
||||
'avond': '19:00',
|
||||
's avonds': '19:00',
|
||||
'vanavond': '19:00',
|
||||
|
||||
// Half hours (common expressions)
|
||||
'half een': '12:30',
|
||||
'half twee': '13:30',
|
||||
'half drie': '14:30',
|
||||
'half vier': '15:30',
|
||||
'half vijf': '16:30',
|
||||
'half zes': '17:30',
|
||||
'half zeven': '18:30',
|
||||
'half acht': '19:30',
|
||||
'half negen': '20:30',
|
||||
'half tien': '21:30',
|
||||
'half elf': '22:30',
|
||||
'half twaalf': '23:30',
|
||||
|
||||
// Quarter hours
|
||||
'kwart over een': '13:15',
|
||||
'kwart over twee': '14:15',
|
||||
'kwart over drie': '15:15',
|
||||
'kwart voor twee': '13:45',
|
||||
'kwart voor drie': '14:45',
|
||||
'kwart voor vier': '15:45',
|
||||
};
|
||||
|
||||
// Check if input matches any time word pattern
|
||||
for (const [pattern, time] of Object.entries(timeWords)) {
|
||||
if (normalized === pattern || normalized.includes(pattern)) {
|
||||
return time;
|
||||
}
|
||||
}
|
||||
|
||||
// Could not parse
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine date and time into ISO datetime string
|
||||
*
|
||||
* @param date - Date object or date string
|
||||
* @param time - Time string in HH:mm format
|
||||
* @returns ISO datetime string
|
||||
*
|
||||
* @example
|
||||
* combineDatetime(new Date('2024-12-28'), '14:00')
|
||||
* // "2024-12-28T14:00:00"
|
||||
*/
|
||||
export function combineDatetime(date: Date | string, time: string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
|
||||
const combined = new Date(dateObj);
|
||||
combined.setHours(hours, minutes, 0, 0);
|
||||
|
||||
return combined.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a date is not in the past
|
||||
*
|
||||
* @param date - Date to validate
|
||||
* @param allowToday - Whether today is considered valid (default: true)
|
||||
* @returns true if date is valid (not in past)
|
||||
*/
|
||||
export function isNotInPast(date: Date, allowToday = true): boolean {
|
||||
const today = startOfDay(new Date());
|
||||
const checkDate = startOfDay(date);
|
||||
|
||||
if (allowToday) {
|
||||
return checkDate >= today;
|
||||
}
|
||||
|
||||
return checkDate > today;
|
||||
}
|
||||
639
lib/cortex/entity-extractor.ts
Normal file
639
lib/cortex/entity-extractor.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* Entity Extractor
|
||||
*
|
||||
* Extracts entities (patient name, category, content, date/time) from user input.
|
||||
*/
|
||||
|
||||
import type { VerpleegkundigCategory } from '@/lib/types/report';
|
||||
import type { ExtractedEntities, CortexIntent } from './types';
|
||||
import {
|
||||
parseRelativeDate,
|
||||
parseTime,
|
||||
isDateRange,
|
||||
dateToRange,
|
||||
type DateRange,
|
||||
} from './date-time-parser';
|
||||
|
||||
// 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: CortexIntent): 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;
|
||||
case 'agenda_query':
|
||||
return extractAgendaQueryEntities(trimmedInput, input);
|
||||
case 'create_appointment':
|
||||
return extractCreateAppointmentEntities(trimmedInput, input);
|
||||
case 'cancel_appointment':
|
||||
return extractCancelAppointmentEntities(trimmedInput, input);
|
||||
case 'reschedule_appointment':
|
||||
return extractRescheduleAppointmentEntities(trimmedInput, input);
|
||||
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];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agenda Entity Extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract entities for agenda_query intent.
|
||||
* Patterns:
|
||||
* - "afspraken vandaag" → dateRange: today
|
||||
* - "agenda morgen" → dateRange: tomorrow
|
||||
* - "wat is volgende afspraak" → dateRange: from now (no explicit range)
|
||||
* - "afspraken deze week" → dateRange: this week
|
||||
*/
|
||||
function extractAgendaQueryEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||
const entities: ExtractedEntities = {};
|
||||
const words = lowerInput.split(/\s+/);
|
||||
|
||||
// Try to find date expression
|
||||
// Check multi-word patterns first (e.g., "deze week", "volgende week")
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const twoWords = `${words[i]} ${words[i + 1]}`;
|
||||
const parsed = parseRelativeDate(twoWords);
|
||||
if (parsed) {
|
||||
if (isDateRange(parsed)) {
|
||||
entities.dateRange = parsed;
|
||||
} else {
|
||||
entities.dateRange = dateToRange(parsed, extractDateLabel(twoWords));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
// Check single word patterns
|
||||
for (const word of words) {
|
||||
const parsed = parseRelativeDate(word);
|
||||
if (parsed) {
|
||||
if (isDateRange(parsed)) {
|
||||
entities.dateRange = parsed;
|
||||
} else {
|
||||
entities.dateRange = dateToRange(parsed, extractDateLabel(word));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to today if no date specified
|
||||
const today = new Date();
|
||||
entities.dateRange = dateToRange(today, 'vandaag');
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract entities for create_appointment intent.
|
||||
* Patterns:
|
||||
* - "maak afspraak jan morgen 14:00" → patient: Jan, date: tomorrow, time: 14:00
|
||||
* - "plan intake marie vrijdag 10:00" → patient: Marie, type: intake, date: friday, time: 10:00
|
||||
* - "afspraak met piet 14:00" → patient: Piet, time: 14:00, date: today (implied)
|
||||
*/
|
||||
function extractCreateAppointmentEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||
const entities: ExtractedEntities = {};
|
||||
const words = lowerInput.split(/\s+/);
|
||||
|
||||
// Remove command words
|
||||
const appointmentCommands = ['maak', 'plan', 'afspraak', 'met', 'nieuwe', 'voor'];
|
||||
const filteredWords = words.filter(w => !appointmentCommands.includes(w));
|
||||
|
||||
// Extract appointment type
|
||||
const typeKeywords: Record<string, ExtractedEntities['appointmentType']> = {
|
||||
'intake': 'intake',
|
||||
'behandeling': 'behandeling',
|
||||
'vervolg': 'follow-up',
|
||||
'vervolgafspraak': 'follow-up',
|
||||
'telefonisch': 'telefonisch',
|
||||
'bellen': 'telefonisch',
|
||||
'huisbezoek': 'huisbezoek',
|
||||
'thuis': 'huisbezoek',
|
||||
'online': 'online',
|
||||
'video': 'online',
|
||||
'crisis': 'crisis',
|
||||
'spoed': 'crisis',
|
||||
};
|
||||
|
||||
for (const [keyword, type] of Object.entries(typeKeywords)) {
|
||||
if (lowerInput.includes(keyword)) {
|
||||
entities.appointmentType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract location
|
||||
const locationKeywords: Record<string, ExtractedEntities['location']> = {
|
||||
'praktijk': 'praktijk',
|
||||
'online': 'online',
|
||||
'video': 'online',
|
||||
'thuis': 'thuis',
|
||||
'huisbezoek': 'thuis',
|
||||
};
|
||||
|
||||
for (const [keyword, location] of Object.entries(locationKeywords)) {
|
||||
if (lowerInput.includes(keyword)) {
|
||||
entities.location = location;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract patient name
|
||||
for (const word of filteredWords) {
|
||||
if (isLikelyName(word, originalInput)) {
|
||||
entities.patientName = capitalizeFirst(word);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract date and time
|
||||
let foundDate: Date | null = null;
|
||||
let foundTime: string | null = null;
|
||||
|
||||
// Try multi-word date patterns
|
||||
for (let i = 0; i < filteredWords.length - 1; i++) {
|
||||
const twoWords = `${filteredWords[i]} ${filteredWords[i + 1]}`;
|
||||
const parsed = parseRelativeDate(twoWords);
|
||||
if (parsed && !isDateRange(parsed)) {
|
||||
foundDate = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try single word date patterns
|
||||
if (!foundDate) {
|
||||
for (const word of filteredWords) {
|
||||
const parsed = parseRelativeDate(word);
|
||||
if (parsed && !isDateRange(parsed)) {
|
||||
foundDate = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find time
|
||||
for (const word of filteredWords) {
|
||||
const parsed = parseTime(word);
|
||||
if (parsed) {
|
||||
foundTime = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try multi-word time patterns (e.g., "half drie")
|
||||
if (!foundTime) {
|
||||
for (let i = 0; i < filteredWords.length - 1; i++) {
|
||||
const twoWords = `${filteredWords[i]} ${filteredWords[i + 1]}`;
|
||||
const parsed = parseTime(twoWords);
|
||||
if (parsed) {
|
||||
foundTime = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine date and time if both found
|
||||
if (foundDate && foundTime) {
|
||||
entities.datetime = {
|
||||
date: foundDate,
|
||||
time: foundTime,
|
||||
};
|
||||
} else if (foundDate) {
|
||||
// Date without time
|
||||
entities.datetime = {
|
||||
date: foundDate,
|
||||
time: '', // Will be filled by UI or AI
|
||||
};
|
||||
} else if (foundTime) {
|
||||
// Time without date (assume today)
|
||||
entities.datetime = {
|
||||
date: new Date(),
|
||||
time: foundTime,
|
||||
};
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract entities for cancel_appointment intent.
|
||||
* Patterns:
|
||||
* - "annuleer afspraak jan" → identifier: { type: patient, patientName: Jan }
|
||||
* - "cancel de 14:00 afspraak" → identifier: { type: time, time: 14:00 }
|
||||
* - "annuleer jan morgen" → identifier: { type: both, patientName: Jan, date: tomorrow }
|
||||
*/
|
||||
function extractCancelAppointmentEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||
const entities: ExtractedEntities = {};
|
||||
const words = lowerInput.split(/\s+/);
|
||||
|
||||
// Remove command words
|
||||
const cancelCommands = ['annuleer', 'cancel', 'verwijder', 'afspraak', 'de', 'van'];
|
||||
const filteredWords = words.filter(w => !cancelCommands.includes(w));
|
||||
|
||||
// Extract patient name
|
||||
let patientName: string | undefined;
|
||||
for (const word of filteredWords) {
|
||||
if (isLikelyName(word, originalInput)) {
|
||||
patientName = capitalizeFirst(word);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract time
|
||||
let time: string | null = null;
|
||||
for (const word of filteredWords) {
|
||||
const parsed = parseTime(word);
|
||||
if (parsed) {
|
||||
time = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract date
|
||||
let date: Date | null = null;
|
||||
for (const word of filteredWords) {
|
||||
const parsed = parseRelativeDate(word);
|
||||
if (parsed && !isDateRange(parsed)) {
|
||||
date = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build identifier
|
||||
if (patientName && time) {
|
||||
entities.identifier = {
|
||||
type: 'both',
|
||||
patientName,
|
||||
time,
|
||||
date: date || undefined,
|
||||
};
|
||||
} else if (patientName) {
|
||||
entities.identifier = {
|
||||
type: 'patient',
|
||||
patientName,
|
||||
date: date || undefined,
|
||||
};
|
||||
} else if (time) {
|
||||
entities.identifier = {
|
||||
type: 'time',
|
||||
time,
|
||||
date: date || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract entities for reschedule_appointment intent.
|
||||
* Patterns:
|
||||
* - "verzet 14:00 naar 15:00" → identifier: { time: 14:00 }, newDatetime: { time: 15:00 }
|
||||
* - "verzet jan naar dinsdag" → identifier: { patientName: Jan }, newDatetime: { date: tuesday }
|
||||
* - "verzet de afspraak naar morgen 10:00" → newDatetime: { date: tomorrow, time: 10:00 }
|
||||
*/
|
||||
function extractRescheduleAppointmentEntities(lowerInput: string, originalInput: string): ExtractedEntities {
|
||||
const entities: ExtractedEntities = {};
|
||||
const words = lowerInput.split(/\s+/);
|
||||
|
||||
// Split input on "naar" to separate old and new parts
|
||||
const naarIndex = words.indexOf('naar');
|
||||
const oldPart = naarIndex > 0 ? words.slice(0, naarIndex).join(' ') : lowerInput;
|
||||
const newPart = naarIndex > 0 ? words.slice(naarIndex + 1).join(' ') : '';
|
||||
|
||||
// Remove command words
|
||||
const rescheduleCommands = ['verzet', 'verplaats', 'verschuif', 'afspraak', 'de', 'van'];
|
||||
const oldWords = oldPart.split(/\s+/).filter(w => !rescheduleCommands.includes(w));
|
||||
const newWords = newPart.split(/\s+/);
|
||||
|
||||
// Extract old appointment identifier
|
||||
let patientName: string | undefined;
|
||||
for (const word of oldWords) {
|
||||
if (isLikelyName(word, originalInput)) {
|
||||
patientName = capitalizeFirst(word);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let oldTime: string | null = null;
|
||||
for (const word of oldWords) {
|
||||
const parsed = parseTime(word);
|
||||
if (parsed) {
|
||||
oldTime = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let oldDate: Date | null = null;
|
||||
for (const word of oldWords) {
|
||||
const parsed = parseRelativeDate(word);
|
||||
if (parsed && !isDateRange(parsed)) {
|
||||
oldDate = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build identifier
|
||||
if (patientName && oldTime) {
|
||||
entities.identifier = {
|
||||
type: 'both',
|
||||
patientName,
|
||||
time: oldTime,
|
||||
date: oldDate || undefined,
|
||||
};
|
||||
} else if (patientName) {
|
||||
entities.identifier = {
|
||||
type: 'patient',
|
||||
patientName,
|
||||
date: oldDate || undefined,
|
||||
};
|
||||
} else if (oldTime) {
|
||||
entities.identifier = {
|
||||
type: 'time',
|
||||
time: oldTime,
|
||||
date: oldDate || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract new datetime
|
||||
if (newWords.length > 0) {
|
||||
let newDate: Date | null = null;
|
||||
let newTime: string | null = null;
|
||||
|
||||
// Try multi-word date patterns
|
||||
for (let i = 0; i < newWords.length - 1; i++) {
|
||||
const twoWords = `${newWords[i]} ${newWords[i + 1]}`;
|
||||
const parsed = parseRelativeDate(twoWords);
|
||||
if (parsed && !isDateRange(parsed)) {
|
||||
newDate = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try single word date patterns
|
||||
if (!newDate) {
|
||||
for (const word of newWords) {
|
||||
const parsed = parseRelativeDate(word);
|
||||
if (parsed && !isDateRange(parsed)) {
|
||||
newDate = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find new time
|
||||
for (const word of newWords) {
|
||||
const parsed = parseTime(word);
|
||||
if (parsed) {
|
||||
newTime = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try multi-word time patterns
|
||||
if (!newTime) {
|
||||
for (let i = 0; i < newWords.length - 1; i++) {
|
||||
const twoWords = `${newWords[i]} ${newWords[i + 1]}`;
|
||||
const parsed = parseTime(twoWords);
|
||||
if (parsed) {
|
||||
newTime = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newDate || newTime) {
|
||||
entities.newDatetime = {
|
||||
date: newDate || new Date(), // Default to today if only time specified
|
||||
time: newTime || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract date label from input string
|
||||
*/
|
||||
function extractDateLabel(input: string): DateRange['label'] {
|
||||
const normalized = input.toLowerCase();
|
||||
if (normalized.includes('vandaag')) return 'vandaag';
|
||||
if (normalized.includes('morgen')) return 'morgen';
|
||||
if (normalized.includes('deze week')) return 'deze week';
|
||||
if (normalized.includes('volgende week')) return 'volgende week';
|
||||
return 'custom';
|
||||
}
|
||||
300
lib/cortex/error-handler.ts
Normal file
300
lib/cortex/error-handler.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Error Handler Utility voor Cortex
|
||||
*
|
||||
* E5.S2: Gecentraliseerde error handling met network detection,
|
||||
* gebruiksvriendelijke berichten en retry logic.
|
||||
*/
|
||||
|
||||
export interface ErrorContext {
|
||||
operation: string;
|
||||
endpoint?: string;
|
||||
statusCode?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
retryable: boolean;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecteert of de browser offline is
|
||||
*/
|
||||
export function isOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && !navigator.onLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of een error een network error is
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof TypeError) {
|
||||
return (
|
||||
error.message.includes('fetch') ||
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('Failed to fetch')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of een error een timeout is
|
||||
*/
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return error.message.toLowerCase().includes('timeout');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP error response en extraheert gebruiksvriendelijke berichten
|
||||
*/
|
||||
export async function parseErrorResponse(
|
||||
response: Response
|
||||
): Promise<{ error: string; details?: string }> {
|
||||
try {
|
||||
const errorText = await response.text();
|
||||
|
||||
// Check if response is HTML (likely redirect to login)
|
||||
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
|
||||
return {
|
||||
error: 'Niet geautoriseerd. Log opnieuw in.',
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
return {
|
||||
error: errorData.error || errorData.message || 'Onbekende fout',
|
||||
details: errorData.details,
|
||||
};
|
||||
} catch {
|
||||
// Not JSON, return text (truncated if too long)
|
||||
return {
|
||||
error: errorText.length > 200 ? errorText.substring(0, 200) + '...' : errorText,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
error: 'Kon foutmelding niet lezen',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert gebruiksvriendelijke error informatie op basis van error type en context
|
||||
*/
|
||||
export function getErrorInfo(error: unknown, context?: ErrorContext): ErrorInfo {
|
||||
// Offline detection
|
||||
if (isOffline()) {
|
||||
return {
|
||||
title: 'Geen internetverbinding',
|
||||
description: 'Controleer je internetverbinding en probeer het opnieuw.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Network errors (fetch failures)
|
||||
if (isNetworkError(error)) {
|
||||
return {
|
||||
title: 'Verbinding verbroken',
|
||||
description: 'Kon geen verbinding maken met de server. Probeer het opnieuw.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (isTimeoutError(error)) {
|
||||
return {
|
||||
title: 'Verbinding timeout',
|
||||
description: 'De verbinding duurde te lang. Probeer het opnieuw.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP status codes
|
||||
if (context?.statusCode) {
|
||||
switch (context.statusCode) {
|
||||
case 401:
|
||||
return {
|
||||
title: 'Niet geautoriseerd',
|
||||
description: 'Je sessie is verlopen. Log opnieuw in.',
|
||||
retryable: false,
|
||||
statusCode: 401,
|
||||
};
|
||||
case 403:
|
||||
return {
|
||||
title: 'Geen toegang',
|
||||
description: 'Je hebt geen toegang tot deze actie.',
|
||||
retryable: false,
|
||||
statusCode: 403,
|
||||
};
|
||||
case 404:
|
||||
return {
|
||||
title: 'Niet gevonden',
|
||||
description: context.operation
|
||||
? `${context.operation} niet gevonden.`
|
||||
: 'De gevraagde resource bestaat niet.',
|
||||
retryable: false,
|
||||
statusCode: 404,
|
||||
};
|
||||
case 400:
|
||||
return {
|
||||
title: 'Ongeldige aanvraag',
|
||||
description: error instanceof Error ? error.message : 'Controleer je invoer en probeer het opnieuw.',
|
||||
retryable: false,
|
||||
statusCode: 400,
|
||||
};
|
||||
case 429:
|
||||
return {
|
||||
title: 'Te veel aanvragen',
|
||||
description: 'Je hebt te veel aanvragen gedaan. Wacht even en probeer het later opnieuw.',
|
||||
retryable: true,
|
||||
statusCode: 429,
|
||||
};
|
||||
case 500:
|
||||
return {
|
||||
title: 'Serverfout',
|
||||
description: 'Er ging iets mis op de server. Probeer het later opnieuw.',
|
||||
retryable: true,
|
||||
statusCode: 500,
|
||||
};
|
||||
case 503:
|
||||
return {
|
||||
title: 'Service niet beschikbaar',
|
||||
description: 'De service is tijdelijk niet beschikbaar. Probeer het later opnieuw.',
|
||||
retryable: true,
|
||||
statusCode: 503,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Fout opgetreden',
|
||||
description: error instanceof Error ? error.message : `HTTP ${context.statusCode}`,
|
||||
retryable: context.statusCode >= 500,
|
||||
statusCode: context.statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
if (error instanceof Error) {
|
||||
// Check for specific error messages
|
||||
if (error.message.includes('Niet geautoriseerd') || error.message.includes('Log opnieuw in')) {
|
||||
return {
|
||||
title: 'Niet geautoriseerd',
|
||||
description: error.message,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message.includes('Validatiefout') || error.message.includes('validatie')) {
|
||||
return {
|
||||
title: 'Validatiefout',
|
||||
description: error.message,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Fout opgetreden',
|
||||
description: error.message,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
return {
|
||||
title: 'Onbekende fout',
|
||||
description: 'Er ging iets mis. Probeer het opnieuw.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper voor fetch met verbeterde error handling
|
||||
*/
|
||||
export async function safeFetch(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
context?: Omit<ErrorContext, 'statusCode'>
|
||||
): Promise<Response> {
|
||||
// Check offline first
|
||||
if (isOffline()) {
|
||||
throw new Error('Geen internetverbinding');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
// Add timeout (30 seconds)
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await parseErrorResponse(response);
|
||||
const error = new Error(errorData.error);
|
||||
(error as any).statusCode = response.status;
|
||||
(error as any).details = errorData.details;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Re-throw with context if it's already an Error with statusCode
|
||||
if (error instanceof Error && (error as any).statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wrap network errors
|
||||
if (isNetworkError(error) || isTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Re-throw as-is
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry logic voor retryable errors
|
||||
*/
|
||||
export async function retryFetch<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const errorInfo = getErrorInfo(error);
|
||||
|
||||
// Don't retry if not retryable
|
||||
if (!errorInfo.retryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === maxRetries - 1) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
10
lib/cortex/index.ts
Normal file
10
lib/cortex/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Cortex Library Barrel Export
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './use-cortex-voice';
|
||||
export * from './intent-classifier';
|
||||
export * from './intent-classifier-ai';
|
||||
export * from './entity-extractor';
|
||||
export * from './date-time-parser';
|
||||
230
lib/cortex/intent-classifier-ai.ts
Normal file
230
lib/cortex/intent-classifier-ai.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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 { CortexIntent, 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',
|
||||
'agenda_query',
|
||||
'create_appointment',
|
||||
'cancel_appointment',
|
||||
'reschedule_appointment',
|
||||
'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(),
|
||||
query: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
identifier: z.string().optional(),
|
||||
}).optional(),
|
||||
reasoning: z.string().optional(),
|
||||
});
|
||||
|
||||
type AIIntentResponse = z.infer<typeof AIIntentResponseSchema>;
|
||||
|
||||
export interface AIClassificationResult {
|
||||
intent: CortexIntent;
|
||||
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 Cortex.
|
||||
|
||||
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. **agenda_query** - Gebruiker wil afspraken opvragen of de agenda zien
|
||||
Voorbeelden: "afspraken vandaag", "wat is mijn volgende afspraak", "agenda volgende week"
|
||||
|
||||
5. **create_appointment** - Gebruiker wil een afspraak maken of plannen
|
||||
Voorbeelden: "maak afspraak met jan morgen 14:00", "plan intake volgende week"
|
||||
|
||||
6. **cancel_appointment** - Gebruiker wil een afspraak annuleren
|
||||
Voorbeelden: "annuleer afspraak jan", "zeg afspraak af"
|
||||
|
||||
7. **reschedule_appointment** - Gebruiker wil een afspraak verzetten
|
||||
Voorbeelden: "verzet 14:00 naar 15:00", "verplaats afspraak naar dinsdag"
|
||||
|
||||
8. **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
|
||||
|
||||
Voor agenda_query, extraheer:
|
||||
- query: het relevante datum-/tijd-bereik of scope (bijv. "vandaag", "volgende week")
|
||||
- patientName: de patiëntnaam als die expliciet genoemd is
|
||||
- date: een expliciete datum als losse waarde (bijv. "2025-01-05")
|
||||
- time: een expliciete tijd (24-uurs, bijv. "14:00")
|
||||
|
||||
Voor create_appointment, extraheer:
|
||||
- patientName: de patiëntnaam als die expliciet genoemd is
|
||||
- query: datum/tijd/type/locatie details in vrije tekst (bijv. "morgen 14:00 intake")
|
||||
- date: een expliciete datum als losse waarde (bijv. "2025-01-05")
|
||||
- time: een expliciete tijd (24-uurs, bijv. "14:00")
|
||||
|
||||
Voor cancel_appointment, extraheer:
|
||||
- patientName: de patiëntnaam als die expliciet genoemd is
|
||||
- query: afspraakdetails in vrije tekst (bijv. "afspraak om 14:00", "afspraak van vrijdag")
|
||||
- identifier: een expliciete afspraak-id indien genoemd
|
||||
|
||||
Voor reschedule_appointment, extraheer:
|
||||
- patientName: de patiëntnaam als die expliciet genoemd is
|
||||
- query: huidige + nieuwe datum/tijd in vrije tekst (bijv. "14:00 naar 15:00")
|
||||
- date: de nieuwe expliciete datum indien genoemd
|
||||
- time: de nieuwe expliciete tijd indien genoemd
|
||||
|
||||
Antwoord ALLEEN met een JSON object in dit formaat:
|
||||
{
|
||||
"intent": "dagnotitie" | "zoeken" | "overdracht" | "agenda_query" | "create_appointment" | "cancel_appointment" | "reschedule_appointment" | "unknown",
|
||||
"confidence": 0.0-1.0,
|
||||
"entities": {
|
||||
"patientName": "naam" (optioneel),
|
||||
"category": "medicatie" | "adl" | "gedrag" | "incident" | "observatie" (optioneel),
|
||||
"content": "inhoud" (optioneel),
|
||||
"query": "vrije tekst voor planning" (optioneel),
|
||||
"date": "YYYY-MM-DD" (optioneel),
|
||||
"time": "HH:MM" (optioneel),
|
||||
"identifier": "id" (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;
|
||||
|
||||
// Build entities object with backward compatibility
|
||||
const entities: ExtractedEntities = {
|
||||
patientName: validated.entities?.patientName,
|
||||
category: validated.entities?.category as VerpleegkundigCategory | undefined,
|
||||
content: validated.entities?.content,
|
||||
query: validated.entities?.query,
|
||||
// Legacy fields for backward compatibility
|
||||
date: validated.entities?.date,
|
||||
time: validated.entities?.time,
|
||||
};
|
||||
|
||||
// For agenda intents, we'll rely on local entity extraction
|
||||
// AI just provides the basic fields (patientName, date, time)
|
||||
// and the local extractor will structure them properly
|
||||
|
||||
return {
|
||||
intent: validated.intent as CortexIntent,
|
||||
confidence: validated.confidence,
|
||||
entities,
|
||||
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;
|
||||
}
|
||||
248
lib/cortex/intent-classifier.ts
Normal file
248
lib/cortex/intent-classifier.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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"
|
||||
],
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
147
lib/cortex/types.ts
Normal file
147
lib/cortex/types.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Cortex Type Definitions
|
||||
*
|
||||
* Core types for the Cortex Contextual UI system.
|
||||
*/
|
||||
|
||||
import type { VerpleegkundigCategory } from '@/lib/types/report';
|
||||
|
||||
// Intent types
|
||||
export type CortexIntent =
|
||||
| 'dagnotitie'
|
||||
| 'zoeken'
|
||||
| 'overdracht'
|
||||
| 'agenda_query'
|
||||
| 'create_appointment'
|
||||
| 'cancel_appointment'
|
||||
| 'reschedule_appointment'
|
||||
| 'unknown';
|
||||
|
||||
export type BlockType = Exclude<CortexIntent, 'unknown'> | 'patient-dashboard';
|
||||
|
||||
// Shift types
|
||||
export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond';
|
||||
|
||||
// Intent classification result
|
||||
export interface IntentClassificationResult {
|
||||
intent: CortexIntent;
|
||||
confidence: number;
|
||||
entities: ExtractedEntities;
|
||||
source: 'local' | 'ai';
|
||||
}
|
||||
|
||||
// Extracted entities from user input
|
||||
export interface ExtractedEntities {
|
||||
// Common entities
|
||||
patientName?: string;
|
||||
patientId?: string;
|
||||
|
||||
// Dagnotitie entities
|
||||
category?: VerpleegkundigCategory;
|
||||
content?: string;
|
||||
|
||||
// Search entities
|
||||
query?: string;
|
||||
|
||||
// Agenda entities
|
||||
dateRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label: 'vandaag' | 'morgen' | 'deze week' | 'volgende week' | 'custom';
|
||||
};
|
||||
datetime?: {
|
||||
date: Date;
|
||||
time: string; // "HH:mm" format
|
||||
};
|
||||
appointmentType?: 'intake' | 'behandeling' | 'follow-up' | 'telefonisch' |
|
||||
'huisbezoek' | 'online' | 'crisis' | 'overig';
|
||||
location?: 'praktijk' | 'online' | 'thuis';
|
||||
identifier?: {
|
||||
type: 'patient' | 'time' | 'both';
|
||||
patientName?: string;
|
||||
patientId?: string;
|
||||
time?: string;
|
||||
date?: Date;
|
||||
encounterId?: string;
|
||||
};
|
||||
newDatetime?: {
|
||||
date: Date;
|
||||
time: string;
|
||||
};
|
||||
|
||||
// Legacy fields (for backward compatibility)
|
||||
date?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
// Block sizes
|
||||
export type BlockSize = 'sm' | 'md' | 'lg' | 'full';
|
||||
|
||||
// Block configuration
|
||||
export interface BlockConfig {
|
||||
type: BlockType;
|
||||
title: string;
|
||||
size: BlockSize;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Block configs for each type
|
||||
export const BLOCK_CONFIGS: Record<BlockType, BlockConfig> = {
|
||||
dagnotitie: {
|
||||
type: 'dagnotitie',
|
||||
title: 'Dagnotitie',
|
||||
size: 'md',
|
||||
icon: 'FileText',
|
||||
},
|
||||
zoeken: {
|
||||
type: 'zoeken',
|
||||
title: 'Patiënt zoeken',
|
||||
size: 'md',
|
||||
icon: 'Search',
|
||||
},
|
||||
overdracht: {
|
||||
type: 'overdracht',
|
||||
title: 'Overdracht',
|
||||
size: 'lg',
|
||||
icon: 'ArrowRightLeft',
|
||||
},
|
||||
agenda_query: {
|
||||
type: 'agenda_query',
|
||||
title: 'Agenda',
|
||||
size: 'lg',
|
||||
icon: 'Calendar',
|
||||
},
|
||||
create_appointment: {
|
||||
type: 'create_appointment',
|
||||
title: 'Nieuwe afspraak',
|
||||
size: 'lg',
|
||||
icon: 'Plus',
|
||||
},
|
||||
cancel_appointment: {
|
||||
type: 'cancel_appointment',
|
||||
title: 'Afspraak annuleren',
|
||||
size: 'lg',
|
||||
icon: 'X',
|
||||
},
|
||||
reschedule_appointment: {
|
||||
type: 'reschedule_appointment',
|
||||
title: 'Afspraak verzetten',
|
||||
size: 'lg',
|
||||
icon: 'Clock',
|
||||
},
|
||||
'patient-dashboard': {
|
||||
type: 'patient-dashboard',
|
||||
title: 'Patiëntoverzicht',
|
||||
size: 'lg',
|
||||
icon: 'LayoutDashboard',
|
||||
},
|
||||
};
|
||||
|
||||
// Recent action type
|
||||
export interface RecentAction {
|
||||
id: string;
|
||||
intent: CortexIntent;
|
||||
label: string;
|
||||
timestamp: Date;
|
||||
patientName?: string;
|
||||
}
|
||||
115
lib/cortex/use-cortex-voice.ts
Normal file
115
lib/cortex/use-cortex-voice.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Cortex Voice Hook
|
||||
*
|
||||
* Wraps useDeepgramStreaming for Cortex-specific voice input behavior.
|
||||
* Streams transcript directly to the command input.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import {
|
||||
useDeepgramStreaming,
|
||||
type TranscriptResult,
|
||||
} from '@/hooks/use-deepgram-streaming';
|
||||
|
||||
export interface UseCortexVoiceReturn {
|
||||
isRecording: boolean;
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
error: string | null;
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => void;
|
||||
analyserNode: AnalyserNode | null;
|
||||
isBrowserSupported: boolean;
|
||||
}
|
||||
|
||||
export function useCortexVoice(): UseCortexVoiceReturn {
|
||||
const { setInputValue, setVoiceActive, inputValue } = useCortexStore();
|
||||
|
||||
// Track the base text (what was in input before recording started)
|
||||
const baseTextRef = useRef('');
|
||||
// Track interim transcript for replacement
|
||||
const lastInterimRef = useRef('');
|
||||
|
||||
const handleTranscript = useCallback(
|
||||
(result: TranscriptResult) => {
|
||||
const { transcript, isFinal } = result;
|
||||
|
||||
if (isFinal) {
|
||||
// Final transcript: append to base text and update base
|
||||
const newText = baseTextRef.current
|
||||
? `${baseTextRef.current} ${transcript}`
|
||||
: transcript;
|
||||
baseTextRef.current = newText;
|
||||
lastInterimRef.current = '';
|
||||
setInputValue(newText);
|
||||
} else {
|
||||
// Interim transcript: show as preview (replace previous interim)
|
||||
const previewText = baseTextRef.current
|
||||
? `${baseTextRef.current} ${transcript}`
|
||||
: transcript;
|
||||
setInputValue(previewText);
|
||||
lastInterimRef.current = transcript;
|
||||
}
|
||||
},
|
||||
[setInputValue]
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: Error) => {
|
||||
console.error('[CortexVoice] Error:', error.message);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
status,
|
||||
isRecording,
|
||||
startRecording: startDeepgram,
|
||||
stopRecording: stopDeepgram,
|
||||
analyserNode,
|
||||
error,
|
||||
isBrowserSupported,
|
||||
} = useDeepgramStreaming({
|
||||
onTranscript: handleTranscript,
|
||||
onError: handleError,
|
||||
language: 'nl',
|
||||
model: 'nova-2',
|
||||
endpointingMs: 2000, // Shorter for command-style input
|
||||
});
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
// Store current input as base text
|
||||
baseTextRef.current = inputValue;
|
||||
lastInterimRef.current = '';
|
||||
setVoiceActive(true);
|
||||
await startDeepgram();
|
||||
}, [inputValue, setVoiceActive, startDeepgram]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
stopDeepgram();
|
||||
setVoiceActive(false);
|
||||
// Keep whatever text is in the input
|
||||
lastInterimRef.current = '';
|
||||
}, [stopDeepgram, setVoiceActive]);
|
||||
|
||||
// Sync voice active state with recording state
|
||||
useEffect(() => {
|
||||
if (!isRecording) {
|
||||
setVoiceActive(false);
|
||||
}
|
||||
}, [isRecording, setVoiceActive]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isConnecting: status === 'connecting' || status === 'reconnecting',
|
||||
isConnected: status === 'connected',
|
||||
error,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
analyserNode,
|
||||
isBrowserSupported,
|
||||
};
|
||||
}
|
||||
102
lib/cortex/verify-entity-extraction.ts
Normal file
102
lib/cortex/verify-entity-extraction.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Manual verification script for entity extraction with date/time parser
|
||||
* Run with: pnpm tsx lib/cortex/verify-entity-extraction.ts
|
||||
*/
|
||||
|
||||
import { extractEntities } from './entity-extractor';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
|
||||
console.log('🧪 Testing Entity Extraction with Date/Time Parser\n');
|
||||
|
||||
// Test cases for each agenda intent
|
||||
const testCases = [
|
||||
{
|
||||
intent: 'agenda_query' as const,
|
||||
inputs: [
|
||||
'afspraken vandaag',
|
||||
'agenda morgen',
|
||||
'wat is volgende afspraak',
|
||||
'afspraken deze week',
|
||||
],
|
||||
},
|
||||
{
|
||||
intent: 'create_appointment' as const,
|
||||
inputs: [
|
||||
'maak afspraak jan morgen 14:00',
|
||||
'plan intake marie vrijdag 10:00',
|
||||
'afspraak met piet twee uur',
|
||||
'maak behandeling lisa dinsdag half drie',
|
||||
],
|
||||
},
|
||||
{
|
||||
intent: 'cancel_appointment' as const,
|
||||
inputs: [
|
||||
'annuleer afspraak jan',
|
||||
'cancel de 14:00 afspraak',
|
||||
'annuleer jan morgen',
|
||||
],
|
||||
},
|
||||
{
|
||||
intent: 'reschedule_appointment' as const,
|
||||
inputs: [
|
||||
'verzet 14:00 naar 15:00',
|
||||
'verzet jan naar dinsdag',
|
||||
'verplaats de afspraak naar morgen 10:00',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ intent, inputs }) => {
|
||||
console.log(`\n📋 Testing ${intent}:\n`);
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const entities = extractEntities(input, intent);
|
||||
console.log(` Input: "${input}"`);
|
||||
|
||||
// Display extracted entities
|
||||
if (entities.patientName) {
|
||||
console.log(` 👤 Patient: ${entities.patientName}`);
|
||||
}
|
||||
|
||||
if (entities.dateRange) {
|
||||
const { start, end, label } = entities.dateRange;
|
||||
console.log(
|
||||
` 📅 Date Range: ${format(start, 'dd MMM', { locale: nl })} - ${format(end, 'dd MMM', { locale: nl })} (${label})`
|
||||
);
|
||||
}
|
||||
|
||||
if (entities.datetime) {
|
||||
const { date, time } = entities.datetime;
|
||||
const dateStr = format(date, 'dd MMMM yyyy', { locale: nl });
|
||||
console.log(` 🕐 Datetime: ${dateStr} om ${time || '(tijd niet gespecificeerd)'}`);
|
||||
}
|
||||
|
||||
if (entities.appointmentType) {
|
||||
console.log(` 📝 Type: ${entities.appointmentType}`);
|
||||
}
|
||||
|
||||
if (entities.location) {
|
||||
console.log(` 📍 Location: ${entities.location}`);
|
||||
}
|
||||
|
||||
if (entities.identifier) {
|
||||
const { type, patientName, time, date } = entities.identifier;
|
||||
let identifierStr = ` 🔍 Identifier: type=${type}`;
|
||||
if (patientName) identifierStr += `, patient=${patientName}`;
|
||||
if (time) identifierStr += `, time=${time}`;
|
||||
if (date) identifierStr += `, date=${format(date, 'dd MMM', { locale: nl })}`;
|
||||
console.log(identifierStr);
|
||||
}
|
||||
|
||||
if (entities.newDatetime) {
|
||||
const { date, time } = entities.newDatetime;
|
||||
const dateStr = format(date, 'dd MMMM yyyy', { locale: nl });
|
||||
console.log(` 🔄 New Datetime: ${dateStr} om ${time || '(tijd niet gespecificeerd)'}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ Verification complete!');
|
||||
93
lib/cortex/verify-parser.ts
Normal file
93
lib/cortex/verify-parser.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Manual verification script for date-time parser
|
||||
* Run with: pnpm tsx lib/cortex/verify-parser.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
parseRelativeDate,
|
||||
parseTime,
|
||||
isDateRange,
|
||||
combineDatetime,
|
||||
isNotInPast,
|
||||
} from './date-time-parser';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
|
||||
console.log('🧪 Testing Date/Time Parser\n');
|
||||
|
||||
// Test parseRelativeDate
|
||||
console.log('📅 Testing parseRelativeDate():\n');
|
||||
|
||||
const dateTests = [
|
||||
'vandaag',
|
||||
'morgen',
|
||||
'overmorgen',
|
||||
'maandag',
|
||||
'dinsdag',
|
||||
'deze week',
|
||||
'volgende week',
|
||||
'30 december',
|
||||
'2024-12-28',
|
||||
'28-12-2024',
|
||||
];
|
||||
|
||||
dateTests.forEach((input) => {
|
||||
const result = parseRelativeDate(input);
|
||||
if (result === null) {
|
||||
console.log(` ❌ "${input}" → null`);
|
||||
} else if (isDateRange(result)) {
|
||||
console.log(` ✅ "${input}" → Range: ${format(result.start, 'dd MMM', { locale: nl })} - ${format(result.end, 'dd MMM', { locale: nl })}`);
|
||||
} else {
|
||||
console.log(` ✅ "${input}" → ${format(result, 'EEEE dd MMMM yyyy', { locale: nl })}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test parseTime
|
||||
console.log('\n⏰ Testing parseTime():\n');
|
||||
|
||||
const timeTests = [
|
||||
'14:00',
|
||||
'14',
|
||||
'twee uur',
|
||||
'half drie',
|
||||
'kwart over twee',
|
||||
'kwart voor drie',
|
||||
'ochtend',
|
||||
'middag',
|
||||
'avond',
|
||||
];
|
||||
|
||||
timeTests.forEach((input) => {
|
||||
const result = parseTime(input);
|
||||
if (result === null) {
|
||||
console.log(` ❌ "${input}" → null`);
|
||||
} else {
|
||||
console.log(` ✅ "${input}" → ${result}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test combineDatetime
|
||||
console.log('\n🔗 Testing combineDatetime():\n');
|
||||
|
||||
const morgen = parseRelativeDate('morgen');
|
||||
if (morgen && !isDateRange(morgen)) {
|
||||
const combined = combineDatetime(morgen, '14:00');
|
||||
console.log(` ✅ morgen + 14:00 → ${combined}`);
|
||||
}
|
||||
|
||||
// Test isNotInPast
|
||||
console.log('\n✔️ Testing isNotInPast():\n');
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = parseRelativeDate('morgen');
|
||||
const yesterday = parseRelativeDate('gisteren');
|
||||
|
||||
console.log(` Today: ${isNotInPast(today)} (expected: true)`);
|
||||
if (tomorrow && !isDateRange(tomorrow)) {
|
||||
console.log(` Tomorrow: ${isNotInPast(tomorrow)} (expected: true)`);
|
||||
}
|
||||
if (yesterday && !isDateRange(yesterday)) {
|
||||
console.log(` Yesterday: ${isNotInPast(yesterday)} (expected: false)`);
|
||||
}
|
||||
|
||||
console.log('\n✅ Verification complete!');
|
||||
Reference in New Issue
Block a user