Boopster commited on
Commit
b152b60
Β·
1 Parent(s): 6d2637d

feat: enhance session summary UI and streamline headache logging with guided wizard steps.

Browse files
README.md CHANGED
@@ -89,6 +89,15 @@ Copy `.env.example` to `.env` and configure:
89
  | `API_TOKEN` | No | Bearer token for LAN deployments |
90
  | `LANGSMITH_API_KEY` | No | For LangGraph tracing (free at [smith.langchain.com](https://smith.langchain.com)) |
91
 
 
 
 
 
 
 
 
 
 
92
  ## Knowledge Graph (Optional)
93
 
94
  For cross-session memory (entity extraction, relationship tracking), you can connect a Neo4j graph database via Docker:
 
89
  | `API_TOKEN` | No | Bearer token for LAN deployments |
90
  | `LANGSMITH_API_KEY` | No | For LangGraph tracing (free at [smith.langchain.com](https://smith.langchain.com)) |
91
 
92
+ ## Privacy & Data
93
+
94
+ Mini Minder stores all health data **locally** in an unencrypted SQLite file (`mini_minder.db`). No data is sent to a remote database.
95
+
96
+ **Text pipelines** β€” Session summaries, memory notes, appointment exports, and health-history queries are redacted by [`pii_guard.py`](src/reachy_mini_conversation_app/pii_guard.py) before reaching any cloud LLM. Names are replaced with roles (e.g. _"the patient"_) and medication names are replaced with symptom categories (e.g. _"migraine prevention medication"_).
97
+
98
+ > [!IMPORTANT]
99
+ > **Audio gap:** Microphone audio is streamed directly to the OpenAI Realtime API as raw PCM frames. There is no way to redact speech before it leaves the device. If the user speaks their name, medication names, or other sensitive details aloud, OpenAI's servers will receive that audio. OpenAI's [API data usage policy](https://openai.com/policies/api-data-usage-policies) states that API inputs are not used for model training, but the audio does traverse their infrastructure. Eliminating this gap would require switching to a local speech-to-text model.
100
+
101
  ## Knowledge Graph (Optional)
102
 
103
  For cross-session memory (entity extraction, relationship tracking), you can connect a Neo4j graph database via Docker:
frontend/public/reachy-mini-minder.ai CHANGED
The diff for this file is too large to render. See raw diff
 
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"]);
15
 
16
  export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps) {
17
  const isFullscreen = FULLSCREEN_COMPONENTS.has(component.name);
@@ -39,7 +39,7 @@ export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps
39
  if (isSessionSummary) {
40
  const timer = setTimeout(() => {
41
  onDismiss();
42
- }, 15000);
43
  return () => clearTimeout(timer);
44
  }
45
  }, [component, onDismiss]);
 
11
  }
12
 
13
  // Components that render full-screen (TV-distance readable)
14
+ const FULLSCREEN_COMPONENTS = new Set(["HeadacheLog", "MedLog", "SessionSummary"]);
15
 
16
  export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps) {
17
  const isFullscreen = FULLSCREEN_COMPONENTS.has(component.name);
 
39
  if (isSessionSummary) {
40
  const timer = setTimeout(() => {
41
  onDismiss();
42
+ }, 5000);
43
  return () => clearTimeout(timer);
44
  }
45
  }, [component, onDismiss]);
