From 4b759c9b3ddb4a5faa0dbcf98083aaa203a79925 Mon Sep 17 00:00:00 2001 From: colinislit Date: Thu, 1 Jan 2026 12:42:49 +0100 Subject: [PATCH] feat(cortex): Epic 4 - Nudge MVP (Layer 3) complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/cortex/blocks/dagnotitie-block.tsx | 22 +- .../cortex/command-center/command-center.tsx | 51 +++- .../cortex/command-center/nudge-toast.tsx | 186 ++++++++++++++ docs/intent/bouwplan-cortex-v2.md | 29 +-- lib/cortex/index.ts | 1 + lib/cortex/nudge.ts | 234 ++++++++++++++++++ 6 files changed, 506 insertions(+), 17 deletions(-) create mode 100644 components/cortex/command-center/nudge-toast.tsx create mode 100644 lib/cortex/nudge.ts diff --git a/components/cortex/blocks/dagnotitie-block.tsx b/components/cortex/blocks/dagnotitie-block.tsx index e46a57f..8c681ed 100644 --- a/components/cortex/blocks/dagnotitie-block.tsx +++ b/components/cortex/blocks/dagnotitie-block.tsx @@ -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(); diff --git a/components/cortex/command-center/command-center.tsx b/components/cortex/command-center/command-center.tsx index 179e16a..95ac089 100644 --- a/components/cortex/command-center/command-center.tsx +++ b/components/cortex/command-center/command-center.tsx @@ -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(null); // Global keyboard shortcuts @@ -118,6 +130,43 @@ export function CommandCenter() { + + {/* Nudge Toast - E4 (fixed position, bottom-left above chat) */} + + {suggestions.length > 0 && ( +
+ { + 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); + }} + /> +
+ )} +
); } diff --git a/components/cortex/command-center/nudge-toast.tsx b/components/cortex/command-center/nudge-toast.tsx new file mode 100644 index 0000000..e095388 --- /dev/null +++ b/components/cortex/command-center/nudge-toast.tsx @@ -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 ( + + {/* Header: Icon + Message + Dismiss */} +
+ +
+

+ {suggestion.suggestion.message} +

+

+ {suggestion.suggestion.rationale} +

+
+ +
+ + {/* Action buttons */} +
+ + +
+ + {/* Progress bar countdown */} +
+ +
+
+ ); +} diff --git a/docs/intent/bouwplan-cortex-v2.md b/docs/intent/bouwplan-cortex-v2.md index ca287a0..8c79b64 100644 --- a/docs/intent/bouwplan-cortex-v2.md +++ b/docs/intent/bouwplan-cortex-v2.md @@ -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 | diff --git a/lib/cortex/index.ts b/lib/cortex/index.ts index 02414e3..beb57e8 100644 --- a/lib/cortex/index.ts +++ b/lib/cortex/index.ts @@ -10,3 +10,4 @@ export * from './reflex-classifier'; export * from './entity-extractor'; export * from './date-time-parser'; export * from './logger'; +export * from './nudge'; diff --git a/lib/cortex/nudge.ts b/lib/cortex/nudge.ts new file mode 100644 index 0000000..eff459c --- /dev/null +++ b/lib/cortex/nudge.ts @@ -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; + }; + /** 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 = { + 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, + }, +];