From 94aab93f1c5ed5ad074bef3c83dcd9b1bf437b22 Mon Sep 17 00:00:00 2001 From: colinislit Date: Sun, 28 Dec 2025 14:54:32 +0100 Subject: [PATCH] feat: wire agenda artifacts --- .../swift/artifacts/artifact-container.tsx | 140 +++++++++++++++++- .../swift/artifacts/blocks/agenda-block.tsx | 4 +- .../swift/command-center/command-center.tsx | 21 ++- lib/swift/action-parser.ts | 37 ++++- 4 files changed, 194 insertions(+), 8 deletions(-) diff --git a/components/swift/artifacts/artifact-container.tsx b/components/swift/artifacts/artifact-container.tsx index 8f93b23..57f6bcc 100644 --- a/components/swift/artifacts/artifact-container.tsx +++ b/components/swift/artifacts/artifact-container.tsx @@ -11,11 +11,13 @@ */ import { ArtifactTab } from './artifact-tab'; +import { AgendaBlock, type AgendaBlockProps } from './blocks/agenda-block'; import { DagnotatieBlock } from '../blocks/dagnotitie-block'; import { ZoekenBlock } from '../blocks/zoeken-block'; import { OverdrachtBlock } from '../blocks/overdracht-block'; import { PatientDashboardBlock } from '../blocks/patient-dashboard-block'; import { FallbackPicker } from '../blocks/fallback-picker'; +import { APPOINTMENT_TYPES, type AppointmentTypeCode, type LocationClassCode } from '@/app/epd/agenda/types'; import type { Artifact, BlockType } from '@/stores/swift-store'; interface ArtifactContainerProps { @@ -25,10 +27,107 @@ interface ArtifactContainerProps { onCloseArtifact: (id: string) => void; } +type AgendaIntent = 'agenda_query' | 'create_appointment' | 'cancel_appointment' | 'reschedule_appointment'; +type AgendaMode = AgendaBlockProps['mode']; + +const AGENDA_MODE_MAP: Record = { + agenda_query: 'list', + create_appointment: 'create', + cancel_appointment: 'cancel', + reschedule_appointment: 'reschedule', +}; + +const LOCATION_CODE_MAP: Record = { + praktijk: 'AMB', + online: 'VR', + thuis: 'HH', +}; + +function coerceDate(value: unknown): Date | undefined { + if (value instanceof Date) return value; + if (typeof value === 'string' || typeof value === 'number') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + return undefined; +} + +function coerceDateRange(raw: any): AgendaBlockProps['dateRange'] | undefined { + const start = coerceDate(raw?.start); + const end = coerceDate(raw?.end); + if (!start || !end) return undefined; + return { + start, + end, + label: typeof raw?.label === 'string' ? raw.label : 'custom', + }; +} + +function resolveAppointmentType(value: unknown): AppointmentTypeCode | undefined { + if (typeof value !== 'string') return undefined; + if (value in APPOINTMENT_TYPES) return value as AppointmentTypeCode; + return undefined; +} + +function resolveLocation(value: unknown): LocationClassCode | undefined { + if (typeof value !== 'string') return undefined; + if (value in LOCATION_CODE_MAP) return LOCATION_CODE_MAP[value]; + if (value === 'AMB' || value === 'VR' || value === 'HH') return value; + return undefined; +} + +function buildAgendaPrefill(prefill: Record | undefined): AgendaBlockProps['prefillData'] { + if (!prefill || typeof prefill !== 'object') return undefined; + + const patient = + prefill.patient || + (prefill.patientName || prefill.patientId + ? { + id: prefill.patientId || '', + name: prefill.patientName || '', + } + : undefined); + + const datetimeDate = + coerceDate(prefill?.datetime?.date) || + (prefill?.datetime?.time ? new Date() : undefined); + const datetime = datetimeDate + ? { + date: datetimeDate, + time: typeof prefill?.datetime?.time === 'string' ? prefill.datetime.time : '', + } + : undefined; + + const appointmentType = resolveAppointmentType(prefill?.appointmentType || prefill?.type); + const location = resolveLocation(prefill?.location); + + const newDatetimeDate = + coerceDate(prefill?.newDatetime?.date) || + (prefill?.newDatetime?.time ? new Date() : undefined); + const newDatetime = newDatetimeDate + ? { + date: newDatetimeDate, + time: typeof prefill?.newDatetime?.time === 'string' ? prefill.newDatetime.time : '', + } + : undefined; + + return { + patient, + datetime, + type: appointmentType, + location, + notes: typeof prefill?.notes === 'string' ? prefill.notes : undefined, + identifier: prefill?.identifier, + newDatetime, + }; +} + /** * Render the appropriate block component based on artifact type */ -function renderArtifactBlock(artifact: Artifact) { +function renderArtifactBlock(artifact: Artifact, onCloseArtifact: (id: string) => void) { switch (artifact.type) { case 'dagnotitie': return ; @@ -36,6 +135,33 @@ function renderArtifactBlock(artifact: Artifact) { return ; case 'overdracht': return ; + case 'agenda_query': + case 'create_appointment': + case 'cancel_appointment': + case 'reschedule_appointment': { + const agendaType = artifact.type as AgendaIntent; + const rawPrefill = artifact.prefill as Record; + const dateRange = coerceDateRange(rawPrefill?.dateRange); + const prefillData = buildAgendaPrefill(rawPrefill); + const appointments = Array.isArray(rawPrefill?.appointments) + ? rawPrefill.appointments + : undefined; + const disambiguationOptions = Array.isArray(rawPrefill?.disambiguationOptions) + ? rawPrefill.disambiguationOptions + : undefined; + + return ( + onCloseArtifact(artifact.id)} + /> + ); + } case 'patient-dashboard': return ; case 'fallback': @@ -62,6 +188,16 @@ export function getArtifactTitle(type: BlockType, prefill?: any): string { return 'Patiƫnt Zoeken'; case 'overdracht': return 'Dienst Overdracht'; + case 'agenda_query': + return 'Agenda'; + case 'create_appointment': + return prefill?.patientName + ? `Nieuwe afspraak - ${prefill.patientName}` + : 'Nieuwe afspraak'; + case 'cancel_appointment': + return 'Afspraak annuleren'; + case 'reschedule_appointment': + return 'Afspraak verzetten'; case 'fallback': return 'Kies een actie'; case 'patient-dashboard': @@ -120,7 +256,7 @@ export function ArtifactContainer({
{activeArtifact ? (
- {renderArtifactBlock(activeArtifact)} + {renderArtifactBlock(activeArtifact, onCloseArtifact)}
) : (
diff --git a/components/swift/artifacts/blocks/agenda-block.tsx b/components/swift/artifacts/blocks/agenda-block.tsx index 9263a16..96a94fa 100644 --- a/components/swift/artifacts/blocks/agenda-block.tsx +++ b/components/swift/artifacts/blocks/agenda-block.tsx @@ -1,6 +1,6 @@ 'use client'; import React from 'react'; -import { Encounter, AppointmentTypeCode, LocationClassCode, CalendarEvent } from '@/app/epd/agenda/types'; +import { AppointmentTypeCode, LocationClassCode, CalendarEvent } from '@/app/epd/agenda/types'; import { AgendaListView } from './agenda-list-view'; import { AgendaCreateForm } from './agenda-create-form'; import { AgendaCancelView } from './agenda-cancel-view'; @@ -16,6 +16,8 @@ export interface AgendaBlockProps { type?: AppointmentTypeCode; location?: LocationClassCode; notes?: string; + identifier?: { encounterId?: string; encounter?: CalendarEvent }; + newDatetime?: { date: Date; time: string }; }; disambiguationOptions?: CalendarEvent[]; onClose?: () => void; diff --git a/components/swift/command-center/command-center.tsx b/components/swift/command-center/command-center.tsx index 6f6f3ad..d3abb6d 100644 --- a/components/swift/command-center/command-center.tsx +++ b/components/swift/command-center/command-center.tsx @@ -23,6 +23,7 @@ import { OfflineBanner } from './offline-banner'; import { ChatPanel } from '../chat/chat-panel'; import { ArtifactArea } from '../artifacts/artifact-area'; import { getArtifactTitle } from '../artifacts/artifact-container'; +import { routeIntentToArtifact } from '@/lib/swift/action-parser'; export function CommandCenter() { const { closeAllArtifacts, openArtifacts, openArtifact, pendingAction, setPendingAction } = useSwiftStore(); @@ -73,12 +74,28 @@ export function CommandCenter() { title, }); - // Clear pending action after processing setPendingAction(null); + return; + } + + const routedArtifact = routeIntentToArtifact( + pendingAction.intent, + pendingAction.entities, + pendingAction.confidence + ); + + if (routedArtifact) { + console.log('[CommandCenter] Routing action to artifact:', routedArtifact.type); + openArtifact({ + type: routedArtifact.type, + prefill: routedArtifact.prefill, + title: routedArtifact.title, + }); } else { console.log('[CommandCenter] Action has no artifact, skipping'); - setPendingAction(null); } + + setPendingAction(null); }, [pendingAction, openArtifact, setPendingAction]); return ( diff --git a/lib/swift/action-parser.ts b/lib/swift/action-parser.ts index e817350..a6e90ca 100644 --- a/lib/swift/action-parser.ts +++ b/lib/swift/action-parser.ts @@ -1,9 +1,9 @@ /** - * Action Parser for Swift Medical Scribe + * Action Parser for Swift Assistent * * Parses JSON action objects from AI responses and validates them. * - * Epic: E3 (Chat API & Medical Scribe) + * Epic: E3 (Chat API & Swift Assistent) * Story: E3.S4 (Intent detection in response) */ @@ -29,9 +29,40 @@ const ActionSchema = z.object({ 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.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