Cortex handelt een no-show af vanuit één chatcommando: afspraak annuleren (declarabiliteits-nudge), en de openstaande concept- huisartsbrief wordt via LLM herschreven en ter review aangeboden in een document artifact (human-in-the-loop, PATCH dispatch zet status op verzendklaar). - API-routes: context, rescript, cancel, dispatch - NoShowDocumentBlock: review/edit UI met origineel-vergelijk - Mock-data voor concept huisartsbrief - PRD, FO, bouwplan en epics in docs/intent/noshow-case/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 KiB
NS.E4 — Document Artifact Block
Casus: Cortex No Show Afhandeling Epic doel: Een block component bouwen dat de herschreven huisartsbrief toont in het artifact paneel, inclusief accordering. Geschatte tijd: ~2 uur Afhankelijkheden: NS.E1 (BlockType), NS.E3 (dispatch API)
Context & waarschuwingen
Bestaande renderArtifactBlock heeft een default case
artifact-container.tsx heeft op regel 209:
default:
return (
<div className="p-4 text-slate-500">
Onbekend artifact type: {artifact.type}
</div>
);
TypeScript heeft dit als default case, niet als een exhaustive check. Dat betekent: als we register_no_show vergeten toe te voegen aan de switch, valt de app niet over — maar toont wel "Onbekend artifact type". Visueel zichtbaar, maar geen compile-time fout.
getArtifactTitle heeft ook een switch
De getArtifactTitle functie onderaan artifact-container.tsx heeft een eigen switch over BlockType met een default: return 'Artifact'. Ook hier: geen compile-time fout bij ontbrekende case, maar een generieke titel.
Geen rich text editor in codebase
Er bestaat geen TipTap, Quill of Slate in de codebase. dagnotitie-block.tsx gebruikt een <Textarea> van shadcn/ui. Wij doen hetzelfde — dat is voldoende voor het prototype.
BlockPrefillData is een open interface
BlockPrefillData extends ChatEntities — dat is een open interface. We kunnen er extra velden aan toevoegen voor no-show specifieke data (documentId, originalContent) zonder de store te wijzigen. De cast as NoShowDocumentPrefill in de container zorgt voor type safety binnen het block.
NS.E4.S1 — NoShowDocumentBlock component
Nieuw bestand: components/cortex/blocks/noshow-document-block.tsx
Props interface
interface NoShowDocumentPrefill {
documentId: string;
content: string; // Herschreven inhoud van LLM
title: string;
originalContent?: string; // Originele inhoud voor "bekijk origineel"
rescriptWarning?: string; // Tonen als LLM fout had
}
Component implementatie
'use client';
import { useState } from 'react';
import { FileText, ChevronDown, ChevronUp } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { useCortexStore } from '@/stores/cortex-store';
import { cn } from '@/lib/utils';
interface NoShowDocumentPrefill {
documentId: string;
content: string;
title: string;
originalContent?: string;
rescriptWarning?: string;
}
interface NoShowDocumentBlockProps {
prefill: NoShowDocumentPrefill;
}
export function NoShowDocumentBlock({ prefill }: NoShowDocumentBlockProps) {
const [content, setContent] = useState(prefill.content);
const [showOriginal, setShowOriginal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDone, setIsDone] = useState(false);
const addChatMessage = useCortexStore((s) => s.addChatMessage);
const setNoShowStep = useCortexStore((s) => s.setNoShowStep);
const handleDispatch = async () => {
setIsSubmitting(true);
try {
const res = await fetch('/api/cortex/noshow/dispatch', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId: prefill.documentId,
finalContent: content,
}),
});
if (!res.ok) throw new Error('Dispatch mislukt');
setIsDone(true);
setNoShowStep('done');
addChatMessage({
type: 'assistant',
content: 'Brief is verzendklaar gemaakt. De no-show afhandeling is compleet.',
});
} catch {
addChatMessage({
type: 'error',
content: 'Opslaan mislukt. Probeer het opnieuw.',
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="w-full max-w-2xl">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-slate-600" />
<h2 className="text-lg font-semibold text-slate-800">
{prefill.title || 'Huisartsbrief'}
</h2>
<span className="ml-auto text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded font-medium">
Aangepast door Cortex
</span>
</div>
{/* LLM waarschuwing (bij fallback naar origineel) */}
{prefill.rescriptWarning && (
<div className="mb-3 p-3 bg-amber-50 border border-amber-200 rounded text-sm text-amber-800">
⚠️ {prefill.rescriptWarning}
</div>
)}
{/* Bewerkbare tekstinhoud */}
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={isDone}
className={cn(
'min-h-[320px] font-mono text-sm resize-y',
isDone && 'opacity-60 cursor-not-allowed'
)}
placeholder="Brief inhoud..."
/>
{/* Origineel bekijken — collapsible */}
{prefill.originalContent && (
<div className="mt-2">
<button
type="button"
onClick={() => setShowOriginal(!showOriginal)}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors"
>
{showOriginal ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
{showOriginal ? 'Origineel verbergen' : 'Originele brief bekijken'}
</button>
{showOriginal && (
<div className="mt-2 p-3 bg-slate-50 border border-slate-200 rounded text-sm text-slate-500 font-mono whitespace-pre-wrap">
{prefill.originalContent}
</div>
)}
</div>
)}
{/* Footer met Akkoord knop */}
<div className="mt-4 flex items-center justify-between border-t border-slate-200 pt-4">
<span className="text-xs text-slate-400">
{isDone ? '✓ Brief is verzendklaar' : 'Controleer en pas aan indien nodig'}
</span>
{isDone ? (
<span className="text-sm font-medium text-green-600 flex items-center gap-1">
✓ Verzendklaar
</span>
) : (
<Button
onClick={handleDispatch}
disabled={isSubmitting || content.trim().length === 0}
className="bg-slate-800 hover:bg-slate-700 text-white"
>
{isSubmitting ? 'Opslaan...' : 'Akkoord & Verzendklaar →'}
</Button>
)}
</div>
</div>
);
}
Done criteria
- Component rendert de herschreven inhoud in een bewerkbare textarea
- "Originele brief bekijken" toggle werkt (collapsible)
- Textarea is editeerbaar — aanpassingen blijven behouden
- "Akkoord & Verzendklaar" knop roept dispatch API aan
- Na succesvolle dispatch: knop verdwijnt, "✓ Verzendklaar" verschijnt, chat ontvangt bevestiging
- Bij leeg content: knop is disabled
- Bij API fout: error message in chat, knop hergebruikbaar
NS.E4.S2 — ArtifactContainer uitbreiden
Bestand: components/cortex/artifacts/artifact-container.tsx
Wijziging 1 — Import toevoegen
Voeg toe na de bestaande block imports:
import { NoShowDocumentBlock } from '../blocks/noshow-document-block';
Wijziging 2 — Case toevoegen in renderArtifactBlock
Voeg toe in de switch, vóór de default case:
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}
/>
);
}
Wijziging 3 — Case toevoegen in getArtifactTitle
Voeg toe in de switch, vóór de default case:
case 'register_no_show':
return prefill?.title
? `Brief — ${prefill.title}`
: 'Huisartsbrief';
Done criteria
openArtifact({ type: 'register_no_show', title: 'Huisartsbrief n.a.v. intake', prefill: { documentId: '...', content: '...', title: '...' } })opent hetNoShowDocumentBlock- Tab titel in multi-artifact view toont "Brief — Huisartsbrief n.a.v. intake"
- Geen TypeScript errors in
artifact-container.tsx - Geen "Onbekend artifact type" fallback zichtbaar
NS.E4.S3 — openArtifact aanroep in handleNoShowRescriptStep
Bestand: components/cortex/chat/chat-panel.tsx
Dit is de koppeling tussen de rescript API (NS.E3.S4) en het artifact (NS.E4.S1).
Voeg handleNoShowRescriptStep toe als useCallback in ChatPanel:
const handleNoShowRescriptStep = useCallback(async () => {
const { noShowFlow } = useCortexStore.getState();
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, // undefined als LLM succesvol was
},
});
addChatMessage({
type: 'assistant',
content: 'Huisartsbrief is aangepast. Kijk of je hiermee akkoord bent.',
});
} catch {
setNoShowStep('waiting_brief'); // Terug naar vorige stap
addChatMessage({
type: 'error',
content: 'Herschrijven mislukt. Probeer het opnieuw.',
});
}
}, [activePatient, setNoShowStep, addChatMessage, openArtifact]);
Let op: useCortexStore.getState() wordt hier gebruikt (niet de hook) omdat we binnen een useCallback zitten en de state op het moment van uitvoering nodig hebben, niet de state van de laatste render.
Done criteria
- Klikken op
[Ja, pas brief aan]→ rescript API aangeroepen → artifact paneel opent metNoShowDocumentBlock - Brief inhoud is de AI-herschreven versie (of origineel bij LLM fout)
- Als
rescriptWarningaanwezig is: gele waarschuwingsbalk zichtbaar in het block
Validatie na NS.E4
Visuele test:
- Doorloop de flow tot en met nudge 2 acceptatie
- Verwacht: artifact paneel schuift open met
NoShowDocumentBlock - Inhoud is de AI-herschreven brief
- Textarea is editeerbaar
- Klik
[Akkoord & Verzendklaar] - Verwacht: "✓ Verzendklaar" in het block + bevestiging in chat
pnpm build— geen errors