Boopster commited on
Commit
cd73917
·
1 Parent(s): c386965

feat: Enhance onboarding flow with progressive medication setup and new LangGraph UI component, alongside dependency updates.

Browse files
Files changed (26) hide show
  1. frontend/src/components/ComponentOverlay.tsx +1 -1
  2. frontend/src/components/DashboardGrid.tsx +39 -20
  3. frontend/src/components/ReportsPanel.tsx +376 -108
  4. frontend/src/hooks/useDashboardData.ts +9 -1
  5. frontend/src/hooks/useLangGraph.ts +15 -13
  6. frontend/src/registry/MedLog.tsx +267 -88
  7. frontend/src/registry/MyMedsList.tsx +217 -78
  8. frontend/src/registry/OnboardingProgress.tsx +3 -3
  9. frontend/src/registry/index.tsx +3 -1
  10. langgraph.json +3 -0
  11. src/reachy_mini_conversation_app/database.py +171 -0
  12. src/reachy_mini_conversation_app/langgraph_agent/nodes/report_builder.py +39 -12
  13. src/reachy_mini_conversation_app/langgraph_agent/nodes/session_summary_node.py +12 -10
  14. src/reachy_mini_conversation_app/langgraph_agent/nodes/trend_analyzer.py +11 -6
  15. src/reachy_mini_conversation_app/langgraph_agent/ui.tsx +19 -0
  16. src/reachy_mini_conversation_app/openai_realtime.py +62 -21
  17. src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/complete_onboarding.py +19 -5
  18. src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/get_onboarding_status.py +23 -3
  19. src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/setup_medication.py +7 -4
  20. src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/show_onboarding_step.py +23 -5
  21. src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/update_settings.py +3 -1
  22. src/reachy_mini_conversation_app/prompts/sections/onboarding.txt +1 -1
  23. src/reachy_mini_conversation_app/stream_api.py +30 -0
  24. tests/test_database_onboarding.py +59 -0
  25. tests/test_onboarding_tools.py +39 -0
  26. uv.lock +75 -4
frontend/src/components/ComponentOverlay.tsx CHANGED
@@ -11,7 +11,7 @@ interface ComponentOverlayProps {
11
  }
12
 
13
  // Components that render full-screen (TV-distance readable)
14
- const FULLSCREEN_COMPONENTS = new Set(["HeadacheLog", "MedLog", "SessionSummary", "OnboardingProgress", "OnboardingSummary"]);
15
 
16
  export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps) {
17
  const isFullscreen = FULLSCREEN_COMPONENTS.has(component.name);
 
11
  }
12
 
13
  // Components that render full-screen (TV-distance readable)
14
+ const FULLSCREEN_COMPONENTS = new Set(["HeadacheLog", "MedLog", "SessionSummary", "OnboardingProgress", "OnboardingSummary", "MyMedsList"]);
15
 
