Files
triqura-ecd/docs/intent/noshow-case/epics/NS-E2-nudge-rules.md
colinislit 924988dd15 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>
2026-07-09 23:14:55 +02:00

375 lines
12 KiB
Markdown

# 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`