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:
296
docs/intent/noshow-case/epics/NS-E1-intent-foundation.md
Normal file
296
docs/intent/noshow-case/epics/NS-E1-intent-foundation.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# NS.E1 — Intent Foundation
|
||||
|
||||
**Casus:** Cortex No Show Afhandeling
|
||||
**Epic doel:** `register_no_show` herkenbaar maken door het volledige classificatiesysteem — types, reflex patterns, en orchestrator prompt.
|
||||
**Geschatte tijd:** ~1.5 uur
|
||||
**Afhankelijkheden:** Geen (dit is de basis voor alle andere epics)
|
||||
|
||||
---
|
||||
|
||||
## Context & waarschuwingen
|
||||
|
||||
### Dubbele BlockType definitie — kritiek
|
||||
|
||||
Er zijn **twee** `BlockType` definities in de codebase die allebei bijgewerkt moeten worden:
|
||||
|
||||
| Bestand | Definitie |
|
||||
|---|---|
|
||||
| `lib/cortex/types.ts` | `Exclude<CortexIntent, 'unknown' \| 'intake_navigeer'> \| 'patient-dashboard'` |
|
||||
| `stores/cortex-store.ts` | `Exclude<CortexIntent, 'unknown'> \| 'fallback' \| 'patient-dashboard'` |
|
||||
|
||||
De store-versie is breder (includeert `intake_navigeer` en `fallback`). Door `register_no_show` toe te voegen aan `CortexIntent` wordt het automatisch onderdeel van beide `BlockType` afgeleidingen — maar alleen als je ook `BLOCK_CONFIGS` bijwerkt.
|
||||
|
||||
### `INTENT_PATTERNS` is strikt getypeerd
|
||||
|
||||
`reflex-classifier.ts` regel 27:
|
||||
```typescript
|
||||
const INTENT_PATTERNS: Record<Exclude<CortexIntent, 'unknown'>, PatternConfig[]> = { ... }
|
||||
```
|
||||
|
||||
Dit betekent: als je `register_no_show` toevoegt aan `CortexIntent`, **eist TypeScript** dat je ook een entry toevoegt aan `INTENT_PATTERNS`. De build faalt anders. Dit is bewust — het voorkomt vergeten patronen.
|
||||
|
||||
---
|
||||
|
||||
## NS.E1.S1 — CortexIntent type + BLOCK_CONFIGS uitbreiden
|
||||
|
||||
**Bestand:** `lib/cortex/types.ts`
|
||||
|
||||
### Wijziging 1 — CortexIntent union
|
||||
|
||||
Voeg `'register_no_show'` toe na de intake-intents:
|
||||
|
||||
```typescript
|
||||
export type CortexIntent =
|
||||
| 'dagnotitie'
|
||||
| 'zoeken'
|
||||
| 'overdracht'
|
||||
| 'agenda_query'
|
||||
| 'create_appointment'
|
||||
| 'cancel_appointment'
|
||||
| 'reschedule_appointment'
|
||||
| 'intake_status'
|
||||
| 'intake_navigeer'
|
||||
| 'risico_query'
|
||||
| 'diagnose_query'
|
||||
// No Show casus
|
||||
| 'register_no_show'
|
||||
| 'unknown';
|
||||
```
|
||||
|
||||
### Wijziging 2 — BLOCK_CONFIGS
|
||||
|
||||
Voeg een entry toe in `BLOCK_CONFIGS` (het is een `Record<BlockType, BlockConfig>` — alle BlockTypes moeten erin):
|
||||
|
||||
```typescript
|
||||
register_no_show: {
|
||||
type: 'register_no_show',
|
||||
title: 'No Show Registratie',
|
||||
size: 'md',
|
||||
icon: 'UserX',
|
||||
},
|
||||
```
|
||||
|
||||
Locatie: onderaan de `BLOCK_CONFIGS` definitie, na `diagnose_query`.
|
||||
|
||||
### Done criteria
|
||||
- `pnpm build` slaagt — geen TypeScript errors
|
||||
- `register_no_show` is een geldige waarde voor `CortexIntent`
|
||||
- `BLOCK_CONFIGS['register_no_show']` bestaat
|
||||
|
||||
---
|
||||
|
||||
## NS.E1.S2 — Reflex Classifier patronen toevoegen
|
||||
|
||||
**Bestand:** `lib/cortex/reflex-classifier.ts`
|
||||
|
||||
### Context
|
||||
|
||||
`INTENT_PATTERNS` is getypeerd als `Record<Exclude<CortexIntent, 'unknown'>, PatternConfig[]>`. Na toevoeging van `register_no_show` aan de union **moet** je hier ook een entry toevoegen — anders faalt de build met:
|
||||
|
||||
```
|
||||
Type '{ dagnotitie: ...; ... }' is missing the following properties
|
||||
from type 'Record<...>': register_no_show
|
||||
```
|
||||
|
||||
### Wijziging — patronen toevoegen
|
||||
|
||||
Voeg toe aan `INTENT_PATTERNS`, na het `diagnose_query` blok:
|
||||
|
||||
```typescript
|
||||
// =========================================================================
|
||||
// No Show casus
|
||||
// =========================================================================
|
||||
register_no_show: [
|
||||
// Exacte no-show varianten
|
||||
{ pattern: /no.?show/i, weight: 1.0 },
|
||||
{ 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 },
|
||||
],
|
||||
```
|
||||
|
||||
**Toelichting weights:**
|
||||
- `1.0` — directe "no show" termen, geen ambiguïteit
|
||||
- `0.95` — sterke klinische uitdrukkingen
|
||||
- `0.85–0.9` — contextuele varianten
|
||||
- `0.7` — vaag ("is er niet geweest") — escaleer naar AI bij twijfel
|
||||
|
||||
### Escalatie check
|
||||
|
||||
Controleer of de bestaande escalatiepatronen niet per ongeluk triggeren op no-show input:
|
||||
- `"patiënt niet verschenen"` — bevat geen conjuncties → geen multi_intent
|
||||
- `"patiënt is er niet"` — bevat geen voornaamwoorden die escaleren → OK
|
||||
- `"patiënt niet verschenen morgen"` — bevat `morgen` → triggert `relative_time` escalatie → correct (AI lost dit op)
|
||||
|
||||
### Done criteria
|
||||
- `classifyWithReflex("patiënt is niet verschenen")` geeft `{ intent: 'register_no_show', confidence: 0.95, shouldEscalateToAI: false }`
|
||||
- `classifyWithReflex("no show vandaag")` geeft `{ intent: 'register_no_show', confidence: 1.0, shouldEscalateToAI: false }`
|
||||
- `classifyWithReflex("patiënt niet verschenen morgen")` geeft `shouldEscalateToAI: true, escalationReason: 'relative_time'`
|
||||
- `pnpm build` slaagt — TypeScript tevreden met de nieuwe entry
|
||||
|
||||
---
|
||||
|
||||
## NS.E1.S3 — Orchestrator system prompt uitbreiden
|
||||
|
||||
**Bestand:** `app/api/cortex/chat/route.ts`
|
||||
|
||||
### Context
|
||||
|
||||
De chat API bouwt een system prompt via `buildSystemPrompt(context)`. Die prompt bevat een opsomming van alle intents met beschrijvingen. De Orchestrator (Layer 2) én de chat AI (Layer 3) gebruiken allebei deze prompt.
|
||||
|
||||
Zoek de sectie in de prompt waar intents worden opgesomd — het zal er zo uitzien:
|
||||
```
|
||||
- dagnotitie: Gebruik wanneer...
|
||||
- zoeken: Gebruik wanneer...
|
||||
```
|
||||
|
||||
### Wijziging — intent beschrijving toevoegen
|
||||
|
||||
Voeg toe aan de intent-opsomming in `buildSystemPrompt`:
|
||||
|
||||
```
|
||||
- register_no_show: Gebruik wanneer de zorgverlener aangeeft dat een patiënt
|
||||
niet op de geplande afspraak is verschenen. Signaalwoorden: "no show",
|
||||
"niet verschenen", "niet gekomen", "afwezig bij afspraak", "komt niet op".
|
||||
Entiteiten: geen specifieke entiteiten nodig — de actieve patiënt en
|
||||
huidige context worden gebruikt.
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- System prompt in de chat API bevat `register_no_show` met beschrijving
|
||||
- Handmatige test: typ "cliënt was er niet vandaag" in de chat → AI classificeert als `register_no_show` (zichtbaar in console via `[ChatPanel] Action detected:`)
|
||||
- Typ "patiënt heeft de afspraak gemist" → zelfde resultaat
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## NS.E1.S4 — `action-parser.ts` uitbreiden
|
||||
|
||||
**Bestand:** `lib/cortex/action-parser.ts`
|
||||
|
||||
### Context — waarom dit hier hoort
|
||||
|
||||
`action-parser.ts` bevat vier hardcoded enums en switches die de volledige lijst van geldige intents bevatten. Ze zijn **niet** afgeleid van `CortexIntent` in `types.ts` — ze zijn handmatig gesynchroniseerd. Dit is het meest kritieke gat: als `register_no_show` hier ontbreekt, faalt de Zod validatie silently op de AI response en wordt `parsed.action` altijd `null`. De nudge triggert dan **nooit** — ook niet na alle andere fixes.
|
||||
|
||||
### Wijziging 1 — `ActionSchema.intent` enum (regel ~16)
|
||||
|
||||
Voeg `'register_no_show'` toe aan de `z.enum([...])` lijst:
|
||||
|
||||
```typescript
|
||||
intent: z.enum([
|
||||
'dagnotitie',
|
||||
'zoeken',
|
||||
'overdracht',
|
||||
'agenda_query',
|
||||
'create_appointment',
|
||||
'cancel_appointment',
|
||||
'reschedule_appointment',
|
||||
'intake_status',
|
||||
'intake_navigeer',
|
||||
'risico_query',
|
||||
'diagnose_query',
|
||||
// No Show casus
|
||||
'register_no_show',
|
||||
'unknown',
|
||||
]),
|
||||
```
|
||||
|
||||
### Wijziging 2 — `ActionSchema.artifact.type` enum (regel ~77)
|
||||
|
||||
Voeg `'register_no_show'` toe aan de artifact type enum:
|
||||
|
||||
```typescript
|
||||
type: z.enum([
|
||||
'dagnotitie',
|
||||
'zoeken',
|
||||
'overdracht',
|
||||
'agenda_query',
|
||||
'create_appointment',
|
||||
'cancel_appointment',
|
||||
'reschedule_appointment',
|
||||
'intake_status',
|
||||
'risico_query',
|
||||
'diagnose_query',
|
||||
'fallback',
|
||||
'patient-dashboard',
|
||||
// No Show casus
|
||||
'register_no_show',
|
||||
]),
|
||||
```
|
||||
|
||||
### Wijziging 3 — `routeIntentToArtifact` switch
|
||||
|
||||
De switch heeft een `default: return null`. Voeg een expliciete case toe **vóór** de `default`, na het `intake_navigeer` blok:
|
||||
|
||||
```typescript
|
||||
case 'register_no_show':
|
||||
// Artifact wordt geopend via de no-show handler in chat-panel.tsx,
|
||||
// niet via de generieke routing. Geef null terug zodat de handler
|
||||
// de controle houdt.
|
||||
return null;
|
||||
```
|
||||
|
||||
**Toelichting:** We returnen bewust `null` — het artifact voor no-show wordt door `handleNoShowRescriptStep` geopend met volledige prefill data (documentId, content, originalContent) die pas beschikbaar is ná de rescript API call. De generieke router heeft die data niet.
|
||||
|
||||
### Wijziging 4 — `getDefaultConfirmationMessage` switch
|
||||
|
||||
Voeg toe na het `overdracht` case, vóór de `default`:
|
||||
|
||||
```typescript
|
||||
case 'register_no_show':
|
||||
return 'Ik registreer de no show en controleer de agenda op declarabiliteit.';
|
||||
```
|
||||
|
||||
### Wijziging 5 — `getAcceptButtonText` in `nudge-chat-message.tsx`
|
||||
|
||||
**Bestand:** `components/cortex/chat/nudge-chat-message.tsx`
|
||||
|
||||
De `getAcceptButtonText` functie bepaalt de knoptekst op basis van `suggestion.suggestion.intent`:
|
||||
- Nudge 1 heeft `intent: 'cancel_appointment'` → toont al correct **"Ja, annuleren"**
|
||||
- Nudge 2 heeft `intent: 'register_no_show'` → valt op `default: 'Ja, uitvoeren'`
|
||||
|
||||
Voeg toe in de switch van `getAcceptButtonText`:
|
||||
|
||||
```typescript
|
||||
case 'register_no_show':
|
||||
return 'Ja, pas brief aan';
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- `ActionSchema.safeParse({ type: 'action', intent: 'register_no_show', entities: {}, confidence: 0.9 })` geeft `success: true`
|
||||
- `routeIntentToArtifact('register_no_show', {}, 0.9)` geeft `null` terug (geen artifact via generieke router)
|
||||
- `getDefaultConfirmationMessage('register_no_show', {})` geeft correcte Nederlandse zin
|
||||
- Nudge 2 accept-knop toont "Ja, pas brief aan"
|
||||
- `pnpm build` slaagt
|
||||
|
||||
---
|
||||
|
||||
## Validatie na NS.E1 (alle stories)
|
||||
|
||||
Run na voltooiing van alle vier stories:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Verwacht: geen errors. Als er TypeScript errors zijn over `INTENT_PATTERNS` of `BLOCK_CONFIGS`, controleer dan of beide `Record<>` types volledig zijn bijgewerkt.
|
||||
|
||||
**Snelle handmatige smoke test:**
|
||||
1. Open de app (`pnpm dev`)
|
||||
2. Navigeer naar het Cortex dashboard
|
||||
3. Typ: `"patiënt niet verschenen"`
|
||||
4. Verwacht: AI response met intent `register_no_show` zichtbaar in browser console via `[ChatPanel] Action detected: { intent: 'register_no_show', ... }`
|
||||
5. Verwacht: nudge bubble verschijnt (NS.E2 vereist — maar de action parse moet nu wel slagen)
|
||||
374
docs/intent/noshow-case/epics/NS-E2-nudge-rules.md
Normal file
374
docs/intent/noshow-case/epics/NS-E2-nudge-rules.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# NS.E2 — Nudge Rules & Chained Flow
|
||||
|
||||
**Casus:** Cortex No Show Afhandeling
|
||||
**Epic doel:** Twee proactieve nudges bouwen die sequentieel volgen op de no-show registratie, plus de store-uitbreiding die de multi-step state bijhoudt.
|
||||
**Geschatte tijd:** ~2 uur
|
||||
**Afhankelijkheden:** NS.E1 volledig klaar (CortexIntent type moet bestaan)
|
||||
|
||||
---
|
||||
|
||||
## Context & waarschuwingen
|
||||
|
||||
### Nudge evaluatie wordt NIET aangeroepen vanuit de hoofd-chat flow
|
||||
|
||||
Dit is het grootste architecturele gat. `evaluateNudge()` wordt momenteel **alleen** aangeroepen vanuit `handleConfirmAction` in `chat-panel.tsx` — dat is de V2 chain flow (feature-flagged). De hoofd-chat SSE flow (`onDone` callback) roept dit **niet** aan.
|
||||
|
||||
Gevolg: als een gebruiker "patiënt niet verschenen" typt en de AI antwoordt met `register_no_show`, triggert er **geen nudge** via de protocol rules engine.
|
||||
|
||||
**Oplossing in dit epic:** We voegen nudge-evaluatie toe aan de `onDone` callback van de hoofd-chat flow, maar alleen voor de `register_no_show` intent. Dit is bewust scopebegrensd — geen generieke uitbreiding van de flow.
|
||||
|
||||
### NudgeSuggestion ID bevat timestamp
|
||||
|
||||
Nudge IDs worden gegenereerd als `nudge-${rule.id}-${Date.now()}`. We kunnen dus **niet** matchen op het volledige ID in `handleAcceptNudge`. Match op `suggestion.trigger.intent` of de `suggestion.suggestion.rationale` (= `rule.name`).
|
||||
|
||||
**Gekozen aanpak:** `suggestion.trigger.intent` gebruiken als discriminator.
|
||||
- Nudge 1: `trigger.intent === 'register_no_show'`
|
||||
- Nudge 2: handmatig geconstrueerd (niet via `evaluateNudge`) — zie NS.E2.S3
|
||||
|
||||
### ProtocolRule interface vereisten
|
||||
|
||||
De `ProtocolRule` interface in `nudge.ts` heeft **verplichte** velden die het bouwplan oorspronkelijk wegliet:
|
||||
|
||||
```typescript
|
||||
interface ProtocolRule {
|
||||
id: string;
|
||||
name: string; // ← verplicht
|
||||
trigger: {
|
||||
intent: CortexIntent;
|
||||
conditions: ProtocolCondition[]; // ← verplicht, mag [] zijn
|
||||
};
|
||||
suggestion: {
|
||||
intent: CortexIntent;
|
||||
message: string;
|
||||
prefillEntities: (source: ExtractedEntities) => Partial<ExtractedEntities>; // ← FUNCTIE, niet object
|
||||
};
|
||||
protocol?: ProtocolMetadata;
|
||||
priority: NudgePriority;
|
||||
enabled: boolean; // ← verplicht
|
||||
expiresAfterMs: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NS.E2.S1 — No-show flow state aan store toevoegen
|
||||
|
||||
**Bestand:** `stores/cortex-store.ts`
|
||||
|
||||
### Context
|
||||
|
||||
De no-show flow heeft 5 sequentiële stappen. We slaan de state op in de Zustand store zodat alle componenten er toegang toe hebben.
|
||||
|
||||
### Wijziging 1 — Types toevoegen (boven `CortexStore` interface)
|
||||
|
||||
```typescript
|
||||
// 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;
|
||||
}
|
||||
```
|
||||
|
||||
### Wijziging 2 — State toevoegen aan `CortexStore` interface
|
||||
|
||||
Voeg toe na `pendingClarification`:
|
||||
```typescript
|
||||
// No-show flow state
|
||||
noShowFlow: NoShowFlowState;
|
||||
```
|
||||
|
||||
Voeg actions toe na bestaande clarification actions:
|
||||
```typescript
|
||||
// No-show flow actions
|
||||
setNoShowStep: (step: NoShowStep) => void;
|
||||
setNoShowContext: (ctx: Partial<Omit<NoShowFlowState, 'step'>>) => void;
|
||||
resetNoShowFlow: () => void;
|
||||
```
|
||||
|
||||
### Wijziging 3 — Initiële waarde in `initialState`
|
||||
|
||||
```typescript
|
||||
noShowFlow: {
|
||||
step: 'idle',
|
||||
appointmentId: null,
|
||||
documentId: null,
|
||||
originalContent: null,
|
||||
} as NoShowFlowState,
|
||||
```
|
||||
|
||||
### Wijziging 4 — Actions implementeren (in `create()` body)
|
||||
|
||||
```typescript
|
||||
setNoShowStep: (step) =>
|
||||
set(
|
||||
(state) => ({ noShowFlow: { ...state.noShowFlow, step } }),
|
||||
false,
|
||||
'setNoShowStep'
|
||||
),
|
||||
|
||||
setNoShowContext: (ctx) =>
|
||||
set(
|
||||
(state) => ({ noShowFlow: { ...state.noShowFlow, ...ctx } }),
|
||||
false,
|
||||
'setNoShowContext'
|
||||
),
|
||||
|
||||
resetNoShowFlow: () =>
|
||||
set(
|
||||
{
|
||||
noShowFlow: {
|
||||
step: 'idle',
|
||||
appointmentId: null,
|
||||
documentId: null,
|
||||
originalContent: null,
|
||||
},
|
||||
},
|
||||
false,
|
||||
'resetNoShowFlow'
|
||||
),
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- `pnpm build` slaagt
|
||||
- `useCortexStore(s => s.noShowFlow)` geeft `{ step: 'idle', ... }` terug
|
||||
- `useCortexStore(s => s.setNoShowStep)('waiting_cancel')` werkt zonder error
|
||||
|
||||
---
|
||||
|
||||
## NS.E2.S2 — Nudge Rule 1: Declarabiliteitscheck
|
||||
|
||||
**Bestand:** `lib/cortex/nudge.ts`
|
||||
|
||||
### Wijziging — rule toevoegen aan `PROTOCOL_RULES`
|
||||
|
||||
Voeg toe **vóór** de bestaande wondzorg-regel (hogere priority verdient eerste positie):
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'noshow-declarabel-check',
|
||||
name: 'No Show declarabiliteitscheck',
|
||||
trigger: {
|
||||
intent: 'register_no_show',
|
||||
conditions: [], // Geen aanvullende condities — altijd triggeren bij no-show
|
||||
},
|
||||
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) => ({}), // Geen prefill nodig — context wordt via API opgehaald
|
||||
},
|
||||
priority: 'high',
|
||||
enabled: true,
|
||||
expiresAfterMs: DEFAULT_EXPIRY_MS,
|
||||
},
|
||||
```
|
||||
|
||||
**Let op:** Er is geen `protocol` metadata voor deze rule — dat is optioneel en ontbreekt hier bewust (het gaat om een inkoopverplichting, geen klinisch protocol).
|
||||
|
||||
### Done criteria
|
||||
- `evaluateNudge({ intent: 'register_no_show', actionId: 'test', entities: {}, content: '' })` geeft een array terug met één nudge
|
||||
- De nudge heeft `priority: 'high'` en `suggestion.intent: 'cancel_appointment'`
|
||||
- De message bevat "declarabel"
|
||||
|
||||
---
|
||||
|
||||
## NS.E2.S3 — Hoofd-chat flow uitbreiden met nudge trigger
|
||||
|
||||
**Bestand:** `components/cortex/chat/chat-panel.tsx`
|
||||
|
||||
### Context
|
||||
|
||||
De nudge voor no-show moet triggeren ná de AI-response, niet ná een chain action. Dat betekent: uitbreiding van de `onDone` callback in de `ChatInput` `onSend` handler.
|
||||
|
||||
Momenteel (vereenvoudigd):
|
||||
```typescript
|
||||
onDone: () => {
|
||||
setStreaming(false);
|
||||
const parsed = parseActionFromResponse(accumulatedContent);
|
||||
if (parsed.action) {
|
||||
updateLastMessage(...);
|
||||
setPendingAction(parsed.action);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stap 1 — Store actions ophalen
|
||||
|
||||
Voeg bovenaan `ChatPanel()` toe:
|
||||
```typescript
|
||||
const noShowFlow = useCortexStore((s) => s.noShowFlow);
|
||||
const setNoShowStep = useCortexStore((s) => s.setNoShowStep);
|
||||
const setNoShowContext = useCortexStore((s) => s.setNoShowContext);
|
||||
```
|
||||
|
||||
### Stap 2 — Nudge trigger in `onDone`
|
||||
|
||||
Voeg toe in de `onDone` callback, **ná** het bestaande `setPendingAction` blok:
|
||||
|
||||
```typescript
|
||||
// No-show flow: trigger nudge na register_no_show intent
|
||||
if (parsed.action?.intent === 'register_no_show' && isFeatureEnabled('CORTEX_NUDGE')) {
|
||||
const suggestions = evaluateNudge({
|
||||
intent: 'register_no_show',
|
||||
actionId: crypto.randomUUID(),
|
||||
entities: parsed.action.entities,
|
||||
content: message, // originele user input
|
||||
});
|
||||
|
||||
suggestions.forEach((suggestion) => {
|
||||
addChatMessage({
|
||||
type: 'nudge',
|
||||
content: suggestion.suggestion.message,
|
||||
nudge: suggestion,
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Stap 3 — `handleAcceptNudge` uitbreiden met no-show logica
|
||||
|
||||
De bestaande `handleAcceptNudge` doet: `acceptSuggestion` → `routeIntentToArtifact` → `openArtifact`. We voegen een **vroege exit** toe voor no-show nudges die een aparte flow hebben.
|
||||
|
||||
Voeg toe aan het begin van `handleAcceptNudge`, vóór de bestaande `routeIntentToArtifact` aanroep:
|
||||
|
||||
```typescript
|
||||
const handleAcceptNudge = useCallback(async (
|
||||
suggestionId: string,
|
||||
suggestion: ChatMessageType['nudge']
|
||||
) => {
|
||||
acceptSuggestion(suggestionId);
|
||||
|
||||
if (!suggestion) return;
|
||||
|
||||
// --- No-show flow: stap 2 → stap 3 ---
|
||||
if (suggestion.trigger.intent === 'register_no_show') {
|
||||
await handleNoShowCancelStep(suggestion);
|
||||
return; // Vroege exit — geen generieke artifact routing
|
||||
}
|
||||
|
||||
// Bestaande generieke flow (voor alle andere nudges)
|
||||
const artifact = routeIntentToArtifact(
|
||||
suggestion.suggestion.intent,
|
||||
suggestion.suggestion.entities,
|
||||
0.9
|
||||
);
|
||||
if (artifact) {
|
||||
openArtifact({ type: artifact.type, prefill: artifact.prefill, title: artifact.title });
|
||||
}
|
||||
}, [acceptSuggestion, openArtifact, setNoShowStep, setNoShowContext, addChatMessage, activePatient]);
|
||||
```
|
||||
|
||||
**Let op:** `handleAcceptNudge` is nu `async`. Controleer of de prop-definitie in `NudgeChatMessage` dit ondersteunt — zo niet, pas de prop type aan.
|
||||
|
||||
### Stap 4 — `handleNoShowCancelStep` functie
|
||||
|
||||
Voeg toe als aparte `useCallback` in `ChatPanel`:
|
||||
|
||||
```typescript
|
||||
const handleNoShowCancelStep = useCallback(async (suggestion: NudgeSuggestion) => {
|
||||
setNoShowStep('waiting_cancel');
|
||||
|
||||
// Voeg verwerking chat message toe
|
||||
addChatMessage({ type: 'assistant', content: 'Bezig met annuleren...' });
|
||||
|
||||
try {
|
||||
// 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', // mock voor nu
|
||||
patientId: activePatient?.id ?? 'demo-patient-001',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!cancelRes.ok) throw new Error('Cancel mislukt');
|
||||
|
||||
// Check op concept brief
|
||||
const patientId = activePatient?.id ?? 'demo-patient-001';
|
||||
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 (niet via evaluateNudge)
|
||||
const briefNudge: NudgeSuggestion = {
|
||||
id: `nudge-noshow-brief-${Date.now()}`,
|
||||
trigger: {
|
||||
actionId: 'noshow-cancel-done',
|
||||
intent: 'cancel_appointment',
|
||||
entities: {},
|
||||
},
|
||||
suggestion: {
|
||||
intent: 'register_no_show', // gebruikt als signaal voor brief-stap
|
||||
entities: {},
|
||||
message: 'Afspraak geannuleerd. Er staat nog een concept huisartsbrief klaar. Zal ik daar de No Show in verwerken?',
|
||||
rationale: 'noshow-brief-check', // gebruikt als discriminator in handleAcceptNudge stap 5
|
||||
},
|
||||
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.',
|
||||
});
|
||||
}
|
||||
}, [activePatient, setNoShowStep, setNoShowContext, addChatMessage]);
|
||||
```
|
||||
|
||||
### Stap 5 — `handleAcceptNudge` uitbreiden voor brief-stap
|
||||
|
||||
In de bestaande `handleAcceptNudge`, voeg een tweede no-show check toe vóór de generieke flow:
|
||||
|
||||
```typescript
|
||||
// --- No-show flow: stap 4 → brief openen ---
|
||||
if (suggestion.suggestion.rationale === 'noshow-brief-check') {
|
||||
await handleNoShowRescriptStep();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Voeg `handleNoShowRescriptStep` toe als aparte `useCallback` (zie NS.E3.S4 voor de rescript API, die hier aangeroepen wordt). De volledige implementatie staat in NS.E5.S1.
|
||||
|
||||
### Done criteria
|
||||
- Typen "patiënt niet verschenen" → AI antwoordt → nudge 1 verschijnt in chat
|
||||
- Klikken `[Ja]` op nudge 1 → cancel API aangeroepen → nudge 2 verschijnt
|
||||
- Klikken `[Ja]` op nudge 2 → rescript API aangeroepen → artifact opent (NS.E4)
|
||||
- Klikken `[Nee]` op nudge 1 → `dismissSuggestion` → nudge verdwijnt, geen verdere actie
|
||||
- `noShowFlow.step` doorloopt correct: `idle` → `waiting_cancel` → `waiting_brief` → `brief_open`
|
||||
|
||||
---
|
||||
|
||||
## Validatie na NS.E2
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
**Visuele check:**
|
||||
1. Typ `"patiënt niet verschenen"` in de chat
|
||||
2. Wacht op AI response
|
||||
3. Verwacht: nudge bubble met "Ik zie een declarabel consult..." en `[Ja, annuleer]` / `[Nee]` knoppen
|
||||
4. Browser console toont: `[ChatPanel] Nudge suggestions (chat-based): 1`
|
||||
452
docs/intent/noshow-case/epics/NS-E3-api-routes.md
Normal file
452
docs/intent/noshow-case/epics/NS-E3-api-routes.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# NS.E3 — No-Show API Routes
|
||||
|
||||
**Casus:** Cortex No Show Afhandeling
|
||||
**Epic doel:** Vier backend routes bouwen die de no-show flow ondersteunen — annuleren, context ophalen, brief herschrijven, en accordering.
|
||||
**Geschatte tijd:** ~3 uur
|
||||
**Afhankelijkheden:** NS.E1 (types), NS.E2.S1 (store types voor type safety in aanroepen)
|
||||
|
||||
---
|
||||
|
||||
## Context & aanpak
|
||||
|
||||
Alle routes volgen het bestaande patroon in `app/api/cortex/`:
|
||||
- Auth check via `createClient()` uit `lib/auth/server.ts`
|
||||
- Zod validatie op request body / query params
|
||||
- Nederlandse foutmeldingen in responses
|
||||
- Mock-first: routes checken eerst op bekende mock IDs, dan echte DB
|
||||
|
||||
**Mock strategie:** De demo moet altijd werken zonder echte database-inhoud. Elke route checkt of de input overeenkomt met mock data (vaste IDs/patientIds). Als dat zo is, return een mock response. Zo is de happy path altijd demonstreerbaar.
|
||||
|
||||
---
|
||||
|
||||
## NS.E3.S1 — Mock data aanmaken
|
||||
|
||||
**Nieuw bestand:** `lib/cortex/mock-data/noshow.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Mock data voor de No Show demo flow.
|
||||
* Gebruikt in alle /api/cortex/noshow/* routes voor de happy-path demo.
|
||||
*/
|
||||
|
||||
export const MOCK_NO_SHOW_PATIENT_ID = 'demo-patient-001';
|
||||
|
||||
export const MOCK_NO_SHOW_APPOINTMENT = {
|
||||
id: 'mock-appt-noshow-001',
|
||||
patientId: MOCK_NO_SHOW_PATIENT_ID,
|
||||
date: new Date().toISOString(),
|
||||
type: 'intake_consult',
|
||||
is_billable: true,
|
||||
status: 'scheduled',
|
||||
title: 'Intake Consult',
|
||||
duration_minutes: 60,
|
||||
} as const;
|
||||
|
||||
export const MOCK_CONCEPT_BRIEF = {
|
||||
id: 'mock-brief-noshow-001',
|
||||
patientId: MOCK_NO_SHOW_PATIENT_ID,
|
||||
type: 'huisartsbrief',
|
||||
status: 'concept',
|
||||
title: 'Huisartsbrief n.a.v. intake',
|
||||
content: `Geachte collega,
|
||||
|
||||
Hierbij informeer ik u over de intake van uw patiënt die bij ons in behandeling is gekomen voor ambulante GGZ-zorg.
|
||||
|
||||
Tijdens de intake is uitvoerig stilgestaan bij de hulpvraag. De patiënt heeft aangegeven al langere tijd last te hebben van stemmingsgerelateerde klachten, waarbij slaapproblemen en concentratieproblemen op de voorgrond staan. Er is sprake van een beperkt sociaal netwerk en recente stresserende levensgebeurtenissen.
|
||||
|
||||
Op basis van het gesprek en de afgenomen vragenlijsten lijkt er sprake te zijn van een depressieve stoornis, mogelijk in samenhang met een aanpassingsstoornis. Een nadere diagnostische verdieping is aangewezen.
|
||||
|
||||
Het voorgestelde vervolgtraject bestaat uit wekelijkse individuele gesprekken gericht op stabilisatie, psycho-educatie en het in kaart brengen van de klachten. Ik stel voor om na zes sessies de voortgang te evalueren en u dan nader te informeren.
|
||||
|
||||
Mocht u vragen hebben of aanvullende informatie willen delen, neemt u dan gerust contact op.
|
||||
|
||||
Met vriendelijke groet,
|
||||
|
||||
[Behandelaar naam]
|
||||
GGZ Instelling`,
|
||||
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 dagen geleden
|
||||
} as const;
|
||||
|
||||
/** Helper: check of een patientId de mock demo patient is */
|
||||
export function isMockPatient(patientId: string): boolean {
|
||||
return patientId === MOCK_NO_SHOW_PATIENT_ID;
|
||||
}
|
||||
|
||||
/** Helper: check of een appointmentId het mock appointment is */
|
||||
export function isMockAppointment(appointmentId: string): boolean {
|
||||
return appointmentId === MOCK_NO_SHOW_APPOINTMENT.id;
|
||||
}
|
||||
|
||||
/** Helper: check of een documentId het mock document is */
|
||||
export function isMockDocument(documentId: string): boolean {
|
||||
return documentId === MOCK_CONCEPT_BRIEF.id;
|
||||
}
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- Bestand bestaat en compileert zonder errors
|
||||
- Exports zijn beschikbaar voor import in API routes
|
||||
|
||||
---
|
||||
|
||||
## NS.E3.S2 — Cancel Route
|
||||
|
||||
**Nieuw bestand:** `app/api/cortex/noshow/cancel/route.ts`
|
||||
|
||||
**Doel:** Annuleer een afspraak en registreer het als no-show.
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockAppointment } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
const CancelNoShowSchema = z.object({
|
||||
appointmentId: z.string().min(1, 'appointmentId is verplicht'),
|
||||
patientId: z.string().min(1, 'patientId is verplicht'),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validatie
|
||||
let body: z.infer<typeof CancelNoShowSchema>;
|
||||
try {
|
||||
body = CancelNoShowSchema.parse(await request.json());
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { appointmentId, patientId } = body;
|
||||
|
||||
// Mock path — demo flow
|
||||
if (isMockAppointment(appointmentId)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
appointmentId,
|
||||
newStatus: 'cancelled_no_show',
|
||||
message: 'Afspraak geregistreerd als no show (demo)',
|
||||
});
|
||||
}
|
||||
|
||||
// Productie path — Supabase update
|
||||
// Pas aan naar de juiste tabel in jouw schema (encounters of appointments)
|
||||
const { error } = await supabase
|
||||
.from('encounters')
|
||||
.update({ status: 'cancelled_no_show' })
|
||||
.eq('id', appointmentId)
|
||||
.eq('patient_id', patientId);
|
||||
|
||||
if (error) {
|
||||
console.error('[noshow/cancel] DB error:', error);
|
||||
return NextResponse.json({ error: 'Annuleren mislukt' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
appointmentId,
|
||||
newStatus: 'cancelled_no_show',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- `POST /api/cortex/noshow/cancel` met `{ appointmentId: 'mock-appt-noshow-001', patientId: 'demo-patient-001' }` geeft `{ success: true, newStatus: 'cancelled_no_show' }` terug
|
||||
- Geeft `401` zonder auth
|
||||
- Geeft `400` bij ontbrekende velden
|
||||
|
||||
---
|
||||
|
||||
## NS.E3.S3 — Context Route
|
||||
|
||||
**Nieuw bestand:** `app/api/cortex/noshow/context/route.ts`
|
||||
|
||||
**Doel:** Check of er openstaande conceptbrieven zijn voor de actieve patiënt.
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockPatient, MOCK_CONCEPT_BRIEF } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Query param
|
||||
const { searchParams } = new URL(request.url);
|
||||
const patientId = searchParams.get('patientId');
|
||||
|
||||
if (!patientId) {
|
||||
return NextResponse.json({ error: 'patientId is verplicht' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Mock path — demo flow
|
||||
if (isMockPatient(patientId)) {
|
||||
return NextResponse.json({
|
||||
hasConceptBrief: true,
|
||||
document: {
|
||||
id: MOCK_CONCEPT_BRIEF.id,
|
||||
title: MOCK_CONCEPT_BRIEF.title,
|
||||
content: MOCK_CONCEPT_BRIEF.content,
|
||||
type: MOCK_CONCEPT_BRIEF.type,
|
||||
createdAt: MOCK_CONCEPT_BRIEF.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Productie path — Supabase query
|
||||
// Zoek eerste concept huisartsbrief voor deze patiënt
|
||||
const { data, error } = await supabase
|
||||
.from('reports')
|
||||
.select('id, title, structured_data, type, created_at')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'huisartsbrief')
|
||||
.eq('status', 'concept')
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ hasConceptBrief: false });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
hasConceptBrief: true,
|
||||
document: {
|
||||
id: data.id,
|
||||
title: data.title || 'Huisartsbrief',
|
||||
content: data.structured_data?.content ?? '',
|
||||
type: data.type,
|
||||
createdAt: data.created_at,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- `GET /api/cortex/noshow/context?patientId=demo-patient-001` geeft `{ hasConceptBrief: true, document: { ... } }` terug
|
||||
- `GET /api/cortex/noshow/context?patientId=onbekend-id` geeft `{ hasConceptBrief: false }` terug
|
||||
- Geeft `400` zonder `patientId` param
|
||||
|
||||
---
|
||||
|
||||
## NS.E3.S4 — Rescript Route
|
||||
|
||||
**Nieuw bestand:** `app/api/cortex/noshow/rescript/route.ts`
|
||||
|
||||
**Doel:** Roep de LLM aan om de huisartsbrief professioneel te herschrijven met de no-show verwerkt.
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockDocument } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
const RescriptSchema = z.object({
|
||||
documentId: z.string().min(1),
|
||||
originalContent: z.string().min(1, 'Originele inhoud is verplicht'),
|
||||
patientId: z.string().min(1),
|
||||
});
|
||||
|
||||
const RESCRIPT_SYSTEM_PROMPT = `Je bent een klinisch schrijver in een GGZ instelling.
|
||||
Je taak is een bestaande conceptbrief aanpassen zodat de no-show van de patiënt
|
||||
professioneel en contextbewust is verwerkt.
|
||||
|
||||
Regels:
|
||||
- Integreer de no-show in de lopende tekst — voeg het NIET als losse zin achteraan toe
|
||||
- Gebruik formele GGZ-briefstijl: "de patiënt is helaas niet verschenen op het geplande consult"
|
||||
- Bewaar alle bestaande informatie in de brief volledig
|
||||
- Voeg een zin toe over het verzetten van het vervolgcontact
|
||||
- De brief moet leesbaar en coherent blijven
|
||||
- Reageer UITSLUITEND met de herschreven brieftekst — geen uitleg, geen inleiding`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validatie
|
||||
let body: z.infer<typeof RescriptSchema>;
|
||||
try {
|
||||
body = RescriptSchema.parse(await request.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { documentId, originalContent } = body;
|
||||
|
||||
// LLM aanroepen (zowel voor mock als productie — we willen altijd echte AI output)
|
||||
try {
|
||||
const client = new Anthropic();
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1500,
|
||||
system: RESCRIPT_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Herschrijf de volgende conceptbrief zodat de no-show van vandaag professioneel is verwerkt:\n\n${originalContent}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rescriptedContent =
|
||||
response.content[0].type === 'text'
|
||||
? response.content[0].text
|
||||
: originalContent; // Fallback naar origineel
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
documentId,
|
||||
rescriptedContent,
|
||||
originalContent, // Meesturen zodat de UI "origineel bekijken" kan tonen
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[noshow/rescript] LLM error:', error);
|
||||
|
||||
// Graceful degradation: geef originele inhoud terug met waarschuwing
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
documentId,
|
||||
rescriptedContent: originalContent, // Origineel als fallback
|
||||
originalContent,
|
||||
warning: 'AI herschrijving mislukt — originele tekst wordt getoond',
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Noot over model:** We gebruiken `claude-haiku-4-5-20251001` (snel + goedkoop) voor de rescript taak. Als de kwaliteit onvoldoende is, schakel over naar `claude-sonnet-4-6`.
|
||||
|
||||
**Noot over fallback:** De route geeft altijd `200` terug — ook bij LLM fout. Het `success: false` veld + `warning` laat de UI zien dat de AI niet werkte, maar de brief is alsnog bewerkbaar (met originele inhoud).
|
||||
|
||||
### Done criteria
|
||||
- `POST /api/cortex/noshow/rescript` met mock document content geeft herschreven tekst terug
|
||||
- De herschreven tekst is langer dan de originele (no-show is toegevoegd)
|
||||
- Bij Anthropic API fout: `{ success: false, rescriptedContent: <origineel>, warning: '...' }`
|
||||
- Response tijd < 10 seconden (Haiku is snel)
|
||||
|
||||
---
|
||||
|
||||
## NS.E3.S5 — Dispatch Route
|
||||
|
||||
**Nieuw bestand:** `app/api/cortex/noshow/dispatch/route.ts`
|
||||
|
||||
**Doel:** Document status naar `ready_for_dispatch` zetten en definitieve inhoud opslaan.
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { isMockDocument } from '@/lib/cortex/mock-data/noshow';
|
||||
|
||||
const DispatchSchema = z.object({
|
||||
documentId: z.string().min(1),
|
||||
finalContent: z.string().min(1, 'Definitieve inhoud is verplicht'),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validatie
|
||||
let body: z.infer<typeof DispatchSchema>;
|
||||
try {
|
||||
body = DispatchSchema.parse(await request.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { documentId, finalContent } = body;
|
||||
|
||||
// Mock path — demo flow
|
||||
if (isMockDocument(documentId)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
documentId,
|
||||
newStatus: 'ready_for_dispatch',
|
||||
message: 'Brief verzendklaar gemaakt (demo)',
|
||||
});
|
||||
}
|
||||
|
||||
// Productie path — Supabase update
|
||||
const { error } = await supabase
|
||||
.from('reports')
|
||||
.update({
|
||||
status: 'ready_for_dispatch',
|
||||
// Sla de definitieve inhoud op — pas het veld aan naar het juiste kolom in je schema
|
||||
structured_data: { content: finalContent },
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', documentId)
|
||||
.eq('user_id', user.id); // Extra veiligheidscheck — alleen eigen documenten
|
||||
|
||||
if (error) {
|
||||
console.error('[noshow/dispatch] DB error:', error);
|
||||
return NextResponse.json({ error: 'Opslaan mislukt' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
documentId,
|
||||
newStatus: 'ready_for_dispatch',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- `PATCH /api/cortex/noshow/dispatch` met `{ documentId: 'mock-brief-noshow-001', finalContent: '...' }` geeft `{ success: true, newStatus: 'ready_for_dispatch' }` terug
|
||||
- Geeft `401` zonder auth
|
||||
- Geeft `400` bij lege `finalContent`
|
||||
|
||||
---
|
||||
|
||||
## Validatie na NS.E3
|
||||
|
||||
Test alle routes met de browser devtools of een API client:
|
||||
|
||||
```bash
|
||||
# Controleer of de routes bestaan
|
||||
pnpm build
|
||||
```
|
||||
|
||||
**Snelle curl tests (vervang <TOKEN> met een geldig Supabase session token):**
|
||||
|
||||
```bash
|
||||
# Cancel
|
||||
curl -X POST http://localhost:3000/api/cortex/noshow/cancel \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: <supabase-session-cookie>" \
|
||||
-d '{"appointmentId":"mock-appt-noshow-001","patientId":"demo-patient-001"}'
|
||||
|
||||
# Context
|
||||
curl "http://localhost:3000/api/cortex/noshow/context?patientId=demo-patient-001" \
|
||||
-H "Cookie: <supabase-session-cookie>"
|
||||
|
||||
# Dispatch
|
||||
curl -X PATCH http://localhost:3000/api/cortex/noshow/dispatch \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: <supabase-session-cookie>" \
|
||||
-d '{"documentId":"mock-brief-noshow-001","finalContent":"test inhoud"}'
|
||||
```
|
||||
|
||||
Verwacht: alle drie geven `{ success: true }` terug.
|
||||
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
|
||||
299
docs/intent/noshow-case/epics/NS-E5-integration.md
Normal file
299
docs/intent/noshow-case/epics/NS-E5-integration.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# NS.E5 — Integration & Demo Polish
|
||||
|
||||
**Casus:** Cortex No Show Afhandeling
|
||||
**Epic doel:** De volledige end-to-end flow werkend krijgen, edge cases afvangen, en de demo valideren aan de succescriteria uit het PRD.
|
||||
**Geschatte tijd:** ~2 uur
|
||||
**Afhankelijkheden:** NS.E1 t/m NS.E4 volledig afgerond
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Na NS.E1–NS.E4 zijn alle individuele onderdelen gebouwd:
|
||||
- Intent wordt herkend (E1)
|
||||
- Nudges triggeren en lopen sequentieel (E2)
|
||||
- API routes werken (E3)
|
||||
- Artifact component toont de brief (E4)
|
||||
|
||||
Dit epic zorgt dat alles correct samenwerkt, fouten netjes worden afgevangen, en de demo vloeiend loopt.
|
||||
|
||||
---
|
||||
|
||||
## NS.E5.S1 — Volledige `chat-panel.tsx` integratie valideren
|
||||
|
||||
**Bestand:** `components/cortex/chat/chat-panel.tsx`
|
||||
|
||||
### Checklist: zijn alle functies aanwezig en correct verbonden?
|
||||
|
||||
Na NS.E2 en NS.E4 moeten de volgende functies bestaan in `ChatPanel`:
|
||||
|
||||
| Functie | Aangemaakt in | Doet |
|
||||
|---|---|---|
|
||||
| `handleNoShowCancelStep` | NS.E2.S3 | Cancel API → context check → nudge 2 of done |
|
||||
| `handleNoShowRescriptStep` | NS.E4.S3 | Rescript API → openArtifact |
|
||||
| `handleAcceptNudge` (uitgebreid) | NS.E2.S3 | Brancht op `trigger.intent` en `suggestion.rationale` |
|
||||
|
||||
### Verbinding `handleAcceptNudge` → `handleNoShowRescriptStep`
|
||||
|
||||
Controleer dat de stap-5 check correct is geïmplementeerd:
|
||||
|
||||
```typescript
|
||||
const handleAcceptNudge = useCallback(async (
|
||||
suggestionId: string,
|
||||
suggestion: ChatMessageType['nudge']
|
||||
) => {
|
||||
acceptSuggestion(suggestionId);
|
||||
if (!suggestion) return;
|
||||
|
||||
// Stap 2→3: declarabiliteitscheck geaccepteerd → annuleer + check brief
|
||||
if (suggestion.trigger.intent === 'register_no_show') {
|
||||
await handleNoShowCancelStep(suggestion);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stap 4→5: brief-check geaccepteerd → herschrijf brief
|
||||
if (suggestion.suggestion.rationale === 'noshow-brief-check') {
|
||||
await handleNoShowRescriptStep();
|
||||
return;
|
||||
}
|
||||
|
||||
// Generieke flow voor alle andere nudges
|
||||
const artifact = routeIntentToArtifact(
|
||||
suggestion.suggestion.intent,
|
||||
suggestion.suggestion.entities,
|
||||
0.9
|
||||
);
|
||||
if (artifact) {
|
||||
openArtifact({ type: artifact.type, prefill: artifact.prefill, title: artifact.title });
|
||||
}
|
||||
}, [
|
||||
acceptSuggestion,
|
||||
openArtifact,
|
||||
handleNoShowCancelStep,
|
||||
handleNoShowRescriptStep,
|
||||
]);
|
||||
```
|
||||
|
||||
**Belangrijk:** `handleNoShowCancelStep` en `handleNoShowRescriptStep` moeten in de `useCallback` dependency array staan. Als ze er niet in staan, kan je stale closures krijgen.
|
||||
|
||||
### `onSend` handler: no-show nudge trigger
|
||||
|
||||
Controleer dat de `onDone` callback de no-show nudge triggert. Dit blok moet aanwezig zijn in de `onDone`:
|
||||
|
||||
```typescript
|
||||
// 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: parsed.action.entities,
|
||||
content: message,
|
||||
});
|
||||
|
||||
suggestions.forEach((suggestion) => {
|
||||
addChatMessage({
|
||||
type: 'nudge',
|
||||
content: suggestion.suggestion.message,
|
||||
nudge: suggestion,
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- Volledige flow doorloopbaar zonder errors in de browser console
|
||||
- `[ChatPanel]` log berichten verschijnen op de juiste momenten
|
||||
- Geen `TypeError` of `undefined is not a function` errors
|
||||
|
||||
---
|
||||
|
||||
## NS.E5.S2 — Loading states en dubbele submits voorkomen
|
||||
|
||||
**Bestand:** `components/cortex/chat/chat-panel.tsx` + `stores/cortex-store.ts`
|
||||
|
||||
### Probleem
|
||||
|
||||
Tussen de API calls (cancel → context → rescript) zit latentie. Als de gebruiker snel klikt, kunnen er dubbele calls ontstaan. Ook moet de UI duidelijk maken dat er iets "bezig" is.
|
||||
|
||||
### Oplossing A — `isNoShowProcessing` flag in store
|
||||
|
||||
Voeg toe aan store (in `CortexStore` interface, `initialState`, en actions):
|
||||
|
||||
```typescript
|
||||
// In interface:
|
||||
isNoShowProcessing: boolean;
|
||||
|
||||
// In initialState:
|
||||
isNoShowProcessing: false,
|
||||
|
||||
// Actions:
|
||||
setNoShowProcessing: (processing: boolean) => void;
|
||||
```
|
||||
|
||||
Implementatie:
|
||||
```typescript
|
||||
setNoShowProcessing: (processing) =>
|
||||
set({ isNoShowProcessing: processing }, false, 'setNoShowProcessing'),
|
||||
```
|
||||
|
||||
### Oplossing B — Gebruik in `handleNoShowCancelStep` en `handleNoShowRescriptStep`
|
||||
|
||||
Wrap elke async functie:
|
||||
|
||||
```typescript
|
||||
const handleNoShowCancelStep = useCallback(async (...) => {
|
||||
const { isNoShowProcessing } = useCortexStore.getState();
|
||||
if (isNoShowProcessing) return; // Dubbele klik preventie
|
||||
|
||||
setNoShowProcessing(true);
|
||||
try {
|
||||
// ... bestaande logica
|
||||
} finally {
|
||||
setNoShowProcessing(false);
|
||||
}
|
||||
}, [..., setNoShowProcessing]);
|
||||
```
|
||||
|
||||
### Oplossing C — Nudge knop disablen tijdens processing
|
||||
|
||||
In `NudgeChatMessage` is de accept/dismiss knop al aanwezig. We moeten de accept knop disablen tijdens `isNoShowProcessing`:
|
||||
|
||||
**Bestand:** `components/cortex/chat/nudge-chat-message.tsx`
|
||||
|
||||
Voeg toe:
|
||||
```typescript
|
||||
const isNoShowProcessing = useCortexStore((s) => s.isNoShowProcessing);
|
||||
```
|
||||
|
||||
Voeg `disabled={isNoShowProcessing}` toe aan de accept-knop als de nudge een no-show nudge is (`suggestion.trigger.intent === 'register_no_show'` of `suggestion.suggestion.rationale === 'noshow-brief-check'`).
|
||||
|
||||
### Done criteria
|
||||
- Snel twee keer klikken op `[Ja, annuleer]` triggert de API maar één keer
|
||||
- Knop is visueel disabled tijdens verwerking
|
||||
- Na voltooiing is de knop weer actief (relevant voor de "Nee" knop)
|
||||
|
||||
---
|
||||
|
||||
## NS.E5.S3 — Cleanup bij unmount
|
||||
|
||||
**Bestand:** `components/cortex/chat/chat-panel.tsx`
|
||||
|
||||
Als de gebruiker weg navigeert tijdens een lopende no-show flow, moet de state worden opgeruimd.
|
||||
|
||||
Voeg toe in `ChatPanel`:
|
||||
|
||||
```typescript
|
||||
const resetNoShowFlow = useCortexStore((s) => s.resetNoShowFlow);
|
||||
|
||||
// Cleanup bij unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Reset no-show flow als component unmount tijdens actieve flow
|
||||
const { noShowFlow } = useCortexStore.getState();
|
||||
if (noShowFlow.step !== 'idle' && noShowFlow.step !== 'done') {
|
||||
resetNoShowFlow();
|
||||
}
|
||||
};
|
||||
}, [resetNoShowFlow]);
|
||||
```
|
||||
|
||||
### Done criteria
|
||||
- Navigeren weg van het dashboard tijdens een actieve no-show flow reset de state
|
||||
- Bij terugkeren: flow begint opnieuw (geen "stuck" state)
|
||||
|
||||
---
|
||||
|
||||
## NS.E5.S4 — Demo Validatie Checklist
|
||||
|
||||
Doorloop de volledige flow handmatig en vink elk punt af:
|
||||
|
||||
### Classificatie
|
||||
- [ ] Input `"patiënt is niet verschenen"` → `register_no_show` intent herkend (zichtbaar in console)
|
||||
- [ ] Input `"no show vandaag"` → `register_no_show` intent herkend
|
||||
- [ ] Input `"cliënt was er niet"` → AI classificeert als `register_no_show`
|
||||
- [ ] Input `"patiënt niet verschenen volgende week"` → escaleert naar AI (relative_time)
|
||||
|
||||
### Nudge 1 — Declarabiliteitscheck
|
||||
- [ ] Na no-show input: nudge bubble verschijnt met declarabiliteits-waarschuwing
|
||||
- [ ] Nudge heeft prioriteit badge `high` (rode kleur)
|
||||
- [ ] `[Nee]` klik: nudge verdwijnt, geen verdere actie, flow eindigt
|
||||
- [ ] `[Ja, annuleer]` klik: loading state actief (knop disabled)
|
||||
|
||||
### Stap 3 — Annulering
|
||||
- [ ] Cancel API aangeroepen: `POST /api/cortex/noshow/cancel` → 200 response
|
||||
- [ ] Context API aangeroepen: `GET /api/cortex/noshow/context` → `hasConceptBrief: true`
|
||||
- [ ] Chat toont "Bezig met annuleren..." tijdens verwerking
|
||||
- [ ] Na voltooiing: nudge 2 verschijnt met brief-vraag
|
||||
|
||||
### Nudge 2 — Brief check
|
||||
- [ ] Nudge 2 toont correcte message over conceptbrief
|
||||
- [ ] `[Nee]` klik: nudge verdwijnt, chat bevestigt annulering
|
||||
- [ ] `[Ja, pas brief aan]` klik: rescript API aangeroepen
|
||||
|
||||
### Stap 4 — Rescript
|
||||
- [ ] Rescript API aangeroepen: `POST /api/cortex/noshow/rescript` → herschreven inhoud
|
||||
- [ ] Artifact paneel opent met `NoShowDocumentBlock`
|
||||
- [ ] Chat toont "Huisartsbrief is aangepast. Kijk of je hiermee akkoord bent."
|
||||
- [ ] Herschreven brief is NIET alleen de originele tekst met "no show" erachter geplakt
|
||||
|
||||
### Stap 5 — Accordering
|
||||
- [ ] Textarea is bewerkbaar — gebruiker kan aanpassingen maken
|
||||
- [ ] "Originele brief bekijken" toggle werkt
|
||||
- [ ] `[Akkoord & Verzendklaar]` roept dispatch API aan
|
||||
- [ ] Na dispatch: knop verandert naar "✓ Verzendklaar"
|
||||
- [ ] Chat ontvangt bevestigingsbericht
|
||||
|
||||
### Technische criteria
|
||||
- [ ] Volledige flow doorloopbaar in < 30 seconden (excl. LLM latency ~3-5s)
|
||||
- [ ] Geen schermwissels nodig gedurende de hele flow
|
||||
- [ ] `pnpm build` slaagt zonder errors
|
||||
- [ ] `pnpm lint` slaagt zonder errors
|
||||
- [ ] Geen rode console errors in de browser
|
||||
|
||||
### Edge cases
|
||||
- [ ] Offline/netwerk fout tijdens cancel → error message in chat, flow herstelbaar
|
||||
- [ ] LLM fout in rescript → originele brief getoond + gele waarschuwingsbalk
|
||||
- [ ] Dubbele klik op `[Ja, annuleer]` → slechts één API call
|
||||
|
||||
---
|
||||
|
||||
## NS.E5.S5 — Commit strategie
|
||||
|
||||
Commitvolgorde die logisch werkt voor code review:
|
||||
|
||||
```
|
||||
feat(cortex): NS.E1 — register_no_show intent type + reflex patterns
|
||||
feat(cortex): NS.E2.S1 — no-show flow state in cortex store
|
||||
feat(cortex): NS.E2.S2 — nudge rule: declarabiliteitscheck
|
||||
feat(cortex): NS.E2.S3 — chat panel: no-show nudge trigger + handlers
|
||||
feat(cortex): NS.E3 — no-show API routes (cancel, context, rescript, dispatch)
|
||||
feat(cortex): NS.E4.S1 — NoShowDocumentBlock component
|
||||
feat(cortex): NS.E4.S2 — artifact-container: register_no_show case
|
||||
feat(cortex): NS.E4.S3 — chat panel: rescript step + artifact koppeling
|
||||
feat(cortex): NS.E5 — loading states, cleanup, demo polish
|
||||
```
|
||||
|
||||
Elke commit moet `pnpm build` laten slagen.
|
||||
|
||||
---
|
||||
|
||||
## Overzicht gewijzigde bestanden (totaal)
|
||||
|
||||
| Bestand | Wijziging |
|
||||
|---|---|
|
||||
| `lib/cortex/types.ts` | `register_no_show` in union + BLOCK_CONFIGS |
|
||||
| `lib/cortex/reflex-classifier.ts` | Patronen voor register_no_show |
|
||||
| `app/api/cortex/chat/route.ts` | System prompt uitgebreid |
|
||||
| `lib/cortex/nudge.ts` | Nudge rule 1 toegevoegd |
|
||||
| `stores/cortex-store.ts` | NoShowFlowState + actions + isNoShowProcessing |
|
||||
| `lib/cortex/mock-data/noshow.ts` | **Nieuw** |
|
||||
| `app/api/cortex/noshow/cancel/route.ts` | **Nieuw** |
|
||||
| `app/api/cortex/noshow/context/route.ts` | **Nieuw** |
|
||||
| `app/api/cortex/noshow/rescript/route.ts` | **Nieuw** |
|
||||
| `app/api/cortex/noshow/dispatch/route.ts` | **Nieuw** |
|
||||
| `components/cortex/blocks/noshow-document-block.tsx` | **Nieuw** |
|
||||
| `components/cortex/artifacts/artifact-container.tsx` | Case + import + getArtifactTitle |
|
||||
| `components/cortex/chat/chat-panel.tsx` | Nudge trigger + handlers + cleanup |
|
||||
| `components/cortex/chat/nudge-chat-message.tsx` | isNoShowProcessing check |
|
||||
|
||||
**Totaal: 14 bestanden** — 5 nieuw, 9 aangepast
|
||||
Reference in New Issue
Block a user