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:
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Loader2, Search, User, RefreshCw } from 'lucide-react';
|
import { Loader2, Search, User, RefreshCw } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/cortex/error-handler';
|
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/cortex/error-handler';
|
||||||
|
import { evaluateNudge } from '@/lib/cortex/nudge';
|
||||||
|
|
||||||
interface DagnotitieBlockProps {
|
interface DagnotitieBlockProps {
|
||||||
prefill?: BlockPrefillData;
|
prefill?: BlockPrefillData;
|
||||||
@@ -39,7 +40,7 @@ interface Patient {
|
|||||||
|
|
||||||
export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
||||||
const config = BLOCK_CONFIGS.dagnotitie;
|
const config = BLOCK_CONFIGS.dagnotitie;
|
||||||
const { closeBlock } = useCortexStore();
|
const { closeBlock, addSuggestion } = useCortexStore();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
@@ -221,6 +222,23 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
|||||||
description: `Notitie voor ${patientName} is opgeslagen`,
|
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
|
// Close block after short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
closeBlock();
|
closeBlock();
|
||||||
@@ -241,7 +259,7 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [patientId, content, category, includeInHandover, patientName, toast, closeBlock]);
|
}, [patientId, content, category, includeInHandover, patientName, toast, closeBlock, addSuggestion]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -17,16 +17,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useCallback, useRef } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { useCortexStore } from '@/stores/cortex-store';
|
import { useCortexStore } from '@/stores/cortex-store';
|
||||||
import { ContextBar } from './context-bar';
|
import { ContextBar } from './context-bar';
|
||||||
import { OfflineBanner } from './offline-banner';
|
import { OfflineBanner } from './offline-banner';
|
||||||
|
import { NudgeToast } from './nudge-toast';
|
||||||
import { ChatPanel } from '../chat/chat-panel';
|
import { ChatPanel } from '../chat/chat-panel';
|
||||||
import { ArtifactArea } from '../artifacts/artifact-area';
|
import { ArtifactArea } from '../artifacts/artifact-area';
|
||||||
import { getArtifactTitle } from '../artifacts/artifact-container';
|
import { getArtifactTitle } from '../artifacts/artifact-container';
|
||||||
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
|
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
|
||||||
|
|
||||||
export function CommandCenter() {
|
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);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
@@ -118,6 +130,43 @@ export function CommandCenter() {
|
|||||||
<ArtifactArea />
|
<ArtifactArea />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
186
components/cortex/command-center/nudge-toast.tsx
Normal file
186
components/cortex/command-center/nudge-toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Bouwplan — Cortex Intent System V2
|
# Bouwplan — Cortex Intent System V2
|
||||||
|
|
||||||
**Projectnaam:** Cortex V2 - Agentic Intent Architecture
|
**Projectnaam:** Cortex V2 - Agentic Intent Architecture
|
||||||
**Versie:** v1.3
|
**Versie:** v1.4
|
||||||
**Datum:** 01-01-2026
|
**Datum:** 01-01-2026
|
||||||
**Auteur:** Colin Lit
|
**Auteur:** Colin Lit
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ lib/config/
|
|||||||
| **E1** | Reflex Arc (Layer 1) | Snelle lokale classificatie | ✅ Done | 4 | 6 SP |
|
| **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 |
|
| **E2** | Intent Orchestrator (Layer 2) | AI-gedreven multi-intent | ✅ Done | 6 | 13 SP |
|
||||||
| **E3** | UI Components | ActionChainCard, ClarificationCard | ✅ Done | 4 | 8 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 |
|
| **E5** | Integration & Polish | End-to-end flow, testing | ⏳ To Do | 4 | 8 SP |
|
||||||
|
|
||||||
**Totaal MVP: 26 stories, 48 Story Points**
|
**Totaal MVP: 26 stories, 48 Story Points**
|
||||||
@@ -848,9 +848,9 @@ interface ClarificationCardProps {
|
|||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP |
|
||||||
|----------|--------------|---------------------|--------|------------------|----|
|
|----------|--------------|---------------------|--------|------------------|----|
|
||||||
| E4.S1 | **Protocol rules** definiëren | Wondzorg-controle regel (hardcoded) | ⏳ | E0.S1 | 1 |
|
| 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.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.S3 | **NudgeToast** component | Toast met countdown timer, accept/dismiss | ✅ | E0.S4 | 2 |
|
||||||
|
|
||||||
**Demo Case:**
|
**Demo Case:**
|
||||||
```
|
```
|
||||||
@@ -910,8 +910,8 @@ export const PROTOCOL_RULES: ProtocolRule[] = [
|
|||||||
- `exists` - field is not null/empty
|
- `exists` - field is not null/empty
|
||||||
|
|
||||||
*Done criteria:*
|
*Done criteria:*
|
||||||
- [ ] ProtocolRule type gedefinieerd
|
- [x] ProtocolRule type gedefinieerd
|
||||||
- [ ] Minimaal 1 werkende regel (wondzorg)
|
- [x] Minimaal 1 werkende regel (wondzorg)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -944,9 +944,9 @@ function checkCondition(condition: ProtocolCondition, entities: ExtractedEntitie
|
|||||||
```
|
```
|
||||||
|
|
||||||
*Done criteria:*
|
*Done criteria:*
|
||||||
- [ ] Notitie met "wond" → NudgeSuggestion returned
|
- [x] Notitie met "wond" → NudgeSuggestion returned
|
||||||
- [ ] Notitie zonder "wond" → empty array
|
- [x] Notitie zonder "wond" → empty array
|
||||||
- [ ] Suggesties gesorteerd op priority
|
- [x] Suggesties gesorteerd op priority
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -986,10 +986,10 @@ useEffect(() => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
*Done criteria:*
|
*Done criteria:*
|
||||||
- [ ] Toast verschijnt na matching actie
|
- [x] Toast verschijnt na matching actie
|
||||||
- [ ] Progress bar animeert
|
- [x] Progress bar animeert
|
||||||
- [ ] Accept triggert nieuwe actie
|
- [x] Accept triggert nieuwe actie
|
||||||
- [ ] Dismiss verwijdert toast
|
- [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.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.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.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 |
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from './reflex-classifier';
|
|||||||
export * from './entity-extractor';
|
export * from './entity-extractor';
|
||||||
export * from './date-time-parser';
|
export * from './date-time-parser';
|
||||||
export * from './logger';
|
export * from './logger';
|
||||||
|
export * from './nudge';
|
||||||
|
|||||||
234
lib/cortex/nudge.ts
Normal file
234
lib/cortex/nudge.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user