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:
colinislit
2025-12-30 09:18:06 +01:00
parent c8aaba657e
commit 2170b23348
62 changed files with 364 additions and 355 deletions

View 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
View 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
View 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');
}
}

View 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;
}

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

View 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;
}

View 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
View 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;
}

View 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,
};
}

View 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!');

View 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!');