Spaces:
Running
Running
feat: enhance session summary UI and streamline headache logging with guided wizard steps.
Browse files- README.md +9 -0
- frontend/public/reachy-mini-minder.ai +0 -0
- frontend/src/components/ComponentOverlay.tsx +2 -2
- frontend/src/components/DashboardGrid.tsx +97 -44
- frontend/src/hooks/useDashboardData.ts +1 -0
- frontend/src/registry/SessionSummary.tsx +31 -33
- src/reachy_mini_conversation_app/console.py +35 -31
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/instructions.txt +12 -6
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/log_entry.py +14 -0
- src/reachy_mini_conversation_app/stream_api.py +77 -4
- tests/test_session_logging.py +190 -0
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 |
-
},
|
| 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.
|
| 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.
|
| 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}
|
| 426 |
</div>
|
| 427 |
<div style={{
|
| 428 |
fontSize: 12,
|
|
@@ -444,23 +444,43 @@ function HeadacheCalendarCard({
|
|
| 444 |
}: {
|
| 445 |
headaches: DashboardStats["headaches"];
|
| 446 |
}) {
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
if (intensity
|
| 451 |
-
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
return {
|
| 454 |
-
background: "var(--color-accent-pink)",
|
| 455 |
-
height
|
| 456 |
-
boxShadow: "0 0
|
| 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:
|
| 476 |
-
<Brain style={{ width:
|
| 477 |
<span style={{
|
| 478 |
fontSize: 12,
|
| 479 |
fontWeight: 600,
|
|
@@ -485,37 +505,70 @@ function HeadacheCalendarCard({
|
|
| 485 |
</span>
|
| 486 |
</div>
|
| 487 |
|
| 488 |
-
<div style={{
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
>
|
| 500 |
<div
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
|
|
|
| 507 |
}}
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 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-
|
| 38 |
-
<div className="flex items-center gap-
|
| 39 |
-
<Icon className="w-
|
| 40 |
-
<span className="text-
|
| 41 |
{title}
|
| 42 |
</span>
|
| 43 |
</div>
|
|
@@ -54,26 +54,24 @@ export function SessionSummary(props: SessionSummaryProps) {
|
|
| 54 |
|
| 55 |
return (
|
| 56 |
<div
|
| 57 |
-
className="relative
|
| 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-
|
| 64 |
|
| 65 |
{/* Header */}
|
| 66 |
-
<div className="flex items-center gap-
|
| 67 |
<div
|
| 68 |
-
className="relative w-
|
| 69 |
bg-gradient-to-br from-cta/25 to-cta/10
|
| 70 |
border border-cta/30"
|
| 71 |
>
|
| 72 |
-
<ClipboardCheck className="w-
|
| 73 |
</div>
|
| 74 |
<div className="flex-1">
|
| 75 |
-
<h3 className="text-
|
| 76 |
-
<p className="text-
|
| 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-
|
| 86 |
{medications_logged.map((med, i) => (
|
| 87 |
<div
|
| 88 |
key={i}
|
| 89 |
-
className="flex items-center gap-
|
| 90 |
bg-surface-subtle/60 border border-success/20"
|
| 91 |
>
|
| 92 |
-
<span className="text-success text-
|
| 93 |
-
<span className="flex-1 text-
|
| 94 |
-
<span className="text-
|
| 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-
|
| 105 |
{headaches_logged.map((h, i) => (
|
| 106 |
<div
|
| 107 |
key={i}
|
| 108 |
-
className="flex items-center gap-
|
| 109 |
bg-surface-subtle/60 border border-warning/20"
|
| 110 |
>
|
| 111 |
-
<span className="text-
|
| 112 |
{h.intensity && (
|
| 113 |
-
<span className="ml-auto text-
|
| 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-
|
| 127 |
{questions_for_provider.map((q, i) => (
|
| 128 |
<div
|
| 129 |
key={i}
|
| 130 |
-
className="px-
|
| 131 |
bg-surface-subtle/60 border-l-3 border-l-cta border border-cta/10
|
| 132 |
-
text-
|
| 133 |
>
|
| 134 |
"{q}"
|
| 135 |
</div>
|
| 136 |
))}
|
| 137 |
</div>
|
| 138 |
-
<p className="text-
|
| 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-
|
| 148 |
{items_mentioned.map((item, i) => (
|
| 149 |
<span
|
| 150 |
key={i}
|
| 151 |
-
className="px-
|
| 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-
|
| 163 |
-
<Clock className="w-
|
| 164 |
-
<span className="text-
|
| 165 |
Next check-in: {props.next_checkin}
|
| 166 |
</span>
|
| 167 |
</div>
|
| 168 |
)}
|
| 169 |
|
| 170 |
{/* Disclaimer */}
|
| 171 |
-
<p className="text-
|
| 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 |
"{q}"
|
| 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 |
-
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 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 |
-
-
|
| 58 |
-
- When a user mentions
|
|
|
|
| 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=
|
| 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
|