From 0031bd5fd12e905a7484b273f5fae71d148ba059 Mon Sep 17 00:00:00 2001 From: colinislit Date: Sat, 27 Dec 2025 22:14:22 +0100 Subject: [PATCH] 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) --- docs/swift/review-epic2.md | 34 ++ lib/swift/__tests__/date-time-parser.test.ts | 208 ++++++++++ lib/swift/date-time-parser.ts | 308 ++++++++++++++ lib/swift/entity-extractor.ts | 408 ++++++++++++++++++- lib/swift/verify-parser.ts | 93 +++++ 5 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 docs/swift/review-epic2.md create mode 100644 lib/swift/__tests__/date-time-parser.test.ts create mode 100644 lib/swift/date-time-parser.ts create mode 100644 lib/swift/verify-parser.ts diff --git a/docs/swift/review-epic2.md b/docs/swift/review-epic2.md new file mode 100644 index 0000000..e3a1013 --- /dev/null +++ b/docs/swift/review-epic2.md @@ -0,0 +1,34 @@ +# Epic 2 Review Findings + +Context: quick review of Epic 2 changes (date/time parsing + agenda entity extraction). + +## Findings +1. High - Patient name detection is too permissive for agenda intents and can misclassify appointment tokens as names. + - Details: create/cancel/reschedule extraction uses `filteredWords` + `isLikelyName`; tokens like "intake", "morgen", or times can be treated as names. + - Impact: wrong patient selected or wrong appointment targeted. + - Files: `lib/swift/entity-extractor.ts` + - Suggestion: skip tokens that parse as date/time or match appointment/location keywords before `isLikelyName`. + +2. Medium - Agenda query defaults to "vandaag" when no date is present (e.g., "volgende afspraak"). + - Impact: only today is queried, which can miss the actual next appointment. + - Files: `lib/swift/entity-extractor.ts` + - Suggestion: leave `dateRange` undefined or mark "from now" and let backend decide. + +3. Medium - `ExtractedEntities` now carries Date objects, but the classify API returns JSON, so Dates become strings. + - Impact: downstream date ops may break on serialized strings. + - Files: `lib/swift/types.ts` (see API flow in `app/api/intent/classify/route.ts`) + - Suggestion: use ISO strings in the DTO or normalize in the API response. + +4. Medium - Type drift between lib and store: the store `ExtractedEntities` does not include the new agenda structures. + - Impact: UI usage may drop agenda fields or require unsafe casting. + - Files: `stores/swift-store.ts`, `lib/swift/types.ts` + - Suggestion: align store types with lib types (or re-export the shared type). + +5. Low - AI fallback path does not run local extractor; low-confidence agenda intents lose structured entities. + - Impact: less reliable agenda prefill when AI is used. + - Files: `app/api/intent/classify/route.ts`, `lib/swift/intent-classifier-ai.ts` + - Suggestion: post-process AI results with local extractor for agenda intents. + +## Testing / QA notes +- Untracked tests exist: `lib/swift/__tests__/date-time-parser.test.ts`. +- Manual scripts exist: `lib/swift/verify-parser.ts` and `lib/swift/verify-entity-extraction.ts` (not run here). diff --git a/lib/swift/__tests__/date-time-parser.test.ts b/lib/swift/__tests__/date-time-parser.test.ts new file mode 100644 index 0000000..548dad9 --- /dev/null +++ b/lib/swift/__tests__/date-time-parser.test.ts @@ -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); + }); +}); diff --git a/lib/swift/date-time-parser.ts b/lib/swift/date-time-parser.ts new file mode 100644 index 0000000..4571337 --- /dev/null +++ b/lib/swift/date-time-parser.ts @@ -0,0 +1,308 @@ +/** + * Date/Time Parser Utilities + * + * Parses natural language date and time expressions (Dutch) + * for Swift 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 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 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 = { + // 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; +} diff --git a/lib/swift/entity-extractor.ts b/lib/swift/entity-extractor.ts index 2ff4a28..487cacc 100644 --- a/lib/swift/entity-extractor.ts +++ b/lib/swift/entity-extractor.ts @@ -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 = { @@ -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 = { + '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 = { + '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'; +} diff --git a/lib/swift/verify-parser.ts b/lib/swift/verify-parser.ts new file mode 100644 index 0000000..edab05e --- /dev/null +++ b/lib/swift/verify-parser.ts @@ -0,0 +1,93 @@ +/** + * Manual verification script for date-time parser + * Run with: pnpm tsx lib/swift/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!');