diff --git a/.gitignore b/.gitignore index c10f180..bdbe13f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,14 @@ # testing /coverage +/playwright/.auth/ +/tests/playwright/.auth/ +/tests/playwright-report/ +/tests/test-results/ +/playwright-report/ +/test-results/ +/tests/cypress/videos/ +/tests/cypress/screenshots/ # next.js /.next/ diff --git a/app/api/cortex/chat/route.ts b/app/api/cortex/chat/route.ts index 7e0d576..8e2138e 100644 --- a/app/api/cortex/chat/route.ts +++ b/app/api/cortex/chat/route.ts @@ -212,6 +212,11 @@ Je herkent de volgende gebruikersintenties en voert acties uit: - Required: navigationTarget, actieve patient - Actie: Navigeer naar de intake sectie in het EPD +- **register_no_show** — Registreer dat een patiënt niet op de afspraak is verschenen + - Triggers: "no show", "niet verschenen", "niet gekomen", "afwezig bij afspraak", "komt niet op", "patiënt er niet" + - Entities: geen specifieke entiteiten nodig — actieve patient en huidige context worden gebruikt + - Actie: Registreer no show, controleer declarabiliteit en openstaande correspondentie + ### 2. Verduidelijkingsvragen stellen Als je twijfelt over de intent of belangrijke informatie mist: diff --git a/app/api/deepgram/token/route.ts b/app/api/deepgram/token/route.ts index a55c26b..8e18a3b 100644 --- a/app/api/deepgram/token/route.ts +++ b/app/api/deepgram/token/route.ts @@ -2,117 +2,12 @@ import { NextResponse } from 'next/server' import { createClient as createSupabaseClient } from '@/lib/auth/server' import { createClient as createDeepgramClient } from '@deepgram/sdk' -// Rate limiting configuratie -const MAX_TOKENS_PER_USER_PER_HOUR = 5 // Per gebruiker -const MAX_TOKENS_GLOBAL_PER_HOUR = 50 // Totaal voor hele app -const MAX_RECORDING_MINUTES_PER_DAY = 30 // Geschatte minuten per dag (globaal) const TOKEN_TTL_SECONDS = 600 // Token geldig voor 10 min (korter = veiliger) -const HOUR_IN_MS = 60 * 60 * 1000 -const DAY_IN_MS = 24 * 60 * 60 * 1000 - -type RateLimitEntry = { - count: number - resetAt: number -} - -type RateLimitResult = { - allowed: boolean - remaining: number - resetAt: number - reason?: string -} - -// Per-user rate limiting -const userRateLimitStore = new Map() - -// Global rate limiting (beschermt tegen misbruik door meerdere users) -let globalHourlyCount = 0 -let globalHourlyResetAt = Date.now() + HOUR_IN_MS -let globalDailyMinutes = 0 -let globalDailyResetAt = Date.now() + DAY_IN_MS - -function consumeRateLimit(userId: string): RateLimitResult { - const now = Date.now() - - // Reset global counters if needed - if (now >= globalHourlyResetAt) { - globalHourlyCount = 0 - globalHourlyResetAt = now + HOUR_IN_MS - } - if (now >= globalDailyResetAt) { - globalDailyMinutes = 0 - globalDailyResetAt = now + DAY_IN_MS - } - - // Check global hourly limit - if (globalHourlyCount >= MAX_TOKENS_GLOBAL_PER_HOUR) { - console.log('[RateLimit] Global hourly limit reached:', globalHourlyCount) - return { - allowed: false, - remaining: 0, - resetAt: globalHourlyResetAt, - reason: 'Globale limiet bereikt. Probeer later opnieuw.', - } - } - - // Check global daily minutes (rough estimate: 1 token ≈ 10 min recording) - const estimatedMinutes = globalHourlyCount * 10 - if (estimatedMinutes >= MAX_RECORDING_MINUTES_PER_DAY) { - console.log('[RateLimit] Daily recording limit reached:', estimatedMinutes, 'min') - return { - allowed: false, - remaining: 0, - resetAt: globalDailyResetAt, - reason: 'Dagelijkse opnamelimiet bereikt.', - } - } - - // Check per-user limit - const entry = userRateLimitStore.get(userId) - - if (!entry || now >= entry.resetAt) { - const resetAt = now + HOUR_IN_MS - userRateLimitStore.set(userId, { count: 1, resetAt }) - globalHourlyCount++ - return { - allowed: true, - remaining: MAX_TOKENS_PER_USER_PER_HOUR - 1, - resetAt, - } - } - - if (entry.count >= MAX_TOKENS_PER_USER_PER_HOUR) { - return { - allowed: false, - remaining: 0, - resetAt: entry.resetAt, - reason: 'Je hebt de limiet van 5 opnames per uur bereikt.', - } - } - - entry.count += 1 - globalHourlyCount++ - - return { - allowed: true, - remaining: MAX_TOKENS_PER_USER_PER_HOUR - entry.count, - resetAt: entry.resetAt, - } -} - -function buildRateLimitHeaders(result: RateLimitResult) { - return { - 'X-RateLimit-Limit': `${MAX_TOKENS_PER_USER_PER_HOUR}`, - 'X-RateLimit-Remaining': `${Math.max(result.remaining, 0)}`, - 'X-RateLimit-Reset': `${Math.floor(result.resetAt / 1000)}`, - } -} export async function POST() { console.log('[API /deepgram/token] POST request received') try { const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true' - let userId = 'demo-user' // Auth check (skip in development/demo mode) if (!skipAuthCheck) { @@ -133,8 +28,7 @@ export async function POST() { return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }) } - userId = authData.user.id - console.log('[API /deepgram/token] Auth OK, userId:', userId.slice(0, 8) + '...') + console.log('[API /deepgram/token] Auth OK') } else { console.log('[API /deepgram/token] Auth check skipped (SKIP_AUTH_CHECK=true)') } @@ -152,28 +46,6 @@ export async function POST() { ) } - const rateLimit = consumeRateLimit(userId) - const headers = buildRateLimitHeaders(rateLimit) - console.log('[API /deepgram/token] Rate limit check:', { allowed: rateLimit.allowed, remaining: rateLimit.remaining }) - - if (!rateLimit.allowed) { - const retryAfterSeconds = Math.max( - 0, - Math.ceil((rateLimit.resetAt - Date.now()) / 1000) - ) - console.log('[API /deepgram/token] Rate limit exceeded:', rateLimit.reason, 'retry after:', retryAfterSeconds) - return NextResponse.json( - { error: rateLimit.reason || 'Rate limit bereikt.' }, - { - status: 429, - headers: { - ...headers, - 'Retry-After': `${retryAfterSeconds}`, - }, - } - ) - } - // Check if we should use direct API key (for development when grantToken doesn't work) const useDirectKey = process.env.DEEPGRAM_USE_DIRECT_KEY === 'true' @@ -183,8 +55,9 @@ export async function POST() { { token: apiKey, expiresIn: TOKEN_TTL_SECONDS, + authMode: 'apiKey', }, - { headers } + { status: 200 } ) } @@ -202,7 +75,7 @@ export async function POST() { console.error('[API /deepgram/token] Deepgram SDK threw error:', deepgramError) return NextResponse.json( { error: 'Deepgram SDK error: ' + (deepgramError instanceof Error ? deepgramError.message : String(deepgramError)) }, - { status: 502, headers } + { status: 502 } ) } @@ -211,7 +84,7 @@ export async function POST() { console.log('[API /deepgram/token] TIP: Set DEEPGRAM_USE_DIRECT_KEY=true in .env.local to use API key directly') return NextResponse.json( { error: 'Genereren van tijdelijk token mislukt', details: tokenResponse.error }, - { status: 502, headers } + { status: 502 } ) } @@ -220,8 +93,9 @@ export async function POST() { { token: tokenResponse.result.access_token, expiresIn: tokenResponse.result.expires_in, + authMode: 'accessToken', }, - { headers } + { status: 200 } ) } catch (error) { console.error('[API /deepgram/token] Unexpected error:', error) diff --git a/components/cortex/artifacts/artifact-container.tsx b/components/cortex/artifacts/artifact-container.tsx index 211d759..14fe14d 100644 --- a/components/cortex/artifacts/artifact-container.tsx +++ b/components/cortex/artifacts/artifact-container.tsx @@ -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 ; case 'diagnose_query': return ; + // No Show casus + case 'register_no_show': { + const nsPrefill = artifact.prefill as { + documentId: string; + content: string; + title: string; + originalContent?: string; + rescriptWarning?: string; + }; + return ; + } default: return (
@@ -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'; } diff --git a/components/cortex/artifacts/blocks/agenda-create-form.tsx b/components/cortex/artifacts/blocks/agenda-create-form.tsx index 76c6a16..f9febdc 100644 --- a/components/cortex/artifacts/blocks/agenda-create-form.tsx +++ b/components/cortex/artifacts/blocks/agenda-create-form.tsx @@ -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(prefillData?.patient?.id || ''); const [patientName, setPatientName] = useState(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(null); + const autoResolvedPatientRef = useRef(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) { diff --git a/components/cortex/chat/chat-input.tsx b/components/cortex/chat/chat-input.tsx index 9c28e22..984ac15 100644 --- a/components/cortex/chat/chat-input.tsx +++ b/components/cortex/chat/chat-input.tsx @@ -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(function Ch onSend, disabled = false, }, ref) { - const [inputValue, setInputValue] = useState(''); const textareaRef = useRef(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(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(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(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(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 (
@@ -208,20 +253,36 @@ export const ChatInput = forwardRef(function Ch style={{ minHeight: '48px' }} /> - {/* Voice input button (placeholder for now) */} + {/* Voice input button */} {/* Send button */} @@ -258,6 +319,16 @@ export const ChatInput = forwardRef(function Ch {' '} versturen

+ {isRecording && ( +

+ Opname actief. Spreek je opdracht in en druk daarna op Enter. +

+ )} + {voiceError && ( +

+ {voiceError} +

+ )}
); }); diff --git a/components/cortex/chat/chat-panel.tsx b/components/cortex/chat/chat-panel.tsx index a236892..fabe680 100644 --- a/components/cortex/chat/chat-panel.tsx +++ b/components/cortex/chat/chat-panel.tsx @@ -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(null); const messagesEndRef = useRef(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'); } diff --git a/components/cortex/chat/nudge-chat-message.tsx b/components/cortex/chat/nudge-chat-message.tsx index a2bf0f5..edd4c6e 100644 --- a/components/cortex/chat/nudge-chat-message.tsx +++ b/components/cortex/chat/nudge-chat-message.tsx @@ -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({