feat(cortex): Epic 4 - Nudge MVP (Layer 3) complete

Proactive suggestions after successful actions - proof of concept.

E4.S1 - Protocol Rules (lib/cortex/nudge.ts)
- ProtocolRule and ProtocolCondition types
- Condition operators: equals, contains, matches, exists
- 2 hardcoded rules: wondzorg-controle, medicatie-controle

E4.S2 - evaluateNudge Function
- evaluateNudge(input) returns NudgeSuggestion[]
- checkCondition() helper for rule matching
- Priority sorting (high → medium → low)

E4.S3 - NudgeToast Component
- Countdown progress bar with auto-dismiss (5 min)
- Priority-based styling (low/medium/high)
- Accept and dismiss buttons
- Framer Motion animations

E4.S4 - Integration
- NudgeToast rendered in CommandCenter (fixed position)
- Trigger added in DagnotatieBlock after successful save
- Accept opens corresponding artifact with prefilled data

Demo flow:
1. Create dagnotitie with "wond" in content
2. Save → NudgeToast appears
3. Click "Ja, inplannen" → Appointment artifact opens

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-01-01 12:42:49 +01:00
parent 42d64761ec
commit 4b759c9b3d
6 changed files with 506 additions and 17 deletions

View File

@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button';
import { Loader2, Search, User, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/cortex/error-handler';
import { evaluateNudge } from '@/lib/cortex/nudge';
interface DagnotitieBlockProps {
prefill?: BlockPrefillData;
@@ -39,7 +40,7 @@ interface Patient {
export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
const config = BLOCK_CONFIGS.dagnotitie;
const { closeBlock } = useCortexStore();
const { closeBlock, addSuggestion } = useCortexStore();
const { toast } = useToast();
// Form state
@@ -221,6 +222,23 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
description: `Notitie voor ${patientName} is opgeslagen`,
});
// E4: Evaluate nudge after successful save
const nudges = evaluateNudge({
intent: 'dagnotitie',
actionId: data.id || `dagnotitie-${Date.now()}`,
entities: {
patientName,
category,
},
content: content.trim(),
});
// Add nudge suggestions to store
nudges.forEach((nudge) => {
addSuggestion(nudge);
console.log('[DagnotatieBlock] Nudge triggered:', nudge.suggestion.message);
});
// Close block after short delay
setTimeout(() => {
closeBlock();
@@ -241,7 +259,7 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
} finally {
setIsSubmitting(false);
}
}, [patientId, content, category, includeInHandover, patientName, toast, closeBlock]);
}, [patientId, content, category, includeInHandover, patientName, toast, closeBlock, addSuggestion]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -17,16 +17,28 @@
*/
import { useEffect, useCallback, useRef } from 'react';
import { AnimatePresence } from 'framer-motion';
import { useCortexStore } from '@/stores/cortex-store';
import { ContextBar } from './context-bar';
import { OfflineBanner } from './offline-banner';
import { NudgeToast } from './nudge-toast';
import { ChatPanel } from '../chat/chat-panel';
import { ArtifactArea } from '../artifacts/artifact-area';
import { getArtifactTitle } from '../artifacts/artifact-container';
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
export function CommandCenter() {
const { closeAllArtifacts, openArtifacts, openArtifact, pendingAction, setPendingAction } = useCortexStore();
const {
closeAllArtifacts,
openArtifacts,
openArtifact,
pendingAction,
setPendingAction,
// Nudge state (E4)
suggestions,
acceptSuggestion,
dismissSuggestion,
} = useCortexStore();
const inputRef = useRef<HTMLInputElement>(null);
// Global keyboard shortcuts
@@ -118,6 +130,43 @@ export function CommandCenter() {
<ArtifactArea />
</div>
</div>
{/* Nudge Toast - E4 (fixed position, bottom-left above chat) */}
<AnimatePresence mode="wait">
{suggestions.length > 0 && (
<div className="fixed bottom-20 left-4 right-4 lg:left-4 lg:right-auto lg:w-[38%] z-50">
<NudgeToast
key={suggestions[0].id}
suggestion={suggestions[0]}
onAccept={(id) => {
const suggestion = suggestions.find((s) => s.id === id);
if (suggestion) {
// Route the suggested intent to an artifact
const artifact = routeIntentToArtifact(
suggestion.suggestion.intent,
suggestion.suggestion.entities,
1.0 // High confidence since user explicitly accepted
);
if (artifact) {
console.log('[CommandCenter] Opening artifact from nudge:', artifact.type);
openArtifact({
type: artifact.type,
prefill: artifact.prefill,
title: artifact.title,
});
}
}
acceptSuggestion(id);
}}
onDismiss={(id) => {
dismissSuggestion(id);
console.log('[CommandCenter] Nudge dismissed:', id);
}}
/>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
/**
* NudgeToast Component
*
* Proactive suggestion toast that appears after successful actions.
* Features:
* - Countdown progress bar with auto-dismiss
* - Priority-based styling (low/medium/high)
* - Accept and dismiss actions
* - Framer Motion animations
*
* Epic: E4 (Nudge MVP)
* Story: E4.S3 (NudgeToast component)
*/
import { useEffect, useState, useCallback } from 'react';
import { Lightbulb, X } from 'lucide-react';
import { motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { NudgeSuggestion } from '@/lib/cortex/types';
interface NudgeToastProps {
suggestion: NudgeSuggestion;
onAccept: (suggestionId: string) => void;
onDismiss: (suggestionId: string) => void;
}
/**
* Animation variants for the toast container
*/
const containerVariants = {
initial: { opacity: 0, y: 20, scale: 0.95 },
animate: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.2, ease: [0.4, 0, 0.2, 1] as const },
},
exit: {
opacity: 0,
y: -10,
scale: 0.98,
transition: { duration: 0.15 },
},
};
/**
* Priority-based styling for the toast
*/
const PRIORITY_STYLES = {
low: {
container: 'bg-slate-50 border-slate-200',
text: 'text-slate-700',
icon: 'text-slate-500',
progress: 'bg-slate-300',
},
medium: {
container: 'bg-amber-50 border-amber-200',
text: 'text-amber-800',
icon: 'text-amber-600',
progress: 'bg-amber-400',
},
high: {
container: 'bg-red-50 border-red-200',
text: 'text-red-800',
icon: 'text-red-600',
progress: 'bg-red-400',
},
};
/**
* Get button text based on suggested intent
*/
function getAcceptButtonText(intent: string): string {
switch (intent) {
case 'create_appointment':
return 'Ja, inplannen';
case 'dagnotitie':
return 'Ja, notitie maken';
case 'cancel_appointment':
return 'Ja, annuleren';
default:
return 'Ja, uitvoeren';
}
}
export function NudgeToast({ suggestion, onAccept, onDismiss }: NudgeToastProps) {
const [progress, setProgress] = useState(100);
const styles = PRIORITY_STYLES[suggestion.priority];
// Memoize dismiss handler to avoid effect re-runs
const handleDismiss = useCallback(() => {
onDismiss(suggestion.id);
}, [onDismiss, suggestion.id]);
// Auto-dismiss countdown effect
useEffect(() => {
if (!suggestion.expiresAt) return;
const expiresAt = new Date(suggestion.expiresAt).getTime();
const createdAt = new Date(suggestion.createdAt).getTime();
const total = expiresAt - createdAt;
const interval = setInterval(() => {
const now = Date.now();
const remaining = expiresAt - now;
if (remaining <= 0) {
handleDismiss();
clearInterval(interval);
return;
}
setProgress((remaining / total) * 100);
}, 1000);
return () => clearInterval(interval);
}, [suggestion.expiresAt, suggestion.createdAt, handleDismiss]);
return (
<motion.div
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
className={cn(
'rounded-lg border p-4 shadow-sm',
styles.container
)}
>
{/* Header: Icon + Message + Dismiss */}
<div className="flex items-start gap-3">
<Lightbulb className={cn('h-5 w-5 flex-shrink-0 mt-0.5', styles.icon)} />
<div className="flex-1 min-w-0">
<p className={cn('font-medium', styles.text)}>
{suggestion.suggestion.message}
</p>
<p className={cn('text-sm mt-1 opacity-75', styles.text)}>
{suggestion.suggestion.rationale}
</p>
</div>
<button
onClick={handleDismiss}
className={cn(
'flex-shrink-0 rounded-md p-1 hover:bg-black/5 transition-colors',
styles.text
)}
aria-label="Sluiten"
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</button>
</div>
{/* Action buttons */}
<div className="flex gap-2 mt-4">
<Button
size="sm"
onClick={() => onAccept(suggestion.id)}
className="bg-teal-600 hover:bg-teal-700 text-white"
>
{getAcceptButtonText(suggestion.suggestion.intent)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleDismiss}
className={styles.text}
>
Later
</Button>
</div>
{/* Progress bar countdown */}
<div className="mt-3 h-1 bg-white/50 rounded-full overflow-hidden">
<motion.div
className={cn('h-full rounded-full', styles.progress)}
initial={{ width: '100%' }}
animate={{ width: `${progress}%` }}
transition={{ duration: 1, ease: 'linear' }}
/>
</div>
</motion.div>
);
}

View File

@@ -1,7 +1,7 @@
# Bouwplan — Cortex Intent System V2
**Projectnaam:** Cortex V2 - Agentic Intent Architecture
**Versie:** v1.3
**Versie:** v1.4
**Datum:** 01-01-2026
**Auteur:** Colin Lit
@@ -135,7 +135,7 @@ lib/config/
| **E1** | Reflex Arc (Layer 1) | Snelle lokale classificatie | ✅ Done | 4 | 6 SP |
| **E2** | Intent Orchestrator (Layer 2) | AI-gedreven multi-intent | ✅ Done | 6 | 13 SP |
| **E3** | UI Components | ActionChainCard, ClarificationCard | ✅ Done | 4 | 8 SP |
| **E4** | Nudge MVP (Layer 3) | Proactieve suggesties | ⏳ To Do | 3 | 5 SP |
| **E4** | Nudge MVP (Layer 3) | Proactieve suggesties | Done | 3 | 5 SP |
| **E5** | Integration & Polish | End-to-end flow, testing | ⏳ To Do | 4 | 8 SP |
**Totaal MVP: 26 stories, 48 Story Points**
@@ -848,9 +848,9 @@ interface ClarificationCardProps {
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP |
|----------|--------------|---------------------|--------|------------------|----|
| E4.S1 | **Protocol rules** definiëren | Wondzorg-controle regel (hardcoded) | | E0.S1 | 1 |
| E4.S2 | **evaluateNudge** functie | Check protocol rules na actie completion | | E4.S1 | 2 |
| E4.S3 | **NudgeToast** component | Toast met countdown timer, accept/dismiss | | E0.S4 | 2 |
| E4.S1 | **Protocol rules** definiëren | Wondzorg-controle regel (hardcoded) | | E0.S1 | 1 |
| E4.S2 | **evaluateNudge** functie | Check protocol rules na actie completion | | E4.S1 | 2 |
| E4.S3 | **NudgeToast** component | Toast met countdown timer, accept/dismiss | | E0.S4 | 2 |
**Demo Case:**
```
@@ -910,8 +910,8 @@ export const PROTOCOL_RULES: ProtocolRule[] = [
- `exists` - field is not null/empty
*Done criteria:*
- [ ] ProtocolRule type gedefinieerd
- [ ] Minimaal 1 werkende regel (wondzorg)
- [x] ProtocolRule type gedefinieerd
- [x] Minimaal 1 werkende regel (wondzorg)
---
@@ -944,9 +944,9 @@ function checkCondition(condition: ProtocolCondition, entities: ExtractedEntitie
```
*Done criteria:*
- [ ] Notitie met "wond" → NudgeSuggestion returned
- [ ] Notitie zonder "wond" → empty array
- [ ] Suggesties gesorteerd op priority
- [x] Notitie met "wond" → NudgeSuggestion returned
- [x] Notitie zonder "wond" → empty array
- [x] Suggesties gesorteerd op priority
---
@@ -986,10 +986,10 @@ useEffect(() => {
```
*Done criteria:*
- [ ] Toast verschijnt na matching actie
- [ ] Progress bar animeert
- [ ] Accept triggert nieuwe actie
- [ ] Dismiss verwijdert toast
- [x] Toast verschijnt na matching actie
- [x] Progress bar animeert
- [x] Accept triggert nieuwe actie
- [x] Dismiss verwijdert toast
---
@@ -1399,3 +1399,4 @@ De MVP User Stories uit `mvp-userstories-intent-system.md` zijn als volgt verdee
| v1.1 | 31-12-2025 | Colin Lit | Dev-instructies per epic toegevoegd, file mappings, done criteria |
| v1.2 | 01-01-2026 | Colin Lit | Epic 2 (Intent Orchestrator) compleet - alle 6 stories afgerond |
| v1.3 | 01-01-2026 | Colin Lit | Epic 3 (UI Components) compleet - ActionChainCard, ActionItem, ClarificationCard, ProcessingIndicator |
| v1.4 | 01-01-2026 | Colin Lit | Epic 4 (Nudge MVP) compleet - ProtocolRules, evaluateNudge, NudgeToast, DagnotatieBlock integratie |

View File

@@ -10,3 +10,4 @@ export * from './reflex-classifier';
export * from './entity-extractor';
export * from './date-time-parser';
export * from './logger';
export * from './nudge';

234
lib/cortex/nudge.ts Normal file
View File

@@ -0,0 +1,234 @@
/**
* Cortex Layer 3: Nudge System
*
* Proactive suggestions after successful actions based on protocol rules.
* MVP implementation with hardcoded rules for demonstration.
*/
import type {
CortexIntent,
ExtractedEntities,
NudgePriority,
NudgeSuggestion,
} from './types';
// -----------------------------------------------------------------------------
// Protocol Rule Types
// -----------------------------------------------------------------------------
/** Condition operator for matching */
export type ConditionOperator = 'equals' | 'contains' | 'matches' | 'exists';
/** Single condition to check against entities or content */
export interface ProtocolCondition {
/** Field to check - 'content' for the original input text, or entity field */
field: keyof ExtractedEntities | 'content';
/** Comparison operator */
operator: ConditionOperator;
/** Value to compare against (not used for 'exists' operator) */
value: string;
}
/** Protocol rule that triggers a nudge suggestion */
export interface ProtocolRule {
/** Unique identifier for the rule */
id: string;
/** Human-readable name for the rule */
name: string;
/** Trigger conditions */
trigger: {
/** Intent that must match */
intent: CortexIntent;
/** Additional conditions that must all be met */
conditions: ProtocolCondition[];
};
/** Suggestion to show when rule matches */
suggestion: {
/** Suggested follow-up intent */
intent: CortexIntent;
/** User-facing message */
message: string;
/** Function to prefill entities from source action */
prefillEntities: (source: ExtractedEntities) => Partial<ExtractedEntities>;
};
/** Priority for sorting multiple suggestions */
priority: NudgePriority;
/** Whether this rule is active */
enabled: boolean;
/** Time in ms before suggestion expires (default 5 minutes) */
expiresAfterMs: number;
}
// -----------------------------------------------------------------------------
// Condition Checker (Internal)
// -----------------------------------------------------------------------------
/**
* Check if a single condition is met
*/
function checkCondition(
condition: ProtocolCondition,
entities: ExtractedEntities,
content?: string
): boolean {
// Get the value to check
const value = condition.field === 'content'
? content
: entities[condition.field as keyof ExtractedEntities];
// Handle null/undefined values
if (value === undefined || value === null || value === '') {
return condition.operator === 'exists' ? false : false;
}
const stringValue = String(value);
switch (condition.operator) {
case 'equals':
return stringValue.toLowerCase() === condition.value.toLowerCase();
case 'contains':
return stringValue.toLowerCase().includes(condition.value.toLowerCase());
case 'matches':
try {
return new RegExp(condition.value, 'i').test(stringValue);
} catch {
// Invalid regex, return false
return false;
}
case 'exists':
return true;
default:
return false;
}
}
// -----------------------------------------------------------------------------
// Nudge Evaluation
// -----------------------------------------------------------------------------
/** Input for nudge evaluation */
export interface NudgeEvaluationInput {
/** The completed action's intent */
intent: CortexIntent;
/** The action's ID */
actionId: string;
/** Extracted entities from the action */
entities: ExtractedEntities;
/** Original user input text (for content matching) */
content?: string;
}
/**
* Evaluate completed action against protocol rules and generate suggestions
*
* @param input - The completed action details
* @returns Array of NudgeSuggestions sorted by priority (high first)
*/
export function evaluateNudge(input: NudgeEvaluationInput): NudgeSuggestion[] {
const suggestions: NudgeSuggestion[] = [];
const now = new Date();
for (const rule of PROTOCOL_RULES) {
// Skip disabled rules
if (!rule.enabled) continue;
// Check intent match
if (rule.trigger.intent !== input.intent) continue;
// Check all conditions (AND logic)
const allConditionsMet = rule.trigger.conditions.every(
(cond) => checkCondition(cond, input.entities, input.content)
);
if (allConditionsMet) {
suggestions.push({
id: `nudge-${rule.id}-${Date.now()}`,
trigger: {
actionId: input.actionId,
intent: input.intent,
entities: input.entities,
},
suggestion: {
intent: rule.suggestion.intent,
entities: rule.suggestion.prefillEntities(input.entities),
message: rule.suggestion.message,
rationale: rule.name,
},
status: 'pending',
priority: rule.priority,
expiresAt: new Date(now.getTime() + rule.expiresAfterMs),
createdAt: now,
});
}
}
// Sort by priority (high first)
const priorityOrder: Record<NudgePriority, number> = {
high: 0,
medium: 1,
low: 2,
};
return suggestions.sort(
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
);
}
// -----------------------------------------------------------------------------
// MVP Protocol Rules (Hardcoded)
// -----------------------------------------------------------------------------
/** Default expiry time: 5 minutes */
const DEFAULT_EXPIRY_MS = 5 * 60 * 1000;
/**
* MVP Protocol Rules
*
* For the prototype, we hardcode a few demonstration rules.
* In production, these would come from a database or configuration.
*/
export const PROTOCOL_RULES: ProtocolRule[] = [
{
id: 'wondzorg-controle',
name: 'Wondcontrole na verzorging',
trigger: {
intent: 'dagnotitie',
conditions: [
{ field: 'content', operator: 'contains', value: 'wond' },
],
},
suggestion: {
intent: 'create_appointment',
message: 'Wondcontrole inplannen over 3 dagen?',
prefillEntities: (source) => ({
patientName: source.patientName,
appointmentType: 'follow-up',
}),
},
priority: 'medium',
enabled: true,
expiresAfterMs: DEFAULT_EXPIRY_MS,
},
{
id: 'medicatie-controle',
name: 'Medicatie controle na wijziging',
trigger: {
intent: 'dagnotitie',
conditions: [
{ field: 'content', operator: 'contains', value: 'medicatie' },
{ field: 'content', operator: 'contains', value: 'gewijzigd' },
],
},
suggestion: {
intent: 'create_appointment',
message: 'Medicatie evaluatie inplannen over 1 week?',
prefillEntities: (source) => ({
patientName: source.patientName,
appointmentType: 'follow-up',
}),
},
priority: 'medium',
enabled: true,
expiresAfterMs: DEFAULT_EXPIRY_MS,
},
];