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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : isRecording ? (
|
||||||
|
<Square className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
<Mic 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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,16 +345,29 @@ 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
|
|
||||||
|
// No-show flow stap 2→3: declarabiliteitscheck geaccepteerd
|
||||||
|
if (suggestion.trigger.intent === 'register_no_show') {
|
||||||
|
await handleNoShowCancelStep(suggestion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
const artifact = routeIntentToArtifact(
|
||||||
suggestion.suggestion.intent,
|
suggestion.suggestion.intent,
|
||||||
suggestion.suggestion.entities,
|
suggestion.suggestion.entities,
|
||||||
0.9 // High confidence for nudge-initiated actions
|
0.9
|
||||||
);
|
);
|
||||||
|
|
||||||
if (artifact) {
|
if (artifact) {
|
||||||
@@ -243,8 +378,7 @@ export function ChatPanel() {
|
|||||||
title: artifact.title,
|
title: artifact.title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, [acceptSuggestion, openArtifact, handleNoShowCancelStep, handleNoShowRescriptStep]);
|
||||||
}, [acceptSuggestion, openArtifact]);
|
|
||||||
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +240,26 @@ export function useDeepgramStreaming({
|
|||||||
})
|
})
|
||||||
console.log('[Deepgram] Live connection created, setting up event handlers...')
|
console.log('[Deepgram] Live connection created, setting up event handlers...')
|
||||||
|
|
||||||
|
liveClientRef.current = connection
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let opened = false
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
if (liveClientRef.current === connection) {
|
||||||
|
liveClientRef.current = null
|
||||||
|
}
|
||||||
|
connection.requestClose()
|
||||||
|
reject(new Error('Timeout bij verbinden met Deepgram'))
|
||||||
|
}, CONNECTION_TIMEOUT_MS)
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
connection.on(LiveTranscriptionEvents.Open, () => {
|
connection.on(LiveTranscriptionEvents.Open, () => {
|
||||||
console.log('[Deepgram] WebSocket OPEN')
|
console.log('[Deepgram] WebSocket OPEN')
|
||||||
|
opened = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
updateStatus('connected')
|
updateStatus('connected')
|
||||||
reconnectAttemptsRef.current = 0
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
connection.on(
|
connection.on(
|
||||||
@@ -240,26 +294,43 @@ export function useDeepgramStreaming({
|
|||||||
|
|
||||||
connection.on(LiveTranscriptionEvents.Error, (err: Error) => {
|
connection.on(LiveTranscriptionEvents.Error, (err: Error) => {
|
||||||
console.error('[Deepgram] WebSocket ERROR:', err)
|
console.error('[Deepgram] WebSocket ERROR:', err)
|
||||||
|
window.clearTimeout(timeout)
|
||||||
setError(err.message || 'WebSocket fout')
|
setError(err.message || 'WebSocket fout')
|
||||||
updateStatus('error')
|
updateStatus('error')
|
||||||
onErrorRef.current?.(err)
|
onErrorRef.current?.(err)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Probeer te reconnecten als we nog aan het opnemen waren
|
// Probeer te reconnecten als we nog aan het opnemen waren
|
||||||
if (shouldReconnectRef.current) {
|
if (shouldReconnectRef.current) {
|
||||||
handleReconnect()
|
handleReconnectRef.current?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
connection.on(LiveTranscriptionEvents.Close, () => {
|
connection.on(LiveTranscriptionEvents.Close, () => {
|
||||||
console.log('[Deepgram] WebSocket CLOSE')
|
console.log('[Deepgram] WebSocket CLOSE')
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
reject(new Error('Deepgram verbinding sloot voordat deze klaar was'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||||
handleReconnect()
|
handleReconnectRef.current?.()
|
||||||
} else {
|
} else {
|
||||||
|
shouldReconnectRef.current = false
|
||||||
|
cleanupRecordingResources()
|
||||||
|
setIsRecording(false)
|
||||||
|
setIsPaused(false)
|
||||||
updateStatus('disconnected')
|
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
|
||||||
}
|
}
|
||||||
}
|
}, [cleanupRecordingResources, fetchToken, model, language, endpointingMs, updateStatus])
|
||||||
}, [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) {
|
||||||
|
try {
|
||||||
await connect()
|
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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -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
973
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v2.65.5
|
v2.104.0
|
||||||
Reference in New Issue
Block a user