frontend/src/components/DashboardGrid.tsx CHANGED
@@ -68,7 +68,7 @@ export function DashboardGrid({ isSessionActive, isConnected, userName, showCame
68
  {/* Quick Stats - 1x1 */}
69
  <QuickStatsCard
70
  daysActive={stats.days_active_this_week ?? 0}
71
- headachesThisWeek={stats.headaches.filter(h => h.intensity > 0).length}
72
  isLoading={isLoading}
73
  hasData={stats.profile.onboarding_completed}
74
  />
@@ -83,7 +83,7 @@ export function DashboardGrid({ isSessionActive, isConnected, userName, showCame
83
  onboardingCompleted={stats.profile.onboarding_completed}
84
  medicationTaken={stats.medication.taken}
85
  medicationTotal={stats.medication.total}
86
- headachesToday={stats.headaches.filter(h => h.intensity > 0).length}
87
  />
88
  </div>
89
 
@@ -422,7 +422,7 @@ function QuickStatsCard({
422
  </div>
423
  <div style={{ flex: 1 }}>
424
  <div style={{ fontSize: 18, fontWeight: 600, color: "var(--color-text-primary)" }}>
425
- {isLoading ? "β€”" : hasData ? `${headachesThisWeek} day${headachesThisWeek !== 1 ? "s" : ""}` : "β€”"}
426
  </div>
427
  <div style={{
428
  fontSize: 12,
@@ -444,23 +444,43 @@ function HeadacheCalendarCard({
444
  }: {
445
  headaches: DashboardStats["headaches"];
446
  }) {
447
- // Intensity classes from mockup
448
- const getBarStyle = (intensity: number): React.CSSProperties => {
449
- if (intensity === 0) return { background: "var(--color-surface-overlay)", height: 8 };
450
- if (intensity <= 2.5) return { background: "rgba(255, 193, 204, 0.3)", height: 20 };
451
- if (intensity <= 5) return { background: "rgba(255, 193, 204, 0.5)", height: 35 };
452
- if (intensity <= 7.5) return { background: "rgba(255, 193, 204, 0.7)", height: 50 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  return {
454
- background: "var(--color-accent-pink)",
455
- height: 65,
456
- boxShadow: "0 0 12px rgba(255, 193, 204, 0.4)"
457
  };
458
  };
459
 
460
  // Default days if no data
461
  const days = headaches.length > 0
462
  ? headaches
463
- : ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(day => ({ day, intensity: 0 }));
 
 
464
 
465
  return (
466
  <div
@@ -472,8 +492,8 @@ function HeadacheCalendarCard({
472
  minHeight: 120,
473
  }}
474
  >
475
- <div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 16 }}>
476
- <Brain style={{ width: 30, height: 30, color: "var(--color-accent-pink)" }} />
477
  <span style={{
478
  fontSize: 12,
479
  fontWeight: 600,
@@ -485,37 +505,70 @@ function HeadacheCalendarCard({
485
  </span>
486
  </div>
487
 
488
- <div style={{ display: "flex", gap: 6, flex: 1, alignItems: "flex-end" }}>
489
- {days.map((day, i) => (
490
- <div
491
- key={i}
492
- style={{
493
- flex: 1,
494
- display: "flex",
495
- flexDirection: "column",
496
- alignItems: "center",
497
- gap: 8
498
- }}
499
- >
500
  <div
501
- style={{
502
- width: "100%",
503
- borderRadius: 6,
504
- minHeight: 8,
505
- transition: "all 0.3s ease",
506
- ...getBarStyle(day.intensity),
 
507
  }}
508
- />
509
- <span style={{
510
- fontSize: 9,
511
- fontWeight: 600,
512
- color: "var(--color-text-secondary)",
513
- textTransform: "uppercase"
514
- }}>
515
- {day.day}
516
- </span>
517
- </div>
518
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  </div>
520
  </div>
521
  );
 
68
  {/* Quick Stats - 1x1 */}
69
  <QuickStatsCard
70
  daysActive={stats.days_active_this_week ?? 0}
71
+ headachesThisWeek={stats.headaches.reduce((sum, h) => sum + (h.count ?? (h.intensity > 0 ? 1 : 0)), 0)}
72
  isLoading={isLoading}
73
  hasData={stats.profile.onboarding_completed}
74
  />
 
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>
89
 
 
422
  </div>
423
  <div style={{ flex: 1 }}>
424
  <div style={{ fontSize: 18, fontWeight: 600, color: "var(--color-text-primary)" }}>
425
+ {isLoading ? "β€”" : hasData ? `${headachesThisWeek} total` : "β€”"}
426
  </div>
427
  <div style={{
428
  fontSize: 12,
 
444
  }: {
445
  headaches: DashboardStats["headaches"];
446
  }) {
447
+ const MAX_BAR_HEIGHT = 80;
448
+
449
+ const getBarStyle = (intensity: number, count: number): React.CSSProperties => {
450
+ if (intensity === 0 && count === 0) return {
451
+ background: "var(--color-surface-overlay)",
452
+ height: 6,
453
+ opacity: 0.5,
454
+ };
455
+ // Scale height proportionally: count drives height, intensity drives color
456
+ const heightFraction = Math.min(count / 4, 1); // 4+ headaches = full bar
457
+ const height = Math.max(16, heightFraction * MAX_BAR_HEIGHT);
458
+
459
+ if (intensity <= 3) return {
460
+ background: "linear-gradient(to top, rgba(255, 193, 204, 0.2), rgba(255, 193, 204, 0.35))",
461
+ height,
462
+ };
463
+ if (intensity <= 5) return {
464
+ background: "linear-gradient(to top, rgba(255, 193, 204, 0.3), rgba(255, 193, 204, 0.55))",
465
+ height,
466
+ };
467
+ if (intensity <= 7) return {
468
+ background: "linear-gradient(to top, rgba(255, 193, 204, 0.5), rgba(255, 193, 204, 0.75))",
469
+ height,
470
+ };
471
  return {
472
+ background: "linear-gradient(to top, rgba(255, 193, 204, 0.6), var(--color-accent-pink))",
473
+ height,
474
+ boxShadow: "0 0 16px rgba(255, 193, 204, 0.3)",
475
  };
476
  };
477
 
478
  // Default days if no data
479
  const days = headaches.length > 0
480
  ? headaches
481
+ : ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(day => ({ day, intensity: 0, count: 0 }));
482
+
483
+ const today = new Date().toLocaleDateString("en-US", { weekday: "short" });
484
 
485
  return (
486
  <div
 
492
  minHeight: 120,
493
  }}
494
  >
495
+ <div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 20 }}>
496
+ <Brain style={{ width: 28, height: 28, color: "var(--color-accent-pink)" }} />
497
  <span style={{
498
  fontSize: 12,
499
  fontWeight: 600,
 
505
  </span>
506
  </div>
507
 
508
+ <div style={{
509
+ display: "flex",
510
+ gap: 8,
511
+ flex: 1,
512
+ alignItems: "flex-end",
513
+ paddingTop: 20,
514
+ }}>
515
+ {days.map((day, i) => {
516
+ const isToday = day.day === today;
517
+ const hasData = day.count > 0 || day.intensity > 0;
518
+ return (
 
519
  <div
520
+ key={i}
521
+ style={{
522
+ flex: 1,
523
+ display: "flex",
524
+ flexDirection: "column",
525
+ alignItems: "center",
526
+ gap: 10,
527
  }}
528
+ >
529
+ {/* Count badge */}
530
+ <div style={{ height: 16, display: "flex", alignItems: "center" }}>
531
+ {day.count > 1 && (
532
+ <span style={{
533
+ fontSize: 10,
534
+ fontWeight: 700,
535
+ color: "var(--color-accent-pink)",
536
+ whiteSpace: "nowrap",
537
+ }}>
538
+ Γ—{day.count}
539
+ </span>
540
+ )}
541
+ </div>
542
+
543
+ {/* Bar */}
544
+ <div
545
+ style={{
546
+ width: "100%",
547
+ maxWidth: 28,
548
+ borderRadius: 8,
549
+ minHeight: 6,
550
+ transition: "all 0.4s cubic-bezier(0.4, 0, 0.2, 1)",
551
+ ...getBarStyle(day.intensity, day.count ?? (day.intensity > 0 ? 1 : 0)),
552
+ ...(isToday && hasData ? {
553
+ outline: "2px solid rgba(255, 193, 204, 0.4)",
554
+ outlineOffset: 2,
555
+ } : {}),
556
+ }}
557
+ />
558
+
559
+ {/* Day label */}
560
+ <span style={{
561
+ fontSize: 10,
562
+ fontWeight: isToday ? 700 : 500,
563
+ color: isToday ? "var(--color-text-primary)" : "var(--color-text-secondary)",
564
+ textTransform: "uppercase",
565
+ letterSpacing: "0.02em",
566
+ }}>
567
+ {day.day}
568
+ </span>
569
+ </div>
570
+ );
571
+ })}
572
  </div>
573
  </div>
574
  );
frontend/src/hooks/useDashboardData.ts CHANGED
@@ -22,6 +22,7 @@ interface MedicationStats {
22
  interface HeadacheDay {
23
  day: string;
24
  intensity: number;
 
25
  }
26
 
27
  export interface DashboardStats {
 
22
  interface HeadacheDay {
23
  day: string;
24
  intensity: number;
25
+ count: number;
26
  }
27
 
28
  export interface DashboardStats {
frontend/src/registry/SessionSummary.tsx CHANGED
@@ -34,10 +34,10 @@ function SummarySection({
34
  children: React.ReactNode;
35
  }) {
36
  return (
37
- <div className="mb-5">
38
- <div className="flex items-center gap-2 mb-2.5">
39
- <Icon className="w-4 h-4 text-muted" />
40
- <span className="text-[10px] font-bold text-muted uppercase tracking-wider">
41
  {title}
42
  </span>
43
  </div>
@@ -54,26 +54,24 @@ export function SessionSummary(props: SessionSummaryProps) {
54
 
55
  return (
56
  <div
57
- className="relative overflow-hidden rounded-2xl p-5
58
- bg-gradient-to-br from-surface-elevated/90 to-surface-subtle/95
59
- border border-cta/15
60
- shadow-[0_4px_24px_rgba(0,0,0,0.4),inset_0_1px_0_rgba(255,255,255,0.05)]"
61
  >
62
  {/* Top accent line */}
63
- <div className="absolute top-0 left-[20%] right-[20%] h-0.5 bg-gradient-to-r from-transparent via-cta to-transparent opacity-60" />
64
 
65
  {/* Header */}
66
- <div className="flex items-center gap-3.5 mb-5 pb-4 border-b border-white/5">
67
  <div
68
- className="relative w-11 h-11 rounded-xl flex items-center justify-center
69
  bg-gradient-to-br from-cta/25 to-cta/10
70
  border border-cta/30"
71
  >
72
- <ClipboardCheck className="w-5 h-5 text-cta" />
73
  </div>
74
  <div className="flex-1">
75
- <h3 className="text-base font-bold tracking-tight">Session Summary</h3>
76
- <p className="text-[11px] text-muted mt-0.5">
77
  {props.session_date} Β· {props.session_duration}
78
  </p>
79
  </div>
@@ -82,16 +80,16 @@ export function SessionSummary(props: SessionSummaryProps) {
82
  {/* Medications Logged */}
83
  {medications_logged.length > 0 && (
84
  <SummarySection icon={Pill} title="Medications Logged">
85
- <div className="flex flex-col gap-1.5">
86
  {medications_logged.map((med, i) => (
87
  <div
88
  key={i}
89
- className="flex items-center gap-3 px-3 py-2.5 rounded-lg
90
  bg-surface-subtle/60 border border-success/20"
91
  >
92
- <span className="text-success text-[14px] font-bold">βœ“</span>
93
- <span className="flex-1 text-[13px] text-primary font-medium">{med.name}</span>
94
- <span className="text-[11px] text-muted">{med.time}</span>
95
  </div>
96
  ))}
97
  </div>
@@ -101,16 +99,16 @@ export function SessionSummary(props: SessionSummaryProps) {
101
  {/* Headaches Reported */}
102
  {headaches_logged.length > 0 && (
103
  <SummarySection icon={Brain} title="Headaches Reported">
104
- <div className="flex flex-col gap-1.5">
105
  {headaches_logged.map((h, i) => (
106
  <div
107
  key={i}
108
- className="flex items-center gap-3 px-3 py-2.5 rounded-lg
109
  bg-surface-subtle/60 border border-warning/20"
110
  >
111
- <span className="text-[13px] text-primary">Started: {h.onset}</span>
112
  {h.intensity && (
113
- <span className="ml-auto text-[11px] font-bold text-error">
114
  Intensity: {h.intensity}/10
115
  </span>
116
  )}
@@ -123,19 +121,19 @@ export function SessionSummary(props: SessionSummaryProps) {
123
  {/* Questions for Provider */}
124
  {questions_for_provider.length > 0 && (
125
  <SummarySection icon={HelpCircle} title="Questions for Your Provider">
126
- <div className="flex flex-col gap-1.5">
127
  {questions_for_provider.map((q, i) => (
128
  <div
129
  key={i}
130
- className="px-3 py-2.5 rounded-lg
131
  bg-surface-subtle/60 border-l-3 border-l-cta border border-cta/10
132
- text-[13px] text-secondary italic"
133
  >
134
  &quot;{q}&quot;
135
  </div>
136
  ))}
137
  </div>
138
- <p className="text-[11px] text-cta mt-2 font-medium">
139
  These will be included in your next appointment export.
140
  </p>
141
  </SummarySection>
@@ -144,11 +142,11 @@ export function SessionSummary(props: SessionSummaryProps) {
144
  {/* Items Mentioned */}
145
  {items_mentioned.length > 0 && (
146
  <SummarySection icon={MessageSquare} title="Also Mentioned">
147
- <div className="flex flex-wrap gap-1.5">
148
  {items_mentioned.map((item, i) => (
149
  <span
150
  key={i}
151
- className="px-2.5 py-1 rounded-full bg-surface-overlay text-muted text-[11px] font-medium"
152
  >
153
  {item}
154
  </span>
@@ -159,16 +157,16 @@ export function SessionSummary(props: SessionSummaryProps) {
159
 
160
  {/* Next Check-in */}
161
  {props.next_checkin && (
162
- <div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-success/10 border border-success/20 mb-4">
163
- <Clock className="w-4 h-4 text-success" />
164
- <span className="text-[13px] text-success font-medium">
165
  Next check-in: {props.next_checkin}
166
  </span>
167
  </div>
168
  )}
169
 
170
  {/* Disclaimer */}
171
- <p className="text-[10px] text-muted text-center italic leading-relaxed">
172
  This is a summary of what you shared, not a professional evaluation.
173
  Discuss any concerns with your healthcare provider.
174
  </p>
 
34
  children: React.ReactNode;
35
  }) {
36
  return (
37
+ <div className="mb-8">
38
+ <div className="flex items-center gap-3 mb-4">
39
+ <Icon className="w-6 h-6 text-muted" />
40
+ <span className="text-base font-bold text-muted uppercase tracking-wider">
41
  {title}
42
  </span>
43
  </div>
 
54
 
55
  return (
56
  <div
57
+ className="relative h-full overflow-y-auto p-10
58
+ bg-gradient-to-br from-surface-elevated/90 to-surface-subtle/95"
 
 
59
  >
60
  {/* Top accent line */}
61
+ <div className="absolute top-0 left-[20%] right-[20%] h-1 bg-gradient-to-r from-transparent via-cta to-transparent opacity-60" />
62
 
63
  {/* Header */}
64
+ <div className="flex items-center gap-5 mb-8 pb-6 border-b border-white/5">
65
  <div
66
+ className="relative w-16 h-16 rounded-xl flex items-center justify-center
67
  bg-gradient-to-br from-cta/25 to-cta/10
68
  border border-cta/30"
69
  >
70
+ <ClipboardCheck className="w-8 h-8 text-cta" />
71
  </div>
72
  <div className="flex-1">
73
+ <h3 className="text-3xl font-bold tracking-tight">Session Summary</h3>
74
+ <p className="text-lg text-muted mt-1">
75
  {props.session_date} Β· {props.session_duration}
76
  </p>
77
  </div>
 
80
  {/* Medications Logged */}
81
  {medications_logged.length > 0 && (
82
  <SummarySection icon={Pill} title="Medications Logged">
83
+ <div className="flex flex-col gap-2.5">
84
  {medications_logged.map((med, i) => (
85
  <div
86
  key={i}
87
+ className="flex items-center gap-4 px-5 py-4 rounded-xl
88
  bg-surface-subtle/60 border border-success/20"
89
  >
90
+ <span className="text-success text-xl font-bold">βœ“</span>
91
+ <span className="flex-1 text-xl text-primary font-medium">{med.name}</span>
92
+ <span className="text-base text-muted">{med.time}</span>
93
  </div>
94
  ))}
95
  </div>
 
99
  {/* Headaches Reported */}
100
  {headaches_logged.length > 0 && (
101
  <SummarySection icon={Brain} title="Headaches Reported">
102
+ <div className="flex flex-col gap-2.5">
103
  {headaches_logged.map((h, i) => (
104
  <div
105
  key={i}
106
+ className="flex items-center gap-4 px-5 py-4 rounded-xl
107
  bg-surface-subtle/60 border border-warning/20"
108
  >
109
+ <span className="text-xl text-primary">Started: {h.onset}</span>
110
  {h.intensity && (
111
+ <span className="ml-auto text-base font-bold text-error">
112
  Intensity: {h.intensity}/10
113
  </span>
114
  )}
 
121
  {/* Questions for Provider */}
122
  {questions_for_provider.length > 0 && (
123
  <SummarySection icon={HelpCircle} title="Questions for Your Provider">
124
+ <div className="flex flex-col gap-2.5">
125
  {questions_for_provider.map((q, i) => (
126
  <div
127
  key={i}
128
+ className="px-5 py-4 rounded-xl
129
  bg-surface-subtle/60 border-l-3 border-l-cta border border-cta/10
130
+ text-xl text-secondary italic"
131
  >
132
  &quot;{q}&quot;
133
  </div>
134
  ))}
135
  </div>
136
+ <p className="text-base text-cta mt-3 font-medium">
137
  These will be included in your next appointment export.
138
  </p>
139
  </SummarySection>
 
142
  {/* Items Mentioned */}
143
  {items_mentioned.length > 0 && (
144
  <SummarySection icon={MessageSquare} title="Also Mentioned">
145
+ <div className="flex flex-wrap gap-2.5">
146
  {items_mentioned.map((item, i) => (
147
  <span
148
  key={i}
149
+ className="px-4 py-2 rounded-full bg-surface-overlay text-muted text-base font-medium"
150
  >
151
  {item}
152
  </span>
 
157
 
158
  {/* Next Check-in */}
159
  {props.next_checkin && (
160
+ <div className="flex items-center gap-4 px-6 py-5 rounded-xl bg-success/10 border border-success/20 mb-6">
161
+ <Clock className="w-6 h-6 text-success" />
162
+ <span className="text-xl text-success font-medium">
163
  Next check-in: {props.next_checkin}
164
  </span>
165
  </div>
166
  )}
167
 
168
  {/* Disclaimer */}
169
+ <p className="text-sm text-muted text-center italic leading-relaxed">
170
  This is a summary of what you shared, not a professional evaluation.
171
  Discuss any concerns with your healthcare provider.
172
  </p>
src/reachy_mini_conversation_app/console.py CHANGED
@@ -116,6 +116,32 @@ class LocalStream:
116
  ):
117
  self._settings_app.include_router(stream_router)
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def _get_minder_db(self) -> Any:
120
  """Try to get the MiniMinderDB from the handler's deps."""
121
  try:
@@ -1522,37 +1548,15 @@ class LocalStream:
1522
  "Wakeword .feed() returned True β€” will start session"
1523
  )
1524
  await self._start_session_from_wakeword()
1525
- # ── Idle timeout check ──
1526
- if (
1527
- self._is_session_active
1528
- and self._last_activity_time > 0
1529
- and (_time.monotonic() - self._last_activity_time)
1530
- > self._idle_timeout_secs
1531
- ):
1532
- logger.info(
1533
- "Session idle for %.0fs β€” auto-ending session",
1534
- self._idle_timeout_secs,
1535
- )
1536
- self._is_session_active = False
1537
- self._is_listening = False
1538
- try:
1539
- await self.handler.shutdown()
1540
- logger.info("Idle timeout: handler shutdown complete")
1541
- except Exception as e:
1542
- logger.error("Idle timeout shutdown failed: %s", e)
1543
- self._wakeword_detector.reset()
1544
- self._wakeword_resume_after = _time.monotonic() + 3.0
1545
- self._last_activity_time = 0.0
1546
- try:
1547
- await emit_session_event("ended")
1548
- except Exception:
1549
- pass
1550
-
1551
- # Generate and emit real SessionSummary via gpt-4.1-mini
1552
- asyncio.create_task(
1553
- self._generate_post_session_summary(),
1554
- name="idle-session-summary",
1555
- )
1556
 
1557
  await asyncio.sleep(0) # avoid busy loop
1558
 
 
116
  ):
117
  self._settings_app.include_router(stream_router)
118
 
119
+ # ── Persistent structured logging ──
120
+ # Attach a RotatingFileHandler to the package logger so ALL INFO+
121
+ # messages (tool calls, UI events, session lifecycle) are captured
122
+ # to a persistent, searchable file – not just terminal scrollback.
123
+ try:
124
+ from logging.handlers import RotatingFileHandler
125
+
126
+ pkg_logger = logging.getLogger("reachy_mini_conversation_app")
127
+ # Only add once (guard against duplicate LocalStream inits)
128
+ if not any(isinstance(h, RotatingFileHandler) for h in pkg_logger.handlers):
129
+ fh = RotatingFileHandler(
130
+ "minder_session.log",
131
+ maxBytes=5 * 1024 * 1024, # 5 MB
132
+ backupCount=3,
133
+ )
134
+ fh.setLevel(logging.INFO)
135
+ fh.setFormatter(
136
+ logging.Formatter(
137
+ "%(asctime)s %(levelname)s %(name)s:%(lineno)d | %(message)s"
138
+ )
139
+ )
140
+ pkg_logger.addHandler(fh)
141
+ logger.info("Persistent session log: minder_session.log (5MBΓ—3)")
142
+ except Exception as e:
143
+ logger.warning("Failed to set up session file logging: %s", e)
144
+
145
  def _get_minder_db(self) -> Any:
146
  """Try to get the MiniMinderDB from the handler's deps."""
147
  try:
 
1548
  "Wakeword .feed() returned True β€” will start session"
1549
  )
1550
  await self._start_session_from_wakeword()
1551
+ # ── Idle timeout check β€” DISABLED per user request ──
1552
+ # Sessions no longer auto-end on silence.
1553
+ # if (
1554
+ # self._is_session_active
1555
+ # and self._last_activity_time > 0
1556
+ # and (_time.monotonic() - self._last_activity_time)
1557
+ # > self._idle_timeout_secs
1558
+ # ):
1559
+ # ...idle timeout logic removed...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1560
 
1561
  await asyncio.sleep(0) # avoid busy loop
1562
 
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/instructions.txt CHANGED
@@ -53,17 +53,23 @@ When in doubt, fewer tools = faster response. Never call tools speculatively.
53
 
54
  ## Tool Usage Guidelines
55
 
56
- ### Starting Entries
57
- - When a user mentions a headache, immediately call `start_headache_entry` with any details they already provided.
58
- - When a user mentions taking medication, call `start_medication_entry`.
 
59
  - If the user says something ambiguous like "I need to log something", ask whether it's a headache or medication.
60
-
61
- ### Building Entries
62
- - As the user provides details, call `update_headache_entry` or `update_medication_entry` to add each piece of information.
63
  - Guide the user through the important fields, but don't require all of them. Minimum for headache: intensity. Minimum for medication: medication_name.
64
  - For headaches, try to collect: intensity, location, pain_type. The rest are optional bonus details.
65
  - For medications, try to collect: medication_name, dose. The rest are optional.
66
 
 
 
 
 
 
 
 
67
  ### Saving and Discarding
68
  - When the user indicates they're done (or you've collected the key details), confirm what you have and then call `save_current_entry`.
69
  - If the user wants to cancel, call `discard_current_entry`.
 
53
 
54
  ## Tool Usage Guidelines
55
 
56
+ ### Starting & Building Entries (log_entry)
57
+ - Use `log_entry` for ALL entry logging. Set `entry_type` to "headache" or "medication".
58
+ - When a user mentions a headache, immediately call `log_entry` with `entry_type: "headache"` and any details they already provided.
59
+ - When a user mentions taking medication, call `log_entry` with `entry_type: "medication"`.
60
  - If the user says something ambiguous like "I need to log something", ask whether it's a headache or medication.
61
+ - As the user provides details, call `log_entry` again to update the active entry with new fields.
 
 
62
  - Guide the user through the important fields, but don't require all of them. Minimum for headache: intensity. Minimum for medication: medication_name.
63
  - For headaches, try to collect: intensity, location, pain_type. The rest are optional bonus details.
64
  - For medications, try to collect: medication_name, dose. The rest are optional.
65
 
66
+ ### Speech ↔ UI Synchronization β€” CRITICAL
67
+ - When `log_entry` returns `wizard_step` and `next_question`, you MUST speak that question (or a natural variation of it) β€” do NOT ask about a different field.
68
+ - The on-screen UI shows the same question to the user. Your speech must match what they see.
69
+ - If `wizard_step` is "done", all core fields are filled β€” offer to save.
70
+ - If `next_question` is null, the remaining fields are optional β€” you may ask about them or offer to save.
71
+ - Example: if `wizard_step` is "location" and `next_question` is "Where is the pain?", you should say something like "Where is the pain?" or "Whereabouts on your head is it?" β€” NOT "What does it feel like?" (which is a different step).
72
+
73
  ### Saving and Discarding
74
  - When the user indicates they're done (or you've collected the key details), confirm what you have and then call `save_current_entry`.
75
  - If the user wants to cancel, call `discard_current_entry`.
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/log_entry.py CHANGED
@@ -150,6 +150,16 @@ class LogEntry(Tool):
150
  "notes",
151
  ]
152
 
 
 
 
 
 
 
 
 
 
 
153
  async def _handle_headache(
154
  self, deps: ToolDependencies, fields: Dict[str, Any]
155
  ) -> Dict[str, Any]:
@@ -200,6 +210,10 @@ class LogEntry(Tool):
200
  "total_steps": 3, # Core steps: intensity, location, pain_type
201
  },
202
  )
 
 
 
 
203
  return result
204
 
205
  # ── Medication ─────────────────────────────────────────────────────
 
150
  "notes",
151
  ]
152
 
153
+ # Human-readable questions for each wizard step (must match frontend STEP_META)
154
+ _STEP_QUESTIONS: Dict[str, str] = {
155
+ "intensity": "How intense is the pain, on a scale of 1 to 10?",
156
+ "location": "Where is the pain?",
157
+ "pain_type": "What does it feel like?",
158
+ "onset_time": "When did it start?",
159
+ "triggers": "Any suspected triggers?",
160
+ "medication_taken": "Did you take anything for it?",
161
+ }
162
+
163
  async def _handle_headache(
164
  self, deps: ToolDependencies, fields: Dict[str, Any]
165
  ) -> Dict[str, Any]:
 
210
  "total_steps": 3, # Core steps: intensity, location, pain_type
211
  },
212
  )
213
+
214
+ # Include wizard state in tool result so LLM can coordinate speech
215
+ result["wizard_step"] = wizard_step or "done"
216
+ result["next_question"] = self._STEP_QUESTIONS.get(wizard_step or "")
217
  return result
218
 
219
  # ── Medication ─────────────────────────────────────────────────────
src/reachy_mini_conversation_app/stream_api.py CHANGED
@@ -420,7 +420,7 @@ async def emit_tool_start(
420
  args: dict[str, Any],
421
  event_id: str,
422
  ) -> None:
423
- """Emit tool execution started event."""
424
  event = ToolEvent(
425
  type="tool.start",
426
  id=event_id,
@@ -430,6 +430,18 @@ async def emit_tool_start(
430
  )
431
  await manager.broadcast(event)
432
 
 
 
 
 
 
 
 
 
 
 
 
 
433
 
434
  async def emit_tool_result(
435
  name: str,
@@ -437,7 +449,7 @@ async def emit_tool_result(
437
  event_id: str,
438
  status: str = "completed",
439
  ) -> None:
440
- """Emit tool execution completed event."""
441
  event = ToolEvent(
442
  type="tool.result",
443
  id=event_id,
@@ -447,6 +459,22 @@ async def emit_tool_result(
447
  )
448
  await manager.broadcast(event)
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
  async def emit_ui_component(
452
  component_name: str,
@@ -454,7 +482,7 @@ async def emit_ui_component(
454
  message_id: str | None = None,
455
  event_id: str | None = None,
456
  ) -> None:
457
- """Emit a Gen-UI component event.
458
 
459
  Args:
460
  component_name: Registry key for the React component
@@ -464,15 +492,35 @@ async def emit_ui_component(
464
  """
465
  import uuid
466
 
 
467
  event = UIComponentEvent(
468
  type="ui.component",
469
- id=event_id or str(uuid.uuid4()),
470
  name=component_name,
471
  props=props,
472
  message_id=message_id,
473
  )
474
  await manager.broadcast(event)
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
  async def emit_ui_dismiss() -> None:
478
  """Emit a UI dismiss event to close the active GenUI overlay."""
@@ -666,6 +714,30 @@ async def observability_metrics() -> dict[str, Any]:
666
  }
667
 
668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  @router.get("/cost-summary")
670
  async def cost_summary(session_id: str | None = None) -> dict[str, Any]:
671
  """Get aggregated token usage and cost for cost monitoring.
@@ -802,6 +874,7 @@ async def dashboard_stats() -> dict[str, Any]:
802
  {
803
  "day": day_names[day.weekday()],
804
  "intensity": max_intensity,
 
805
  }
806
  )
807
 
 
420
  args: dict[str, Any],
421
  event_id: str,
422
  ) -> None:
423
+ """Emit tool execution started event and persist to conversation log."""
424
  event = ToolEvent(
425
  type="tool.start",
426
  id=event_id,
 
430
  )
431
  await manager.broadcast(event)
432
 
433
+ # Persist tool start to conversation log for post-mortem debugging
434
+ if _conversation_logger and _current_session_id:
435
+ try:
436
+ _conversation_logger.log_turn(
437
+ session_id=_current_session_id,
438
+ role="tool",
439
+ content=f"{name}({json.dumps(args, default=str)})",
440
+ metadata={"event_id": event_id, "status": "started"},
441
+ )
442
+ except Exception as e:
443
+ logger.warning("Failed to log tool start: %s", e)
444
+
445
 
446
  async def emit_tool_result(
447
  name: str,
 
449
  event_id: str,
450
  status: str = "completed",
451
  ) -> None:
452
+ """Emit tool execution completed event and persist to conversation log."""
453
  event = ToolEvent(
454
  type="tool.result",
455
  id=event_id,
 
459
  )
460
  await manager.broadcast(event)
461
 
462
+ # Persist tool result to conversation log
463
+ if _conversation_logger and _current_session_id:
464
+ try:
465
+ # Truncate large results to avoid bloating the DB
466
+ result_str = json.dumps(result, default=str)
467
+ if len(result_str) > 1000:
468
+ result_str = result_str[:1000] + "...(truncated)"
469
+ _conversation_logger.log_turn(
470
+ session_id=_current_session_id,
471
+ role="tool_result",
472
+ content=f"{name} -> {result_str}",
473
+ metadata={"event_id": event_id, "status": status},
474
+ )
475
+ except Exception as e:
476
+ logger.warning("Failed to log tool result: %s", e)
477
+
478
 
479
  async def emit_ui_component(
480
  component_name: str,
 
482
  message_id: str | None = None,
483
  event_id: str | None = None,
484
  ) -> None:
485
+ """Emit a Gen-UI component event and persist to conversation log.
486
 
487
  Args:
488
  component_name: Registry key for the React component
 
492
  """
493
  import uuid
494
 
495
+ eid = event_id or str(uuid.uuid4())
496
  event = UIComponentEvent(
497
  type="ui.component",
498
+ id=eid,
499
  name=component_name,
500
  props=props,
501
  message_id=message_id,
502
  )
503
  await manager.broadcast(event)
504
 
505
+ # Persist UI event to conversation log for post-mortem debugging
506
+ if _conversation_logger and _current_session_id:
507
+ try:
508
+ # Log component name and a summary of props (skip large blobs)
509
+ props_summary = {
510
+ k: v for k, v in props.items() if not isinstance(v, (bytes, bytearray))
511
+ }
512
+ props_str = json.dumps(props_summary, default=str)
513
+ if len(props_str) > 500:
514
+ props_str = props_str[:500] + "...(truncated)"
515
+ _conversation_logger.log_turn(
516
+ session_id=_current_session_id,
517
+ role="ui",
518
+ content=f"{component_name}: {props_str}",
519
+ metadata={"event_id": eid, "message_id": message_id},
520
+ )
521
+ except Exception as e:
522
+ logger.warning("Failed to log UI component event: %s", e)
523
+
524
 
525
  async def emit_ui_dismiss() -> None:
526
  """Emit a UI dismiss event to close the active GenUI overlay."""
 
714
  }
715
 
716
 
717
+ @router.get("/session-log")
718
+ async def session_log(session_id: str | None = None) -> dict[str, Any]:
719
+ """Get the full event log for a session (transcripts, tool calls, UI events).
720
+
721
+ Used for post-mortem debugging when terminal scrollback is unavailable.
722
+
723
+ Args:
724
+ session_id: Optional session ID. Defaults to current session.
725
+ """
726
+ if not _conversation_logger:
727
+ return {"error": "Conversation logger not initialized", "turns": []}
728
+
729
+ target = session_id or _current_session_id
730
+ if not target:
731
+ return {"error": "No active session", "turns": []}
732
+
733
+ turns = _conversation_logger.get_session(target)
734
+ return {
735
+ "session_id": target,
736
+ "turn_count": len(turns),
737
+ "turns": turns,
738
+ }
739
+
740
+
741
  @router.get("/cost-summary")
742
  async def cost_summary(session_id: str | None = None) -> dict[str, Any]:
743
  """Get aggregated token usage and cost for cost monitoring.
 
874
  {
875
  "day": day_names[day.weekday()],
876
  "intensity": max_intensity,
877
+ "count": len(day_headaches),
878
  }
879
  )
880
 
tests/test_session_logging.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for session event logging (tool calls, UI events persistence)."""
2
+
3
+ import json
4
+ import asyncio
5
+ from unittest.mock import patch, MagicMock, AsyncMock
6
+
7
+ import pytest
8
+
9
+ import reachy_mini_conversation_app.stream_api as stream_api
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_conversation_logger():
14
+ """Provide a mock ConversationLogger and wire it into stream_api."""
15
+ mock_logger = MagicMock()
16
+ mock_logger.log_turn = MagicMock(return_value=1)
17
+ mock_logger.get_session = MagicMock(return_value=[])
18
+
19
+ original_logger = stream_api._conversation_logger
20
+ original_session_id = stream_api._current_session_id
21
+ stream_api._conversation_logger = mock_logger
22
+ stream_api._current_session_id = "test-session-abc"
23
+
24
+ yield mock_logger
25
+
26
+ stream_api._conversation_logger = original_logger
27
+ stream_api._current_session_id = original_session_id
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_broadcast():
32
+ """Mock the broadcast to avoid needing real WebSocket connections."""
33
+ with patch.object(stream_api.manager, "broadcast", new_callable=AsyncMock) as m:
34
+ yield m
35
+
36
+
37
+ # ─────────────────────────────────────────────────────────────
38
+ # Tool event persistence
39
+ # ─────────────────────────────────────────────────────────────
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_emit_tool_start_persists_to_conversation_log(
44
+ mock_conversation_logger, mock_broadcast
45
+ ):
46
+ """emit_tool_start should log a 'tool' turn to the conversation log."""
47
+ await stream_api.emit_tool_start(
48
+ name="log_entry",
49
+ args={"type": "headache", "intensity": 7},
50
+ event_id="evt-001",
51
+ )
52
+
53
+ mock_conversation_logger.log_turn.assert_called_once()
54
+ call_kwargs = mock_conversation_logger.log_turn.call_args
55
+ assert call_kwargs[1]["role"] == "tool"
56
+ assert "log_entry" in call_kwargs[1]["content"]
57
+ assert "headache" in call_kwargs[1]["content"]
58
+ assert call_kwargs[1]["session_id"] == "test-session-abc"
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_emit_tool_result_persists_to_conversation_log(
63
+ mock_conversation_logger, mock_broadcast
64
+ ):
65
+ """emit_tool_result should log a 'tool_result' turn."""
66
+ await stream_api.emit_tool_result(
67
+ name="log_entry",
68
+ result={"status": "ok", "entry_id": 42},
69
+ event_id="evt-001",
70
+ status="completed",
71
+ )
72
+
73
+ mock_conversation_logger.log_turn.assert_called_once()
74
+ call_kwargs = mock_conversation_logger.log_turn.call_args
75
+ assert call_kwargs[1]["role"] == "tool_result"
76
+ assert "log_entry" in call_kwargs[1]["content"]
77
+ assert call_kwargs[1]["metadata"]["status"] == "completed"
78
+
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_emit_tool_result_truncates_large_results(
82
+ mock_conversation_logger, mock_broadcast
83
+ ):
84
+ """Large tool results should be truncated to avoid DB bloat."""
85
+ big_result = {"data": "x" * 2000}
86
+
87
+ await stream_api.emit_tool_result(
88
+ name="big_tool",
89
+ result=big_result,
90
+ event_id="evt-002",
91
+ )
92
+
93
+ call_kwargs = mock_conversation_logger.log_turn.call_args
94
+ content = call_kwargs[1]["content"]
95
+ assert len(content) < 1200 # truncated from 2000+
96
+ assert "truncated" in content
97
+
98
+
99
+ # ─────────────────────────────────────────────────────────────
100
+ # UI event persistence
101
+ # ─────────────────────────────────────────────────────────────
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_emit_ui_component_persists_to_conversation_log(
106
+ mock_conversation_logger, mock_broadcast
107
+ ):
108
+ """emit_ui_component should log a 'ui' turn."""
109
+ await stream_api.emit_ui_component(
110
+ component_name="HeadacheLog",
111
+ props={"intensity": 7, "location": "frontal"},
112
+ message_id="msg-001",
113
+ )
114
+
115
+ mock_conversation_logger.log_turn.assert_called_once()
116
+ call_kwargs = mock_conversation_logger.log_turn.call_args
117
+ assert call_kwargs[1]["role"] == "ui"
118
+ assert "HeadacheLog" in call_kwargs[1]["content"]
119
+ assert "frontal" in call_kwargs[1]["content"]
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_emit_ui_component_truncates_large_props(
124
+ mock_conversation_logger, mock_broadcast
125
+ ):
126
+ """Large UI props should be truncated."""
127
+ big_props = {"data": "y" * 1000}
128
+
129
+ await stream_api.emit_ui_component(
130
+ component_name="BigComponent",
131
+ props=big_props,
132
+ )
133
+
134
+ call_kwargs = mock_conversation_logger.log_turn.call_args
135
+ content = call_kwargs[1]["content"]
136
+ assert len(content) < 700 # truncated from 1000+
137
+
138
+
139
+ # ─────────────────────────────────���───────────────────────────
140
+ # No-op when logger is not initialized
141
+ # ─────────────────────────────────────────────────────────────
142
+
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_emit_tool_start_no_crash_without_logger(mock_broadcast):
146
+ """Should not crash when conversation logger is not initialized."""
147
+ original = stream_api._conversation_logger
148
+ stream_api._conversation_logger = None
149
+ try:
150
+ await stream_api.emit_tool_start(
151
+ name="test_tool",
152
+ args={"a": 1},
153
+ event_id="evt-999",
154
+ )
155
+ # Should complete without error
156
+ finally:
157
+ stream_api._conversation_logger = original
158
+
159
+
160
+ # ─────────────────────────────────────────────────────────────
161
+ # Session-log endpoint
162
+ # ─────────────────────────────────────────────────────────────
163
+
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_session_log_endpoint_returns_turns(mock_conversation_logger):
167
+ """GET /session-log should return turns from ConversationLogger."""
168
+ mock_conversation_logger.get_session.return_value = [
169
+ {"turn_index": 0, "role": "user", "content": "Hello"},
170
+ {"turn_index": 1, "role": "tool", "content": "log_entry({})"},
171
+ {"turn_index": 2, "role": "ui", "content": "HeadacheLog: {}"},
172
+ ]
173
+
174
+ result = await stream_api.session_log(session_id="test-session-abc")
175
+
176
+ assert result["session_id"] == "test-session-abc"
177
+ assert result["turn_count"] == 3
178
+ assert result["turns"][1]["role"] == "tool"
179
+
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_session_log_endpoint_no_logger():
183
+ """Should return error when logger not initialized."""
184
+ original = stream_api._conversation_logger
185
+ stream_api._conversation_logger = None
186
+ try:
187
+ result = await stream_api.session_log()
188
+ assert "error" in result
189
+ finally:
190
+ stream_api._conversation_logger = original