feat(cortex): add Intake Blocks MVP for voice/text commands

Adds 4 new intents for intake workflow:
- intake_status: shows completion percentage and section checklist
- intake_navigeer: navigation to specific intake tabs
- risico_query: displays risk assessments with severity indicators
- diagnose_query: shows primary/secondary diagnoses

New components:
- IntakeStatusBlock, RisicoBlock, DiagnoseBlock
- Shared block components (BlockLoading, BlockError, BlockEmpty, BlockSection, BlockItem, BlockFooter)
- useBlockData and useIntakeContext hooks

New API routes:
- GET /api/cortex/intake/status
- GET /api/cortex/intake/risico
- GET /api/cortex/intake/diagnose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-02-03 23:21:18 +01:00
parent 05836c5c6a
commit b095b1e492
22 changed files with 2415 additions and 15 deletions

View File

@@ -123,6 +123,14 @@ export function extractEntities(
return extractCancelAppointmentEntities(trimmedInput, input, referenceDate);
case 'reschedule_appointment':
return extractRescheduleAppointmentEntities(trimmedInput, input, referenceDate);
// Intake intents (MVP)
case 'intake_navigeer':
return extractIntakeNavigeerEntities(trimmedInput);
case 'intake_status':
case 'risico_query':
case 'diagnose_query':
// These intents use patient context from store, no entity extraction needed
return entities;
default:
return entities;
}
@@ -665,3 +673,83 @@ function extractDateLabel(input: string): DateRange['label'] {
if (normalized.includes('volgende week')) return 'volgende week';
return 'custom';
}
// ============================================================================
// Intake Entity Extraction (MVP)
// ============================================================================
/**
* Intake tab targets for navigation.
*/
export type IntakeTab =
| 'contacts'
| 'kindcheck'
| 'risk'
| 'anamnese'
| 'examination'
| 'rom'
| 'diagnosis'
| 'behandeladvies';
/**
* Mapping of keywords to intake tabs.
*/
const INTAKE_TAB_KEYWORDS: Record<string, IntakeTab> = {
// Risk
'risico': 'risk',
'risicotaxatie': 'risk',
'risicos': 'risk',
// Diagnosis
'diagnose': 'diagnosis',
'diagnoses': 'diagnosis',
'dsm': 'diagnosis',
// Anamnese
'anamnese': 'anamnese',
'voorgeschiedenis': 'anamnese',
'geschiedenis': 'anamnese',
// Contacts
'contact': 'contacts',
'contacten': 'contacts',
'contactmomenten': 'contacts',
// Kindcheck
'kindcheck': 'kindcheck',
'kinderen': 'kindcheck',
// Examination
'onderzoek': 'examination',
'psychiatrisch': 'examination',
// ROM
'rom': 'rom',
'meetinstrumenten': 'rom',
'vragenlijst': 'rom',
'vragenlijsten': 'rom',
// Behandeladvies
'behandeladvies': 'behandeladvies',
'advies': 'behandeladvies',
'behandeling': 'behandeladvies',
'samenvatting': 'behandeladvies',
// Doelen (maps to behandeladvies as it's part of that tab in MVP)
'doelen': 'behandeladvies',
'doelstellingen': 'behandeladvies',
// Netwerk (maps to contacts as it's closest in MVP)
'netwerk': 'contacts',
'sociaal': 'contacts',
};
/**
* Extract entities for intake_navigeer intent.
* Determines which intake tab to navigate to.
*/
function extractIntakeNavigeerEntities(lowerInput: string): ExtractedEntities & { navigationTarget?: IntakeTab } {
const words = lowerInput.split(/\s+/);
// Find the navigation target
for (const word of words) {
const target = INTAKE_TAB_KEYWORDS[word];
if (target) {
return { navigationTarget: target } as ExtractedEntities & { navigationTarget?: IntakeTab };
}
}
// Default to risk (most common navigation target in intake context)
return { navigationTarget: 'risk' } as ExtractedEntities & { navigationTarget?: IntakeTab };
}

View File

@@ -4,5 +4,10 @@
* Reusable hooks for Cortex functionality.
*/
// Patient hooks
export { usePatientSearch, type PatientSearchResult } from './use-patient-search';
export { usePatientSelection } from './use-patient-selection';
// Block hooks (E2)
export { useBlockData } from './use-block-data';
export { useIntakeContext, type IntakePrefillData } from './use-intake-context';

View File