16
  export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps) {
17
  const isFullscreen = FULLSCREEN_COMPONENTS.has(component.name);
frontend/src/components/DashboardGrid.tsx CHANGED
@@ -81,8 +81,7 @@ export function DashboardGrid({ isSessionActive, isConnected, userName, showCame
81
  {/* Next Step CTA - 1x1 */}
82
  <NextStepCard
83
  onboardingCompleted={stats.profile.onboarding_completed}
84
- medicationTaken={stats.medication.taken}
85
- medicationTotal={stats.medication.total}
86
  headachesToday={stats.headaches.length > 0 ? (stats.headaches[stats.headaches.length - 1].count ?? (stats.headaches[stats.headaches.length - 1].intensity > 0 ? 1 : 0)) : 0}
87
  />
88
  </div>
@@ -316,7 +315,7 @@ function MedicationRingCard({ taken, total, adHocCount, isLoading, hasData }: {
316
  letterSpacing: "0.1em",
317
  color: "var(--color-text-secondary)"
318
  }}>
319
- Meds Logged
320
  </span>
321
  {adHocCount > 0 && (
322
  <span style={{
@@ -574,42 +573,62 @@ function HeadacheCalendarCard({
574
  );
575
  }
576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  function NextStepCard({
578
  onboardingCompleted,
579
- medicationTaken,
580
- medicationTotal,
581
  headachesToday,
582
  }: {
583
  onboardingCompleted: boolean;
584
- medicationTaken: number;
585
- medicationTotal: number;
586
  headachesToday: number;
587
  }) {
588
- // Determine the most relevant next step based on context
589
  const getCtaContent = () => {
590
  // If onboarding not done, that's the priority
591
  if (!onboardingCompleted) {
592
  return { action: "Start your setup", hint: "Tell Reachy Mini Minder about yourself to get started" };
593
  }
594
 
595
- const hour = new Date().getHours();
596
- const hasPendingMeds = medicationTotal > 0 && medicationTaken < medicationTotal;
597
 
598
- // Time-based medication suggestions — neutral, no compliance framing (Dignity-First)
599
- if (hasPendingMeds) {
600
- if (hour >= 6 && hour < 11) {
601
- return { action: "Morning medication", hint: "Ready when you are" };
602
- } else if (hour >= 17 && hour < 23) {
603
- return { action: "Evening medication", hint: "Just say the word" };
604
- }
605
  }
606
 
607
- // All meds logged neutral acknowledgment, no celebration (Dignity-First)
608
- if (medicationTotal > 0 && medicationTaken >= medicationTotal) {
609
- return { action: "All logged", hint: "Anything else I can help with?" };
 
 
 
 
 
610
  }
611
 
612
  // Afternoon — companion check-in
 
613
  if (hour >= 12 && hour < 17 && headachesToday === 0) {
614
  return { action: "Chat with Reachy", hint: "I'm here if you'd like to talk" };
615
  }
 
81
  {/* Next Step CTA - 1x1 */}
82
  <NextStepCard
83
  onboardingCompleted={stats.profile.onboarding_completed}
84
+ scheduledMeds={stats.medication.scheduled ?? []}
 
85
  headachesToday={stats.headaches.length > 0 ? (stats.headaches[stats.headaches.length - 1].count ?? (stats.headaches[stats.headaches.length - 1].intensity > 0 ? 1 : 0)) : 0}
86
  />
87
  </div>
 
315
  letterSpacing: "0.1em",
316
  color: "var(--color-text-secondary)"
317
  }}>
318
+ Meds Logged Today
319
  </span>
320
  {adHocCount > 0 && (
321
  <span style={{
 
573
  );
574
  }
575
 
576
+ /** Convert a schedule window value to a friendly display string. */
577
+ function formatScheduleWindow(window: string): string {
578
+ // Handle named windows
579
+ const lower = window.toLowerCase();
580
+ if (lower === "morning") return "this morning";
581
+ if (lower === "evening" || lower === "night") return "this evening";
582
+ if (lower === "afternoon") return "this afternoon";
583
+ // Handle HH:MM times
584
+ const match = window.match(/^(\d{1,2}):(\d{2})$/);
585
+ if (match) {
586
+ let h = parseInt(match[1], 10);
587
+ const m = match[2];
588
+ const ampm = h >= 12 ? "PM" : "AM";
589
+ if (h === 0) h = 12;
590
+ else if (h > 12) h -= 12;
591
+ return m === "00" ? `${h} ${ampm}` : `${h}:${m} ${ampm}`;
592
+ }
593
+ return window;
594
+ }
595
+
596
  function NextStepCard({
597
  onboardingCompleted,
598
+ scheduledMeds,
 
599
  headachesToday,
600
  }: {
601
  onboardingCompleted: boolean;
602
+ scheduledMeds: { name: string; status: string; scheduled_window: string }[];
 
603
  headachesToday: number;
604
  }) {
605
+ // Determine the most relevant next step based on actual schedule
606
  const getCtaContent = () => {
607
  // If onboarding not done, that's the priority
608
  if (!onboardingCompleted) {
609
  return { action: "Start your setup", hint: "Tell Reachy Mini Minder about yourself to get started" };
610
  }
611
 
612
+ const pending = scheduledMeds.filter(m => m.status === "pending");
613
+ const total = scheduledMeds.length;
614
 
615
+ // All meds logged — neutral acknowledgment (Dignity-First)
616
+ if (total > 0 && pending.length === 0) {
617
+ return { action: "All logged", hint: "Anything else I can help with?" };
 
 
 
 
618
  }
619
 
620
+ // Has pending meds — show the next one with its actual schedule
621
+ if (pending.length > 0) {
622
+ const next = pending[0];
623
+ const timeLabel = formatScheduleWindow(next.scheduled_window);
624
+ return {
625
+ action: next.name,
626
+ hint: `Scheduled for ${timeLabel} · Ready when you are`,
627
+ };
628
  }
629
 
630
  // Afternoon — companion check-in
631
+ const hour = new Date().getHours();
632
  if (hour >= 12 && hour < 17 && headachesToday === 0) {
633
  return { action: "Chat with Reachy", hint: "I'm here if you'd like to talk" };
634
  }
frontend/src/components/ReportsPanel.tsx CHANGED
@@ -1,13 +1,16 @@
1
  "use client";
2
 
3
- import React, { useState } from "react";
4
  import { createPortal } from "react-dom";
5
- import {
6
- FileText,
7
- BarChart3,
8
- X,
9
- ChevronRight,
10
- Sparkles
 
 
 
11
  } from "lucide-react";
12
  import { useLangGraph } from "@/hooks/useLangGraph";
13
  import { renderComponent } from "@/registry";
@@ -19,26 +22,100 @@ interface UIEvent {
19
  status: "loading" | "streaming" | "complete" | "error";
20
  }
21
 
 
 
 
 
 
 
 
 
 
22
  interface ReportsPanelProps {
23
  isOpen?: boolean;
24
  onToggle?: () => void;
25
  }
26
 
27
- export function ReportsPanel({ isOpen: controlledIsOpen, onToggle }: ReportsPanelProps = {}) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  const [internalIsOpen, setInternalIsOpen] = useState(false);
29
-
30
- // Use controlled state if provided, otherwise use internal state
31
- const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
32
  const toggleOpen = onToggle || (() => setInternalIsOpen((prev) => !prev));
33
-
34
  const { submit, uiEvents, isProcessing, clear } = useLangGraph<UIEvent>();
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  if (!isOpen) {
37
  return (
38
  <button
39
  onClick={() => toggleOpen()}
40
  className="flex flex-col items-center gap-1 p-2 text-gray-400 hover:text-white transition-all"
41
- title="Reports & Insights"
42
  >
43
  <BarChart3 className="w-5 h-5" />
44
  <span className="text-[10px]">Reports</span>
@@ -46,125 +123,316 @@ export function ReportsPanel({ isOpen: controlledIsOpen, onToggle }: ReportsPane
46
  );
47
  }
48
 
 
 
 
 
49
  return (
50
  <>
51
- <button
52
- onClick={() => toggleOpen()}
53
- className="btn btn-secondary p-2"
54
- >
55
  <BarChart3 className="w-5 h-5" />
56
  </button>
57
 
58
  {createPortal(
59
- <div
60
  className="modal-overlay"
61
- onClick={(e) => { if (e.target === e.currentTarget) toggleOpen(); }}
 
 
62
  >
63
- <div className="modal h-[600px] p-0">
64
- {/* Header */}
65
- <div className="px-6 py-5 border-b border-surface-overlay flex items-center justify-between bg-surface-subtle">
66
- <div className="flex items-center gap-3">
67
- <div className="w-8 h-8 rounded-lg bg-accent-cyan flex items-center justify-center shadow-md transition-transform hover:scale-105">
68
- <BarChart3 className="w-[18px] h-[18px] text-black" />
69
- </div>
70
- <div>
71
- <h3 className="text-base font-bold text-primary tracking-tight">Health Insights</h3>
72
- <p className="text-[10px] text-muted uppercase tracking-[0.1em] font-medium">Data-Driven Wellbeing</p>
73
- </div>
74
- </div>
75
- <button
76
- onClick={() => toggleOpen()}
77
- className="btn btn-ghost p-1.5 hover:bg-surface-elevated rounded-lg transition-colors"
78
  >
79
- <X className="w-5 h-5 text-secondary" />
80
- </button>
81
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
- {/* Content */}
84
- <div className="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar bg-surface-elevated/30">
85
- {!isProcessing && (
86
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
 
 
 
 
 
 
 
 
 
87
  <button
88
- onClick={() => submit({ type: "report" })}
89
- className={`group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-cta/40 hover:shadow-2xl hover:shadow-cta/5 overflow-hidden text-left ${uiEvents.length > 0 ? 'opacity-80 scale-95 hover:opacity-100 hover:scale-100' : ''}`}
90
  >
91
- <div className="absolute top-0 right-0 w-32 h-32 bg-cta/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
92
- <div className="w-14 h-14 bg-cta/10 text-cta rounded-xl flex items-center justify-center shrink-0 border border-cta/20 group-hover:scale-110 group-hover:bg-cta group-hover:text-black transition-all shadow-lg shadow-cta/10">
93
- <FileText className="w-7 h-7" />
94
- </div>
95
- <div className="flex flex-col gap-1 pr-4">
96
- <h3 className="text-base font-bold text-primary">Generate Report</h3>
97
- <p className="text-sm text-secondary leading-relaxed">Expert clinical summary prepared for your next neurology appointment.</p>
98
- <div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-cta group-hover:translate-x-1 transition-transform">
99
- Start Generation <ChevronRight className="w-3 h-3 ml-1" />
100
- </div>
101
- </div>
102
  </button>
 
 
103
 
104
- <button
105
- onClick={() => submit({ type: "trends" })}
106
- className={`group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-accent-cyan/40 hover:shadow-2xl hover:shadow-accent-cyan/5 overflow-hidden text-left ${uiEvents.length > 0 ? 'opacity-80 scale-95 hover:opacity-100 hover:scale-100' : ''}`}
107
- >
108
- <div className="absolute top-0 right-0 w-32 h-32 bg-accent-cyan/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
109
- <div className="w-14 h-14 bg-accent-cyan/10 text-accent-cyan rounded-xl flex items-center justify-center shrink-0 border border-accent-cyan/20 group-hover:scale-110 group-hover:bg-accent-cyan group-hover:text-black transition-all shadow-lg shadow-accent-cyan/10">
110
- <BarChart3 className="w-7 h-7" />
 
 
 
 
 
 
 
111
  </div>
112
- <div className="flex flex-col gap-1 pr-4">
113
- <h3 className="text-base font-bold text-primary">Analyse Trends</h3>
114
- <p className="text-sm text-secondary leading-relaxed">Uncover hidden patterns and triggers in your headache diary history.</p>
115
- <div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-accent-cyan group-hover:translate-x-1 transition-transform">
116
- View Insights <ChevronRight className="w-3 h-3 ml-1" />
117
- </div>
 
118
  </div>
119
- </button>
120
- </div>
121
- )}
122
-
123
- {/* Empty State / Processing */}
124
- {isProcessing && uiEvents.length === 0 && (
125
- <div className="flex flex-col items-center justify-center h-full text-center gap-6">
126
- <div className="relative">
127
- <div className="w-20 h-20 border-4 border-accent-cyan/10 border-t-accent-cyan rounded-full animate-spin shadow-lg shadow-accent-cyan/10" />
128
- <Sparkles className="absolute inset-0 m-auto w-8 h-8 text-accent-cyan animate-pulse" />
129
  </div>
130
- <div className="space-y-2">
131
- <h3 className="text-primary font-bold tracking-tight text-lg">AI Analyst Working</h3>
132
- <p className="text-[10px] text-muted font-bold uppercase tracking-widest">Correlating clinical data...</p>
 
 
 
 
 
 
 
 
 
 
 
 
133
  </div>
134
- </div>
135
- )}
136
 
137
- {/* UI Events (Generated Components) */}
138
- <div className="space-y-6">
139
- {uiEvents.map((ui) => (
140
- <div key={ui.id} className="animate-in fade-in slide-in-from-bottom-8 duration-700">
141
- {renderComponent(ui.name, ui.props)}
 
 
 
 
 
 
 
 
142
  </div>
143
- ))}
144
- </div>
145
 
146
- {uiEvents.length > 0 && !isProcessing && (
147
- <button
148
- onClick={clear}
149
- className="w-full py-4 text-[10px] font-black uppercase tracking-[0.2em] text-muted hover:text-cta transition-colors border-t border-surface-overlay mt-8"
150
- >
151
- Clear History
152
- </button>
153
- )}
154
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- {/* Enhanced Tips Footer */}
157
- <div className="px-8 py-5 bg-surface-subtle border-t border-surface-overlay flex items-center justify-between">
158
- <div className="flex items-center gap-4">
159
- <div className="px-2 py-1 rounded bg-accent-cyan/10 border border-accent-cyan/20 text-accent-cyan text-[9px] font-black uppercase tracking-widest">
160
- Data Policy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  </div>
162
- <p className="text-[11px] text-muted font-medium">
163
- Analysis based on last 30 days of local records in <strong>mini_minder.db</strong>.
164
- </p>
165
  </div>
166
- <Sparkles className="w-4 h-4 text-accent-cyan/30" />
167
- </div>
168
  </div>
169
  </div>,
170
  document.body
 
1
  "use client";
2
 
3
+ import React, { useState, useEffect, useRef } from "react";
4
  import { createPortal } from "react-dom";
5
+ import {
6
+ FileText,
7
+ BarChart3,
8
+ X,
9
+ ChevronRight,
10
+ Sparkles,
11
+ Clock,
12
+ Calendar,
13
+ Plus,
14
  } from "lucide-react";
15
  import { useLangGraph } from "@/hooks/useLangGraph";
16
  import { renderComponent } from "@/registry";
 
22
  status: "loading" | "streaming" | "complete" | "error";
23
  }
24
 
25
+ interface SavedReport {
26
+ id: number;
27
+ report_type: string;
28
+ title: string;
29
+ content: string;
30
+ metadata?: Record<string, unknown>;
31
+ created_at: string;
32
+ }
33
+
34
  interface ReportsPanelProps {
35
  isOpen?: boolean;
36
  onToggle?: () => void;
37
  }
38
 
39
+ const API_BASE =
40
+ process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/stream";
41
+
42
+ function formatReportDate(dateStr: string): string {
43
+ try {
44
+ const d = new Date(dateStr + "Z");
45
+ return d.toLocaleDateString("en-GB", {
46
+ day: "numeric",
47
+ month: "short",
48
+ year: "numeric",
49
+ hour: "2-digit",
50
+ minute: "2-digit",
51
+ });
52
+ } catch {
53
+ return dateStr;
54
+ }
55
+ }
56
+
57
+ export function ReportsPanel({
58
+ isOpen: controlledIsOpen,
59
+ onToggle,
60
+ }: ReportsPanelProps = {}) {
61
  const [internalIsOpen, setInternalIsOpen] = useState(false);
62
+ const isOpen =
63
+ controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
 
64
  const toggleOpen = onToggle || (() => setInternalIsOpen((prev) => !prev));
65
+
66
  const { submit, uiEvents, isProcessing, clear } = useLangGraph<UIEvent>();
67
 
68
+ // Saved reports from API
69
+ const [savedReports, setSavedReports] = useState<SavedReport[]>([]);
70
+ const [viewingReport, setViewingReport] = useState<SavedReport | null>(null);
71
+ const [loadingReports, setLoadingReports] = useState(false);
72
+
73
+ const reportRef = useRef<HTMLDivElement>(null);
74
+
75
+ // Fetch saved reports when modal opens
76
+ useEffect(() => {
77
+ if (!isOpen) return;
78
+ setLoadingReports(true);
79
+ fetch(`${API_BASE}/reports`)
80
+ .then((r) => r.json())
81
+ .then((data: SavedReport[]) => {
82
+ if (Array.isArray(data)) setSavedReports(data);
83
+ })
84
+ .catch(() => {})
85
+ .finally(() => setLoadingReports(false));
86
+ }, [isOpen]);
87
+
88
+ // Auto-scroll to generated report when it appears
89
+ useEffect(() => {
90
+ if (uiEvents.length > 0 && reportRef.current) {
91
+ reportRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
92
+ }
93
+ }, [uiEvents.length]);
94
+
95
+ // When report generation completes, refresh the saved list
96
+ const lastEvent = uiEvents[uiEvents.length - 1];
97
+ const isComplete =
98
+ lastEvent &&
99
+ !isProcessing &&
100
+ (lastEvent.props as Record<string, unknown>)?.status === "complete";
101
+
102
+ useEffect(() => {
103
+ if (isComplete) {
104
+ fetch(`${API_BASE}/reports`)
105
+ .then((r) => r.json())
106
+ .then((data: SavedReport[]) => {
107
+ if (Array.isArray(data)) setSavedReports(data);
108
+ })
109
+ .catch(() => {});
110
+ }
111
+ }, [isComplete]);
112
+
113
  if (!isOpen) {
114
  return (
115
  <button
116
  onClick={() => toggleOpen()}
117
  className="flex flex-col items-center gap-1 p-2 text-gray-400 hover:text-white transition-all"
118
+ title="My Reports"
119
  >
120
  <BarChart3 className="w-5 h-5" />
121
  <span className="text-[10px]">Reports</span>
 
123
  );
124
  }
125
 
126
+ // Are we viewing a past report (not a live-generated one)?
127
+ const showingSavedReport = viewingReport !== null && uiEvents.length === 0;
128
+ const showingGenerated = uiEvents.length > 0;
129
+
130
  return (
131
  <>
132
+ <button onClick={() => toggleOpen()} className="btn btn-secondary p-2">
 
 
 
133
  <BarChart3 className="w-5 h-5" />
134
  </button>
135
 
136
  {createPortal(
137
+ <div
138
  className="modal-overlay"
139
+ onClick={(e) => {
140
+ if (e.target === e.currentTarget) toggleOpen();
141
+ }}
142
  >
143
+ <div className="modal-fullscreen p-0">
144
+ {/* Header */}
145
+ <div className="px-6 py-4 border-b border-surface-overlay flex items-center justify-between bg-surface-subtle"
146
+ style={{ flexShrink: 0 }}
 
 
 
 
 
 
 
 
 
 
 
147
  >
148
+ <div className="flex items-center gap-3">
149
+ <div
150
+ className="w-9 h-9 rounded-lg flex items-center justify-center shadow-md"
151
+ style={{
152
+ background: "var(--color-cta)",
153
+ boxShadow: "0 2px 8px rgba(179, 156, 208, 0.3)",
154
+ }}
155
+ >
156
+ <FileText className="w-5 h-5" style={{ color: "#1a1a1a" }} />
157
+ </div>
158
+ <div>
159
+ <h3
160
+ style={{
161
+ fontSize: 20,
162
+ fontWeight: 700,
163
+ color: "var(--color-text-primary)",
164
+ margin: 0,
165
+ letterSpacing: "-0.01em",
166
+ }}
167
+ >
168
+ My Reports
169
+ </h3>
170
+ <p
171
+ style={{
172
+ fontSize: 11,
173
+ color: "var(--color-text-muted)",
174
+ textTransform: "uppercase",
175
+ letterSpacing: "0.1em",
176
+ fontWeight: 500,
177
+ margin: 0,
178
+ }}
179
+ >
180
+ Clinical Summaries & Trends
181
+ </p>
182
+ </div>
183
+ </div>
184
 
185
+ <div className="flex items-center gap-3">
186
+ {/* Back button when viewing a single report */}
187
+ {(showingSavedReport || showingGenerated) && (
188
+ <button
189
+ onClick={() => {
190
+ setViewingReport(null);
191
+ clear();
192
+ }}
193
+ className="px-3 py-1.5 text-xs font-bold text-cta border border-cta/30 rounded-lg hover:bg-cta/10 transition-colors"
194
+ >
195
+ ← All Reports
196
+ </button>
197
+ )}
198
  <button
199
+ onClick={() => toggleOpen()}
200
+ className="btn btn-ghost p-1.5 hover:bg-surface-elevated rounded-lg transition-colors"
201
  >
202
+ <X className="w-5 h-5 text-secondary" />
 
 
 
 
 
 
 
 
 
 
203
  </button>
204
+ </div>
205
+ </div>
206
 
207
+ {/* Content */}
208
+ <div
209
+ className="flex-1 overflow-y-auto custom-scrollbar"
210
+ style={{
211
+ background: "rgba(18, 18, 18, 0.5)",
212
+ minHeight: 0,
213
+ }}
214
+ >
215
+ {/* === STATE: Processing / Spinner === */}
216
+ {isProcessing && uiEvents.length === 0 && (
217
+ <div className="flex flex-col items-center justify-center h-full text-center gap-6">
218
+ <div className="relative">
219
+ <div className="w-20 h-20 border-4 border-accent-cyan/10 border-t-accent-cyan rounded-full animate-spin shadow-lg shadow-accent-cyan/10" />
220
+ <Sparkles className="absolute inset-0 m-auto w-8 h-8 text-accent-cyan animate-pulse" />
221
  </div>
222
+ <div className="space-y-2">
223
+ <h3 className="text-primary font-bold tracking-tight text-lg">
224
+ AI Analyst Working
225
+ </h3>
226
+ <p className="text-[10px] text-muted font-bold uppercase tracking-widest">
227
+ Correlating clinical data...
228
+ </p>
229
  </div>
 
 
 
 
 
 
 
 
 
 
230
  </div>
231
+ )}
232
+
233
+ {/* === STATE: Viewing a generated report (live) === */}
234
+ {showingGenerated && (
235
+ <div className="p-6 max-w-3xl mx-auto" ref={reportRef}>
236
+ <div className="space-y-6">
237
+ {uiEvents.map((ui) => (
238
+ <div
239
+ key={ui.id}
240
+ className="animate-in fade-in slide-in-from-bottom-8 duration-700"
241
+ >
242
+ {renderComponent(ui.name, ui.props)}
243
+ </div>
244
+ ))}
245
+ </div>
246
  </div>
247
+ )}
 
248
 
249
+ {/* === STATE: Viewing a saved report (from history) === */}
250
+ {showingSavedReport && (
251
+ <div className="p-6 max-w-3xl mx-auto">
252
+ {renderComponent("ReportPreview", {
253
+ id: viewingReport!.id,
254
+ title: viewingReport!.title,
255
+ content: viewingReport!.content,
256
+ status: "complete",
257
+ doctorName: (viewingReport!.metadata as Record<string, string>)
258
+ ?.doctorName,
259
+ doctorEmail: (viewingReport!.metadata as Record<string, string>)
260
+ ?.doctorEmail,
261
+ })}
262
  </div>
263
+ )}
 
264
 
265
+ {/* === STATE: Reports list (default view) === */}
266
+ {!isProcessing && !showingGenerated && !showingSavedReport && (
267
+ <div className="p-6 space-y-8">
268
+ {/* Action cards */}
269
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-3xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
270
+ <button
271
+ onClick={() => submit({ type: "report" })}
272
+ className="group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-cta/40 hover:shadow-2xl hover:shadow-cta/5 overflow-hidden text-left"
273
+ >
274
+ <div className="absolute top-0 right-0 w-32 h-32 bg-cta/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
275
+ <div className="w-14 h-14 bg-cta/10 text-cta rounded-xl flex items-center justify-center shrink-0 border border-cta/20 group-hover:scale-110 group-hover:bg-cta group-hover:text-black transition-all shadow-lg shadow-cta/10">
276
+ <Plus className="w-7 h-7" />
277
+ </div>
278
+ <div className="flex flex-col gap-1 pr-4">
279
+ <h3 className="text-base font-bold text-primary">
280
+ New Report
281
+ </h3>
282
+ <p className="text-sm text-secondary leading-relaxed">
283
+ Generate a clinical summary for your next appointment.
284
+ </p>
285
+ <div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-cta group-hover:translate-x-1 transition-transform">
286
+ Start Generation{" "}
287
+ <ChevronRight className="w-3 h-3 ml-1" />
288
+ </div>
289
+ </div>
290
+ </button>
291
 
292
+ <button
293
+ onClick={() => submit({ type: "trends" })}
294
+ className="group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-accent-cyan/40 hover:shadow-2xl hover:shadow-accent-cyan/5 overflow-hidden text-left"
295
+ >
296
+ <div className="absolute top-0 right-0 w-32 h-32 bg-accent-cyan/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
297
+ <div className="w-14 h-14 bg-accent-cyan/10 text-accent-cyan rounded-xl flex items-center justify-center shrink-0 border border-accent-cyan/20 group-hover:scale-110 group-hover:bg-accent-cyan group-hover:text-black transition-all shadow-lg shadow-accent-cyan/10">
298
+ <BarChart3 className="w-7 h-7" />
299
+ </div>
300
+ <div className="flex flex-col gap-1 pr-4">
301
+ <h3 className="text-base font-bold text-primary">
302
+ Analyse Trends
303
+ </h3>
304
+ <p className="text-sm text-secondary leading-relaxed">
305
+ Uncover hidden patterns and triggers in your headache
306
+ diary.
307
+ </p>
308
+ <div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-accent-cyan group-hover:translate-x-1 transition-transform">
309
+ View Insights{" "}
310
+ <ChevronRight className="w-3 h-3 ml-1" />
311
+ </div>
312
+ </div>
313
+ </button>
314
+ </div>
315
+
316
+ {/* Past reports list */}
317
+ {loadingReports && (
318
+ <div className="flex items-center justify-center py-12">
319
+ <div className="w-8 h-8 border-2 border-cta/20 border-t-cta rounded-full animate-spin" />
320
+ </div>
321
+ )}
322
+
323
+ {!loadingReports && savedReports.length > 0 && (
324
+ <div className="max-w-3xl mx-auto">
325
+ <div className="flex items-center gap-2 mb-4">
326
+ <Clock
327
+ className="w-4 h-4"
328
+ style={{ color: "var(--color-text-muted)" }}
329
+ />
330
+ <span
331
+ style={{
332
+ fontSize: 12,
333
+ fontWeight: 900,
334
+ textTransform: "uppercase",
335
+ letterSpacing: "0.15em",
336
+ color: "var(--color-text-muted)",
337
+ }}
338
+ >
339
+ Report History
340
+ </span>
341
+ </div>
342
+ <div className="space-y-2">
343
+ {savedReports.map((report) => (
344
+ <button
345
+ key={report.id}
346
+ onClick={() => setViewingReport(report)}
347
+ className="w-full flex items-center gap-4 p-4 bg-surface-subtle/40 border border-surface-overlay rounded-xl hover:bg-surface-elevated hover:border-cta/20 transition-all group text-left"
348
+ >
349
+ <div className="w-10 h-10 rounded-lg bg-cta/10 text-cta flex items-center justify-center shrink-0 border border-cta/15 group-hover:bg-cta/20 transition-colors">
350
+ <FileText className="w-5 h-5" />
351
+ </div>
352
+ <div className="flex-1 min-w-0">
353
+ <h4 className="text-sm font-bold text-primary truncate">
354
+ {report.title}
355
+ </h4>
356
+ <div className="flex items-center gap-2 mt-0.5">
357
+ <Calendar
358
+ className="w-3 h-3"
359
+ style={{
360
+ color: "var(--color-text-muted)",
361
+ }}
362
+ />
363
+ <span
364
+ style={{
365
+ fontSize: 11,
366
+ color: "var(--color-text-muted)",
367
+ fontWeight: 500,
368
+ }}
369
+ >
370
+ {formatReportDate(report.created_at)}
371
+ </span>
372
+ <span className="pill pill-lavender text-[9px] font-bold">
373
+ {report.report_type}
374
+ </span>
375
+ </div>
376
+ </div>
377
+ <ChevronRight
378
+ className="w-4 h-4 text-muted group-hover:text-cta group-hover:translate-x-1 transition-all"
379
+ />
380
+ </button>
381
+ ))}
382
+ </div>
383
+ </div>
384
+ )}
385
+
386
+ {!loadingReports && savedReports.length === 0 && (
387
+ <div className="flex flex-col items-center justify-center py-16 text-center gap-4">
388
+ <div className="w-16 h-16 rounded-2xl bg-surface-subtle border border-surface-overlay flex items-center justify-center">
389
+ <FileText
390
+ className="w-8 h-8"
391
+ style={{ color: "var(--color-text-muted)", opacity: 0.4 }}
392
+ />
393
+ </div>
394
+ <div>
395
+ <p
396
+ style={{
397
+ fontSize: 14,
398
+ fontWeight: 600,
399
+ color: "var(--color-text-secondary)",
400
+ }}
401
+ >
402
+ No reports yet
403
+ </p>
404
+ <p
405
+ style={{
406
+ fontSize: 12,
407
+ color: "var(--color-text-muted)",
408
+ marginTop: 4,
409
+ }}
410
+ >
411
+ Generate your first clinical report above.
412
+ </p>
413
+ </div>
414
+ </div>
415
+ )}
416
+ </div>
417
+ )}
418
+ </div>
419
+
420
+ {/* Footer */}
421
+ <div
422
+ className="px-8 py-4 bg-surface-subtle border-t border-surface-overlay flex items-center justify-between"
423
+ style={{ flexShrink: 0 }}
424
+ >
425
+ <div className="flex items-center gap-4">
426
+ <div className="px-2 py-1 rounded bg-accent-cyan/10 border border-accent-cyan/20 text-accent-cyan text-[9px] font-black uppercase tracking-widest">
427
+ Data Policy
428
+ </div>
429
+ <p className="text-[11px] text-muted font-medium">
430
+ Analysis based on last 30 days of local records in{" "}
431
+ <strong>mini_minder.db</strong>.
432
+ </p>
433
  </div>
434
+ <Sparkles className="w-4 h-4 text-accent-cyan/30" />
 
 
435
  </div>
 
 
436
  </div>
437
  </div>,
438
  document.body
frontend/src/hooks/useDashboardData.ts CHANGED
@@ -13,10 +13,18 @@ interface AdHocMedication {
13
  logged_at: string | null;
14
  }
15
 
 
 
 
 
 
 
 
16
  interface MedicationStats {
17
  taken: number;
18
  total: number;
19
  ad_hoc: AdHocMedication[];
 
20
  }
21
 
22
  interface HeadacheDay {
@@ -34,7 +42,7 @@ export interface DashboardStats {
34
 
35
  const DEFAULT_STATS: DashboardStats = {
36
  profile: { display_name: null, onboarding_completed: false },
37
- medication: { taken: 0, total: 0, ad_hoc: [] },
38
  days_active_this_week: 0,
39
  headaches: [],
40
  };
 
13
  logged_at: string | null;
14
  }
15
 
16
+ interface ScheduledMedication {
17
+ name: string;
18
+ status: "logged" | "pending";
19
+ logged_at: string | null;
20
+ scheduled_window: string;
21
+ }
22
+
23
  interface MedicationStats {
24
  taken: number;
25
  total: number;
26
  ad_hoc: AdHocMedication[];
27
+ scheduled: ScheduledMedication[];
28
  }
29
 
30
  interface HeadacheDay {
 
42
 
43
  const DEFAULT_STATS: DashboardStats = {
44
  profile: { display_name: null, onboarding_completed: false },
45
+ medication: { taken: 0, total: 0, ad_hoc: [], scheduled: [] },
46
  days_active_this_week: 0,
47
  headaches: [],
48
  };
frontend/src/hooks/useLangGraph.ts CHANGED
@@ -44,19 +44,21 @@ export function useLangGraph<T extends LangGraphUIEvent = LangGraphUIEvent>({
44
  const data = chunk.data as Record<string, any>;
45
  if (chunk.event === "values" && data?.ui) {
46
  const uiMessages = data.ui as any[];
47
- const latestUI = uiMessages[uiMessages.length - 1];
48
-
49
- if (latestUI) {
50
- setUiEvents((prev) => ({
51
- ...prev,
52
- [latestUI.id]: {
53
- id: latestUI.id,
54
- name: latestUI.name,
55
- props: latestUI.props,
56
- status: latestUI.props.status || "complete"
57
- }
58
- }));
59
- }
 
 
60
  }
61
  }
62
  } catch (error) {
 
44
  const data = chunk.data as Record<string, any>;
45
  if (chunk.event === "values" && data?.ui) {
46
  const uiMessages = data.ui as any[];
47
+ // Reduce ALL ui messages by id so merged updates (merge=True)
48
+ // replace the previous version of the same component, and
49
+ // multiple components can coexist.
50
+ setUiEvents((prev) => {
51
+ const next = { ...prev };
52
+ for (const msg of uiMessages) {
53
+ next[msg.id] = {
54
+ id: msg.id,
55
+ name: msg.name,
56
+ props: msg.props,
57
+ status: msg.props?.status || "complete",
58
+ } as T;
59
+ }
60
+ return next;
61
+ });
62
  }
63
  }
64
  } catch (error) {
frontend/src/registry/MedLog.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { Pill, Check, Edit3 } from "lucide-react";
4
 
5
  interface MedLogProps {
6
  entry_id: string;
@@ -15,21 +15,26 @@ interface MedLogProps {
15
  robot_message?: string;
16
  }
17
 
18
- function DetailRow({
19
- label,
20
- value,
21
- }: {
22
- label: string;
23
- value: string | null;
24
- }) {
25
  return (
26
- <div className="flex flex-col gap-0.5">
27
- <span className="text-[10px] font-bold text-muted uppercase tracking-wider">
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  {label}
29
  </span>
30
- <span className={`text-[13px] ${value ? "text-primary" : "text-muted italic"}`}>
31
- {value || "Asking…"}
32
- </span>
33
  </div>
34
  );
35
  }
@@ -60,129 +65,303 @@ export function MedLog(props: MedLogProps) {
60
  if (props.taken === null) return null;
61
  if (props.taken === false) return "Skipped";
62
  if (props.taken_late) return "Taken late";
63
- return "✓ Yes";
64
  };
65
 
 
 
 
 
 
 
 
 
66
  return (
67
  <div
68
- className={`relative overflow-hidden rounded-2xl p-5
69
- bg-gradient-to-br from-surface-elevated/90 to-surface-subtle/95
70
- border transition-all
71
- shadow-[0_4px_24px_rgba(0,0,0,0.4),inset_0_1px_0_rgba(255,255,255,0.05)]
72
- ${isComplete
73
- ? "border-success/40"
74
- : isCapturing
75
- ? "border-accent-pink/30"
76
- : "border-cta/30"
77
- }`}
78
  >
79
  {/* Top accent line */}
80
  <div
81
- className={`absolute top-0 left-[20%] right-[20%] h-0.5 bg-gradient-to-r from-transparent to-transparent opacity-60
82
- ${isComplete ? "via-success" : isCapturing ? "via-accent-pink" : "via-cta"}`}
 
 
 
 
 
 
 
 
 
 
 
83
  />
84
 
85
- {/* Header */}
86
- <div className="flex items-center gap-3.5 mb-5 pb-4 border-b border-white/5">
87
  <div
88
- className={`relative w-11 h-11 rounded-xl flex items-center justify-center
89
- border transition-all
90
- ${isComplete
91
- ? "bg-success/15 border-success/30 text-success"
92
- : isCapturing
93
- ? "bg-accent-pink/15 border-accent-pink/30 text-accent-pink animate-pulse"
94
- : "bg-cta/15 border-cta/30 text-cta"
95
- }`}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  >
97
- {isComplete ? <Check className="w-5 h-5" /> : <Pill className="w-5 h-5" />}
 
 
 
 
98
  </div>
99
- <div className="flex-1">
100
- <h3 className="text-base font-bold tracking-tight">
 
 
 
 
 
 
 
 
101
  {isCapturing && (
102
  <>
103
- <span className="inline-block w-2 h-2 rounded-full bg-accent-pink animate-pulse mr-2 align-middle" />
 
 
 
 
 
 
 
 
 
 
 
104
  Logging Medication…
105
  </>
106
  )}
107
- {needsConfirmation && "Medication Entry — Confirm?"}
108
- {isComplete && "Medication Logged"}
109
- </h3>
110
  {isCapturing && (
111
- <p className="text-[11px] text-muted mt-0.5">
112
  Filling in details as you speak
113
  </p>
114
  )}
115
  {isComplete && (
116
- <p className="text-[11px] text-muted mt-0.5">
117
  Saved to your record
118
  </p>
119
  )}
120
  </div>
121
  </div>
122
 
123
- {/* Details Grid */}
124
- <div className="grid grid-cols-2 gap-3 mb-4">
125
- <DetailRow label="Medication" value={props.medication_name} />
126
- <DetailRow label="Dose" value={props.dose} />
127
- <DetailRow label="Time Taken" value={props.actual_time} />
128
- <DetailRow
129
- label="On Schedule"
130
- value={
131
- props.taken === null
132
- ? null
133
- : takenDisplay()
134
- }
135
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  </div>
137
 
138
- {/* Notes */}
139
- {props.notes && (
140
- <div className="mb-4">
141
- <span className="text-[10px] font-bold text-muted uppercase tracking-wider block mb-1">
142
- Notes
143
- </span>
144
- <p className="text-[13px] text-secondary leading-relaxed">{props.notes}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  </div>
146
  )}
147
 
148
- {/* Confirmation Buttons — Large touch targets */}
149
  {needsConfirmation && (
150
- <div className="flex gap-3 mt-5">
151
  <button
152
  onClick={handleConfirm}
153
- className="flex-1 flex items-center justify-center gap-2 px-4 py-4
154
- rounded-xl bg-cta text-inverse
155
- hover:brightness-110 active:scale-[0.98]
156
- transition-all min-h-[56px]
157
- text-[14px] font-bold"
 
 
 
 
 
 
 
 
 
 
 
 
158
  >
159
- <Check className="w-5 h-5" />
160
  Looks Right
161
  </button>
162
  <button
163
  onClick={handleCorrect}
164
- className="flex-1 flex items-center justify-center gap-2 px-4 py-4
165
- rounded-xl bg-surface-overlay/50 text-secondary
166
- border border-white/10
167
- hover:bg-surface-overlay/80 hover:text-primary active:scale-[0.98]
168
- transition-all min-h-[56px]
169
- text-[14px] font-bold"
 
 
 
 
 
 
 
 
 
 
 
170
  >
171
- <Edit3 className="w-5 h-5" />
172
  Make Changes
173
  </button>
174
  </div>
175
  )}
176
 
177
- {/* Robot Message */}
178
- {props.robot_message && (
179
- <div className="mt-4 px-4 py-3 rounded-xl bg-cta/10 border border-cta/20">
180
- <p className="text-[13px] text-cta font-medium">{props.robot_message}</p>
181
- </div>
182
- )}
183
-
184
  {/* Disclaimer */}
185
- <p className="mt-4 text-[10px] text-muted text-center italic">
 
 
 
 
 
 
 
 
186
  This records what you tell me, not verified intake.
187
  </p>
188
  </div>
 
1
  "use client";
2
 
3
+ import { Pill, Check, Edit3, Clock, StickyNote } from "lucide-react";
4
 
5
  interface MedLogProps {
6
  entry_id: string;
 
15
  robot_message?: string;
16
  }
17
 
18
+ function FilledFieldPill({ label, value }: { label: string; value: string }) {
 
 
 
 
 
 
19
  return (
20
+ <div
21
+ style={{
22
+ display: "inline-flex",
23
+ alignItems: "center",
24
+ gap: 8,
25
+ padding: "10px 20px",
26
+ borderRadius: 999,
27
+ background: "rgba(168, 218, 220, 0.1)",
28
+ border: "1px solid rgba(168, 218, 220, 0.2)",
29
+ fontSize: 18,
30
+ color: "var(--color-accent-cyan)",
31
+ fontWeight: 500,
32
+ }}
33
+ >
34
+ <span style={{ opacity: 0.6, textTransform: "uppercase", fontSize: 13, letterSpacing: 1 }}>
35
  {label}
36
  </span>
37
+ {value}
 
 
38
  </div>
39
  );
40
  }
 
65
  if (props.taken === null) return null;
66
  if (props.taken === false) return "Skipped";
67
  if (props.taken_late) return "Taken late";
68
+ return "✓ On time";
69
  };
70
 
71
+ // Build filled field pills
72
+ const filledPills: { label: string; value: string }[] = [];
73
+ if (props.medication_name) filledPills.push({ label: "Medication", value: props.medication_name });
74
+ if (props.dose) filledPills.push({ label: "Dose", value: props.dose });
75
+ if (props.actual_time) filledPills.push({ label: "Time", value: props.actual_time });
76
+ if (props.taken !== null) filledPills.push({ label: "Status", value: takenDisplay() || "" });
77
+ if (props.notes) filledPills.push({ label: "Notes", value: props.notes });
78
+
79
  return (
80
  <div
81
+ style={{
82
+ display: "flex",
83
+ flexDirection: "column",
84
+ height: "100%",
85
+ width: "100%",
86
+ padding: "40px 48px",
87
+ position: "relative",
88
+ overflow: "hidden",
89
+ }}
 
90
  >
91
  {/* Top accent line */}
92
  <div
93
+ style={{
94
+ position: "absolute",
95
+ top: 0,
96
+ left: "10%",
97
+ right: "10%",
98
+ height: 3,
99
+ background: isComplete
100
+ ? "linear-gradient(to right, transparent, var(--color-success), transparent)"
101
+ : isCapturing
102
+ ? "linear-gradient(to right, transparent, var(--color-accent-pink), transparent)"
103
+ : "linear-gradient(to right, transparent, var(--color-cta), transparent)",
104
+ opacity: 0.8,
105
+ }}
106
  />
107
 
108
+ {/* Header row */}
109
+ <div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32 }}>
110
  <div
111
+ style={{
112
+ width: 56,
113
+ height: 56,
114
+ borderRadius: 16,
115
+ display: "flex",
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ background: isComplete
119
+ ? "rgba(125, 211, 168, 0.15)"
120
+ : isCapturing
121
+ ? "rgba(255, 193, 204, 0.15)"
122
+ : "rgba(179, 156, 208, 0.15)",
123
+ border: `1px solid ${
124
+ isComplete
125
+ ? "rgba(125, 211, 168, 0.3)"
126
+ : isCapturing
127
+ ? "rgba(255, 193, 204, 0.3)"
128
+ : "rgba(179, 156, 208, 0.3)"
129
+ }`,
130
+ color: isComplete
131
+ ? "var(--color-success)"
132
+ : isCapturing
133
+ ? "var(--color-accent-pink)"
134
+ : "var(--color-cta)",
135
+ }}
136
  >
137
+ {isComplete ? (
138
+ <Check style={{ width: 28, height: 28 }} />
139
+ ) : (
140
+ <Pill style={{ width: 28, height: 28 }} />
141
+ )}
142
  </div>
143
+ <div>
144
+ <h2
145
+ style={{
146
+ fontSize: 28,
147
+ fontWeight: 700,
148
+ color: "var(--color-text-primary)",
149
+ margin: 0,
150
+ lineHeight: 1.2,
151
+ }}
152
+ >
153
  {isCapturing && (
154
  <>
155
+ <span
156
+ style={{
157
+ display: "inline-block",
158
+ width: 10,
159
+ height: 10,
160
+ borderRadius: "50%",
161
+ background: "var(--color-accent-pink)",
162
+ marginRight: 12,
163
+ verticalAlign: "middle",
164
+ animation: "pulse 2s infinite",
165
+ }}
166
+ />
167
  Logging Medication…
168
  </>
169
  )}
170
+ {needsConfirmation && "Confirm Medication Entry"}
171
+ {isComplete && "Medication Logged"}
172
+ </h2>
173
  {isCapturing && (
174
+ <p style={{ fontSize: 18, color: "var(--color-text-muted)", marginTop: 4, margin: 0 }}>
175
  Filling in details as you speak
176
  </p>
177
  )}
178
  {isComplete && (
179
+ <p style={{ fontSize: 18, color: "var(--color-text-muted)", marginTop: 4, margin: 0 }}>
180
  Saved to your record
181
  </p>
182
  )}
183
  </div>
184
  </div>
185
 
186
+ {/* Main content — centered focus area */}
187
+ <div
188
+ style={{
189
+ flex: 1,
190
+ display: "flex",
191
+ flexDirection: "column",
192
+ alignItems: "center",
193
+ justifyContent: "center",
194
+ gap: 24,
195
+ textAlign: "center",
196
+ minHeight: 0,
197
+ }}
198
+ >
199
+ {/* Capturing state: show large medication name or asking prompt */}
200
+ {isCapturing && (
201
+ <>
202
+ <div style={{ color: "var(--color-accent-pink)", marginBottom: 8, opacity: 0.7 }}>
203
+ <Pill style={{ width: 40, height: 40 }} />
204
+ </div>
205
+ <h3
206
+ style={{
207
+ fontSize: 40,
208
+ fontWeight: 600,
209
+ color: "var(--color-text-primary)",
210
+ margin: 0,
211
+ lineHeight: 1.3,
212
+ maxWidth: 600,
213
+ }}
214
+ >
215
+ {props.medication_name || "Which medication?"}
216
+ </h3>
217
+ </>
218
+ )}
219
+
220
+ {/* Complete state */}
221
+ {isComplete && (
222
+ <>
223
+ <div
224
+ style={{
225
+ width: 80,
226
+ height: 80,
227
+ borderRadius: "50%",
228
+ background: "rgba(125, 211, 168, 0.15)",
229
+ border: "2px solid rgba(125, 211, 168, 0.3)",
230
+ display: "flex",
231
+ alignItems: "center",
232
+ justifyContent: "center",
233
+ color: "var(--color-success)",
234
+ }}
235
+ >
236
+ <Check style={{ width: 40, height: 40 }} />
237
+ </div>
238
+ <h3
239
+ style={{
240
+ fontSize: 36,
241
+ fontWeight: 600,
242
+ color: "var(--color-text-primary)",
243
+ margin: 0,
244
+ }}
245
+ >
246
+ Entry saved
247
+ </h3>
248
+ </>
249
+ )}
250
+
251
+ {/* Confirmation state */}
252
+ {needsConfirmation && (
253
+ <h3
254
+ style={{
255
+ fontSize: 36,
256
+ fontWeight: 600,
257
+ color: "var(--color-text-primary)",
258
+ margin: 0,
259
+ }}
260
+ >
261
+ Does this look right?
262
+ </h3>
263
+ )}
264
  </div>
265
 
266
+ {/* Filled fields as pills at the bottom */}
267
+ {filledPills.length > 0 && (
268
+ <div
269
+ style={{
270
+ display: "flex",
271
+ flexWrap: "wrap",
272
+ gap: 10,
273
+ justifyContent: "center",
274
+ marginTop: 24,
275
+ paddingTop: 24,
276
+ borderTop: "1px solid rgba(255,255,255,0.05)",
277
+ }}
278
+ >
279
+ {filledPills.map((pill) => (
280
+ <FilledFieldPill key={pill.label} label={pill.label} value={pill.value} />
281
+ ))}
282
+ </div>
283
+ )}
284
+
285
+ {/* Robot message */}
286
+ {props.robot_message && (
287
+ <div
288
+ style={{
289
+ marginTop: 20,
290
+ padding: "16px 24px",
291
+ borderRadius: 16,
292
+ background: "rgba(179, 156, 208, 0.1)",
293
+ border: "1px solid rgba(179, 156, 208, 0.2)",
294
+ textAlign: "center",
295
+ }}
296
+ >
297
+ <p style={{ fontSize: 20, color: "var(--color-cta)", fontWeight: 500, margin: 0 }}>
298
+ {props.robot_message}
299
+ </p>
300
  </div>
301
  )}
302
 
303
+ {/* Confirmation Buttons — Large TV-distance touch targets */}
304
  {needsConfirmation && (
305
+ <div style={{ display: "flex", gap: 16, marginTop: 24, width: "100%", maxWidth: 500, margin: "24px auto 0" }}>
306
  <button
307
  onClick={handleConfirm}
308
+ style={{
309
+ flex: 1,
310
+ display: "flex",
311
+ alignItems: "center",
312
+ justifyContent: "center",
313
+ gap: 12,
314
+ padding: "24px 32px",
315
+ borderRadius: 20,
316
+ background: "rgba(125, 211, 168, 0.15)",
317
+ color: "var(--color-success)",
318
+ border: "1px solid rgba(125, 211, 168, 0.3)",
319
+ cursor: "pointer",
320
+ fontSize: 22,
321
+ fontWeight: 700,
322
+ minHeight: 80,
323
+ transition: "all 0.2s ease",
324
+ }}
325
  >
326
+ <Check style={{ width: 28, height: 28 }} />
327
  Looks Right
328
  </button>
329
  <button
330
  onClick={handleCorrect}
331
+ style={{
332
+ flex: 1,
333
+ display: "flex",
334
+ alignItems: "center",
335
+ justifyContent: "center",
336
+ gap: 12,
337
+ padding: "24px 32px",
338
+ borderRadius: 20,
339
+ background: "rgba(245, 194, 107, 0.15)",
340
+ color: "var(--color-warning)",
341
+ border: "1px solid rgba(245, 194, 107, 0.3)",
342
+ cursor: "pointer",
343
+ fontSize: 22,
344
+ fontWeight: 700,
345
+ minHeight: 80,
346
+ transition: "all 0.2s ease",
347
+ }}
348
  >
349
+ <Edit3 style={{ width: 28, height: 28 }} />
350
  Make Changes
351
  </button>
352
  </div>
353
  )}
354
 
 
 
 
 
 
 
 
355
  {/* Disclaimer */}
356
+ <p
357
+ style={{
358
+ marginTop: 20,
359
+ fontSize: 14,
360
+ color: "var(--color-text-muted)",
361
+ textAlign: "center",
362
+ fontStyle: "italic",
363
+ }}
364
+ >
365
  This records what you tell me, not verified intake.
366
  </p>
367
  </div>
frontend/src/registry/MyMedsList.tsx CHANGED
@@ -28,103 +28,242 @@ export function MyMedsList(props: MyMedsListProps) {
28
 
29
  return (
30
  <div
31
- className="relative overflow-hidden rounded-2xl p-5
32
- bg-gradient-to-br from-surface-elevated/90 to-surface-subtle/95
33
- border border-cta/15 transition-all
34
- shadow-[0_4px_24px_rgba(0,0,0,0.4),inset_0_1px_0_rgba(255,255,255,0.05)]"
 
 
 
 
 
35
  >
36
  {/* Top accent line */}
37
- <div className="absolute top-0 left-[20%] right-[20%] h-0.5 bg-gradient-to-r from-transparent via-cta to-transparent opacity-60" />
 
 
 
 
 
 
 
 
 
 
38
 
39
  {/* Header */}
40
- <div className="flex items-center gap-3.5 mb-4 pb-3.5 border-b border-white/5">
41
- <div className="w-11 h-11 rounded-xl flex items-center justify-center border bg-cta/15 border-cta/30 text-cta">
42
- <Pill className="w-5 h-5" />
 
 
 
 
 
 
 
 
 
 
 
 
43
  </div>
44
- <div className="flex-1">
45
- <h3 className="text-base font-bold tracking-tight">My Medications</h3>
46
- <p className="text-[11px] text-muted mt-0.5">
 
 
 
 
 
 
 
 
 
 
47
  {props.count} medication{props.count !== 1 ? "s" : ""}
48
  </p>
49
  </div>
50
  </div>
51
 
52
  {/* Medication List */}
53
- {props.medications.length === 0 ? (
54
- <div className="py-6 text-center">
55
- <Pill className="w-8 h-8 text-muted mx-auto mb-2 opacity-50" />
56
- <p className="text-[13px] text-muted">No medications set up yet</p>
57
- </div>
58
- ) : (
59
- <div className="space-y-2">
60
- {props.medications.map((med, i) => {
61
- const isTaken = med.status === "taken";
62
- return (
63
- <button
64
- key={med.id || i}
65
- onClick={() => handleTap(i, med.medication_name)}
66
- className="w-full flex items-center gap-3 p-3 rounded-xl
67
- bg-surface-subtle/30 border border-white/5
68
- hover:bg-surface-subtle/60 hover:border-cta/20
69
- active:scale-[0.98] transition-all text-left group"
70
- >
71
- {/* Number badge */}
72
- <div className="w-8 h-8 rounded-lg bg-cta/15 border border-cta/25 flex items-center justify-center flex-shrink-0">
73
- <span className="text-[13px] font-bold text-cta">{i + 1}</span>
74
- </div>
75
-
76
- {/* Details */}
77
- <div className="flex-1 min-w-0">
78
- <div className="flex items-center gap-2">
79
- <span className="text-[13px] font-semibold text-primary truncate">
80
- {med.medication_name}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </span>
82
- {med.dose && (
83
- <span className="text-[11px] text-muted">{med.dose}</span>
84
- )}
85
  </div>
86
- <div className="flex items-center gap-2 mt-0.5">
87
- {med.frequency && (
88
- <span className="text-[10px] text-muted">{med.frequency}</span>
89
- )}
90
- {med.times_of_day && med.times_of_day.length > 0 && (
91
- <span className="flex items-center gap-0.5 text-[10px] text-muted">
92
- <Clock className="w-3 h-3" />
93
- {med.times_of_day.join(", ")}
94
- </span>
95
- )}
96
- </div>
97
- </div>
98
 
99
- {/* Status indicator */}
100
- {med.status && (
101
- <div className="flex-shrink-0">
102
- {isTaken ? (
103
- <div className="flex items-center gap-1 px-2 py-1 rounded-full bg-success/15 text-success">
104
- <Check className="w-3.5 h-3.5" />
105
- <span className="text-[10px] font-semibold">
106
- {med.logged_at || "Taken"}
 
 
 
 
 
 
 
 
 
 
107
  </span>
108
- </div>
109
- ) : (
110
- <div className="flex items-center gap-1 px-2 py-1 rounded-full bg-warning/15 text-warning">
111
- <Clock className="w-3.5 h-3.5" />
112
- <span className="text-[10px] font-semibold">Pending</span>
113
- </div>
114
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  </div>
116
- )}
117
 
118
- <ChevronRight className="w-4 h-4 text-muted flex-shrink-0 group-hover:text-cta transition-colors" />
119
- </button>
120
- );
121
- })}
122
- </div>
123
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  {/* Voice hint footer */}
126
- <div className="flex items-center justify-center gap-2 mt-4 pt-3 border-t border-white/5">
127
- <span className="text-[10px] text-muted uppercase tracking-[0.12em] font-semibold">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  Say &ldquo;I took number two&rdquo; to log a dose
129
  </span>
130
  </div>
 
28
 
29
  return (
30
  <div
31
+ style={{
32
+ display: "flex",
33
+ flexDirection: "column",
34
+ height: "100%",
35
+ width: "100%",
36
+ padding: "40px 48px",
37
+ position: "relative",
38
+ overflow: "hidden",
39
+ }}
40
  >
41
  {/* Top accent line */}
42
+ <div
43
+ style={{
44
+ position: "absolute",
45
+ top: 0,
46
+ left: "10%",
47
+ right: "10%",
48
+ height: 3,
49
+ background: "linear-gradient(to right, transparent, var(--color-cta), transparent)",
50
+ opacity: 0.8,
51
+ }}
52
+ />
53
 
54
  {/* Header */}
55
+ <div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32, paddingBottom: 24, borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
56
+ <div
57
+ style={{
58
+ width: 56,
59
+ height: 56,
60
+ borderRadius: 16,
61
+ display: "flex",
62
+ alignItems: "center",
63
+ justifyContent: "center",
64
+ background: "rgba(179, 156, 208, 0.15)",
65
+ border: "1px solid rgba(179, 156, 208, 0.3)",
66
+ color: "var(--color-cta)",
67
+ }}
68
+ >
69
+ <Pill style={{ width: 28, height: 28 }} />
70
  </div>
71
+ <div>
72
+ <h2
73
+ style={{
74
+ fontSize: 28,
75
+ fontWeight: 700,
76
+ color: "var(--color-text-primary)",
77
+ margin: 0,
78
+ lineHeight: 1.2,
79
+ }}
80
+ >
81
+ My Medications
82
+ </h2>
83
+ <p style={{ fontSize: 18, color: "var(--color-text-muted)", marginTop: 4, margin: 0 }}>
84
  {props.count} medication{props.count !== 1 ? "s" : ""}
85
  </p>
86
  </div>
87
  </div>
88
 
89
  {/* Medication List */}
90
+ <div style={{ flex: 1, overflow: "auto" }}>
91
+ {props.medications.length === 0 ? (
92
+ <div
93
+ style={{
94
+ display: "flex",
95
+ flexDirection: "column",
96
+ alignItems: "center",
97
+ justifyContent: "center",
98
+ height: "100%",
99
+ gap: 16,
100
+ }}
101
+ >
102
+ <Pill style={{ width: 48, height: 48, color: "var(--color-text-muted)", opacity: 0.5 }} />
103
+ <p style={{ fontSize: 22, color: "var(--color-text-muted)" }}>No medications set up yet</p>
104
+ </div>
105
+ ) : (
106
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
107
+ {props.medications.map((med, i) => {
108
+ const isTaken = med.status === "taken";
109
+ return (
110
+ <button
111
+ key={med.id || i}
112
+ onClick={() => handleTap(i, med.medication_name)}
113
+ style={{
114
+ width: "100%",
115
+ display: "flex",
116
+ alignItems: "center",
117
+ gap: 20,
118
+ padding: "20px 24px",
119
+ borderRadius: 20,
120
+ background: "rgba(36, 36, 36, 0.6)",
121
+ border: "1px solid rgba(255,255,255,0.08)",
122
+ cursor: "pointer",
123
+ textAlign: "left",
124
+ transition: "all 0.2s ease",
125
+ color: "inherit",
126
+ fontFamily: "inherit",
127
+ }}
128
+ >
129
+ {/* Number badge */}
130
+ <div
131
+ style={{
132
+ width: 48,
133
+ height: 48,
134
+ borderRadius: 14,
135
+ background: "rgba(179, 156, 208, 0.15)",
136
+ border: "1px solid rgba(179, 156, 208, 0.25)",
137
+ display: "flex",
138
+ alignItems: "center",
139
+ justifyContent: "center",
140
+ flexShrink: 0,
141
+ }}
142
+ >
143
+ <span style={{ fontSize: 22, fontWeight: 700, color: "var(--color-cta)" }}>
144
+ {i + 1}
145
  </span>
 
 
 
146
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
+ {/* Details */}
149
+ <div style={{ flex: 1, minWidth: 0 }}>
150
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
151
+ <span
152
+ style={{
153
+ fontSize: 22,
154
+ fontWeight: 600,
155
+ color: "var(--color-text-primary)",
156
+ overflow: "hidden",
157
+ textOverflow: "ellipsis",
158
+ whiteSpace: "nowrap",
159
+ }}
160
+ >
161
+ {med.medication_name}
162
+ </span>
163
+ {med.dose && (
164
+ <span style={{ fontSize: 18, color: "var(--color-text-muted)" }}>
165
+ {med.dose}
166
  </span>
167
+ )}
168
+ </div>
169
+ <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 4 }}>
170
+ {med.frequency && (
171
+ <span style={{ fontSize: 16, color: "var(--color-text-muted)" }}>
172
+ {med.frequency}
173
+ </span>
174
+ )}
175
+ {med.times_of_day && med.times_of_day.length > 0 && (
176
+ <span
177
+ style={{
178
+ display: "flex",
179
+ alignItems: "center",
180
+ gap: 6,
181
+ fontSize: 16,
182
+ color: "var(--color-text-muted)",
183
+ }}
184
+ >
185
+ <Clock style={{ width: 16, height: 16 }} />
186
+ {med.times_of_day.join(", ")}
187
+ </span>
188
+ )}
189
+ </div>
190
  </div>
 
191
 
192
+ {/* Status indicator */}
193
+ {med.status && (
194
+ <div style={{ flexShrink: 0 }}>
195
+ {isTaken ? (
196
+ <div
197
+ style={{
198
+ display: "flex",
199
+ alignItems: "center",
200
+ gap: 8,
201
+ padding: "8px 16px",
202
+ borderRadius: 999,
203
+ background: "rgba(125, 211, 168, 0.15)",
204
+ color: "var(--color-success)",
205
+ }}
206
+ >
207
+ <Check style={{ width: 20, height: 20 }} />
208
+ <span style={{ fontSize: 16, fontWeight: 600 }}>
209
+ {med.logged_at || "Taken"}
210
+ </span>
211
+ </div>
212
+ ) : (
213
+ <div
214
+ style={{
215
+ display: "flex",
216
+ alignItems: "center",
217
+ gap: 8,
218
+ padding: "8px 16px",
219
+ borderRadius: 999,
220
+ background: "rgba(245, 194, 107, 0.15)",
221
+ color: "var(--color-warning)",
222
+ }}
223
+ >
224
+ <Clock style={{ width: 20, height: 20 }} />
225
+ <span style={{ fontSize: 16, fontWeight: 600 }}>Pending</span>
226
+ </div>
227
+ )}
228
+ </div>
229
+ )}
230
+
231
+ <ChevronRight
232
+ style={{
233
+ width: 24,
234
+ height: 24,
235
+ color: "var(--color-text-muted)",
236
+ flexShrink: 0,
237
+ }}
238
+ />
239
+ </button>
240
+ );
241
+ })}
242
+ </div>
243
+ )}
244
+ </div>
245
 
246
  {/* Voice hint footer */}
247
+ <div
248
+ style={{
249
+ display: "flex",
250
+ alignItems: "center",
251
+ justifyContent: "center",
252
+ gap: 8,
253
+ marginTop: 24,
254
+ paddingTop: 20,
255
+ borderTop: "1px solid rgba(255,255,255,0.05)",
256
+ }}
257
+ >
258
+ <span
259
+ style={{
260
+ fontSize: 18,
261
+ color: "var(--color-text-muted)",
262
+ textTransform: "uppercase",
263
+ letterSpacing: "0.12em",
264
+ fontWeight: 600,
265
+ }}
266
+ >
267
  Say &ldquo;I took number two&rdquo; to log a dose
268
  </span>
269
  </div>
frontend/src/registry/OnboardingProgress.tsx CHANGED
@@ -58,7 +58,7 @@ export function OnboardingProgress({ currentStep }: OnboardingProgressProps) {
58
  relative w-20 h-20 rounded-full flex items-center justify-center
59
  transition-all duration-500 ease-out
60
  ${status === "completed"
61
- ? "bg-success text-gray-900 scale-100"
62
  : status === "current"
63
  ? "bg-cta text-gray-900 scale-110 shadow-[0_0_30px_rgba(179,156,208,0.5)]"
64
  : "bg-surface-overlay text-muted scale-90 opacity-50"
@@ -82,7 +82,7 @@ export function OnboardingProgress({ currentStep }: OnboardingProgressProps) {
82
  className={`
83
  text-lg font-semibold uppercase tracking-wide
84
  transition-all duration-300
85
- ${status === "current" ? "text-cta" : status === "completed" ? "text-success" : "text-muted opacity-50"}
86
  `}
87
  >
88
  {step.label}
@@ -95,7 +95,7 @@ export function OnboardingProgress({ currentStep }: OnboardingProgressProps) {
95
  className={`
96
  w-16 h-1 mx-4 rounded-full transition-all duration-500
97
  ${getStepStatus(steps[index + 1].id, currentStep) !== "upcoming"
98
- ? "bg-success"
99
  : "bg-surface-overlay opacity-30"
100
  }
101
  `}
 
58
  relative w-20 h-20 rounded-full flex items-center justify-center
59
  transition-all duration-500 ease-out
60
  ${status === "completed"
61
+ ? "bg-accent-cyan text-gray-900 scale-100"
62
  : status === "current"
63
  ? "bg-cta text-gray-900 scale-110 shadow-[0_0_30px_rgba(179,156,208,0.5)]"
64
  : "bg-surface-overlay text-muted scale-90 opacity-50"
 
82
  className={`
83
  text-lg font-semibold uppercase tracking-wide
84
  transition-all duration-300
85
+ ${status === "current" ? "text-cta" : status === "completed" ? "text-accent-cyan" : "text-muted opacity-50"}
86
  `}
87
  >
88
  {step.label}
 
95
  className={`
96
  w-16 h-1 mx-4 rounded-full transition-all duration-500
97
  ${getStepStatus(steps[index + 1].id, currentStep) !== "upcoming"
98
+ ? "bg-accent-cyan"
99
  : "bg-surface-overlay opacity-30"
100
  }
101
  `}
frontend/src/registry/index.tsx CHANGED
@@ -143,7 +143,9 @@ export function renderComponent(
143
  }
144
 
145
  // ---- LoadExternalComponent Wrapper ----
146
- // For compatibility with LangGraph SDK patterns
 
 
147
 
148
  interface LoadComponentProps {
149
  message: {
 
143
  }
144
 
145
  // ---- LoadExternalComponent Wrapper ----
146
+ // Reserved for future LangGraph SDK integration (LoadExternalComponent migration).
147
+ // Currently unused — all rendering goes through renderComponent() above.
148
+ // To adopt: pass this as the `components` prop to LoadExternalComponent.
149
 
150
  interface LoadComponentProps {
151
  message: {
langgraph.json CHANGED
@@ -3,5 +3,8 @@
3
  "dependencies": ["."],
4
  "graphs": {
5
  "report_agent": "./src/reachy_mini_conversation_app/langgraph_agent/graph.py:graph"
 
 
 
6
  }
7
  }
 
3
  "dependencies": ["."],
4
  "graphs": {
5
  "report_agent": "./src/reachy_mini_conversation_app/langgraph_agent/graph.py:graph"
6
+ },
7
+ "ui": {
8
+ "report_agent": "./src/reachy_mini_conversation_app/langgraph_agent/ui.tsx"
9
  }
10
  }
src/reachy_mini_conversation_app/database.py CHANGED
@@ -143,6 +143,17 @@ _TOKEN_USAGE_INDEXES = [
143
  "CREATE INDEX IF NOT EXISTS idx_token_timestamp ON token_usage(timestamp)",
144
  ]
145
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  class MiniMinderDB:
148
  """SQLite-backed storage for headache diary and medication logs."""
@@ -172,6 +183,7 @@ class MiniMinderDB:
172
  conn.execute(_TOKEN_USAGE_SCHEMA)
173
  for idx_sql in _TOKEN_USAGE_INDEXES:
174
  conn.execute(idx_sql)
 
175
  conn.commit()
176
  # Run migrations for existing databases
177
  self._migrate_add_preferred_provider(conn)
@@ -490,6 +502,43 @@ h1 {{ color: #333; }}
490
  )
491
  return cur.lastrowid # type: ignore[return-value]
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  def get_scheduled_medications(
494
  self, active_only: bool = True
495
  ) -> List[Dict[str, Any]]:
@@ -561,6 +610,68 @@ h1 {{ color: #333; }}
561
  """Deactivate a scheduled medication (soft delete)."""
562
  return self.update_scheduled_medication(med_id, {"active": 0})
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  def get_todays_medication_status(self) -> Dict[str, Any]:
565
  """Get today's medication status for dashboard and MedStatus GenUI.
566
 
@@ -744,6 +855,66 @@ h1 {{ color: #333; }}
744
  ).fetchall()
745
  return {r["medication_name"]: r["redacted_as"] for r in rows}
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  def close(self) -> None:
748
  """Close the database connection."""
749
  if self._conn:
 
143
  "CREATE INDEX IF NOT EXISTS idx_token_timestamp ON token_usage(timestamp)",
144
  ]
145
 
146
+ _GENERATED_REPORTS_SCHEMA = """
147
+ CREATE TABLE IF NOT EXISTS generated_reports (
148
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
149
+ report_type TEXT NOT NULL DEFAULT 'clinical',
150
+ title TEXT NOT NULL,
151
+ content TEXT NOT NULL,
152
+ metadata TEXT,
153
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
154
+ )
155
+ """
156
+
157
 
158
  class MiniMinderDB:
159
  """SQLite-backed storage for headache diary and medication logs."""
 
183
  conn.execute(_TOKEN_USAGE_SCHEMA)
184
  for idx_sql in _TOKEN_USAGE_INDEXES:
185
  conn.execute(idx_sql)
186
+ conn.execute(_GENERATED_REPORTS_SCHEMA)
187
  conn.commit()
188
  # Run migrations for existing databases
189
  self._migrate_add_preferred_provider(conn)
 
502
  )
503
  return cur.lastrowid # type: ignore[return-value]
504
 
505
+ def upsert_scheduled_medication(self, data: Dict[str, Any]) -> tuple[int, bool]:
506
+ """Insert or update a scheduled medication by name.
507
+
508
+ If an active medication with the same name (case-insensitive) already
509
+ exists, merge the new fields into it. Otherwise insert a new row.
510
+
511
+ Returns:
512
+ (med_id, is_new) — the row id and whether a new row was created.
513
+ """
514
+ medication_name = data.get("medication_name", "")
515
+ conn = self._get_conn()
516
+ row = conn.execute(
517
+ "SELECT id FROM scheduled_medications "
518
+ "WHERE LOWER(medication_name) = LOWER(?) AND active = 1",
519
+ (medication_name,),
520
+ ).fetchone()
521
+
522
+ if row:
523
+ existing_id: int = row["id"]
524
+ # Only merge fields that are non-None in the incoming data
525
+ updates = {
526
+ k: v
527
+ for k, v in data.items()
528
+ if v is not None and k != "medication_name"
529
+ }
530
+ if updates:
531
+ self.update_scheduled_medication(existing_id, updates)
532
+ logger.info(
533
+ "Upserted (updated) scheduled medication id=%d: %s",
534
+ existing_id,
535
+ medication_name,
536
+ )
537
+ return existing_id, False
538
+
539
+ new_id = self.add_scheduled_medication(data)
540
+ return new_id, True
541
+
542
  def get_scheduled_medications(
543
  self, active_only: bool = True
544
  ) -> List[Dict[str, Any]]:
 
610
  """Deactivate a scheduled medication (soft delete)."""
611
  return self.update_scheduled_medication(med_id, {"active": 0})
612
 
613
+ def deduplicate_scheduled_medications(self) -> int:
614
+ """Merge duplicate active scheduled medications by name.
615
+
616
+ For each medication name, keeps the most complete record (the one
617
+ with the most non-NULL fields) and deactivates the rest. Fields
618
+ from lesser duplicates are merged into the winner when the winner
619
+ has NULL for those fields.
620
+
621
+ Returns:
622
+ Number of duplicate rows deactivated.
623
+ """
624
+ meds = self.get_scheduled_medications(active_only=True)
625
+ # Group by lower-case name
626
+ groups: Dict[str, list] = {}
627
+ for m in meds:
628
+ key = (m.get("medication_name") or "").lower()
629
+ groups.setdefault(key, []).append(m)
630
+
631
+ deactivated = 0
632
+ merge_fields = [
633
+ "dose",
634
+ "frequency",
635
+ "times_of_day",
636
+ "reminder_enabled",
637
+ "reminder_times",
638
+ "notes",
639
+ ]
640
+
641
+ for _name, dupes in groups.items():
642
+ if len(dupes) <= 1:
643
+ continue
644
+
645
+ # Score each by number of non-None fields
646
+ def completeness(m: Dict[str, Any]) -> int:
647
+ return sum(1 for f in merge_fields if m.get(f) is not None)
648
+
649
+ dupes.sort(key=completeness, reverse=True)
650
+ winner = dupes[0]
651
+
652
+ # Merge missing fields from duplicates into winner
653
+ merged_updates: Dict[str, Any] = {}
654
+ for dup in dupes[1:]:
655
+ for f in merge_fields:
656
+ if winner.get(f) is None and dup.get(f) is not None:
657
+ merged_updates[f] = dup[f]
658
+ winner[f] = dup[f] # update in-memory too
659
+
660
+ if merged_updates:
661
+ self.update_scheduled_medication(winner["id"], merged_updates)
662
+
663
+ # Deactivate losers
664
+ for dup in dupes[1:]:
665
+ self.deactivate_scheduled_medication(dup["id"])
666
+ deactivated += 1
667
+
668
+ if deactivated:
669
+ logger.info(
670
+ "Deduplicated scheduled medications: deactivated %d duplicates",
671
+ deactivated,
672
+ )
673
+ return deactivated
674
+
675
  def get_todays_medication_status(self) -> Dict[str, Any]:
676
  """Get today's medication status for dashboard and MedStatus GenUI.
677
 
 
855
  ).fetchall()
856
  return {r["medication_name"]: r["redacted_as"] for r in rows}
857
 
858
+ # -------------------------------------------------------------------------
859
+ # Generated Reports Persistence
860
+ # -------------------------------------------------------------------------
861
+
862
+ def save_report(
863
+ self,
864
+ report_type: str,
865
+ title: str,
866
+ content: str,
867
+ metadata: Optional[Dict[str, Any]] = None,
868
+ ) -> int:
869
+ """Save a generated report and return its id."""
870
+ conn = self._get_conn()
871
+ cursor = conn.execute(
872
+ """INSERT INTO generated_reports (report_type, title, content, metadata)
873
+ VALUES (?, ?, ?, ?)""",
874
+ (
875
+ report_type,
876
+ title,
877
+ content,
878
+ json.dumps(metadata) if metadata else None,
879
+ ),
880
+ )
881
+ conn.commit()
882
+ return cursor.lastrowid # type: ignore[return-value]
883
+
884
+ def get_reports(self, limit: int = 20) -> List[Dict[str, Any]]:
885
+ """Return the most recent saved reports (newest first)."""
886
+ conn = self._get_conn()
887
+ rows = conn.execute(
888
+ "SELECT * FROM generated_reports ORDER BY created_at DESC LIMIT ?",
889
+ (limit,),
890
+ ).fetchall()
891
+ results = []
892
+ for r in rows:
893
+ d = dict(r)
894
+ if d.get("metadata"):
895
+ try:
896
+ d["metadata"] = json.loads(d["metadata"])
897
+ except (json.JSONDecodeError, TypeError):
898
+ pass
899
+ results.append(d)
900
+ return results
901
+
902
+ def get_report(self, report_id: int) -> Optional[Dict[str, Any]]:
903
+ """Return a single report by id."""
904
+ conn = self._get_conn()
905
+ row = conn.execute(
906
+ "SELECT * FROM generated_reports WHERE id = ?", (report_id,)
907
+ ).fetchone()
908
+ if row is None:
909
+ return None
910
+ d = dict(row)
911
+ if d.get("metadata"):
912
+ try:
913
+ d["metadata"] = json.loads(d["metadata"])
914
+ except (json.JSONDecodeError, TypeError):
915
+ pass
916
+ return d
917
+
918
  def close(self) -> None:
919
  """Close the database connection."""
920
  if self._conn:
src/reachy_mini_conversation_app/langgraph_agent/nodes/report_builder.py CHANGED
@@ -10,24 +10,42 @@ from reachy_mini_conversation_app.database import MiniMinderDB
10
  from reachy_mini_conversation_app.config import DB_PATH
11
 
12
 
13
- async def report_builder_node(state: Dict[str, Any]):
 
14
  db = MiniMinderDB(DB_PATH)
15
-
16
- # 1. Fetch data
17
  headaches = db.get_recent_headaches(days=30)
18
  medications = db.get_recent_medications(days=30)
19
  profile = db.get_or_create_profile()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  # 2. Initialize UI Message
22
  ai_message = AIMessage(content="I'm preparing your clinical report now...")
23
 
24
  # Initial push of the component
25
- report_id = str(uuid.uuid4())
26
  ui_message = push_ui_message(
27
  "ReportPreview",
28
  {
29
- "id": report_id,
30
- "title": f"Health Report for {profile.get('display_name', 'User')}",
31
  "status": "loading",
32
  "content": "",
33
  "doctorName": profile.get("neurologist_name"),
@@ -38,9 +56,6 @@ async def report_builder_node(state: Dict[str, Any]):
38
  ui_id = ui_message["id"]
39
 
40
  # 3. Stream content
41
- # In a real app, you might use an LLM here to summarize.
42
- # For now, we'll build it procedurally to demonstrate the streaming pattern.
43
-
44
  contentLines = [
45
  "## Clinical Summary",
46
  f"Period: {datetime.now().strftime('%B %Y')}",
@@ -64,14 +79,26 @@ async def report_builder_node(state: Dict[str, Any]):
64
  )
65
  await asyncio.sleep(0.5) # Simulate processing/streaming
66
 
67
- # Final update
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  push_ui_message(
69
  "ReportPreview",
70
- {"status": "complete"},
71
  id=ui_id,
72
  message=ai_message,
73
  merge=True,
74
  )
75
 
76
- db.close()
77
  return {"messages": [ai_message]}
 
10
  from reachy_mini_conversation_app.config import DB_PATH
11
 
12
 
13
+ def _fetch_report_data():
14
+ """Synchronous DB access — runs in a thread to avoid blocking the event loop."""
15
  db = MiniMinderDB(DB_PATH)
 
 
16
  headaches = db.get_recent_headaches(days=30)
17
  medications = db.get_recent_medications(days=30)
18
  profile = db.get_or_create_profile()
19
+ db.close()
20
+ return headaches, medications, profile
21
+
22
+
23
+ def _save_report(
24
+ report_type: str, title: str, content: str, metadata: dict | None = None
25
+ ) -> int:
26
+ """Save a generated report to SQLite — runs in a thread."""
27
+ db = MiniMinderDB(DB_PATH)
28
+ report_id = db.save_report(report_type, title, content, metadata)
29
+ db.close()
30
+ return report_id
31
+
32
+
33
+ async def report_builder_node(state: Dict[str, Any]):
34
+ headaches, medications, profile = await asyncio.to_thread(_fetch_report_data)
35
+
36
+ display_name = profile.get("display_name", "User")
37
+ title = f"Health Report for {display_name}"
38
 
39
  # 2. Initialize UI Message
40
  ai_message = AIMessage(content="I'm preparing your clinical report now...")
41
 
42
  # Initial push of the component
43
+ ui_report_id = str(uuid.uuid4())
44
  ui_message = push_ui_message(
45
  "ReportPreview",
46
  {
47
+ "id": ui_report_id,
48
+ "title": title,
49
  "status": "loading",
50
  "content": "",
51
  "doctorName": profile.get("neurologist_name"),
 
56
  ui_id = ui_message["id"]
57
 
58
  # 3. Stream content
 
 
 
59
  contentLines = [
60
  "## Clinical Summary",
61
  f"Period: {datetime.now().strftime('%B %Y')}",
 
79
  )
80
  await asyncio.sleep(0.5) # Simulate processing/streaming
81
 
82
+ # 4. Auto-save report to database
83
+ saved_id = await asyncio.to_thread(
84
+ _save_report,
85
+ "clinical",
86
+ title,
87
+ accumulated_content,
88
+ {
89
+ "doctorName": profile.get("neurologist_name"),
90
+ "doctorEmail": profile.get("neurologist_email"),
91
+ "period": datetime.now().strftime("%B %Y"),
92
+ },
93
+ )
94
+
95
+ # Final update with saved id
96
  push_ui_message(
97
  "ReportPreview",
98
+ {"status": "complete", "savedId": saved_id},
99
  id=ui_id,
100
  message=ai_message,
101
  merge=True,
102
  )
103
 
 
104
  return {"messages": [ai_message]}
src/reachy_mini_conversation_app/langgraph_agent/nodes/session_summary_node.py CHANGED
@@ -4,6 +4,7 @@ Generates a summary of the current session's logged medications,
4
  headaches, and questions for provider.
5
  """
6
 
 
7
  import uuid
8
  from datetime import datetime
9
  from typing import Dict, Any
@@ -16,18 +17,24 @@ from reachy_mini_conversation_app.config import DB_PATH
16
  from reachy_mini_conversation_app.stream_api import get_current_session_id
17
 
18
 
 
 
 
 
 
 
 
 
 
 
19
  async def session_summary_node(state: Dict[str, Any]):
20
  """Build and emit a SessionSummary GenUI component.
21
 
22
  Gathers data from today's entries and emits the SessionSummary component.
23
  """
24
- db = MiniMinderDB(DB_PATH)
25
-
26
- # Get today's data
27
- today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
28
 
29
  # Get today's medications
30
- all_meds = db.get_recent_medications(days=1)
31
  medications_logged = [
32
  {
33
  "name": m.get("medication_name", "Unknown"),
@@ -37,15 +44,11 @@ async def session_summary_node(state: Dict[str, Any]):
37
  ]
38
 
39
  # Get today's headaches
40
- all_headaches = db.get_recent_headaches(days=1)
41
  headaches_logged = [
42
  {"onset": h.get("onset_time", "Unknown"), "intensity": h.get("intensity")}
43
  for h in all_headaches
44
  ]
45
 
46
- # Get profile for context
47
- profile = db.get_or_create_profile()
48
-
49
  # Build AI message
50
  ai_message = AIMessage(content="Here's a summary of our session...")
51
 
@@ -71,7 +74,6 @@ async def session_summary_node(state: Dict[str, Any]):
71
  message=ai_message,
72
  )
73
 
74
- db.close()
75
  return {"messages": [ai_message]}
76
 
77
 
 
4
  headaches, and questions for provider.
5
  """
6
 
7
+ import asyncio
8
  import uuid
9
  from datetime import datetime
10
  from typing import Dict, Any
 
17
  from reachy_mini_conversation_app.stream_api import get_current_session_id
18
 
19
 
20
+ def _fetch_session_data():
21
+ """Synchronous DB access — runs in a thread to avoid blocking the event loop."""
22
+ db = MiniMinderDB(DB_PATH)
23
+ all_meds = db.get_recent_medications(days=1)
24
+ all_headaches = db.get_recent_headaches(days=1)
25
+ profile = db.get_or_create_profile()
26
+ db.close()
27
+ return all_meds, all_headaches, profile
28
+
29
+
30
  async def session_summary_node(state: Dict[str, Any]):
31
  """Build and emit a SessionSummary GenUI component.
32
 
33
  Gathers data from today's entries and emits the SessionSummary component.
34
  """
35
+ all_meds, all_headaches, profile = await asyncio.to_thread(_fetch_session_data)
 
 
 
36
 
37
  # Get today's medications
 
38
  medications_logged = [
39
  {
40
  "name": m.get("medication_name", "Unknown"),
 
44
  ]
45
 
46
  # Get today's headaches
 
47
  headaches_logged = [
48
  {"onset": h.get("onset_time", "Unknown"), "intensity": h.get("intensity")}
49
  for h in all_headaches
50
  ]
51
 
 
 
 
52
  # Build AI message
53
  ai_message = AIMessage(content="Here's a summary of our session...")
54
 
 
74
  message=ai_message,
75
  )
76
 
 
77
  return {"messages": [ai_message]}
78
 
79
 
src/reachy_mini_conversation_app/langgraph_agent/nodes/trend_analyzer.py CHANGED
@@ -1,18 +1,24 @@
 
1
  from typing import Dict, Any
2
  from langchain_core.messages import AIMessage
3
  from langgraph.graph.ui import push_ui_message
4
- from datetime import datetime, timedelta
5
  from collections import Counter
6
 
7
  from reachy_mini_conversation_app.database import MiniMinderDB
8
  from reachy_mini_conversation_app.config import DB_PATH
9
 
10
 
11
- async def trend_analyzer_node(state: Dict[str, Any]):
 
12
  db = MiniMinderDB(DB_PATH)
13
-
14
- # 1. Fetch data for last 30 days
15
  headaches = db.get_recent_headaches(days=30)
 
 
 
 
 
 
16
 
17
  # 2. Analyze trends
18
  # Count headaches per day of week
@@ -21,7 +27,7 @@ async def trend_analyzer_node(state: Dict[str, Any]):
21
  try:
22
  dt = datetime.fromisoformat(h["created_at"])
23
  days_of_week.append(dt.strftime("%A"))
24
- except:
25
  continue
26
 
27
  counts = Counter(days_of_week)
@@ -57,5 +63,4 @@ async def trend_analyzer_node(state: Dict[str, Any]):
57
  message=ai_message,
58
  )
59
 
60
- db.close()
61
  return {"messages": [ai_message]}
 
1
+ import asyncio
2
  from typing import Dict, Any
3
  from langchain_core.messages import AIMessage
4
  from langgraph.graph.ui import push_ui_message
5
+ from datetime import datetime
6
  from collections import Counter
7
 
8
  from reachy_mini_conversation_app.database import MiniMinderDB
9
  from reachy_mini_conversation_app.config import DB_PATH
10
 
11
 
12
+ def _fetch_trend_data():
13
+ """Synchronous DB access — runs in a thread to avoid blocking the event loop."""
14
  db = MiniMinderDB(DB_PATH)
 
 
15
  headaches = db.get_recent_headaches(days=30)
16
+ db.close()
17
+ return headaches
18
+
19
+
20
+ async def trend_analyzer_node(state: Dict[str, Any]):
21
+ headaches = await asyncio.to_thread(_fetch_trend_data)
22
 
23
  # 2. Analyze trends
24
  # Count headaches per day of week
 
27
  try:
28
  dt = datetime.fromisoformat(h["created_at"])
29
  days_of_week.append(dt.strftime("%A"))
30
+ except Exception:
31
  continue
32
 
33
  counts = Counter(days_of_week)
 
63
  message=ai_message,
64
  )
65
 
 
66
  return {"messages": [ai_message]}
src/reachy_mini_conversation_app/langgraph_agent/ui.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LangGraph GenUI Component Map (Stub)
3
+ *
4
+ * This file declares the UI components emitted by the report_agent graph.
5
+ * Components are rendered client-side via the frontend registry
6
+ * (frontend/src/registry/index.tsx), NOT via LangSmith bundling.
7
+ *
8
+ * This stub exists so that:
9
+ * 1. langgraph.json has a valid `ui` entry (good hygiene / docs contract)
10
+ * 2. Future migration to LoadExternalComponent has a starting point
11
+ *
12
+ * The component names here MUST match the first argument to push_ui_message()
13
+ * in the graph nodes (report_builder.py, trend_analyzer.py, session_summary_node.py).
14
+ */
15
+
16
+ export default function () {
17
+ // Placeholder — real components live in frontend/src/registry/
18
+ return null;
19
+ }
src/reachy_mini_conversation_app/openai_realtime.py CHANGED
@@ -554,8 +554,11 @@ class OpenaiRealtimeHandler(RealtimeHandler):
554
  ],
555
  },
556
  )
557
- # Phase 1: force speech only — no tool calls allowed
558
- await conn.response.create(response={"tool_choice": "none"})
 
 
 
559
  logger.info(
560
  "Proactive greeting triggered (total startup: %.0fms)",
561
  (time.monotonic() - t_connect) * 1000,
@@ -634,23 +637,44 @@ class OpenaiRealtimeHandler(RealtimeHandler):
634
  logger.info(
635
  "Phase 2: greeting done, triggering onboarding flow"
636
  )
637
- await conn.conversation.item.create(
638
- item={
639
- "type": "message",
640
- "role": "user",
641
- "content": [
642
- {
643
- "type": "input_text",
644
- "text": (
645
- "[SYSTEM] Now begin the onboarding flow. "
646
- "Call get_onboarding_status to check progress, "
647
- "then follow your Onboarding Flow instructions."
648
- ),
649
- }
650
- ],
651
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  )
653
- await conn.response.create()
654
  else:
655
  logger.debug("Response done (no response object)")
656
 
@@ -839,7 +863,6 @@ class OpenaiRealtimeHandler(RealtimeHandler):
839
  )
840
  # Tools that gather info — LLM should chain to next tool
841
  _ONBOARDING_CHAIN_TOOLS = {
842
- "get_onboarding_status",
843
  "get_current_datetime",
844
  }
845
  # Determine if we need to force speech (no more tools)
@@ -852,8 +875,23 @@ class OpenaiRealtimeHandler(RealtimeHandler):
852
  "Follow your system prompt instructions and speak directly to the user. "
853
  "You MUST respond in British English only."
854
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
  elif tool_name in _ONBOARDING_CHAIN_TOOLS:
856
- # After getting status/datetime, call the next tool
857
  resp_instructions = (
858
  "Continue following the Onboarding Flow from your system prompt. "
859
  "Use the tool result to decide what to do next. "
@@ -880,7 +918,10 @@ class OpenaiRealtimeHandler(RealtimeHandler):
880
  "instructions": resp_instructions,
881
  }
882
  if force_speech:
883
- # API-level constraint: prevent tool calls entirely
 
 
 
884
  resp_config["tool_choice"] = "none"
885
  await self.connection.response.create(
886
  response=resp_config,
 
554
  ],
555
  },
556
  )
557
+ # Phase 1: force speech only — remove ALL tools so the
558
+ # model has no choice but to produce audio output.
559
+ await conn.response.create(
560
+ response={"tools": [], "tool_choice": "none"}
561
+ )
562
  logger.info(
563
  "Proactive greeting triggered (total startup: %.0fms)",
564
  (time.monotonic() - t_connect) * 1000,
 
637
  logger.info(
638
  "Phase 2: greeting done, triggering onboarding flow"
639
  )
640
+ try:
641
+ await conn.conversation.item.create(
642
+ item={
643
+ "type": "message",
644
+ "role": "user",
645
+ "content": [
646
+ {
647
+ "type": "input_text",
648
+ "text": (
649
+ "[SYSTEM] Now begin the onboarding flow. "
650
+ "Call get_onboarding_status to check progress, "
651
+ "then follow your Onboarding Flow instructions."
652
+ ),
653
+ }
654
+ ],
655
+ },
656
+ )
657
+ phase2_tools = tools or get_tool_specs()
658
+ logger.info(
659
+ "Phase 2: sending response.create with %d tools, forcing get_onboarding_status",
660
+ len(phase2_tools),
661
+ )
662
+ await conn.response.create(
663
+ response={
664
+ "tools": phase2_tools,
665
+ "tool_choice": "required",
666
+ }
667
+ )
668
+ logger.info(
669
+ "Phase 2: response.create sent successfully"
670
+ )
671
+ except Exception as e:
672
+ logger.error("Phase 2 FAILED: %s", e, exc_info=True)
673
+ else:
674
+ logger.info(
675
+ "response.done: no onboarding followup needed (flag=%s)",
676
+ self._needs_onboarding_followup,
677
  )
 
678
  else:
679
  logger.debug("Response done (no response object)")
680
 
 
863
  )
864
  # Tools that gather info — LLM should chain to next tool
865
  _ONBOARDING_CHAIN_TOOLS = {
 
866
  "get_current_datetime",
867
  }
868
  # Determine if we need to force speech (no more tools)
 
875
  "Follow your system prompt instructions and speak directly to the user. "
876
  "You MUST respond in British English only."
877
  )
878
+ elif tool_name == "get_onboarding_status":
879
+ # The UI has already been shown by the tool.
880
+ # Let the model continue the onboarding flow —
881
+ # it needs tools (show_onboarding_step, etc.)
882
+ # so do NOT strip them with force_speech.
883
+ resp_instructions = (
884
+ "The onboarding status has been checked and the progress UI "
885
+ "is already shown on screen. Now continue the onboarding flow. "
886
+ "Follow the Onboarding Steps from your system prompt. "
887
+ "If on the 'welcome' step, warmly introduce yourself as Mini-Minder, "
888
+ "the user's health companion, then immediately proceed to the 'name' step: "
889
+ "call show_onboarding_step(current_step='name') and ask 'What should I call you?'. "
890
+ "You MUST speak out loud AND call the appropriate tools. "
891
+ "You MUST respond in British English only."
892
+ )
893
  elif tool_name in _ONBOARDING_CHAIN_TOOLS:
894
+ # After getting datetime, call the next tool
895
  resp_instructions = (
896
  "Continue following the Onboarding Flow from your system prompt. "
897
  "Use the tool result to decide what to do next. "
 
918
  "instructions": resp_instructions,
919
  }
920
  if force_speech:
921
+ # API-level constraint: prevent tool calls entirely.
922
+ # tools=[] removes all tools; tool_choice="none"
923
+ # is a redundant safety net.
924
+ resp_config["tools"] = []
925
  resp_config["tool_choice"] = "none"
926
  await self.connection.response.create(
927
  response=resp_config,
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/complete_onboarding.py CHANGED
@@ -116,13 +116,23 @@ class CompleteOnboarding(Tool):
116
  },
117
  )
118
 
119
- # Auto-dismiss the summary overlay after 10s so the user
120
  # returns to the dashboard once the robot finishes speaking.
121
  async def _delayed_dismiss() -> None:
122
- await asyncio.sleep(10)
123
- await emit_ui_dismiss()
124
-
125
- asyncio.create_task(_delayed_dismiss())
 
 
 
 
 
 
 
 
 
 
126
 
127
  return {
128
  "status": "completed",
@@ -131,3 +141,7 @@ class CompleteOnboarding(Tool):
131
  "neurologist_configured": neurologist_email is not None,
132
  "caregiver_configured": caregiver_email is not None,
133
  }
 
 
 
 
 
116
  },
117
  )
118
 
119
+ # Auto-dismiss the summary overlay after a few seconds so the user
120
  # returns to the dashboard once the robot finishes speaking.
121
  async def _delayed_dismiss() -> None:
122
+ try:
123
+ await asyncio.sleep(5)
124
+ logger.info("Auto-dismissing onboarding overlay")
125
+ await emit_ui_dismiss()
126
+ # Kick the dashboard to refresh now that the overlay is gone
127
+ await emit_dashboard_updated("onboarding_overlay_dismissed")
128
+ except Exception:
129
+ logger.exception("Failed to auto-dismiss onboarding overlay")
130
+ finally:
131
+ _background_tasks.discard(task)
132
+
133
+ task = asyncio.create_task(_delayed_dismiss())
134
+ # prevent garbage collection of the fire-and-forget task
135
+ _background_tasks.add(task)
136
 
137
  return {
138
  "status": "completed",
 
141
  "neurologist_configured": neurologist_email is not None,
142
  "caregiver_configured": caregiver_email is not None,
143
  }
144
+
145
+
146
+ # prevent GC on fire-and-forget tasks
147
+ _background_tasks: set[asyncio.Task[None]] = set()
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/get_onboarding_status.py CHANGED
@@ -4,6 +4,10 @@ import logging
4
  from typing import Any, Dict
5
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
 
 
 
 
7
 
8
 
9
  logger = logging.getLogger(__name__)
@@ -24,7 +28,7 @@ class GetOnboardingStatus(Tool):
24
  }
25
 
26
  async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
27
- """Get onboarding status."""
28
  logger.info("Tool call: get_onboarding_status")
29
 
30
  if deps.database is None:
@@ -33,9 +37,25 @@ class GetOnboardingStatus(Tool):
33
  profile = deps.database.get_or_create_profile()
34
  medications = deps.database.get_scheduled_medications()
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  return {
37
- "onboarding_completed": bool(profile.get("onboarding_completed", 0)),
38
- "onboarding_step": profile.get("onboarding_step", "welcome"),
39
  "display_name": profile.get("display_name"),
40
  "medications_count": len(medications),
41
  }
 
4
  from typing import Any, Dict
5
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
+ from reachy_mini_conversation_app.stream_api import (
8
+ emit_ui_component,
9
+ emit_dashboard_updated,
10
+ )
11
 
12
 
13
  logger = logging.getLogger(__name__)
 
28
  }
29
 
30
  async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
31
+ """Get onboarding status and auto-show the onboarding UI if incomplete."""
32
  logger.info("Tool call: get_onboarding_status")
33
 
34
  if deps.database is None:
 
37
  profile = deps.database.get_or_create_profile()
38
  medications = deps.database.get_scheduled_medications()
39
 
40
+ completed = bool(profile.get("onboarding_completed", 0))
41
+ current_step = profile.get("onboarding_step", "welcome")
42
+
43
+ # Following the headache-logging pattern: emit the UI directly
44
+ # so the model doesn't need to call show_onboarding_step separately.
45
+ if not completed:
46
+ await emit_ui_component(
47
+ "OnboardingProgress",
48
+ {"currentStep": current_step},
49
+ )
50
+ await emit_dashboard_updated("onboarding_status_checked")
51
+ logger.info(
52
+ "Onboarding incomplete — auto-emitted OnboardingProgress (step=%s)",
53
+ current_step,
54
+ )
55
+
56
  return {
57
+ "onboarding_completed": completed,
58
+ "onboarding_step": current_step,
59
  "display_name": profile.get("display_name"),
60
  "medications_count": len(medications),
61
  }
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/setup_medication.py CHANGED
@@ -87,11 +87,14 @@ class SetupMedication(Tool):
87
  "notes": notes,
88
  }
89
 
90
- med_id = deps.database.add_scheduled_medication(data)
91
 
92
  # Get current medication count for the index
93
  medications = deps.database.get_scheduled_medications()
94
- index = len(medications) - 1 # 0-based index for the new medication
 
 
 
95
 
96
  # Emit GenUI component to show the medication card
97
  await emit_ui_component(
@@ -101,7 +104,7 @@ class SetupMedication(Tool):
101
  "dose": dose,
102
  "frequency": frequency,
103
  "timesOfDay": times_of_day,
104
- "isNew": True,
105
  "index": index,
106
  },
107
  )
@@ -110,7 +113,7 @@ class SetupMedication(Tool):
110
  await emit_dashboard_updated("medication_setup")
111
 
112
  return {
113
- "status": "added",
114
  "id": med_id,
115
  "medication_name": medication_name,
116
  "dose": dose,
 
87
  "notes": notes,
88
  }
89
 
90
+ med_id, is_new = deps.database.upsert_scheduled_medication(data)
91
 
92
  # Get current medication count for the index
93
  medications = deps.database.get_scheduled_medications()
94
+ index = next(
95
+ (i for i, m in enumerate(medications) if m["id"] == med_id),
96
+ len(medications) - 1,
97
+ )
98
 
99
  # Emit GenUI component to show the medication card
100
  await emit_ui_component(
 
104
  "dose": dose,
105
  "frequency": frequency,
106
  "timesOfDay": times_of_day,
107
+ "isNew": is_new,
108
  "index": index,
109
  },
110
  )
 
113
  await emit_dashboard_updated("medication_setup")
114
 
115
  return {
116
+ "status": "added" if is_new else "updated",
117
  "id": med_id,
118
  "medication_name": medication_name,
119
  "dose": dose,
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/show_onboarding_step.py CHANGED
@@ -1,7 +1,7 @@
1
  """Tool to display the onboarding progress UI component."""
2
 
3
  import logging
4
- from typing import Any, Dict, Literal
5
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
  from reachy_mini_conversation_app.stream_api import (
@@ -22,7 +22,8 @@ class ShowOnboardingStep(Tool):
22
  description = (
23
  "Display or update the onboarding progress indicator to show the user which "
24
  "step of the setup process they are on. Call this when transitioning between "
25
- "onboarding steps to give visual feedback."
 
26
  )
27
  parameters_schema = {
28
  "type": "object",
@@ -35,6 +36,13 @@ class ShowOnboardingStep(Tool):
35
  "'contacts', or 'complete'."
36
  ),
37
  },
 
 
 
 
 
 
 
38
  },
39
  "required": ["current_step"],
40
  }
@@ -43,13 +51,20 @@ class ShowOnboardingStep(Tool):
43
  self,
44
  deps: ToolDependencies,
45
  current_step: OnboardingStep | None = None,
 
46
  **kwargs: Any,
47
  ) -> Dict[str, Any]:
48
  """Emit the OnboardingProgress component."""
49
  # LLM sometimes sends "step" instead of "current_step"
50
  if current_step is None:
51
  current_step = kwargs.get("step", "welcome")
52
- logger.info(f"Tool call: show_onboarding_step (step={current_step})")
 
 
 
 
 
 
53
 
54
  # Emit the GenUI component
55
  await emit_ui_component(
@@ -57,9 +72,12 @@ class ShowOnboardingStep(Tool):
57
  {"currentStep": current_step},
58
  )
59
 
60
- # Update the onboarding step in the database
61
  if deps.database is not None:
62
- deps.database.update_profile({"onboarding_step": current_step})
 
 
 
63
  await emit_dashboard_updated("onboarding_step_changed")
64
 
65
  return {
 
1
  """Tool to display the onboarding progress UI component."""
2
 
3
  import logging
4
+ from typing import Any, Dict, Literal, Optional
5
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
  from reachy_mini_conversation_app.stream_api import (
 
22
  description = (
23
  "Display or update the onboarding progress indicator to show the user which "
24
  "step of the setup process they are on. Call this when transitioning between "
25
+ "onboarding steps to give visual feedback. If you have just learned the user's "
26
+ "name, pass it as display_name so it is saved immediately."
27
  )
28
  parameters_schema = {
29
  "type": "object",
 
36
  "'contacts', or 'complete'."
37
  ),
38
  },
39
+ "display_name": {
40
+ "type": "string",
41
+ "description": (
42
+ "The user's preferred name, if just collected. "
43
+ "Pass this when advancing past the 'name' step."
44
+ ),
45
+ },
46
  },
47
  "required": ["current_step"],
48
  }
 
51
  self,
52
  deps: ToolDependencies,
53
  current_step: OnboardingStep | None = None,
54
+ display_name: Optional[str] = None,
55
  **kwargs: Any,
56
  ) -> Dict[str, Any]:
57
  """Emit the OnboardingProgress component."""
58
  # LLM sometimes sends "step" instead of "current_step"
59
  if current_step is None:
60
  current_step = kwargs.get("step", "welcome")
61
+ if display_name is None:
62
+ display_name = kwargs.get("name")
63
+ logger.info(
64
+ "Tool call: show_onboarding_step (step=%s, name=%s)",
65
+ current_step,
66
+ display_name,
67
+ )
68
 
69
  # Emit the GenUI component
70
  await emit_ui_component(
 
72
  {"currentStep": current_step},
73
  )
74
 
75
+ # Update the onboarding step (and name if provided) in the database
76
  if deps.database is not None:
77
+ updates: Dict[str, Any] = {"onboarding_step": current_step}
78
+ if display_name:
79
+ updates["display_name"] = display_name
80
+ deps.database.update_profile(updates)
81
  await emit_dashboard_updated("onboarding_step_changed")
82
 
83
  return {
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/update_settings.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
  from reachy_mini_conversation_app.stream_api import (
8
  emit_settings_updated,
 
9
  emit_ui_component,
10
  )
11
 
@@ -111,8 +112,9 @@ class UpdateSettings(Tool):
111
  # Persist to database (copy to avoid update_profile mutating with updated_at)
112
  deps.database.update_profile(dict(updates))
113
 
114
- # Broadcast refresh event so SettingsPanel refetches
115
  await emit_settings_updated(list(updates.keys()))
 
116
 
117
  # Build human-readable changes list for GenUI confirmation
118
  field_labels = {
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
  from reachy_mini_conversation_app.stream_api import (
8
  emit_settings_updated,
9
+ emit_dashboard_updated,
10
  emit_ui_component,
11
  )
12
 
 
112
  # Persist to database (copy to avoid update_profile mutating with updated_at)
113
  deps.database.update_profile(dict(updates))
114
 
115
+ # Broadcast refresh events so SettingsPanel AND Dashboard refetch
116
  await emit_settings_updated(list(updates.keys()))
117
+ await emit_dashboard_updated("settings_updated")
118
 
119
  # Build human-readable changes list for GenUI confirmation
120
  field_labels = {
src/reachy_mini_conversation_app/prompts/sections/onboarding.txt CHANGED
@@ -33,7 +33,7 @@ At the very start of each session:
33
  ### Step 3: Medications
34
  - CALL `show_onboarding_step(current_step="medications")`
35
  - Say: "Would you like to set up your regular medications now? You can always add more later."
36
- - If YES: Ask about each medication one at a time (name, dose, frequency, times of day). For each, confirm and call `setup_medication`. Ask "Any others?" Repeat until done.
37
  - If NO/SKIP: Proceed to Step 4.
38
 
39
  ### Step 4: Contacts
 
33
  ### Step 3: Medications
34
  - CALL `show_onboarding_step(current_step="medications")`
35
  - Say: "Would you like to set up your regular medications now? You can always add more later."
36
+ - If YES: For each medication, gather ALL details (name, dose, frequency, times of day) through conversation BEFORE calling `setup_medication` once with all the information. Do NOT call `setup_medication` until you have collected all available details for that medication. After confirming, ask "Any others?" and repeat until done.
37
  - If NO/SKIP: Proceed to Step 4.
38
 
39
  ### Step 4: Contacts
src/reachy_mini_conversation_app/stream_api.py CHANGED
@@ -894,6 +894,7 @@ async def dashboard_stats() -> dict[str, Any]:
894
  "taken": taken_count,
895
  "total": total_count,
896
  "ad_hoc": ad_hoc,
 
897
  },
898
  "days_active_this_week": days_active,
899
  "headaches": headache_by_day,
@@ -952,3 +953,32 @@ async def reset_database() -> dict[str, Any]:
952
  "status": "not_found",
953
  "message": "No database file found to delete.",
954
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
894
  "taken": taken_count,
895
  "total": total_count,
896
  "ad_hoc": ad_hoc,
897
+ "scheduled": scheduled,
898
  },
899
  "days_active_this_week": days_active,
900
  "headaches": headache_by_day,
 
953
  "status": "not_found",
954
  "message": "No database file found to delete.",
955
  }
956
+
957
+
958
+ @router.get("/reports")
959
+ async def list_reports(limit: int = 20) -> list[dict[str, Any]]:
960
+ """Return saved reports, newest first."""
961
+ from pathlib import Path
962
+ from reachy_mini_conversation_app.config import DB_PATH
963
+
964
+ db = MiniMinderDB(Path.cwd() / DB_PATH.name)
965
+ try:
966
+ return db.get_reports(limit=limit)
967
+ finally:
968
+ db.close()
969
+
970
+
971
+ @router.get("/reports/{report_id}")
972
+ async def get_report(report_id: int) -> dict[str, Any]:
973
+ """Return a single saved report by id."""
974
+ from pathlib import Path
975
+ from reachy_mini_conversation_app.config import DB_PATH
976
+
977
+ db = MiniMinderDB(Path.cwd() / DB_PATH.name)
978
+ try:
979
+ result = db.get_report(report_id)
980
+ if result is None:
981
+ return {"error": "Report not found"}
982
+ return result
983
+ finally:
984
+ db.close()
tests/test_database_onboarding.py CHANGED
@@ -160,6 +160,65 @@ def test_update_nonexistent_medication(db: MiniMinderDB) -> None:
160
  assert success is False
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def test_schema_includes_new_tables(db: MiniMinderDB) -> None:
164
  """The new tables should exist."""
165
  conn = db._get_conn()
 
160
  assert success is False
161
 
162
 
163
+ # -----------------------------------------------------------------------------
164
+ # Upsert Scheduled Medication Tests
165
+ # -----------------------------------------------------------------------------
166
+
167
+
168
+ def test_upsert_scheduled_medication_new(db: MiniMinderDB) -> None:
169
+ """Upsert should create a new row when no match exists."""
170
+ med_id, is_new = db.upsert_scheduled_medication(
171
+ {"medication_name": "Aspirin", "dose": "100mg"}
172
+ )
173
+ assert is_new is True
174
+ assert med_id >= 1
175
+
176
+ meds = db.get_scheduled_medications()
177
+ assert len(meds) == 1
178
+ assert meds[0]["medication_name"] == "Aspirin"
179
+
180
+
181
+ def test_upsert_scheduled_medication_existing(db: MiniMinderDB) -> None:
182
+ """Upsert should update existing row and not create a duplicate."""
183
+ # First call: creates the row
184
+ med_id_1, is_new_1 = db.upsert_scheduled_medication(
185
+ {"medication_name": "Amitriptyline"}
186
+ )
187
+ assert is_new_1 is True
188
+
189
+ # Second call: same name, adds dose — should update
190
+ med_id_2, is_new_2 = db.upsert_scheduled_medication(
191
+ {"medication_name": "Amitriptyline", "dose": "10mg"}
192
+ )
193
+ assert is_new_2 is False
194
+ assert med_id_2 == med_id_1
195
+
196
+ # Third call: same name, adds frequency — should update again
197
+ med_id_3, is_new_3 = db.upsert_scheduled_medication(
198
+ {"medication_name": "Amitriptyline", "dose": "10mg", "frequency": "once daily"}
199
+ )
200
+ assert is_new_3 is False
201
+ assert med_id_3 == med_id_1
202
+
203
+ # Only one row should exist with all the merged data
204
+ meds = db.get_scheduled_medications()
205
+ assert len(meds) == 1
206
+ assert meds[0]["dose"] == "10mg"
207
+ assert meds[0]["frequency"] == "once daily"
208
+
209
+
210
+ def test_upsert_case_insensitive(db: MiniMinderDB) -> None:
211
+ """Upsert should match medication names case-insensitively."""
212
+ db.upsert_scheduled_medication({"medication_name": "Aspirin"})
213
+ med_id, is_new = db.upsert_scheduled_medication(
214
+ {"medication_name": "aspirin", "dose": "200mg"}
215
+ )
216
+ assert is_new is False
217
+
218
+ meds = db.get_scheduled_medications()
219
+ assert len(meds) == 1
220
+
221
+
222
  def test_schema_includes_new_tables(db: MiniMinderDB) -> None:
223
  """The new tables should exist."""
224
  conn = db._get_conn()
tests/test_onboarding_tools.py CHANGED
@@ -274,6 +274,45 @@ async def test_full_onboarding_workflow(deps: ToolDependencies) -> None:
274
  assert status["medications_count"] == 2
275
 
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  @pytest.mark.asyncio
278
  async def test_all_tool_specs() -> None:
279
  """All onboarding tools should have valid specs."""
 
274
  assert status["medications_count"] == 2
275
 
276
 
277
+ @pytest.mark.asyncio
278
+ async def test_setup_medication_progressive_update(deps: ToolDependencies) -> None:
279
+ """Simulates the three-stage onboarding pattern: name → dose → schedule.
280
+
281
+ The LLM may call setup_medication progressively as it gathers info.
282
+ The upsert ensures only one row exists with all merged data.
283
+ """
284
+ tool = SetupMedication()
285
+
286
+ # Stage 1: LLM only knows the name
287
+ r1 = await tool(deps, medication_name="Amitriptyline")
288
+ assert r1["status"] == "added"
289
+ first_id = r1["id"]
290
+
291
+ # Stage 2: LLM now has dose
292
+ r2 = await tool(deps, medication_name="Amitriptyline", dose="10 micrograms")
293
+ assert r2["status"] == "updated"
294
+ assert r2["id"] == first_id
295
+
296
+ # Stage 3: LLM now has frequency and time
297
+ r3 = await tool(
298
+ deps,
299
+ medication_name="Amitriptyline",
300
+ dose="10 micrograms",
301
+ frequency="once daily",
302
+ times_of_day=["16:00"],
303
+ )
304
+ assert r3["status"] == "updated"
305
+ assert r3["id"] == first_id
306
+
307
+ # Only 1 medication row should exist
308
+ meds = deps.database.get_scheduled_medications()
309
+ assert len(meds) == 1
310
+ assert meds[0]["medication_name"] == "Amitriptyline"
311
+ assert meds[0]["dose"] == "10 micrograms"
312
+ assert meds[0]["frequency"] == "once daily"
313
+ assert meds[0]["times_of_day"] == ["16:00"]
314
+
315
+
316
  @pytest.mark.asyncio
317
  async def test_all_tool_specs() -> None:
318
  """All onboarding tools should have valid specs."""
uv.lock CHANGED
@@ -3294,6 +3294,40 @@ wheels = [
3294
  { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
3295
  ]
3296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3297
  [[package]]
3298
  name = "openai"
3299
  version = "2.16.0"
@@ -3349,6 +3383,25 @@ wheels = [
3349
  { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
3350
  ]
3351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3352
  [[package]]
3353
  name = "opt-einsum"
3354
  version = "3.4.0"
@@ -4702,8 +4755,6 @@ dependencies = [
4702
  { name = "eclipse-zenoh" },
4703
  { name = "fastrtc" },
4704
  { name = "google-genai" },
4705
- { name = "gradio" },
4706
- { name = "gradio-client" },
4707
  { name = "huggingface-hub" },
4708
  { name = "langchain-google-genai" },
4709
  { name = "langchain-openai" },
@@ -4711,6 +4762,7 @@ dependencies = [
4711
  { name = "neo4j" },
4712
  { name = "openai" },
4713
  { name = "opencv-python" },
 
4714
  { name = "pygame" },
4715
  { name = "python-dotenv" },
4716
  { name = "reachy-mini" },
@@ -4766,8 +4818,6 @@ requires-dist = [
4766
  { name = "eclipse-zenoh", specifier = "~=1.7.0" },
4767
  { name = "fastrtc", specifier = ">=0.0.34" },
4768
  { name = "google-genai", specifier = ">=1.14" },
4769
- { name = "gradio", specifier = "==5.50.1.dev1" },
4770
- { name = "gradio-client", specifier = ">=1.13.3" },
4771
  { name = "gst-signalling", marker = "extra == 'reachy-mini-wireless'", specifier = ">=1.1.2" },
4772
  { name = "huggingface-hub", specifier = "==1.3.0" },
4773
  { name = "langchain-google-genai" },
@@ -4781,6 +4831,7 @@ requires-dist = [
4781
  { name = "num2words", marker = "extra == 'local-vision'" },
4782
  { name = "openai", specifier = ">=2.1" },
4783
  { name = "opencv-python", specifier = ">=4.12.0.88" },
 
4784
  { name = "presidio-analyzer", marker = "extra == 'memory'" },
4785
  { name = "presidio-anonymizer", marker = "extra == 'memory'" },
4786
  { name = "pygame", specifier = ">=2.6.1" },
@@ -5883,6 +5934,22 @@ wheels = [
5883
  { url = "https://files.pythonhosted.org/packages/64/6b/cdc85edb15e384d8e934aad89638cc8646e118c80de94c60125d0fc0a185/tenacity-9.1.3-py3-none-any.whl", hash = "sha256:51171cfc6b8a7826551e2f029426b10a6af189c5ac6986adcd7eb36d42f17954", size = 28858, upload-time = "2026-02-05T06:33:11.219Z" },
5884
  ]
5885
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5886
  [[package]]
5887
  name = "thinc"
5888
  version = "8.3.10"
@@ -6163,6 +6230,10 @@ dependencies = [
6163
  { name = "typing-extensions" },
6164
  ]
6165
  wheels = [
 
 
 
 
6166
  { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" },
6167
  { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" },
6168
  { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },
 
3294
  { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
3295
  ]
3296
 
3297
+ [[package]]
3298
+ name = "onnxruntime"
3299
+ version = "1.24.1"
3300
+ source = { registry = "https://pypi.org/simple" }
3301
+ dependencies = [
3302
+ { name = "flatbuffers" },
3303
+ { name = "numpy" },
3304
+ { name = "packaging" },
3305
+ { name = "protobuf" },
3306
+ { name = "sympy" },
3307
+ ]
3308
+ wheels = [
3309
+ { url = "https://files.pythonhosted.org/packages/d2/88/d9757c62a0f96b5193f8d447a141eefd14498c404cc5caf1a6f3233cf102/onnxruntime-1.24.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:79b3119ab9f4f3817062e6dbe7f4a44937de93905e3a31ba34313d18cb49e7be", size = 17212018, upload-time = "2026-02-05T17:32:13.986Z" },
3310
+ { url = "https://files.pythonhosted.org/packages/7b/61/b3305c39144e19dbe8791802076b29b4b592b09de03d0e340c1314bfd408/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86bc43e922b1f581b3de26a3dc402149c70e5542fceb5bec6b3a85542dbeb164", size = 15018703, upload-time = "2026-02-05T17:30:53.846Z" },
3311
+ { url = "https://files.pythonhosted.org/packages/94/d6/d273b75fe7825ea3feed321dd540aef33d8a1380ddd8ac3bb70a8ed000fe/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cabe71ca14dcfbf812d312aab0a704507ac909c137ee6e89e4908755d0fc60e", size = 17096352, upload-time = "2026-02-05T17:31:29.057Z" },
3312
+ { url = "https://files.pythonhosted.org/packages/21/3f/0616101a3938bfe2918ea60b581a9bbba61ffc255c63388abb0885f7ce18/onnxruntime-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:3273c330f5802b64b4103e87b5bbc334c0355fff1b8935d8910b0004ce2f20c8", size = 12493235, upload-time = "2026-02-05T17:32:04.451Z" },
3313
+ { url = "https://files.pythonhosted.org/packages/c8/30/437de870e4e1c6d237a2ca5e11f54153531270cb5c745c475d6e3d5c5dcf/onnxruntime-1.24.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7307aab9e2e879c0171f37e0eb2808a5b4aec7ba899bb17c5f0cedfc301a8ac2", size = 17211043, upload-time = "2026-02-05T17:32:16.909Z" },
3314
+ { url = "https://files.pythonhosted.org/packages/21/60/004401cd86525101ad8aa9eec301327426555d7a77fac89fd991c3c7aae6/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780add442ce2d4175fafb6f3102cdc94243acffa3ab16eacc03dd627cc7b1b54", size = 15016224, upload-time = "2026-02-05T17:30:56.791Z" },
3315
+ { url = "https://files.pythonhosted.org/packages/7d/a1/43ad01b806a1821d1d6f98725edffcdbad54856775643718e9124a09bfbe/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6119526eda12613f0d0498e2ae59563c247c370c9cef74c2fc93133dde157", size = 17098191, upload-time = "2026-02-05T17:31:31.87Z" },
3316
+ { url = "https://files.pythonhosted.org/packages/ff/37/5beb65270864037d5c8fb25cfe6b23c48b618d1f4d06022d425cbf29bd9c/onnxruntime-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0af2f1cfcfff9094971c7eb1d1dfae7ccf81af197493c4dc4643e4342c0946", size = 12493108, upload-time = "2026-02-05T17:32:07.076Z" },
3317
+ { url = "https://files.pythonhosted.org/packages/95/77/7172ecfcbdabd92f338e694f38c325f6fab29a38fa0a8c3d1c85b9f4617c/onnxruntime-1.24.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:82e367770e8fba8a87ba9f4c04bb527e6d4d7204540f1390f202c27a3b759fb4", size = 17211381, upload-time = "2026-02-05T17:31:09.601Z" },
3318
+ { url = "https://files.pythonhosted.org/packages/79/5b/532a0d75b93bbd0da0e108b986097ebe164b84fbecfdf2ddbf7c8a3a2e83/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1099f3629832580fedf415cfce2462a56cc9ca2b560d6300c24558e2ac049134", size = 15016000, upload-time = "2026-02-05T17:31:00.116Z" },
3319
+ { url = "https://files.pythonhosted.org/packages/f6/b5/40606c7bce0702975a077bc6668cd072cd77695fc5c0b3fcf59bdb1fe65e/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6361dda4270f3939a625670bd67ae0982a49b7f923207450e28433abc9c3a83b", size = 17097637, upload-time = "2026-02-05T17:31:34.787Z" },
3320
+ { url = "https://files.pythonhosted.org/packages/d5/a0/9e8f7933796b466241b934585723c700d8fb6bde2de856e65335193d7c93/onnxruntime-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:bd1e4aefe73b6b99aa303cd72562ab6de3cccb09088100f8ad1c974be13079c7", size = 12492467, upload-time = "2026-02-05T17:32:09.834Z" },
3321
+ { url = "https://files.pythonhosted.org/packages/fb/8a/ee07d86e35035f9fed42497af76435f5a613d4e8b6c537ea0f8ef9fa85da/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88a2b54dca00c90fca6303eedf13d49b5b4191d031372c2e85f5cffe4d86b79e", size = 15025407, upload-time = "2026-02-05T17:31:02.251Z" },
3322
+ { url = "https://files.pythonhosted.org/packages/fd/9e/ab3e1dda4b126313d240e1aaa87792ddb1f5ba6d03ca2f093a7c4af8c323/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dfbba602da840615ed5b431facda4b3a43b5d8276cf9e0dbf13d842df105838", size = 17099810, upload-time = "2026-02-05T17:31:37.537Z" },
3323
+ { url = "https://files.pythonhosted.org/packages/87/23/167d964414cee2af9c72af323b28d2c4cb35beed855c830a23f198265c79/onnxruntime-1.24.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:890c503ca187bc883c3aa72c53f2a604ec8e8444bdd1bf6ac243ec6d5e085202", size = 17214004, upload-time = "2026-02-05T17:31:11.917Z" },
3324
+ { url = "https://files.pythonhosted.org/packages/b4/24/6e5558fdd51027d6830cf411bc003ae12c64054826382e2fab89e99486a0/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da1b84b3bdeec543120df169e5e62a1445bf732fc2c7fb036c2f8a4090455e8", size = 15017034, upload-time = "2026-02-05T17:31:04.331Z" },
3325
+ { url = "https://files.pythonhosted.org/packages/91/d4/3cb1c9eaae1103265ed7eb00a3eaeb0d9ba51dc88edc398b7071c9553bed/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:557753ec345efa227c6a65139f3d29c76330fcbd54cc10dd1b64232ebb939c13", size = 17097531, upload-time = "2026-02-05T17:31:40.303Z" },
3326
+ { url = "https://files.pythonhosted.org/packages/0f/da/4522b199c12db7c5b46aaf265ee0d741abe65ea912f6c0aaa2cc18a4654d/onnxruntime-1.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:ea4942104805e868f3ddddfa1fbb58b04503a534d489ab2d1452bbfa345c78c2", size = 12795556, upload-time = "2026-02-05T17:32:11.886Z" },
3327
+ { url = "https://files.pythonhosted.org/packages/a1/53/3b8969417276b061ff04502ccdca9db4652d397abbeb06c9f6ae05cec9ca/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea8963a99e0f10489acdf00ef3383c3232b7e44aa497b063c63be140530d9f85", size = 15025434, upload-time = "2026-02-05T17:31:06.942Z" },
3328
+ { url = "https://files.pythonhosted.org/packages/ab/a2/cfcf009eb38d90cc628c087b6506b3dfe1263387f3cbbf8d272af4fef957/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34488aa760fb5c2e6d06a7ca9241124eb914a6a06f70936a14c669d1b3df9598", size = 17099815, upload-time = "2026-02-05T17:31:43.092Z" },
3329
+ ]
3330
+
3331
  [[package]]
3332
  name = "openai"
3333
  version = "2.16.0"
 
3383
  { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
3384
  ]
3385
 
3386
+ [[package]]
3387
+ name = "openwakeword"
3388
+ version = "0.6.0"
3389
+ source = { registry = "https://pypi.org/simple" }
3390
+ dependencies = [
3391
+ { name = "onnxruntime" },
3392
+ { name = "requests" },
3393
+ { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
3394
+ { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
3395
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
3396
+ { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
3397
+ { name = "tflite-runtime", marker = "sys_platform == 'linux'" },
3398
+ { name = "tqdm" },
3399
+ ]
3400
+ sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/73b7d98b07f4e1f525ad39703e0c5f30ff61c3fa16c8bfe4d99eadc0567a/openwakeword-0.6.0.tar.gz", hash = "sha256:36858d90f1183e307485597a912a4e3c3384b14ea9923f83feaffae7c1565565", size = 70830, upload-time = "2024-02-11T20:56:17.854Z" }
3401
+ wheels = [
3402
+ { url = "https://files.pythonhosted.org/packages/8a/33/dafd6822bebe463a9098951d06a0d88fb4f8c946ce087025bc4fa132e533/openwakeword-0.6.0-py3-none-any.whl", hash = "sha256:6f423a4e3ae9dd0e3cd12b50ff8abf69679f687b4ab349d7c82c021c0e2abc9d", size = 60690, upload-time = "2024-02-11T20:56:16.179Z" },
3403
+ ]
3404
+
3405
  [[package]]
3406
  name = "opt-einsum"
3407
  version = "3.4.0"
 
4755
  { name = "eclipse-zenoh" },
4756
  { name = "fastrtc" },
4757
  { name = "google-genai" },
 
 
4758
  { name = "huggingface-hub" },
4759
  { name = "langchain-google-genai" },
4760
  { name = "langchain-openai" },
 
4762
  { name = "neo4j" },
4763
  { name = "openai" },
4764
  { name = "opencv-python" },
4765
+ { name = "openwakeword" },
4766
  { name = "pygame" },
4767
  { name = "python-dotenv" },
4768
  { name = "reachy-mini" },
 
4818
  { name = "eclipse-zenoh", specifier = "~=1.7.0" },
4819
  { name = "fastrtc", specifier = ">=0.0.34" },
4820
  { name = "google-genai", specifier = ">=1.14" },
 
 
4821
  { name = "gst-signalling", marker = "extra == 'reachy-mini-wireless'", specifier = ">=1.1.2" },
4822
  { name = "huggingface-hub", specifier = "==1.3.0" },
4823
  { name = "langchain-google-genai" },
 
4831
  { name = "num2words", marker = "extra == 'local-vision'" },
4832
  { name = "openai", specifier = ">=2.1" },
4833
  { name = "opencv-python", specifier = ">=4.12.0.88" },
4834
+ { name = "openwakeword", specifier = ">=0.6" },
4835
  { name = "presidio-analyzer", marker = "extra == 'memory'" },
4836
  { name = "presidio-anonymizer", marker = "extra == 'memory'" },
4837
  { name = "pygame", specifier = ">=2.6.1" },
 
5934
  { url = "https://files.pythonhosted.org/packages/64/6b/cdc85edb15e384d8e934aad89638cc8646e118c80de94c60125d0fc0a185/tenacity-9.1.3-py3-none-any.whl", hash = "sha256:51171cfc6b8a7826551e2f029426b10a6af189c5ac6986adcd7eb36d42f17954", size = 28858, upload-time = "2026-02-05T06:33:11.219Z" },
5935
  ]
5936
 
5937
+ [[package]]
5938
+ name = "tflite-runtime"
5939
+ version = "2.14.0"
5940
+ source = { registry = "https://pypi.org/simple" }
5941
+ dependencies = [
5942
+ { name = "numpy", marker = "sys_platform != 'win32'" },
5943
+ ]
5944
+ wheels = [
5945
+ { url = "https://files.pythonhosted.org/packages/9e/1f/aade0d066bacbe697946ae21f0467a702d81adb939bb64515e9abebae9ed/tflite_runtime-2.14.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:bb11df4283e281cd609c621ac9470ad0cb5674408593272d7593a2c6bde8a808", size = 2413436, upload-time = "2023-10-03T21:15:38.025Z" },
5946
+ { url = "https://files.pythonhosted.org/packages/04/dd/a701667be92bb884644eeec91ebbde57e197d27d9a79241169b6ce15e1ad/tflite_runtime-2.14.0-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:d38c6885f5e9673c11a61ccec5cad7c032ab97340718d26b17794137f398b780", size = 2324943, upload-time = "2023-10-03T21:15:40.253Z" },
5947
+ { url = "https://files.pythonhosted.org/packages/d0/33/ad39ee3706a67139dde4624e66a2dd65604cc800dbb861f719f7cb1f1edc/tflite_runtime-2.14.0-cp310-cp310-manylinux_2_34_armv7l.whl", hash = "sha256:7fe33f763263d1ff2733a09945a7547ab063d8bc311fd2a1be8144d850016ad3", size = 1818760, upload-time = "2023-10-03T21:15:42.321Z" },
5948
+ { url = "https://files.pythonhosted.org/packages/8f/a6/02d68cb62cd221589a0ff055073251d883936237c9c990e34a1d7cecd06f/tflite_runtime-2.14.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:195ab752e7e57329a68e54dd3dd5439fad888b9bff1be0f0dc042a3237a90e4d", size = 2414486, upload-time = "2023-10-03T21:15:44.331Z" },
5949
+ { url = "https://files.pythonhosted.org/packages/f2/e9/5fc0435129c23c17551fcfadc82bd0d5482276213dfbc641f07b4420cb6d/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ce9fa5d770a9725c746dcbf6f59f3178233b3759f09982e8b2db8d2234c333b0", size = 2325913, upload-time = "2023-10-03T21:15:46.348Z" },
5950
+ { url = "https://files.pythonhosted.org/packages/fb/76/e246c39d92929655bac8878d76406d6fb0293c678237e55621e7ece4a269/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:c4e66a74165b18089c86788400af19fa551768ac782d231a9beae2f6434f7949", size = 1820588, upload-time = "2023-10-03T21:15:48.399Z" },
5951
+ ]
5952
+
5953
  [[package]]
5954
  name = "thinc"
5955
  version = "8.3.10"
 
6230
  { name = "typing-extensions" },
6231
  ]
6232
  wheels = [
6233
+ { url = "https://files.pythonhosted.org/packages/e3/ea/304cf7afb744aa626fa9855245526484ee55aba610d9973a0521c552a843/torch-2.10.0-1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:c37fc46eedd9175f9c81814cc47308f1b42cfe4987e532d4b423d23852f2bf63", size = 79411450, upload-time = "2026-02-06T17:37:35.75Z" },
6234
+ { url = "https://files.pythonhosted.org/packages/25/d8/9e6b8e7df981a1e3ea3907fd5a74673e791da483e8c307f0b6ff012626d0/torch-2.10.0-1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:f699f31a236a677b3118bc0a3ef3d89c0c29b5ec0b20f4c4bf0b110378487464", size = 79423460, upload-time = "2026-02-06T17:37:39.657Z" },
6235
+ { url = "https://files.pythonhosted.org/packages/c9/2f/0b295dd8d199ef71e6f176f576473d645d41357b7b8aa978cc6b042575df/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6abb224c2b6e9e27b592a1c0015c33a504b00a0e0938f1499f7f514e9b7bfb5c", size = 79498197, upload-time = "2026-02-06T17:37:27.627Z" },
6236
+ { url = "https://files.pythonhosted.org/packages/a4/1b/af5fccb50c341bd69dc016769503cb0857c1423fbe9343410dfeb65240f2/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7350f6652dfd761f11f9ecb590bfe95b573e2961f7a242eccb3c8e78348d26fe", size = 79498248, upload-time = "2026-02-06T17:37:31.982Z" },
6237
  { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" },
6238
  { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" },
6239
  { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },