notmartin commited on
Commit
e49e294
·
unverified ·
1 Parent(s): 466dc08

Wire medication reminders into Sam runtime

Browse files
mr_reachy/main.py CHANGED
@@ -25,7 +25,10 @@ from reachy_mini import ReachyMini, ReachyMiniApp
25
  from . import expressions
26
  from .config import ServiceConfig, load_settings
27
  from .io_backends import LocalBackend, RobotBackend, select_backend
 
28
  from .og_client import OGClient, Reply
 
 
29
 
30
  # Phrases that make Sam try to look and describe the scene (0G vision).
31
  _VISION_INTENT = re.compile(
@@ -53,9 +56,40 @@ def express_and_speak(reachy, reply: Reply, *, backend, voice: str | None, speak
53
  expressions.talk_animation(reachy, done)
54
 
55
 
56
- def handle_turn(reachy, og, history: list[dict], user_text: str, *, backend, voice, speak) -> Reply:
 
 
 
 
 
 
 
 
 
 
57
  """Run one full think+react turn for the given user utterance."""
58
  user_text = user_text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  history.append({"role": "user", "content": user_text})
60
 
61
  # Optional vision: only if the user asked AND a vision provider is funded.
@@ -88,6 +122,7 @@ def run_conversation(
88
  mode: str = "voice",
89
  voice: str | None = None,
90
  speak: bool = True,
 
91
  ) -> None:
92
  """The main loop. mode='voice' uses the backend mic; mode='text' reads stdin."""
93
  if reachy is None and mode != "text":
@@ -106,6 +141,14 @@ def run_conversation(
106
  print(f" Sam [{greeting.emotion}]: {greeting.speech}")
107
  express_and_speak(reachy, greeting, backend=backend, voice=voice, speak=speak)
108
 
 
 
 
 
 
 
 
 
109
  while not stop_event.is_set():
110
  if mode == "text":
111
  try:
@@ -153,7 +196,16 @@ def run_conversation(
153
  break
154
 
155
  try:
156
- handle_turn(reachy, og, history, user_text, backend=backend, voice=voice, speak=speak)
 
 
 
 
 
 
 
 
 
157
  except Exception as exc:
158
  print(f" [error] {exc}")
159
  express_and_speak(
@@ -166,6 +218,8 @@ def run_conversation(
166
 
167
  if reachy is not None:
168
  expressions.go_rest(reachy)
 
 
169
  print("\nSam: bye!")
170
 
171
 
@@ -188,10 +242,20 @@ class MrReachy(ReachyMiniApp):
188
  return RobotBackend() if self.running_on_wireless else LocalBackend()
189
 
190
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
191
- og = OGClient(load_settings())
 
 
192
  backend = self._backend()
193
  print(f"Sam backend: {backend.name}")
194
- run_conversation(reachy_mini, stop_event, og=og, backend=backend, mode="voice", speak=True)
 
 
 
 
 
 
 
 
195
 
196
 
197
  # --------------------------------------------------------------------------- #
@@ -205,6 +269,10 @@ def _build_client() -> OGClient:
205
  return og
206
 
207
 
 
 
 
 
208
  def _service_status(name: str, cfg: ServiceConfig) -> str:
209
  missing = []
210
  if not cfg.base_url:
@@ -306,12 +374,30 @@ def cli(argv: list[str] | None = None) -> None:
306
  if reachy is not None:
307
  reachy.wake_up()
308
  expressions.go_rest(reachy)
309
- handle_turn(reachy, og, [], args.once, backend=backend, voice=args.voice, speak=speak)
 
 
 
 
 
 
 
 
 
310
  if reachy is not None:
311
  expressions.go_rest(reachy)
312
  return
313
  mode = "text" if args.text else "voice"
314
- run_conversation(reachy, stop_event, og=og, backend=backend, mode=mode, voice=args.voice, speak=speak)
 
 
 
 
 
 
 
 
 
315
 
316
 
317
  if __name__ == "__main__":
 
25
  from . import expressions
26
  from .config import ServiceConfig, load_settings
27
  from .io_backends import LocalBackend, RobotBackend, select_backend
28
+ from .medication import is_confirmation_intent, is_medication_intent, parse_medication_instruction, plan_summary
29
  from .og_client import OGClient, Reply
30
+ from .reminders import confirm_due_dose, start_reminder_thread
31
+ from .storage import HybridMedicationStore, build_medication_store
32
 
33
  # Phrases that make Sam try to look and describe the scene (0G vision).
34
  _VISION_INTENT = re.compile(
 
56
  expressions.talk_animation(reachy, done)
57
 
58
 
59
+ def handle_turn(
60
+ reachy,
61
+ og,
62
+ history: list[dict],
63
+ user_text: str,
64
+ *,
65
+ backend,
66
+ voice,
67
+ speak,
68
+ medication_store: HybridMedicationStore | None = None,
69
+ ) -> Reply:
70
  """Run one full think+react turn for the given user utterance."""
71
  user_text = user_text.strip()
72
+
73
+ if medication_store is not None and is_confirmation_intent(user_text):
74
+ confirmed, message = confirm_due_dose(medication_store)
75
+ reply = Reply(speech=message, emotion="happy" if confirmed else "curious")
76
+ print(f" Sam [{reply.emotion}]: {reply.speech}")
77
+ express_and_speak(reachy, reply, backend=backend, voice=voice, speak=speak)
78
+ return reply
79
+
80
+ if medication_store is not None and is_medication_intent(user_text):
81
+ result = parse_medication_instruction(user_text, og)
82
+ if result.accepted and result.plan is not None:
83
+ memory = medication_store.load()
84
+ memory.plans.append(result.plan)
85
+ medication_store.save(memory)
86
+ reply = Reply(speech=plan_summary(result.plan), emotion="happy")
87
+ else:
88
+ reply = Reply(speech=result.reason, emotion="confused")
89
+ print(f" Sam [{reply.emotion}]: {reply.speech}")
90
+ express_and_speak(reachy, reply, backend=backend, voice=voice, speak=speak)
91
+ return reply
92
+
93
  history.append({"role": "user", "content": user_text})
94
 
95
  # Optional vision: only if the user asked AND a vision provider is funded.
 
122
  mode: str = "voice",
123
  voice: str | None = None,
124
  speak: bool = True,
125
+ medication_store: HybridMedicationStore | None = None,
126
  ) -> None:
127
  """The main loop. mode='voice' uses the backend mic; mode='text' reads stdin."""
128
  if reachy is None and mode != "text":
 
141
  print(f" Sam [{greeting.emotion}]: {greeting.speech}")
142
  express_and_speak(reachy, greeting, backend=backend, voice=voice, speak=speak)
143
 
144
+ reminder_thread = None
145
+ if medication_store is not None:
146
+ reminder_thread = start_reminder_thread(
147
+ store=medication_store,
148
+ stop_event=stop_event,
149
+ notify=lambda reply: express_and_speak(reachy, reply, backend=backend, voice=voice, speak=speak),
150
+ )
151
+
152
  while not stop_event.is_set():
153
  if mode == "text":
154
  try:
 
196
  break
197
 
198
  try:
199
+ handle_turn(
200
+ reachy,
201
+ og,
202
+ history,
203
+ user_text,
204
+ backend=backend,
205
+ voice=voice,
206
+ speak=speak,
207
+ medication_store=medication_store,
208
+ )
209
  except Exception as exc:
210
  print(f" [error] {exc}")
211
  express_and_speak(
 
218
 
219
  if reachy is not None:
220
  expressions.go_rest(reachy)
221
+ if reminder_thread is not None:
222
+ reminder_thread.join(timeout=1.0)
223
  print("\nSam: bye!")
224
 
225
 
 
242
  return RobotBackend() if self.running_on_wireless else LocalBackend()
243
 
244
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
245
+ settings = load_settings()
246
+ og = OGClient(settings)
247
+ medication_store = build_medication_store(settings.storage)
248
  backend = self._backend()
249
  print(f"Sam backend: {backend.name}")
250
+ run_conversation(
251
+ reachy_mini,
252
+ stop_event,
253
+ og=og,
254
+ backend=backend,
255
+ mode="voice",
256
+ speak=True,
257
+ medication_store=medication_store,
258
+ )
259
 
260
 
261
  # --------------------------------------------------------------------------- #
 
269
  return og
270
 
271
 
272
+ def _build_medication_store() -> HybridMedicationStore:
273
+ return build_medication_store(load_settings().storage)
274
+
275
+
276
  def _service_status(name: str, cfg: ServiceConfig) -> str:
277
  missing = []
278
  if not cfg.base_url:
 
374
  if reachy is not None:
375
  reachy.wake_up()
376
  expressions.go_rest(reachy)
377
+ handle_turn(
378
+ reachy,
379
+ og,
380
+ [],
381
+ args.once,
382
+ backend=backend,
383
+ voice=args.voice,
384
+ speak=speak,
385
+ medication_store=_build_medication_store(),
386
+ )
387
  if reachy is not None:
388
  expressions.go_rest(reachy)
389
  return
390
  mode = "text" if args.text else "voice"
391
+ run_conversation(
392
+ reachy,
393
+ stop_event,
394
+ og=og,
395
+ backend=backend,
396
+ mode=mode,
397
+ voice=args.voice,
398
+ speak=speak,
399
+ medication_store=_build_medication_store(),
400
+ )
401
 
402
 
403
  if __name__ == "__main__":
mr_reachy/reminders.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background reminder loop for Sam medication doses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Callable
11
+
12
+ from .medication import Dose, MedicationMemory, _now_iso
13
+ from .storage import HybridMedicationStore
14
+
15
+
16
+ MAX_REMINDERS = 3
17
+ REMINDER_CHECK_SECONDS = float(os.getenv("SAM_REMINDER_CHECK_SECONDS", "30"))
18
+ REMINDER_RETRY_SECONDS = float(os.getenv("SAM_REMINDER_RETRY_SECONDS", "300"))
19
+
20
+
21
+ @dataclass
22
+ class ReminderReply:
23
+ speech: str
24
+ emotion: str = "neutral"
25
+
26
+
27
+ def start_reminder_thread(
28
+ *,
29
+ store: HybridMedicationStore,
30
+ stop_event: threading.Event,
31
+ notify: Callable[[ReminderReply], None],
32
+ check_seconds: float = REMINDER_CHECK_SECONDS,
33
+ retry_seconds: float = REMINDER_RETRY_SECONDS,
34
+ ) -> threading.Thread:
35
+ thread = threading.Thread(
36
+ target=_run_loop,
37
+ kwargs={
38
+ "store": store,
39
+ "stop_event": stop_event,
40
+ "notify": notify,
41
+ "check_seconds": check_seconds,
42
+ "retry_seconds": retry_seconds,
43
+ },
44
+ daemon=True,
45
+ )
46
+ thread.start()
47
+ return thread
48
+
49
+
50
+ def confirm_due_dose(store: HybridMedicationStore, now: datetime | None = None) -> tuple[bool, str]:
51
+ memory = store.load()
52
+ due = memory.active_pending_doses(now)
53
+ if not due:
54
+ return False, "I do not have a due medication waiting for confirmation right now."
55
+
56
+ dose = due[0]
57
+ plan = memory.plan_for_dose(dose.id)
58
+ dose.status = "confirmed"
59
+ dose.confirmed_at = _now_iso(now)
60
+ memory.updated_at = _now_iso(now)
61
+ store.save(memory)
62
+ name = plan.medication_name if plan is not None else "your medicine"
63
+ return True, f"Thank you. I marked your {name} dose as taken."
64
+
65
+
66
+ def process_due_reminders(
67
+ *,
68
+ store: HybridMedicationStore,
69
+ notify: Callable[[ReminderReply], None],
70
+ now: datetime | None = None,
71
+ retry_seconds: float = REMINDER_RETRY_SECONDS,
72
+ ) -> bool:
73
+ now = now or datetime.now().astimezone()
74
+ memory = store.load()
75
+ changed = False
76
+ for dose in memory.active_pending_doses(now):
77
+ if _should_mark_missed(dose):
78
+ dose.status = "missed"
79
+ dose.missed_at = _now_iso(now)
80
+ changed = True
81
+ plan = memory.plan_for_dose(dose.id)
82
+ name = plan.medication_name if plan is not None else "your medicine"
83
+ notify(ReminderReply(speech=f"I am worried. I could not confirm your {name} dose.", emotion="sad"))
84
+ continue
85
+ if _should_remind(dose, retry_seconds, now):
86
+ plan = memory.plan_for_dose(dose.id)
87
+ name = plan.medication_name if plan is not None else "your medicine"
88
+ dose.reminder_count += 1
89
+ dose.last_reminded_at = _now_iso(now)
90
+ changed = True
91
+ notify(
92
+ ReminderReply(
93
+ speech=f"It is time for your {name}. Please take it now, then tell me when you have taken it.",
94
+ emotion="thinking",
95
+ )
96
+ )
97
+ if changed:
98
+ memory.updated_at = _now_iso(now)
99
+ store.save(memory)
100
+ else:
101
+ store.sync_pending_async()
102
+ return changed
103
+
104
+
105
+ def medication_status_text(memory: MedicationMemory) -> str:
106
+ if not memory.plans:
107
+ return "No medication reminders saved yet."
108
+ lines: list[str] = []
109
+ for plan in memory.plans:
110
+ pending = sum(1 for dose in plan.doses if dose.status == "pending")
111
+ confirmed = sum(1 for dose in plan.doses if dose.status == "confirmed")
112
+ missed = sum(1 for dose in plan.doses if dose.status == "missed")
113
+ times = ", ".join(plan.dose_times)
114
+ lines.append(
115
+ f"{plan.medication_name}: {plan.frequency_per_day}x/day at {times} "
116
+ f"for {plan.duration_days} days. Confirmed {confirmed}, pending {pending}, missed {missed}."
117
+ )
118
+ sync = "0G sync pending" if memory.pending_sync else "0G sync current"
119
+ if memory.last_sync_error:
120
+ sync = f"{sync}; last sync error: {memory.last_sync_error}"
121
+ return "\n".join([*lines, sync])
122
+
123
+
124
+ def _run_loop(
125
+ *,
126
+ store: HybridMedicationStore,
127
+ stop_event: threading.Event,
128
+ notify: Callable[[ReminderReply], None],
129
+ check_seconds: float,
130
+ retry_seconds: float,
131
+ ) -> None:
132
+ while not stop_event.wait(check_seconds):
133
+ process_due_reminders(store=store, notify=notify, retry_seconds=retry_seconds)
134
+
135
+
136
+ def _should_remind(dose: Dose, retry_seconds: float, now: datetime | None = None) -> bool:
137
+ if dose.reminder_count == 0 or not dose.last_reminded_at:
138
+ return True
139
+ last = datetime.fromisoformat(dose.last_reminded_at)
140
+ return ((now or datetime.now().astimezone()) - last).total_seconds() >= retry_seconds
141
+
142
+
143
+ def _should_mark_missed(dose: Dose) -> bool:
144
+ return dose.reminder_count >= MAX_REMINDERS
tests/test_reminders.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ from mr_reachy.medication import MedicationMemory, build_plan
9
+ from mr_reachy.reminders import confirm_due_dose, process_due_reminders
10
+ from mr_reachy.storage import HybridMedicationStore, LocalMedicationStore
11
+
12
+
13
+ def _store_with_due_plan(tmp: str) -> tuple[HybridMedicationStore, datetime]:
14
+ now = datetime(2026, 5, 28, 9, 5).astimezone()
15
+ plan = build_plan(
16
+ raw_instruction="Take metformin once a day for one day.",
17
+ medication_name="metformin",
18
+ frequency_per_day=1,
19
+ duration_days=1,
20
+ dose_times=["09:00"],
21
+ now=now,
22
+ )
23
+ store = HybridMedicationStore(LocalMedicationStore(Path(tmp) / "memory.json"), auto_sync=False)
24
+ store.save(MedicationMemory(plans=[plan]))
25
+ return store, now
26
+
27
+
28
+ class ReminderTest(unittest.TestCase):
29
+ def test_due_dose_triggers_reminder(self) -> None:
30
+ with tempfile.TemporaryDirectory() as tmp:
31
+ store, now = _store_with_due_plan(tmp)
32
+ replies = []
33
+
34
+ changed = process_due_reminders(store=store, notify=replies.append, now=now)
35
+ memory = store.load()
36
+
37
+ self.assertTrue(changed)
38
+ self.assertEqual(len(replies), 1)
39
+ self.assertIn("metformin", replies[0].speech)
40
+ self.assertEqual(memory.plans[0].doses[0].reminder_count, 1)
41
+
42
+ def test_confirmation_marks_due_dose_complete(self) -> None:
43
+ with tempfile.TemporaryDirectory() as tmp:
44
+ store, now = _store_with_due_plan(tmp)
45
+
46
+ confirmed, message = confirm_due_dose(store, now=now)
47
+ memory = store.load()
48
+
49
+ self.assertTrue(confirmed)
50
+ self.assertIn("metformin", message)
51
+ self.assertEqual(memory.plans[0].doses[0].status, "confirmed")
52
+
53
+ def test_missed_dose_after_three_reminders(self) -> None:
54
+ with tempfile.TemporaryDirectory() as tmp:
55
+ store, now = _store_with_due_plan(tmp)
56
+ memory = store.load()
57
+ dose = memory.plans[0].doses[0]
58
+ dose.reminder_count = 3
59
+ dose.last_reminded_at = (now - timedelta(minutes=10)).isoformat()
60
+ store.save(memory)
61
+ replies = []
62
+
63
+ changed = process_due_reminders(store=store, notify=replies.append, now=now)
64
+ updated = store.load()
65
+
66
+ self.assertTrue(changed)
67
+ self.assertEqual(updated.plans[0].doses[0].status, "missed")
68
+ self.assertEqual(replies[0].emotion, "sad")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ unittest.main()