@@ -0,0 +1,123 @@
'use client';
/**
* useBlockData Hook
*
* Generic data fetching hook for Cortex blocks.
* Handles loading, error states, and provides refetch capability.
*
* Epic: E2.S1 - Custom Hooks
*/
import { useState, useEffect, useCallback } from 'react';
import { useToast } from '@/hooks/use-toast';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
// ============================================================================
// Types
// ============================================================================
interface UseBlockDataOptions<T> {
/** API endpoint (relatief pad) */
endpoint: string;
/** Query parameters */
params?: Record<string, string | undefined>;
/** Of data moet worden opgehaald */
enabled?: boolean;
/** Callback bij error */
onError?: (error: Error) => void;
/** Operatie naam voor error messages */
operationName?: string;
}
interface UseBlockDataResult<T> {
/** Opgehaalde data */
data: T | null;
/** Loading state */
isLoading: boolean;
/** Error message */
error: string | null;
/** Refetch functie */
refetch: () => Promise<void>;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Generic data fetching hook for Cortex blocks.
*
* @example
* const { data, isLoading, error, refetch } = useBlockData<RiskData>({
* endpoint: `/api/cortex/intake/${intakeId}/risks`,
* enabled: Boolean(intakeId),
* operationName: 'Risico\'s laden',
* });
*/
export function useBlockData<T>({
endpoint,
params,
enabled = true,
onError,
operationName = 'Data laden',
}: UseBlockDataOptions<T>): UseBlockDataResult<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
// Stringify params for dependency comparison
const paramsKey = params ? JSON.stringify(params) : '';
const fetchData = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
setError(null);
try {
// Build URL with params
const url = new URL(endpoint, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, value);
}
});
}
const response = await safeFetch(url.toString(), undefined, {
operation: operationName,
});
const result = await response.json();
setData(result);
} catch (err) {
const statusCode = (err as any)?.statusCode;
const errorInfo = getErrorInfo(err, {
operation: operationName,
statusCode,
});
setError(errorInfo.description);
onError?.(err as Error);
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
} finally {
setIsLoading(false);
}
}, [endpoint, paramsKey, enabled, onError, operationName, toast]);
useEffect(() => {
if (enabled) {
fetchData();
}
}, [fetchData, enabled]);
return { data, isLoading, error, refetch: fetchData };
}

View File

@@ -0,0 +1,85 @@
'use client';
/**
* useIntakeContext Hook
*
* Provides patient and intake context for Cortex blocks.
* Falls back to activePatient from store when prefill is not provided.
*
* Epic: E2.S2 - Custom Hooks
*/
import { useMemo } from 'react';
import { useCortexStore } from '@/stores/cortex-store';
import type { BlockPrefillData } from '@/stores/cortex-store';
import { formatPatientName } from '@/lib/fhir/patient-mapper';
// ============================================================================
// Types
// ============================================================================
/**
* Extended prefill data with intake-specific fields.
* Blocks can pass this to get intake context.
*/
export interface IntakePrefillData extends BlockPrefillData {
/** Intake ID for intake-specific operations */
intakeId?: string;
}
interface UseIntakeContextResult {
/** Patient ID (van prefill of activePatient) */
patientId: string | null;
/** Intake ID (van prefill) */
intakeId: string | null;
/** Patient naam (voor display) */
patientName: string | null;
/** Of er patient context is */
hasPatientContext: boolean;
/** Of er intake context is */
hasIntakeContext: boolean;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Hook for getting patient and intake context in Cortex blocks.
*
* Priority:
* 1. Explicit prefill data (from intent classification)
* 2. activePatient from store (fallback)
*
* @example
* const { patientId, intakeId, hasPatientContext } = useIntakeContext(prefill);
*
* if (!hasPatientContext) {
* return <BlockEmpty icon={User} message="Selecteer eerst een patiënt" />;
* }
*/
export function useIntakeContext(
prefill?: IntakePrefillData
): UseIntakeContextResult {
const { activePatient } = useCortexStore();
return useMemo(() => {
// Patient context: prefill takes priority, then activePatient
const patientId = prefill?.patientId || activePatient?.id || null;
const patientName =
prefill?.patientName ||
(activePatient ? formatPatientName(activePatient) : null);
// Intake context: only from prefill for now
// TODO: Add activeIntake to cortex-store for persistent intake context
const intakeId = prefill?.intakeId || null;
return {
patientId,
intakeId,
patientName,
hasPatientContext: Boolean(patientId),
hasIntakeContext: Boolean(intakeId),
};
}, [prefill, activePatient]);
}

View File

@@ -152,6 +152,60 @@ const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, PatternConfig[]>
{ pattern: /^(verzet|verplaats)\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 },
{ pattern: /^verzet\s+\w+\s+naar\b/i, weight: 0.85 }, // "verzet jan naar dinsdag"
],
// Intake intents (MVP)
intake_status: [
// Exact commands - voortgang/status check
{ pattern: /^(wat\s+)?moet\s+ik\s+nog\s+(doen|invullen)/i, weight: 1.0 },
{ pattern: /^is\s+(de\s+)?intake\s+compleet/i, weight: 0.95 },
{ pattern: /^intake\s+status\b/i, weight: 1.0 },
{ pattern: /^voortgang\s+(intake|invullen)\b/i, weight: 0.95 },
{ pattern: /^welke\s+(velden|onderdelen)\s+(missen|ontbreken)/i, weight: 0.9 },
{ pattern: /^(hoeveel|wat)\s+(is|staat)\s+er\s+(nog\s+)?open\b/i, weight: 0.9 },
{ pattern: /^status\s+(intake|invullen)\b/i, weight: 0.95 },
{ pattern: /^checklist\s+intake\b/i, weight: 0.85 },
],
intake_navigeer: [
// Navigation to specific intake tabs/sections
{ pattern: /^ga\s+naar\s+(de\s+)?(risico|risicotaxatie)/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?diagnose[ns]?/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?anamnese/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?behandelgeschiedenis/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?medicatie/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?meetinstrumenten/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(het\s+)?netwerk/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?doelen/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?samenvatting/i, weight: 1.0 },
// Open patterns
{ pattern: /^open\s+(de\s+)?(risico|diagnose|anamnese|medicatie|netwerk|doelen|samenvatting)/i, weight: 0.95 },
// Show patterns
{ pattern: /^(toon|laat\s+zien)\s+(de\s+)?(risico|diagnose|anamnese|medicatie|netwerk|doelen|samenvatting)/i, weight: 0.9 },
],
risico_query: [
// Exact commands - risk information
{ pattern: /^(wat\s+zijn\s+)?(de\s+)?risico'?s/i, weight: 1.0 },
{ pattern: /^risicotaxatie\b/i, weight: 1.0 },
{ pattern: /^welke\s+risico'?s\s+(zijn|hebben)/i, weight: 0.95 },
{ pattern: /^(is\s+er\s+)?(suïcide|suicide)\s*risico/i, weight: 1.0 },
{ pattern: /^(wat\s+is\s+)?(de\s+)?(sui[cï]daliteit|zelfbeschadiging)/i, weight: 0.95 },
{ pattern: /^risico'?s\s+(van|bij|voor)\s+\w+/i, weight: 0.9 },
{ pattern: /^toon\s+(de\s+)?risico'?s/i, weight: 0.9 },
{ pattern: /^geef\s+(een\s+)?(overzicht|samenvatting)\s+(van\s+)?(de\s+)?risico'?s/i, weight: 0.85 },
],
diagnose_query: [
// Exact commands - diagnosis information
{ pattern: /^(welke\s+)?diagnose[ns]?(\s+heeft)?/i, weight: 1.0 },
{ pattern: /^dsm[- ]?(5|iv|diagnose)/i, weight: 1.0 },
{ pattern: /^(wat\s+is\s+)?(de\s+)?hoofddiagnose/i, weight: 1.0 },
{ pattern: /^(wat\s+zijn\s+)?(de\s+)?nevendiagnose[ns]?/i, weight: 0.95 },
{ pattern: /^toon\s+(de\s+)?diagnose[ns]?/i, weight: 0.9 },
{ pattern: /^diagnose[ns]?\s+(van|bij|voor)\s+\w+/i, weight: 0.9 },
{ 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 },
],
};
// Help patterns (separate, always check)

View File

@@ -20,6 +20,11 @@ export const INTENT_LABELS: Record<CortexIntent, string> = {
create_appointment: 'Afspraak maken',
cancel_appointment: 'Afspraak annuleren',
reschedule_appointment: 'Afspraak verzetten',
// Intake intents (MVP)
intake_status: 'Intake status',
intake_navigeer: 'Naar intake sectie',
risico_query: 'Risicotaxatie',
diagnose_query: 'Diagnoses',
unknown: 'Onbekend',
};

View File

@@ -158,6 +158,60 @@ const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, PatternConfig[]>
{ pattern: /^(verzet|verplaats)\s+\d{1,2}[:.]\d{2}\b/i, weight: 0.9 },
{ pattern: /^verzet\s+\w+\s+naar\b/i, weight: 0.85 },
],
// =========================================================================
// Intake intents (MVP)
// =========================================================================
intake_status: [
// "Wat moet ik nog doen?" patterns
{ pattern: /^(wat\s+)?moet\s+ik\s+nog\s+(doen|invullen)/i, weight: 1.0 },
{ pattern: /^is\s+(de\s+)?intake\s+compleet/i, weight: 0.95 },
{ pattern: /^intake\s+(checklist|status|voortgang)/i, weight: 0.95 },
{ pattern: /^welke\s+secties\s+(zijn|nog)/i, weight: 0.85 },
{ pattern: /^wat\s+is\s+(de\s+)?(intake\s+)?status/i, weight: 0.9 },
{ pattern: /^status\s+(van\s+)?(de\s+)?intake/i, weight: 0.9 },
{ pattern: /^intake\s+overzicht/i, weight: 0.85 },
],
intake_navigeer: [
// "Ga naar [sectie]" patterns
{ pattern: /^ga\s+naar\s+(de\s+)?(risico|risicotaxatie)/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?diagnose/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?kindcheck/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?anamnese/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?behandeladvies/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?(rom|vragenlijst)/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?onderzoek/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?contact/i, weight: 1.0 },
{ pattern: /^ga\s+naar\s+(de\s+)?algemeen/i, weight: 1.0 },
// "Open [sectie]" patterns
{ pattern: /^open\s+(de\s+)?(risico|diagnose|kindcheck|anamnese)/i, weight: 0.95 },
// "Naar [sectie]" patterns (shorter)
{ pattern: /^naar\s+(risico|diagnose|kindcheck|anamnese|behandeladvies)/i, weight: 0.9 },
],
risico_query: [
// "Wat zijn de risico's?" patterns
{ pattern: /^(wat\s+zijn\s+)?(de\s+)?risico'?s/i, weight: 1.0 },
{ pattern: /^(toon|show|bekijk)\s+(de\s+)?risico/i, weight: 0.95 },
{ pattern: /^risicotaxatie/i, weight: 0.95 },
{ pattern: /^risico\s+overzicht/i, weight: 0.9 },
{ pattern: /^welke\s+risico'?s/i, weight: 0.9 },
{ pattern: /^risico'?s\s+(van|voor)\s+\w+/i, weight: 0.95 },
],
diagnose_query: [
// "Welke diagnoses?" patterns
{ pattern: /^(welke\s+)?diagnose[ns]?(\s+heeft)?/i, weight: 1.0 },
{ pattern: /^(toon|show|bekijk)\s+(de\s+)?diagnose/i, weight: 0.95 },
{ pattern: /^wat\s+is\s+(de\s+)?diagnose/i, weight: 0.95 },
{ pattern: /^diagnose\s+overzicht/i, weight: 0.9 },
{ pattern: /^diagnose[ns]?\s+(van|voor)\s+\w+/i, weight: 0.95 },
{ pattern: /^icd\s*-?\s*10/i, weight: 0.8 },
],
};
/**

View File

@@ -15,9 +15,16 @@ export type CortexIntent =
| 'create_appointment'
| 'cancel_appointment'
| 'reschedule_appointment'
// Intake intents (MVP)
| 'intake_status'
| 'intake_navigeer'
| 'risico_query'
| 'diagnose_query'
| 'unknown';
export type BlockType = Exclude<CortexIntent, 'unknown'> | 'patient-dashboard';
export type BlockType =
| Exclude<CortexIntent, 'unknown' | 'intake_navigeer'>
| 'patient-dashboard';
// Shift types
export type ShiftType = 'nacht' | 'ochtend' | 'middag' | 'avond';
@@ -81,6 +88,9 @@ export interface ExtractedEntities {
// Legacy fields (for backward compatibility)
date?: string;
time?: string;
// Intake navigation (MVP)
navigationTarget?: 'contacts' | 'kindcheck' | 'risk' | 'anamnese' | 'examination' | 'rom' | 'diagnosis' | 'behandeladvies';
}
// Block sizes
@@ -144,6 +154,25 @@ export const BLOCK_CONFIGS: Record<BlockType, BlockConfig> = {
size: 'lg',
icon: 'LayoutDashboard',
},
// Intake blocks (MVP)
intake_status: {
type: 'intake_status',
title: 'Intake Status',
size: 'md',
icon: 'ClipboardList',
},
risico_query: {
type: 'risico_query',
title: 'Risicotaxatie',
size: 'md',
icon: 'AlertTriangle',
},
diagnose_query: {
type: 'diagnose_query',
title: 'Diagnoses',
size: 'md',
icon: 'Stethoscope',
},
};
// Recent action type