feat(cortex): update chat/nudge flow, deepgram streaming and agenda form

Refines chat panel, nudge messages and artifact rendering, reworks
deepgram token/streaming handling, and adds test tooling deps
(playwright, cypress) to package.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-07-09 22:37:46 +02:00
parent 6c6dae5eb7
commit cdeea793bd
20 changed files with 1634 additions and 283 deletions

View File

@@ -22,6 +22,8 @@ import { FallbackPicker } from '../blocks/fallback-picker';
import { IntakeStatusBlock } from '../blocks/intake-status-block';
import { RisicoBlock } from '../blocks/risico-block';
import { DiagnoseBlock } from '../blocks/diagnose-block';
// No Show casus
import { NoShowDocumentBlock } from '../blocks/noshow-document-block';
import { APPOINTMENT_TYPES, type AppointmentTypeCode, type LocationClassCode } from '@/app/epd/agenda/types';
import type { Artifact, BlockType } from '@/stores/cortex-store';
import { parseRelativeDate, isDateRange } from '@/lib/cortex/date-time-parser';
@@ -205,6 +207,17 @@ function renderArtifactBlock(artifact: Artifact, onCloseArtifact: (id: string) =
return <RisicoBlock key={artifact.id} prefill={artifact.prefill} />;
case 'diagnose_query':
return <DiagnoseBlock key={artifact.id} prefill={artifact.prefill} />;
// No Show casus
case 'register_no_show': {
const nsPrefill = artifact.prefill as {
documentId: string;
content: string;
title: string;
originalContent?: string;
rescriptWarning?: string;
};
return <NoShowDocumentBlock key={artifact.id} prefill={nsPrefill} />;
}
default:
return (
<div className="p-4 text-slate-500">
@@ -250,6 +263,9 @@ export function getArtifactTitle(type: BlockType, prefill?: any): string {
return 'Risicotaxatie';
case 'diagnose_query':
return 'Diagnoses';
// No Show casus
case 'register_no_show':
return prefill?.title ? `Brief — ${prefill.title}` : 'Huisartsbrief';
default:
return 'Artifact';
}

View File

@@ -20,6 +20,8 @@ import { Badge } from '@/components/ui/badge';
import { toast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast';
import { createEncounter } from '@/app/epd/agenda/actions';
import { useCortexStore } from '@/stores/cortex-store';
import { formatPatientName as formatPatientNameFromDb } from '@/lib/fhir/patient-mapper';
import {
APPOINTMENT_TYPES,
LOCATION_CLASSES,
@@ -47,7 +49,12 @@ interface PatientResult {
birthDate?: string;
}
function normalizePatientName(name: string) {
return name.toLowerCase().trim().replace(/\s+/g, ' ');
}
export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps) {
const activePatient = useCortexStore((s) => s.activePatient);
// Form State
const [patientId, setPatientId] = useState<string>(prefillData?.patient?.id || '');
const [patientName, setPatientName] = useState<string>(prefillData?.patient?.name || '');
@@ -69,6 +76,7 @@ export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const autoResolvedPatientRef = useRef<string | null>(null);
// Initialize search query if patient is prefilled but we want to allow editing
useEffect(() => {
@@ -77,6 +85,69 @@ export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps
}
}, [prefillData]);
// If Cortex only extracted a name, resolve it once so the appointment form
// can submit without forcing the user to retype and select the same client.
useEffect(() => {
const prefilledPatient = prefillData?.patient;
if (!prefilledPatient?.name || prefilledPatient.id || patientId) return;
const normalizedPrefill = normalizePatientName(prefilledPatient.name);
if (autoResolvedPatientRef.current === normalizedPrefill) return;
autoResolvedPatientRef.current = normalizedPrefill;
if (activePatient) {
const activePatientName = formatPatientNameFromDb(activePatient);
if (normalizePatientName(activePatientName) === normalizedPrefill) {
setPatientId(activePatient.id);
setPatientName(activePatientName);
setSearchQuery(activePatientName);
setShowResults(false);
return;
}
}
let cancelled = false;
async function resolvePrefilledPatient() {
setIsSearching(true);
try {
const res = await fetch(`/api/cortex/patients/search?q=${encodeURIComponent(prefilledPatient.name)}`);
if (!res.ok || cancelled) return;
const data = await res.json();
const patients = (data.patients || []) as PatientResult[];
const exactMatch = patients.find(
(patient) => normalizePatientName(patient.name) === normalizedPrefill
);
const match = exactMatch || (patients.length === 1 ? patients[0] : null);
if (match) {
setPatientId(match.id);
setPatientName(match.name);
setSearchQuery(match.name);
setSearchResults([]);
setShowResults(false);
} else {
setSearchResults(patients);
setShowResults(patients.length > 0);
}
} catch (err) {
console.error('Failed to resolve prefilled patient', err);
} finally {
if (!cancelled) {
setIsSearching(false);
}
}
}
resolvePrefilledPatient();
return () => {
cancelled = true;
};
}, [prefillData, patientId, activePatient]);
// Handle outside click to close search results
useEffect(() => {
function handleClickOutside(event: MouseEvent) {

View File

@@ -9,10 +9,19 @@
* Story: E2.S4 (ChatInput component)
*/
import { useState, useRef, KeyboardEvent, ChangeEvent, forwardRef, useImperativeHandle } from 'react';
import { Send, Mic } from 'lucide-react';
import {
useState,
useRef,
useEffect,
KeyboardEvent,
ChangeEvent,
forwardRef,
useImperativeHandle,
} from 'react';
import { Send, Mic, Square, Loader2 } from 'lucide-react';
import { useCortexStore } from '@/stores/cortex-store';
import { cn } from '@/lib/utils';
import { useCortexVoice } from '@/lib/cortex/use-cortex-voice';
import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection';
import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search';
import { PatientMentionDropdown } from '../command-center/patient-mention-dropdown';
@@ -34,9 +43,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
onSend,
disabled = false,
}, ref) {
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const addChatMessage = useCortexStore((s) => s.addChatMessage);
const inputValue = useCortexStore((s) => s.inputValue);
const setInputValue = useCortexStore((s) => s.setInputValue);
const clearInput = useCortexStore((s) => s.clearInput);
const {
isRecording,
isConnecting,
error: voiceError,
startRecording,
stopRecording,
isBrowserSupported,
} = useCortexVoice();
// @mention state (E2)
const [mentionState, setMentionState] = useState<{
@@ -49,13 +68,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
showSuccessToast: false,
});
useEffect(() => {
if (!textareaRef.current) return;
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}, [inputValue]);
// Expose focus, clear, and setValue methods to parent
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus();
},
clear: () => {
setInputValue('');
clearInput();
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
@@ -135,11 +160,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
content: trimmedValue,
});
if (isRecording) {
stopRecording();
}
// Call optional onSend callback
onSend?.(trimmedValue);
// Clear input
setInputValue('');
clearInput();
// Reset textarea height
if (textareaRef.current) {
@@ -165,7 +194,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
setMentionState(null);
return;
}
setInputValue('');
if (isRecording) {
stopRecording();
}
clearInput();
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
@@ -174,6 +206,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
// Shift+Enter for new line (default behavior, no need to handle)
};
const handleVoiceClick = async () => {
if (disabled || isConnecting || !isBrowserSupported) return;
if (isRecording) {
stopRecording();
textareaRef.current?.focus();
return;
}
await startRecording();
textareaRef.current?.focus();
};
return (
<div className="border-t border-slate-200 p-4 bg-white">
<div className="relative flex items-end gap-2">
@@ -208,20 +253,36 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
style={{ minHeight: '48px' }}
/>
{/* Voice input button (placeholder for now) */}
{/* Voice input button */}
<button
type="button"
onClick={handleVoiceClick}
className={cn(
'absolute right-12 bottom-3',
'text-slate-400 hover:text-slate-600',
'transition-colors p-1.5 rounded-md hover:bg-slate-100',
disabled && 'opacity-50 cursor-not-allowed'
isRecording && 'text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100',
isConnecting && 'text-amber-600 bg-amber-50',
(disabled || !isBrowserSupported) && 'opacity-50 cursor-not-allowed'
)}
disabled={disabled}
aria-label="Spraak invoer"
title="Spraak invoer (komt in E5.S3)"
disabled={disabled || isConnecting || !isBrowserSupported}
aria-label={isRecording ? 'Stop spraakopname' : 'Start spraakopname'}
aria-pressed={isRecording}
title={
!isBrowserSupported
? 'Spraakopname wordt niet ondersteund in deze browser'
: isRecording
? 'Stop opname'
: 'Spreek je Cortex opdracht in'
}
>
<Mic className="w-5 h-5" />
{isConnecting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : isRecording ? (
<Square className="w-5 h-5" />
) : (
<Mic className="w-5 h-5" />
)}
</button>
{/* Send button */}
@@ -258,6 +319,16 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
</kbd>{' '}
versturen
</p>
{isRecording && (
<p className="mt-2 text-xs text-red-600">
Opname actief. Spreek je opdracht in en druk daarna op Enter.
</p>
)}
{voiceError && (
<p className="mt-2 text-xs text-red-600">
{voiceError}
</p>
)}
</div>
);
});

