feat(swift): voeg date/time parsing toe (E2)

Epic 2 compleet: NLP-helpers voor datum en tijd interpretatie.

Implementatie:
- parseRelativeDate(): ondersteunt "vandaag", "morgen", "deze week",
  weekdagen (maandag-zondag), en absolute datums (30 december, 28-12-2024)
- parseTime(): ondersteunt "14:00", "14", "half drie", "kwart voor drie",
  en natuurlijke taal tijdsaanduidingen
- combineDatetime(): combineert datum + tijd naar ISO string
- isNotInPast(): validatie voor toekomstige datums
- isDateRange(): type guard voor datum ranges

Integratie:
- Entity extractor gebruikt date/time parser voor agenda intents
- Normaliseert ruwe strings ("morgen", "14:00") naar Date objecten
- Ondersteunt Nederlandse tijdsaanduidingen

Tests & validatie:
- Unit tests voor alle parser functies
- Verify script voor handmatige testing
- Review document met bevindingen en suggesties

Story points: 7 SP (E2.S1: 3, E2.S2: 2, E2.S3: 2)
This commit is contained in:
colinislit
2025-12-27 22:14:22 +01:00
parent d2ffccc22b
commit 0031bd5fd1
5 changed files with 1050 additions and 1 deletions

View File

@@ -1,11 +1,18 @@
/**
* Entity Extractor
*
* Extracts entities (patient name, category, content) from user input.
* Extracts entities (patient name, category, content, date/time) from user input.
*/
import type { VerpleegkundigCategory } from '@/lib/types/report';
import type { ExtractedEntities, SwiftIntent } 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> = {
@@ -95,6 +102,14 @@ export function extractEntities(input: string, intent: SwiftIntent): ExtractedEn
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;
}
@@ -231,3 +246,394 @@ 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';
}