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

8
.gitignore vendored
View File

@@ -12,6 +12,14 @@
# testing # testing
/coverage /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.js
/.next/ /.next/

View File

@@ -212,6 +212,11 @@ Je herkent de volgende gebruikersintenties en voert acties uit:
- Required: navigationTarget, actieve patient - Required: navigationTarget, actieve patient
- Actie: Navigeer naar de intake sectie in het EPD - 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 ### 2. Verduidelijkingsvragen stellen
Als je twijfelt over de intent of belangrijke informatie mist: Als je twijfelt over de intent of belangrijke informatie mist:

View File

@@ -2,117 +2,12 @@ import { NextResponse } from 'next/server'
import { createClient as createSupabaseClient } from '@/lib/auth/server' import { createClient as createSupabaseClient } from '@/lib/auth/server'
import { createClient as createDeepgramClient } from '@deepgram/sdk' 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 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<string, RateLimitEntry>()
// 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() { export async function POST() {
console.log('[API /deepgram/token] POST request received') console.log('[API /deepgram/token] POST request received')
try { try {
const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true' const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true'
let userId = 'demo-user'
// Auth check (skip in development/demo mode) // Auth check (skip in development/demo mode)
if (!skipAuthCheck) { if (!skipAuthCheck) {
@@ -133,8 +28,7 @@ export async function POST() {
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }) return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 })
} }
userId = authData.user.id console.log('[API /deepgram/token] Auth OK')
console.log('[API /deepgram/token] Auth OK, userId:', userId.slice(0, 8) + '...')
} else { } else {
console.log('[API /deepgram/token] Auth check skipped (SKIP_AUTH_CHECK=true)') 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) // Check if we should use direct API key (for development when grantToken doesn't work)
const useDirectKey = process.env.DEEPGRAM_USE_DIRECT_KEY === 'true' const useDirectKey = process.env.DEEPGRAM_USE_DIRECT_KEY === 'true'
@@ -183,8 +55,9 @@ export async function POST() {
{ {
token: apiKey, token: apiKey,
expiresIn: TOKEN_TTL_SECONDS, 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) console.error('[API /deepgram/token] Deepgram SDK threw error:', deepgramError)
return NextResponse.json( return NextResponse.json(
{ error: 'Deepgram SDK error: ' + (deepgramError instanceof Error ? deepgramError.message : String(deepgramError)) }, { 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') console.log('[API /deepgram/token] TIP: Set DEEPGRAM_USE_DIRECT_KEY=true in .env.local to use API key directly')
return NextResponse.json( return NextResponse.json(
{ error: 'Genereren van tijdelijk token mislukt', details: tokenResponse.error }, { 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, token: tokenResponse.result.access_token,
expiresIn: tokenResponse.result.expires_in, expiresIn: tokenResponse.result.expires_in,
authMode: 'accessToken',
}, },
{ headers } { status: 200 }
) )
} catch (error) { } catch (error) {
console.error('[API /deepgram/token] Unexpected error:', error) console.error('[API /deepgram/token] Unexpected error:', error)

View File

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

View File

@@ -20,6 +20,8 @@ import { Badge } from '@/components/ui/badge';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast'; import { ToastAction } from '@/components/ui/toast';
import { createEncounter } from '@/app/epd/agenda/actions'; import { createEncounter } from '@/app/epd/agenda/actions';
import { useCortexStore } from '@/stores/cortex-store';
import { formatPatientName as formatPatientNameFromDb } from '@/lib/fhir/patient-mapper';
import { import {
APPOINTMENT_TYPES, APPOINTMENT_TYPES,
LOCATION_CLASSES, LOCATION_CLASSES,
@@ -47,7 +49,12 @@ interface PatientResult {
birthDate?: string; birthDate?: string;
} }
function normalizePatientName(name: string) {
return name.toLowerCase().trim().replace(/\s+/g, ' ');
}
export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps) { export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps) {
const activePatient = useCortexStore((s) => s.activePatient);
// Form State // Form State
const [patientId, setPatientId] = useState<string>(prefillData?.patient?.id || ''); const [patientId, setPatientId] = useState<string>(prefillData?.patient?.id || '');
const [patientName, setPatientName] = useState<string>(prefillData?.patient?.name || ''); const [patientName, setPatientName] = useState<string>(prefillData?.patient?.name || '');
@@ -69,6 +76,7 @@ export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const autoResolvedPatientRef = useRef<string | null>(null);
// Initialize search query if patient is prefilled but we want to allow editing // Initialize search query if patient is prefilled but we want to allow editing
useEffect(() => { useEffect(() => {
@@ -77,6 +85,69 @@ export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps
} }
}, [prefillData]); }, [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 // Handle outside click to close search results
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {

View File

@@ -9,10 +9,19 @@
* Story: E2.S4 (ChatInput component) * Story: E2.S4 (ChatInput component)
*/ */
import { useState, useRef, KeyboardEvent, ChangeEvent, forwardRef, useImperativeHandle } from 'react'; import {
import { Send, Mic } from 'lucide-react'; useState,
useRef,
useEffect,
KeyboardEvent,
ChangeEvent,
forwardRef,
useImperativeHandle,
} from 'react';
import { Send, Mic, Square, Loader2 } from 'lucide-react';
import { useCortexStore } from '@/stores/cortex-store'; import { useCortexStore } from '@/stores/cortex-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useCortexVoice } from '@/lib/cortex/use-cortex-voice';
import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection'; import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection';
import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search'; import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search';
import { PatientMentionDropdown } from '../command-center/patient-mention-dropdown'; import { PatientMentionDropdown } from '../command-center/patient-mention-dropdown';
@@ -34,9 +43,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
onSend, onSend,
disabled = false, disabled = false,
}, ref) { }, ref) {
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const addChatMessage = useCortexStore((s) => s.addChatMessage); 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) // @mention state (E2)
const [mentionState, setMentionState] = useState<{ const [mentionState, setMentionState] = useState<{
@@ -49,13 +68,19 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
showSuccessToast: false, 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 // Expose focus, clear, and setValue methods to parent
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => { focus: () => {
textareaRef.current?.focus(); textareaRef.current?.focus();
}, },
clear: () => { clear: () => {
setInputValue(''); clearInput();
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
} }
@@ -135,11 +160,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
content: trimmedValue, content: trimmedValue,
}); });
if (isRecording) {
stopRecording();
}
// Call optional onSend callback // Call optional onSend callback
onSend?.(trimmedValue); onSend?.(trimmedValue);
// Clear input // Clear input
setInputValue(''); clearInput();
// Reset textarea height // Reset textarea height
if (textareaRef.current) { if (textareaRef.current) {
@@ -165,7 +194,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
setMentionState(null); setMentionState(null);
return; return;
} }
setInputValue(''); if (isRecording) {
stopRecording();
}
clearInput();
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; 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) // 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 ( return (
<div className="border-t border-slate-200 p-4 bg-white"> <div className="border-t border-slate-200 p-4 bg-white">
<div className="relative flex items-end gap-2"> <div className="relative flex items-end gap-2">
@@ -208,20 +253,36 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
style={{ minHeight: '48px' }} style={{ minHeight: '48px' }}
/> />
{/* Voice input button (placeholder for now) */} {/* Voice input button */}
<button <button
type="button" type="button"
onClick={handleVoiceClick}
className={cn( className={cn(
'absolute right-12 bottom-3', 'absolute right-12 bottom-3',
'text-slate-400 hover:text-slate-600', 'text-slate-400 hover:text-slate-600',
'transition-colors p-1.5 rounded-md hover:bg-slate-100', '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} disabled={disabled || isConnecting || !isBrowserSupported}
aria-label="Spraak invoer" aria-label={isRecording ? 'Stop spraakopname' : 'Start spraakopname'}
title="Spraak invoer (komt in E5.S3)" 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> </button>
{/* Send button */} {/* Send button */}
@@ -258,6 +319,16 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
</kbd>{' '} </kbd>{' '}
versturen versturen
</p> </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> </div>
); );
}); });