View File

@@ -21,7 +21,8 @@ import { ChatEmptyState } from './chat-empty-state';
import { ActionChainCard } from './action-chain-card';
import { ClarificationCard } from './clarification-card';
import { ProcessingIndicator } from './processing-indicator';
import { useCortexStore, type ChatMessage as ChatMessageType } from '@/stores/cortex-store';
import { useCortexStore, type ChatMessage as ChatMessageType, type NoShowFlowState } from '@/stores/cortex-store';
import type { NudgeSuggestion } from '@/lib/cortex/types';
import { sendChatMessage } from '@/lib/cortex/chat-api';
import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact, getDefaultConfirmationMessage } from '@/lib/cortex/action-parser';
import { evaluateNudge } from '@/lib/cortex/nudge';
@@ -56,6 +57,12 @@ export function ChatPanel() {
const acceptSuggestion = useCortexStore((s) => s.acceptSuggestion);
const dismissSuggestion = useCortexStore((s) => s.dismissSuggestion);
// No-show flow state
const setNoShowStep = useCortexStore((s) => s.setNoShowStep);
const setNoShowContext = useCortexStore((s) => s.setNoShowContext);
const setNoShowProcessing = useCortexStore((s) => s.setNoShowProcessing);
const resetNoShowFlow = useCortexStore((s) => s.resetNoShowFlow);
// Refs for scrolling
const scrollContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -118,6 +125,16 @@ export function ChatPanel() {
}
}, [hasMessages]);
// Cleanup no-show flow bij unmount
useEffect(() => {
return () => {
const { noShowFlow } = useCortexStore.getState();
if (noShowFlow.step !== 'idle' && noShowFlow.step !== 'done') {
resetNoShowFlow();
}
};
}, [resetNoShowFlow]);
// Handle suggestion selection - fill input with selected text
const handleSelectSuggestion = useCallback((text: string) => {
chatInputRef.current?.setValue(text);
@@ -137,6 +154,111 @@ export function ChatPanel() {
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
}, []);
// No-show flow: stap 2→3 — annuleer afspraak + check concept brief
const handleNoShowCancelStep = useCallback(async (_suggestion: NudgeSuggestion) => {
const { isNoShowProcessing } = useCortexStore.getState();
if (isNoShowProcessing) return;
setNoShowProcessing(true);
setNoShowStep('waiting_cancel');
addChatMessage({ type: 'assistant', content: 'Bezig met annuleren...' });
try {
const patientId = activePatient?.id ?? 'demo-patient-001';
// Stap 1: annuleer de afspraak
const cancelRes = await fetch('/api/cortex/noshow/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appointmentId: 'mock-appt-noshow-001', patientId }),
});
if (!cancelRes.ok) throw new Error('Annuleren mislukt');
// Stap 2: check op concept brief
const ctxRes = await fetch(`/api/cortex/noshow/context?patientId=${patientId}`);
const ctx = await ctxRes.json();
if (ctx.hasConceptBrief) {
setNoShowContext({ documentId: ctx.document.id, originalContent: ctx.document.content });
setNoShowStep('waiting_brief');
// Construeer nudge 2 handmatig
const briefNudge: NudgeSuggestion = {
id: `nudge-noshow-brief-${Date.now()}`,
trigger: { actionId: 'noshow-cancel-done', intent: 'cancel_appointment', entities: {} },
suggestion: {
intent: 'register_no_show',
entities: {},
message: 'Afspraak geannuleerd. Er staat nog een concept huisartsbrief klaar. Zal ik daar de No Show in verwerken?',
rationale: 'noshow-brief-check',
},
status: 'pending',
priority: 'high',
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
createdAt: new Date(),
};
addChatMessage({ type: 'nudge', content: briefNudge.suggestion.message, nudge: briefNudge });
} else {
setNoShowStep('done');
addChatMessage({
type: 'assistant',
content: 'Afspraak geannuleerd als No Show. Er zijn geen openstaande conceptbrieven gevonden.',
});
}
} catch {
setNoShowStep('idle');
addChatMessage({ type: 'error', content: 'Er is iets misgegaan bij het annuleren. Probeer het opnieuw.' });
} finally {
setNoShowProcessing(false);
}
}, [activePatient, setNoShowStep, setNoShowContext, setNoShowProcessing, addChatMessage]);
// No-show flow: stap 4→5 — rescript brief + open artifact
const handleNoShowRescriptStep = useCallback(async () => {
const { isNoShowProcessing, noShowFlow } = useCortexStore.getState();
if (isNoShowProcessing) return;
setNoShowProcessing(true);
setNoShowStep('brief_open');
addChatMessage({ type: 'assistant', content: 'Huisartsbrief aanpassen...' });
try {
const res = await fetch('/api/cortex/noshow/rescript', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId: noShowFlow.documentId ?? 'mock-brief-noshow-001',
originalContent: noShowFlow.originalContent ?? '',
patientId: activePatient?.id ?? 'demo-patient-001',
}),
});
const result = await res.json();
openArtifact({
type: 'register_no_show',
title: 'Huisartsbrief n.a.v. intake',
prefill: {
documentId: result.documentId,
content: result.rescriptedContent,
title: 'Huisartsbrief n.a.v. intake',
originalContent: result.originalContent,
rescriptWarning: result.warning,
},
});
addChatMessage({
type: 'assistant',
content: 'Huisartsbrief is aangepast. Kijk of je hiermee akkoord bent.',
});
} catch {
setNoShowStep('waiting_brief');
addChatMessage({ type: 'error', content: 'Herschrijven mislukt. Probeer het opnieuw.' });
} finally {
setNoShowProcessing(false);
}
}, [activePatient, setNoShowStep, setNoShowProcessing, addChatMessage, openArtifact]);
// V2 Chain action handlers (E5.S2)
const handleConfirmAction = useCallback((actionId: string) => {
// Find the action in the active chain
@@ -223,28 +345,40 @@ export function ChatPanel() {
}, [setPendingClarification]);
// Nudge handlers (chat-based nudges)
const handleAcceptNudge = useCallback((suggestionId: string, suggestion: ChatMessageType['nudge']) => {
const handleAcceptNudge = useCallback(async (suggestionId: string, suggestion: ChatMessageType['nudge']) => {
console.log('[ChatPanel] Nudge accepted:', suggestionId);
acceptSuggestion(suggestionId);
if (suggestion) {
// Route to artifact with prefilled entities
const artifact = routeIntentToArtifact(
suggestion.suggestion.intent,
suggestion.suggestion.entities,
0.9 // High confidence for nudge-initiated actions
);
if (!suggestion) return;
if (artifact) {
console.log('[ChatPanel] Opening artifact from nudge:', artifact.type);
openArtifact({
type: artifact.type,
prefill: artifact.prefill,
title: artifact.title,
});
}
// No-show flow stap 2→3: declarabiliteitscheck geaccepteerd
if (suggestion.trigger.intent === 'register_no_show') {
await handleNoShowCancelStep(suggestion);
return;
}
}, [acceptSuggestion, openArtifact]);
// No-show flow stap 4→5: brief-check geaccepteerd
if (suggestion.suggestion.rationale === 'noshow-brief-check') {
await handleNoShowRescriptStep();
return;
}
// Generieke flow voor alle andere nudges
const artifact = routeIntentToArtifact(
suggestion.suggestion.intent,
suggestion.suggestion.entities,
0.9
);
if (artifact) {
console.log('[ChatPanel] Opening artifact from nudge:', artifact.type);
openArtifact({
type: artifact.type,
prefill: artifact.prefill,
title: artifact.title,
});
}
}, [acceptSuggestion, openArtifact, handleNoShowCancelStep, handleNoShowRescriptStep]);
const handleDismissNudge = useCallback((suggestionId: string) => {
console.log('[ChatPanel] Nudge dismissed:', suggestionId);
@@ -453,6 +587,19 @@ export function ChatPanel() {
} else {
console.log('[ChatPanel] Action confidence too low:', parsed.action.confidence);
}
// No-show nudge trigger na register_no_show classificatie
if (parsed.action.intent === 'register_no_show' && isFeatureEnabled('CORTEX_NUDGE')) {
const suggestions = evaluateNudge({
intent: 'register_no_show',
actionId: crypto.randomUUID(),
entities: {}, // register_no_show heeft geen entiteiten nodig
content: message,
});
suggestions.forEach((suggestion) => {
addChatMessage({ type: 'nudge', content: suggestion.suggestion.message, nudge: suggestion });
});
}
} else {
console.log('[ChatPanel] No action detected in response');
}

View File

@@ -15,6 +15,7 @@ import { motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { NudgeSuggestion } from '@/lib/cortex/types';
import { useCortexStore } from '@/stores/cortex-store';
interface NudgeChatMessageProps {
suggestion: NudgeSuggestion;
@@ -60,6 +61,8 @@ function getAcceptButtonText(intent: string): string {
return 'Ja, notitie maken';
case 'cancel_appointment':
return 'Ja, annuleren';
case 'register_no_show':
return 'Ja, pas brief aan';
default:
return 'Ja, uitvoeren';
}
@@ -71,6 +74,7 @@ export function NudgeChatMessage({
onDismiss,
}: NudgeChatMessageProps) {
const [progress, setProgress] = useState(100);
const isNoShowProcessing = useCortexStore((s) => s.isNoShowProcessing);
const styles = PRIORITY_STYLES[suggestion.priority];
const protocol = suggestion.suggestion.protocol;
@@ -169,9 +173,10 @@ export function NudgeChatMessage({
<Button
size="sm"
onClick={() => onAccept(suggestion.id)}
className="bg-teal-600 hover:bg-teal-700 text-white"
disabled={isNoShowProcessing}
className="bg-teal-600 hover:bg-teal-700 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{getAcceptButtonText(suggestion.suggestion.intent)}
{isNoShowProcessing ? 'Bezig...' : getAcceptButtonText(suggestion.suggestion.intent)}
</Button>
<Button
size="sm"

View File

@@ -10,7 +10,7 @@
import { useCortexStore, type CortexIntent } from '@/stores/cortex-store';
import {
FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X,
ClipboardList, AlertTriangle, Stethoscope, Navigation,
ClipboardList, AlertTriangle, Stethoscope, Navigation, UserX,
} from 'lucide-react';
const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string; label: string }> = {
@@ -26,6 +26,8 @@ const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string
intake_navigeer: { icon: Navigation, color: 'text-indigo-600 bg-indigo-50 border border-indigo-200', label: 'Navigeer' },
risico_query: { icon: AlertTriangle, color: 'text-orange-600 bg-orange-50 border border-orange-200', label: 'Risico' },
diagnose_query: { icon: Stethoscope, color: 'text-rose-600 bg-rose-50 border border-rose-200', label: 'Diagnose' },
// No Show casus
register_no_show: { icon: UserX, color: 'text-red-600 bg-red-50 border border-red-200', label: 'No Show' },
unknown: { icon: HelpCircle, color: 'text-slate-600 bg-slate-50 border border-slate-200', label: 'Actie' },
};