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

12 KiB

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:

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)

// 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:

// No-show flow state
noShowFlow: NoShowFlowState;

Voeg actions toe na bestaande clarification actions:

// No-show flow actions
setNoShowStep: (step: NoShowStep) => void;
setNoShowContext: (ctx: Partial<Omit<NoShowFlowState, 'step'>>) => void;
resetNoShowFlow: () => void;

Wijziging 3 — Initiële waarde in initialState

noShowFlow: {
  step: 'idle',
  appointmentId: null,
  documentId: null,
  originalContent: null,
} as NoShowFlowState,

Wijziging 4 — Actions implementeren (in create() body)

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):

{
  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):

onDone: () => {
  setStreaming(false);
  const parsed = parseActionFromResponse(accumulatedContent);
  if (parsed.action) {
    updateLastMessage(...);
    setPendingAction(parsed.action);
  }
}

Stap 1 — Store actions ophalen

Voeg bovenaan ChatPanel() toe:

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, het bestaande setPendingAction blok:

// 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: acceptSuggestionrouteIntentToArtifactopenArtifact. 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:

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:

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:

// --- 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: idlewaiting_cancelwaiting_briefbrief_open

Validatie na NS.E2

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