View File

@@ -21,7 +21,8 @@ import { ChatEmptyState } from './chat-empty-state';
import { ActionChainCard } from './action-chain-card'; import { ActionChainCard } from './action-chain-card';
import { ClarificationCard } from './clarification-card'; import { ClarificationCard } from './clarification-card';
import { ProcessingIndicator } from './processing-indicator'; 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 { sendChatMessage } from '@/lib/cortex/chat-api';
import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact, getDefaultConfirmationMessage } from '@/lib/cortex/action-parser'; import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact, getDefaultConfirmationMessage } from '@/lib/cortex/action-parser';
import { evaluateNudge } from '@/lib/cortex/nudge'; import { evaluateNudge } from '@/lib/cortex/nudge';
@@ -56,6 +57,12 @@ export function ChatPanel() {
const acceptSuggestion = useCortexStore((s) => s.acceptSuggestion); const acceptSuggestion = useCortexStore((s) => s.acceptSuggestion);
const dismissSuggestion = useCortexStore((s) => s.dismissSuggestion); 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 // Refs for scrolling
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -118,6 +125,16 @@ export function ChatPanel() {
} }
}, [hasMessages]); }, [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 // Handle suggestion selection - fill input with selected text
const handleSelectSuggestion = useCallback((text: string) => { const handleSelectSuggestion = useCallback((text: string) => {
chatInputRef.current?.setValue(text); chatInputRef.current?.setValue(text);
@@ -137,6 +154,111 @@ export function ChatPanel() {
return () => window.removeEventListener('keydown', handleGlobalKeyDown); 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) // V2 Chain action handlers (E5.S2)
const handleConfirmAction = useCallback((actionId: string) => { const handleConfirmAction = useCallback((actionId: string) => {
// Find the action in the active chain // Find the action in the active chain
@@ -223,28 +345,40 @@ export function ChatPanel() {
}, [setPendingClarification]); }, [setPendingClarification]);
// Nudge handlers (chat-based nudges) // 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); console.log('[ChatPanel] Nudge accepted:', suggestionId);
acceptSuggestion(suggestionId); acceptSuggestion(suggestionId);
if (suggestion) { if (!suggestion) return;
// Route to artifact with prefilled entities
const artifact = routeIntentToArtifact(
suggestion.suggestion.intent,
suggestion.suggestion.entities,
0.9 // High confidence for nudge-initiated actions
);
if (artifact) { // No-show flow stap 2→3: declarabiliteitscheck geaccepteerd
console.log('[ChatPanel] Opening artifact from nudge:', artifact.type); if (suggestion.trigger.intent === 'register_no_show') {
openArtifact({ await handleNoShowCancelStep(suggestion);
type: artifact.type, return;
prefill: artifact.prefill,
title: artifact.title,
});
}
} }
}, [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) => { const handleDismissNudge = useCallback((suggestionId: string) => {
console.log('[ChatPanel] Nudge dismissed:', suggestionId); console.log('[ChatPanel] Nudge dismissed:', suggestionId);
@@ -453,6 +587,19 @@ export function ChatPanel() {
} else { } else {
console.log('[ChatPanel] Action confidence too low:', parsed.action.confidence); 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 { } else {
console.log('[ChatPanel] No action detected in response'); 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 { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { NudgeSuggestion } from '@/lib/cortex/types'; import type { NudgeSuggestion } from '@/lib/cortex/types';
import { useCortexStore } from '@/stores/cortex-store';
interface NudgeChatMessageProps { interface NudgeChatMessageProps {
suggestion: NudgeSuggestion; suggestion: NudgeSuggestion;
@@ -60,6 +61,8 @@ function getAcceptButtonText(intent: string): string {
return 'Ja, notitie maken'; return 'Ja, notitie maken';
case 'cancel_appointment': case 'cancel_appointment':
return 'Ja, annuleren'; return 'Ja, annuleren';
case 'register_no_show':
return 'Ja, pas brief aan';
default: default:
return 'Ja, uitvoeren'; return 'Ja, uitvoeren';
} }
@@ -71,6 +74,7 @@ export function NudgeChatMessage({
onDismiss, onDismiss,
}: NudgeChatMessageProps) { }: NudgeChatMessageProps) {
const [progress, setProgress] = useState(100); const [progress, setProgress] = useState(100);
const isNoShowProcessing = useCortexStore((s) => s.isNoShowProcessing);
const styles = PRIORITY_STYLES[suggestion.priority]; const styles = PRIORITY_STYLES[suggestion.priority];
const protocol = suggestion.suggestion.protocol; const protocol = suggestion.suggestion.protocol;
@@ -169,9 +173,10 @@ export function NudgeChatMessage({
<Button <Button
size="sm" size="sm"
onClick={() => onAccept(suggestion.id)} 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>
<Button <Button
size="sm" size="sm"

View File

@@ -10,7 +10,7 @@
import { useCortexStore, type CortexIntent } from '@/stores/cortex-store'; import { useCortexStore, type CortexIntent } from '@/stores/cortex-store';
import { import {
FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X, FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X,
ClipboardList, AlertTriangle, Stethoscope, Navigation, ClipboardList, AlertTriangle, Stethoscope, Navigation, UserX,
} from 'lucide-react'; } from 'lucide-react';
const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string; label: string }> = { 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' }, 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' }, 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' }, 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' }, unknown: { icon: HelpCircle, color: 'text-slate-600 bg-slate-50 border border-slate-200', label: 'Actie' },
}; };

View File

@@ -72,6 +72,20 @@ export interface UseDeepgramStreamingReturn {
isBrowserSupported: boolean isBrowserSupported: boolean
} }
type DeepgramAuthMode = 'accessToken' | 'apiKey'
interface DeepgramTokenResponse {
token: string
expiresIn: number
authMode?: DeepgramAuthMode
}
function createTokenError(message: string) {
const error = new Error(message)
error.name = 'DeepgramTokenError'
return error
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Browser Support Check // Browser Support Check
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -94,6 +108,7 @@ function checkBrowserSupport(): boolean {
const MAX_RECONNECT_ATTEMPTS = 3 const MAX_RECONNECT_ATTEMPTS = 3
const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_BASE_DELAY_MS = 1000
const AUDIO_CHUNK_INTERVAL_MS = 250 const AUDIO_CHUNK_INTERVAL_MS = 250
const CONNECTION_TIMEOUT_MS = 5000
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Hook Implementation // Hook Implementation
@@ -127,6 +142,7 @@ export function useDeepgramStreaming({
const audioContextRef = useRef<AudioContext | null>(null) const audioContextRef = useRef<AudioContext | null>(null)
const reconnectAttemptsRef = useRef(0) const reconnectAttemptsRef = useRef(0)
const shouldReconnectRef = useRef(false) const shouldReconnectRef = useRef(false)
const handleReconnectRef = useRef<(() => Promise<void>) | null>(null)
const isPausedRef = useRef(isPaused) const isPausedRef = useRef(isPaused)
// Callback refs voor stabiele referenties // Callback refs voor stabiele referenties
@@ -144,6 +160,29 @@ export function useDeepgramStreaming({
isPausedRef.current = isPaused isPausedRef.current = isPaused
}, [isPaused]) }, [isPaused])
const cleanupRecordingResources = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
}
mediaStreamRef.current = null
if (audioContextRef.current) {
audioContextRef.current.close()
}
audioContextRef.current = null
setAnalyserNode(null)
if (liveClientRef.current) {
liveClientRef.current.requestClose()
}
liveClientRef.current = null
}, [])
// Status update helper // Status update helper
const updateStatus = useCallback((newStatus: ConnectionStatus) => { const updateStatus = useCallback((newStatus: ConnectionStatus) => {
setStatus(newStatus) setStatus(newStatus)
@@ -151,7 +190,7 @@ export function useDeepgramStreaming({
}, []) }, [])
// Fetch token van onze proxy endpoint // Fetch token van onze proxy endpoint
const fetchToken = useCallback(async (): Promise<string> => { const fetchToken = useCallback(async (): Promise<DeepgramTokenResponse> => {
console.log('[Deepgram] Fetching token from /api/deepgram/token...') console.log('[Deepgram] Fetching token from /api/deepgram/token...')
const response = await fetch('/api/deepgram/token', { method: 'POST' }) const response = await fetch('/api/deepgram/token', { method: 'POST' })
console.log('[Deepgram] Token response status:', response.status) console.log('[Deepgram] Token response status:', response.status)
@@ -160,17 +199,17 @@ export function useDeepgramStreaming({
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
console.error('[Deepgram] Token fetch failed:', response.status, data) console.error('[Deepgram] Token fetch failed:', response.status, data)
if (response.status === 429) { if (response.status === 429) {
throw new Error('Rate limit bereikt. Probeer over een uur opnieuw.') throw createTokenError('Rate limit bereikt. Probeer over een uur opnieuw.')
} }
if (response.status === 401) { if (response.status === 401) {
throw new Error('Niet ingelogd. Log opnieuw in.') throw createTokenError('Niet ingelogd. Log opnieuw in.')
} }
throw new Error(data.error || 'Kon geen token ophalen') throw createTokenError(data.error || 'Kon geen token ophalen')
} }
const data = await response.json() const data = (await response.json()) as DeepgramTokenResponse
console.log('[Deepgram] Token received, expiresIn:', data.expiresIn) console.log('[Deepgram] Token received, expiresIn:', data.expiresIn, 'authMode:', data.authMode || 'accessToken')
return data.token return data
}, []) }, [])
// Setup WebSocket verbinding met Deepgram // Setup WebSocket verbinding met Deepgram
@@ -180,13 +219,14 @@ export function useDeepgramStreaming({
updateStatus('connecting') updateStatus('connecting')
setError(null) setError(null)
const token = await fetchToken() const tokenResponse = await fetchToken()
const authMode = tokenResponse.authMode || 'accessToken'
// IMPORTANT: Use accessToken option for JWT tokens from grantToken() console.log('[Deepgram] Creating Deepgram client with authMode:', authMode)
// This uses Bearer scheme authentication instead of Token scheme const deepgram =
// See: https://github.com/deepgram/deepgram-js-sdk authMode === 'apiKey'
console.log('[Deepgram] Creating Deepgram client with accessToken...') ? createClient(tokenResponse.token)
const deepgram = createClient({ accessToken: token }) : createClient({ accessToken: tokenResponse.token })
console.log('[Deepgram] Creating live connection with options:', { model, language, endpointingMs }) console.log('[Deepgram] Creating live connection with options:', { model, language, endpointingMs })
const connection = deepgram.listen.live({ const connection = deepgram.listen.live({
@@ -200,66 +240,97 @@ export function useDeepgramStreaming({
}) })
console.log('[Deepgram] Live connection created, setting up event handlers...') console.log('[Deepgram] Live connection created, setting up event handlers...')
// Event handlers liveClientRef.current = connection
connection.on(LiveTranscriptionEvents.Open, () => {
console.log('[Deepgram] WebSocket OPEN')
updateStatus('connected')
reconnectAttemptsRef.current = 0
setError(null)
})
connection.on( await new Promise<void>((resolve, reject) => {
LiveTranscriptionEvents.Transcript, let opened = false
(data: LiveTranscriptionEvent) => { const timeout = window.setTimeout(() => {
const alternative = data.channel?.alternatives?.[0] if (liveClientRef.current === connection) {
if (!alternative) return liveClientRef.current = null
}
connection.requestClose()
reject(new Error('Timeout bij verbinden met Deepgram'))
}, CONNECTION_TIMEOUT_MS)
const transcript = alternative.transcript || '' // Event handlers
if (!transcript.trim()) return connection.on(LiveTranscriptionEvents.Open, () => {
console.log('[Deepgram] WebSocket OPEN')
opened = true
window.clearTimeout(timeout)
updateStatus('connected')
setError(null)
resolve()
})
const words: TranscriptWord[] = (alternative.words || []).map( connection.on(
(w: { word: string; confidence: number; start: number; end: number }) => ({ LiveTranscriptionEvents.Transcript,
word: w.word, (data: LiveTranscriptionEvent) => {
confidence: w.confidence, const alternative = data.channel?.alternatives?.[0]
start: w.start, if (!alternative) return
end: w.end,
})
)
const result: TranscriptResult = { const transcript = alternative.transcript || ''
transcript, if (!transcript.trim()) return
isFinal: data.is_final ?? false,
confidence: alternative.confidence ?? 1, const words: TranscriptWord[] = (alternative.words || []).map(
words, (w: { word: string; confidence: number; start: number; end: number }) => ({
speechFinal: data.speech_final ?? false, word: w.word,
confidence: w.confidence,
start: w.start,
end: w.end,
})
)
const result: TranscriptResult = {
transcript,
isFinal: data.is_final ?? false,
confidence: alternative.confidence ?? 1,
words,
speechFinal: data.speech_final ?? false,
}
onTranscriptRef.current(result)
}
)
connection.on(LiveTranscriptionEvents.Error, (err: Error) => {
console.error('[Deepgram] WebSocket ERROR:', err)
window.clearTimeout(timeout)
setError(err.message || 'WebSocket fout')
updateStatus('error')
onErrorRef.current?.(err)
if (!opened) {
reject(err)
return
} }
onTranscriptRef.current(result) // Probeer te reconnecten als we nog aan het opnemen waren
} if (shouldReconnectRef.current) {
) handleReconnectRef.current?.()
}
})
connection.on(LiveTranscriptionEvents.Error, (err: Error) => { connection.on(LiveTranscriptionEvents.Close, () => {
console.error('[Deepgram] WebSocket ERROR:', err) console.log('[Deepgram] WebSocket CLOSE')
setError(err.message || 'WebSocket fout') window.clearTimeout(timeout)
updateStatus('error')
onErrorRef.current?.(err)
// Probeer te reconnecten als we nog aan het opnemen waren if (!opened) {
if (shouldReconnectRef.current) { reject(new Error('Deepgram verbinding sloot voordat deze klaar was'))
handleReconnect() return
} }
if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
handleReconnectRef.current?.()
} else {
shouldReconnectRef.current = false
cleanupRecordingResources()
setIsRecording(false)
setIsPaused(false)
updateStatus('disconnected')
}
})
}) })
connection.on(LiveTranscriptionEvents.Close, () => {
console.log('[Deepgram] WebSocket CLOSE')
if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
handleReconnect()
} else {
updateStatus('disconnected')
}
})
liveClientRef.current = connection
console.log('[Deepgram] Connection setup complete') console.log('[Deepgram] Connection setup complete')
} catch (err) { } catch (err) {
console.error('[Deepgram] Connection error:', err) console.error('[Deepgram] Connection error:', err)
@@ -268,11 +339,13 @@ export function useDeepgramStreaming({
updateStatus('error') updateStatus('error')
onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage)) onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage))
if (shouldReconnectRef.current) { shouldReconnectRef.current = false
handleReconnect() cleanupRecordingResources()
} setIsRecording(false)
setIsPaused(false)
throw err
} }
}, [fetchToken, model, language, endpointingMs, updateStatus]) }, [cleanupRecordingResources, fetchToken, model, language, endpointingMs, updateStatus])
// Reconnect met exponential backoff // Reconnect met exponential backoff
const handleReconnect = useCallback(async () => { const handleReconnect = useCallback(async () => {
@@ -280,6 +353,9 @@ export function useDeepgramStreaming({
setError('Kon niet herverbinden na 3 pogingen') setError('Kon niet herverbinden na 3 pogingen')
updateStatus('error') updateStatus('error')
shouldReconnectRef.current = false shouldReconnectRef.current = false
cleanupRecordingResources()
setIsRecording(false)
setIsPaused(false)
return return
} }
@@ -290,9 +366,19 @@ export function useDeepgramStreaming({
await new Promise((resolve) => setTimeout(resolve, delay)) await new Promise((resolve) => setTimeout(resolve, delay))
if (shouldReconnectRef.current) { if (shouldReconnectRef.current) {
await connect() try {
await connect()
} catch (err) {
console.error('[Deepgram] Reconnect failed:', err)
shouldReconnectRef.current = false
cleanupRecordingResources()
setIsRecording(false)
setIsPaused(false)
}
} }
}, [connect, updateStatus]) }, [cleanupRecordingResources, connect, updateStatus])
handleReconnectRef.current = handleReconnect
// Setup audio stream en analyser // Setup audio stream en analyser
const setupAudio = useCallback(async () => { const setupAudio = useCallback(async () => {
@@ -344,12 +430,13 @@ export function useDeepgramStreaming({
try { try {
setError(null) setError(null)
shouldReconnectRef.current = true shouldReconnectRef.current = true
reconnectAttemptsRef.current = 0
// Eerst WebSocket verbinding opzetten // Eerst verbinden en wachten op WebSocket OPEN. Daarna pas de MediaRecorder starten,
// zodat het eerste chunk (met WebM/EBML-header) bij Deepgram aankomt.
console.log('[Deepgram] Step 1: Connecting to Deepgram...') console.log('[Deepgram] Step 1: Connecting to Deepgram...')
await connect() await connect()
// Dan audio stream starten
console.log('[Deepgram] Step 2: Setting up audio...') console.log('[Deepgram] Step 2: Setting up audio...')
await setupAudio() await setupAudio()
@@ -370,44 +457,24 @@ export function useDeepgramStreaming({
setError(errorMessage) setError(errorMessage)
} }
shouldReconnectRef.current = false
cleanupRecordingResources()
setIsRecording(false)
setIsPaused(false)
updateStatus('error') updateStatus('error')
onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage)) onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage))
} }
}, [connect, setupAudio, updateStatus]) }, [cleanupRecordingResources, connect, setupAudio, updateStatus])
// Stop opname // Stop opname
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
shouldReconnectRef.current = false shouldReconnectRef.current = false
cleanupRecordingResources()
// Stop MediaRecorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
// Stop alle audio tracks
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
}
mediaStreamRef.current = null
// Close AudioContext
if (audioContextRef.current) {
audioContextRef.current.close()
}
audioContextRef.current = null
setAnalyserNode(null)
// Close WebSocket
if (liveClientRef.current) {
liveClientRef.current.requestClose()
}
liveClientRef.current = null
setIsRecording(false) setIsRecording(false)
setIsPaused(false) setIsPaused(false)
updateStatus('disconnected') updateStatus('disconnected')
}, [updateStatus]) }, [cleanupRecordingResources, updateStatus])
// Pauzeer opname // Pauzeer opname
const pauseRecording = useCallback(() => { const pauseRecording = useCallback(() => {

View File

@@ -26,6 +26,8 @@ const ActionSchema = z.object({
'intake_navigeer', 'intake_navigeer',
'risico_query', 'risico_query',
'diagnose_query', 'diagnose_query',
// No Show casus
'register_no_show',
'unknown', 'unknown',
]), ]),
entities: z.object({ entities: z.object({
@@ -88,6 +90,8 @@ const ActionSchema = z.object({
'diagnose_query', 'diagnose_query',
'fallback', 'fallback',
'patient-dashboard', 'patient-dashboard',
// No Show casus
'register_no_show',
]), ]),
prefill: z.record(z.string(), z.any()), prefill: z.record(z.string(), z.any()),
}) })
@@ -253,6 +257,9 @@ export function getDefaultConfirmationMessage(intent: CortexIntent, entities: Re
case 'overdracht': case 'overdracht':
return 'Ik bereid de overdracht voor.'; return 'Ik bereid de overdracht voor.';
case 'register_no_show':
return 'Ik registreer de no show en controleer de agenda op declarabiliteit.';
default: default:
return 'Ik help je verder.'; return 'Ik help je verder.';
} }
@@ -404,6 +411,11 @@ export function routeIntentToArtifact(
// Returns null to trigger navigation instead of artifact // Returns null to trigger navigation instead of artifact
return null; return null;
case 'register_no_show':
// Artifact opened via handleNoShowRescriptStep in chat-panel.tsx after API calls —
// not via generic routing (needs documentId + rescripted content from API response)
return null;
case 'unknown': case 'unknown':
// Unknown intent - show fallback picker // Unknown intent - show fallback picker
return { return {

View File

@@ -206,6 +206,18 @@ const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, PatternConfig[]>
{ pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?diagnose[ns]?/i, weight: 0.85 }, { pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?diagnose[ns]?/i, weight: 0.85 },
{ pattern: /^(is\s+er\s+)?(een\s+)?persoonlijkheidsstoornis/i, weight: 0.9 }, { pattern: /^(is\s+er\s+)?(een\s+)?persoonlijkheidsstoornis/i, weight: 0.9 },
], ],
// No Show casus
register_no_show: [
{ pattern: /no.?show/i, weight: 1.0 },
{ pattern: /niet\s+verschenen/i, weight: 0.95 },
{ pattern: /niet\s+gekomen/i, weight: 0.95 },
{ pattern: /niet\s+op\s+komen\s+dagen/i, weight: 0.95 },
{ pattern: /afwezig\s+(bij|voor)\s+(de\s+)?afspraak/i, weight: 0.9 },
{ pattern: /pati[eë]nt\s+afwezig/i, weight: 0.85 },
{ pattern: /komt?\s+niet\s+(op|naar)/i, weight: 0.85 },
{ pattern: /is\s+er\s+niet\s+(geweest)?/i, weight: 0.7 },
],
}; };
// Help patterns (separate, always check) // Help patterns (separate, always check)

