feat(cortex): no-show casus — intent flow, brief-rescript en document artifact
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>
This commit is contained in:
336
docs/intent/noshow-case/epics/NS-E4-document-artifact.md
Normal file
336
docs/intent/noshow-case/epics/NS-E4-document-artifact.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# 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:
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
'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:
|
||||
```typescript
|
||||
import { NoShowDocumentBlock } from '../blocks/noshow-document-block';
|
||||
```
|
||||
|
||||
### Wijziging 2 — Case toevoegen in `renderArtifactBlock`
|
||||
|
||||
Voeg toe in de switch, vóór de `default` case:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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 het `NoShowDocumentBlock`
|
||||
- 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`:
|
||||
|
||||
```typescript
|
||||
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 met `NoShowDocumentBlock`
|
||||
- Brief inhoud is de AI-herschreven versie (of origineel bij LLM fout)
|
||||
- Als `rescriptWarning` aanwezig is: gele waarschuwingsbalk zichtbaar in het block
|
||||
|
||||
---
|
||||
|
||||
## Validatie na NS.E4
|
||||
|
||||
**Visuele test:**
|
||||
1. Doorloop de flow tot en met nudge 2 acceptatie
|
||||
2. Verwacht: artifact paneel schuift open met `NoShowDocumentBlock`
|
||||
3. Inhoud is de AI-herschreven brief
|
||||
4. Textarea is editeerbaar
|
||||
5. Klik `[Akkoord & Verzendklaar]`
|
||||
6. Verwacht: "✓ Verzendklaar" in het block + bevestiging in chat
|
||||
7. `pnpm build` — geen errors
|
||||
Reference in New Issue
Block a user