View File

@@ -25,6 +25,8 @@ export const INTENT_LABELS: Record<CortexIntent, string> = {
intake_navigeer: 'Naar intake sectie', intake_navigeer: 'Naar intake sectie',
risico_query: 'Risicotaxatie', risico_query: 'Risicotaxatie',
diagnose_query: 'Diagnoses', diagnose_query: 'Diagnoses',
// No Show casus
register_no_show: 'No Show registratie',
unknown: 'Onbekend', unknown: 'Onbekend',
}; };

View File

@@ -192,6 +192,22 @@ const DEFAULT_EXPIRY_MS = 5 * 60 * 1000;
* In production, these would come from a database or configuration. * In production, these would come from a database or configuration.
*/ */
export const PROTOCOL_RULES: ProtocolRule[] = [ export const PROTOCOL_RULES: ProtocolRule[] = [
{
id: 'noshow-declarabel-check',
name: 'No Show declarabiliteitscheck',
trigger: {
intent: 'register_no_show',
conditions: [],
},
suggestion: {
intent: 'cancel_appointment',
message: 'Ik zie een declarabel consult in de agenda. Volgens inkoopvoorwaarden mag deze afspraak NIET gedeclareerd worden. Wil je dat ik deze annuleer als \'No Show\'?',
prefillEntities: (_source) => ({}),
},
priority: 'high',
enabled: true,
expiresAfterMs: DEFAULT_EXPIRY_MS,
},
{ {
id: 'wondzorg-controle', id: 'wondzorg-controle',
name: 'Wondcontrole na verzorging', name: 'Wondcontrole na verzorging',

View File

@@ -212,6 +212,27 @@ const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, PatternConfig[]>
{ pattern: /^diagnose[ns]?\s+(van|voor)\s+\w+/i, weight: 0.95 }, { pattern: /^diagnose[ns]?\s+(van|voor)\s+\w+/i, weight: 0.95 },
{ pattern: /^icd\s*-?\s*10/i, weight: 0.8 }, { pattern: /^icd\s*-?\s*10/i, weight: 0.8 },
], ],
// =========================================================================
// No Show casus
// =========================================================================
register_no_show: [
// Exacte no-show varianten
{ pattern: /no.?show/i, weight: 1.0 },
// "Niet verschenen" varianten
{ pattern: /niet\s+verschenen/i, weight: 0.95 },
{ pattern: /niet\s+gekomen/i, weight: 0.95 },
{ pattern: /niet\s+op\s+komen\s+dagen/i, weight: 0.95 },
// "Afwezig" + context
{ pattern: /afwezig\s+(bij|voor)\s+(de\s+)?afspraak/i, weight: 0.9 },
{ pattern: /pati[eë]nt\s+afwezig/i, weight: 0.85 },
// Werkwoordvormen
{ pattern: /komt?\s+niet\s+(op|naar)/i, weight: 0.85 },
{ pattern: /is\s+er\s+niet\s+(geweest)?/i, weight: 0.7 },
],
}; };
/** /**

View File

@@ -20,6 +20,8 @@ export type CortexIntent =
| 'intake_navigeer' | 'intake_navigeer'
| 'risico_query' | 'risico_query'
| 'diagnose_query' | 'diagnose_query'
// No Show casus
| 'register_no_show'
| 'unknown'; | 'unknown';
export type BlockType = export type BlockType =
@@ -173,6 +175,13 @@ export const BLOCK_CONFIGS: Record<BlockType, BlockConfig> = {
size: 'md', size: 'md',
icon: 'Stethoscope', icon: 'Stethoscope',
}, },
// No Show casus
register_no_show: {
type: 'register_no_show',
title: 'No Show Registratie',
size: 'md',
icon: 'UserX',
},
}; };
// Recent action type // Recent action type

View File

@@ -9,7 +9,15 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts", "types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
"setup:auth-hook": "tsx scripts/setup-auth-hook.ts" "setup:auth-hook": "tsx scripts/setup-auth-hook.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report tests/playwright-report",
"test:cypress": "cypress run",
"test:cypress:open": "cypress open",
"test:cypress:run": "cypress run --browser chrome",
"test:cypress:demo": "next build && next start & sleep 5 && cypress open"
}, },
"dependencies": { "dependencies": {
"@deepgram/sdk": "^4.11.2", "@deepgram/sdk": "^4.11.2",
@@ -62,6 +70,7 @@
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^20", "@types/node": "^20",
@@ -69,6 +78,7 @@
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/three": "^0.181.0", "@types/three": "^0.181.0",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"cypress": "^15.14.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-next": "14.2.18", "eslint-config-next": "14.2.18",

973
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,16 @@ export type Patient = Database['public']['Tables']['patients']['Row'];
// Store-specific types (not in lib/cortex/types.ts) // Store-specific types (not in lib/cortex/types.ts)
export type BlockType = Exclude<CortexIntent, 'unknown'> | 'fallback' | 'patient-dashboard'; export type BlockType = Exclude<CortexIntent, 'unknown'> | 'fallback' | 'patient-dashboard';
// No-show flow state machine
export type NoShowStep = 'idle' | 'waiting_cancel' | 'waiting_brief' | 'brief_open' | 'done';
export interface NoShowFlowState {
step: NoShowStep;
appointmentId: string | null;
documentId: string | null;
originalContent: string | null;
}
// Chat entities - simplified version for AI responses (strings, not Dates) // Chat entities - simplified version for AI responses (strings, not Dates)
// This differs from ExtractedEntities in lib/cortex/types.ts which uses Date objects // This differs from ExtractedEntities in lib/cortex/types.ts which uses Date objects
// All properties are optional to match Zod schema flexibility // All properties are optional to match Zod schema flexibility
@@ -79,8 +89,9 @@ export interface ChatAction {
} }
// Block prefill data - uses ChatEntities (string-based) for UI prefilling // Block prefill data - uses ChatEntities (string-based) for UI prefilling
// Index signature allows block-specific extra fields (e.g. documentId for NoShowDocumentBlock)
export interface BlockPrefillData extends ChatEntities { export interface BlockPrefillData extends ChatEntities {
// Additional prefill data specific to blocks [key: string]: unknown;
} }
// Artifact type (E4 - Multiple artifacts support) // Artifact type (E4 - Multiple artifacts support)
@@ -145,6 +156,10 @@ interface CortexStore {
// V2 Clarification state // V2 Clarification state
pendingClarification: ClarificationRequest | null; pendingClarification: ClarificationRequest | null;
// No-show flow state
noShowFlow: NoShowFlowState;
isNoShowProcessing: boolean;
// Context actions // Context actions
setActivePatient: (patient: Patient | null) => void; setActivePatient: (patient: Patient | null) => void;
setShift: (shift: ShiftType) => void; setShift: (shift: ShiftType) => void;
@@ -202,6 +217,12 @@ interface CortexStore {
setPendingClarification: (clarification: ClarificationRequest | null) => void; setPendingClarification: (clarification: ClarificationRequest | null) => void;
resolveClarification: (selectedOption: string) => void; resolveClarification: (selectedOption: string) => void;
// No-show flow actions
setNoShowStep: (step: NoShowStep) => void;
setNoShowContext: (ctx: Partial<Omit<NoShowFlowState, 'step'>>) => void;
setNoShowProcessing: (processing: boolean) => void;
resetNoShowFlow: () => void;
// Reset // Reset
reset: () => void; reset: () => void;
} }
@@ -233,6 +254,14 @@ const initialState = {
chainHistory: [] as IntentChain[], chainHistory: [] as IntentChain[],
suggestions: [] as NudgeSuggestion[], suggestions: [] as NudgeSuggestion[],
pendingClarification: null as ClarificationRequest | null, pendingClarification: null as ClarificationRequest | null,
// No-show flow state
noShowFlow: {
step: 'idle',
appointmentId: null,
documentId: null,
originalContent: null,
} as NoShowFlowState,
isNoShowProcessing: false,
}; };
// Create the store // Create the store
@@ -576,6 +605,39 @@ export const useCortexStore = create<CortexStore>()(
'resolveClarification' 'resolveClarification'
), ),
// No-show flow actions
setNoShowStep: (step) =>
set(
(state) => ({ noShowFlow: { ...state.noShowFlow, step } }),
false,
'setNoShowStep'
),
setNoShowContext: (ctx) =>
set(
(state) => ({ noShowFlow: { ...state.noShowFlow, ...ctx } }),
false,
'setNoShowContext'
),
setNoShowProcessing: (processing) =>
set({ isNoShowProcessing: processing }, false, 'setNoShowProcessing'),
resetNoShowFlow: () =>
set(
{
noShowFlow: {
step: 'idle',
appointmentId: null,
documentId: null,
originalContent: null,
},
isNoShowProcessing: false,
},
false,
'resetNoShowFlow'
),
// Reset // Reset
reset: () => set(initialState, false, 'reset'), reset: () => set(initialState, false, 'reset'),
}), }),

View File

@@ -1 +1 @@
v2.65.5 v2.104.0