Files
Synculous-2/scheduler/daemon.py
chelsea e89656a87c Fix adaptive medication timing and update README
- Fix double notifications: remove redundant check_medication_reminders()
  call, use adaptive path as primary with basic as fallback
- Fix nag firing immediately: require nag_interval minutes after scheduled
  dose time before first nag
- Fix missing schedules: create on-demand if midnight window was missed
- Fix wrong timezone: use user_now_for() instead of request-context
  user_now() in calculate_adjusted_times()
- Fix immutable schedules: recalculate pending schedules on wake event
  detection so adaptive timing actually adapts
- Fix take/skip not updating schedule: API endpoints now call
  mark_med_taken/skipped so nags stop after logging a dose
- Fix skipped doses still triggering reminders: check both taken and
  skipped in adaptive reminder and log queries
- Update README with tasks, AI step generation, auth refresh tokens,
  knowledge base improvements, and current architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 23:34:38 -06:00

697 lines
26 KiB
Python

"""
daemon.py - Background polling loop for scheduled tasks
Override poll_callback() with your domain-specific logic.
"""
import os
import time as time_module
import logging
from datetime import datetime, timezone, timedelta, time as time_type
import core.postgres as postgres
import core.notifications as notifications
import core.adaptive_meds as adaptive_meds
import core.snitch as snitch
import core.tz as tz
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
def _user_now_for(user_uuid):
"""Get current datetime in a user's timezone using their stored preferences."""
return tz.user_now_for(user_uuid)
def _utc_to_local_date(created_at, user_tz):
"""Convert a DB created_at (naive UTC datetime) to a local date string YYYY-MM-DD."""
if created_at is None:
return ""
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
return created_at.astimezone(user_tz).date().isoformat()
return str(created_at)[:10]
def check_medication_reminders():
"""Check for medications due now and send notifications."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
# Group by user so we only look up timezone once per user
user_meds = {}
for med in meds:
uid = med.get("user_uuid")
if uid not in user_meds:
user_meds[uid] = []
user_meds[uid].append(med)
for user_uuid, user_med_list in user_meds.items():
now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
today = now.date()
today_str = today.isoformat()
user_tz = tz.tz_for_user(user_uuid)
for med in user_med_list:
freq = med.get("frequency", "daily")
# Skip as_needed -- no scheduled reminders for PRN
if freq == "as_needed":
continue
# Day-of-week check for specific_days
if freq == "specific_days":
med_days = med.get("days_of_week", [])
if current_day not in med_days:
continue
# Interval check for every_n_days
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
continue
else:
continue
# Time check
times = med.get("times", [])
if current_time not in times:
continue
# Already taken today? Check by created_at date in user's timezone
logs = postgres.select(
"med_logs", where={"medication_id": med["id"], "action": "taken"}
)
already_taken = any(
log.get("scheduled_time") == current_time
and _utc_to_local_date(log.get("created_at"), user_tz) == today_str
for log in logs
)
if already_taken:
continue
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']}) · {current_time}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
except Exception as e:
logger.error(f"Error checking medication reminders: {e}")
def check_routine_reminders():
"""Check for scheduled routines due now and send notifications."""
try:
from datetime import date as date_type
schedules = postgres.select("routine_schedules", where={"remind": True})
for schedule in schedules:
routine = postgres.select_one("routines", {"id": schedule["routine_id"]})
if not routine:
continue
now = _user_now_for(routine["user_uuid"])
current_time = now.strftime("%H:%M")
today = now.date()
if current_time != schedule.get("time"):
continue
frequency = schedule.get("frequency", "weekly")
if frequency == "every_n_days":
start = schedule.get("start_date")
interval = schedule.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
continue
else:
continue
else:
current_day = now.strftime("%a").lower()
days = schedule.get("days", [])
if current_day not in days:
continue
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
if user_settings:
msg = f"Time to start your routine: {routine['name']}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=routine["user_uuid"]
)
except Exception as e:
logger.error(f"Error checking routine reminders: {e}")
def check_refills():
"""Check for medications running low on refills."""
try:
meds = postgres.select("medications")
for med in meds:
qty = med.get("quantity_remaining")
if qty is not None and qty <= 7:
user_settings = notifications.getNotificationSettings(med["user_uuid"])
if user_settings:
msg = f"Low on {med['name']}: only {qty} doses remaining. Time to refill!"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=med["user_uuid"]
)
except Exception as e:
logger.error(f"Error checking refills: {e}")
def create_daily_adaptive_schedules():
"""Create today's medication schedules with adaptive timing.
Called per-user when it's midnight in their timezone."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
for med in meds:
user_uuid = med.get("user_uuid")
med_id = med.get("id")
times = med.get("times", [])
if not times:
continue
# Create daily schedule with adaptive adjustments
adaptive_meds.create_daily_schedule(user_uuid, med_id, times)
except Exception as e:
logger.error(f"Error creating daily adaptive schedules: {e}")
def check_adaptive_medication_reminders():
"""Check for medications due now with adaptive timing."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
# Group by user
user_meds = {}
for med in meds:
uid = med.get("user_uuid")
if uid not in user_meds:
user_meds[uid] = []
user_meds[uid].append(med)
for user_uuid, user_med_list in user_meds.items():
now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M")
today = now.date()
user_tz = tz.tz_for_user(user_uuid)
# Check if adaptive timing is enabled
settings = adaptive_meds.get_adaptive_settings(user_uuid)
adaptive_enabled = settings and settings.get("adaptive_timing_enabled")
for med in user_med_list:
freq = med.get("frequency", "daily")
if freq == "as_needed":
continue
# Day-of-week check
if freq == "specific_days":
current_day = now.strftime("%a").lower()
med_days = med.get("days_of_week", [])
if current_day not in med_days:
continue
# Interval check
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (
today - start_d
).days % interval != 0:
continue
else:
continue
# Get today's schedule (any status — we filter below)
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med["id"],
"adjustment_date": today,
},
)
# If no schedules exist yet, create them on demand
if not schedules:
times = med.get("times", [])
if times:
try:
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med["id"],
"adjustment_date": today,
},
)
except Exception as e:
logger.warning(f"Could not create on-demand schedule for {med['id']}: {e}")
for sched in schedules:
# Skip already-taken or skipped schedules
if sched.get("status") in ("taken", "skipped"):
continue
# Check if it's time to take this med
if adaptive_enabled:
# Use adjusted time
check_time = sched.get("adjusted_time")
else:
# Use base time
check_time = sched.get("base_time")
# Normalize TIME objects to "HH:MM" strings for comparison
if isinstance(check_time, time_type):
check_time = check_time.strftime("%H:%M")
elif check_time is not None:
check_time = str(check_time)[:5]
if check_time != current_time:
continue
# Check if already taken or skipped for this time slot today
logs = postgres.select(
"med_logs",
where={
"medication_id": med["id"],
"user_uuid": user_uuid,
},
)
already_handled = any(
log.get("action") in ("taken", "skipped")
and log.get("scheduled_time") == check_time
and _utc_to_local_date(log.get("created_at"), user_tz)
== today.isoformat()
for log in logs
)
if already_handled:
continue
# Send notification
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
offset = sched.get("adjustment_minutes", 0)
if offset > 0:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)"
else:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
except Exception as e:
logger.error(f"Error checking adaptive medication reminders: {e}")
def check_nagging():
"""Check for missed medications and send nag notifications."""
try:
from datetime import date as date_type
# Get all active medications
meds = postgres.select("medications", where={"active": True})
for med in meds:
user_uuid = med.get("user_uuid")
med_id = med.get("id")
# Get user's settings
settings = adaptive_meds.get_adaptive_settings(user_uuid)
if not settings:
logger.debug(f"No adaptive settings for user {user_uuid}")
continue
if not settings.get("nagging_enabled"):
logger.debug(f"Nagging disabled for user {user_uuid}")
continue
now = _user_now_for(user_uuid)
today = now.date()
# Skip nagging if medication is not due today
if not _is_med_due_today(med, today):
continue
# Get today's schedules
try:
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"status": "pending",
},
)
except Exception as e:
logger.warning(
f"Could not query medication_schedules for {med_id}: {e}"
)
# Table may not exist yet
continue
# If no schedules exist, try to create them — but only if med is due today
if not schedules:
if not _is_med_due_today(med, today):
continue
logger.info(
f"No schedules found for medication {med_id}, attempting to create"
)
times = med.get("times", [])
if times:
try:
adaptive_meds.create_daily_schedule(user_uuid, med_id, times)
# Re-query for schedules
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"status": "pending",
},
)
except Exception as e:
logger.warning(f"Could not create schedules for {med_id}: {e}")
continue
if not schedules:
logger.debug(f"No pending schedules for medication {med_id}")
continue
for sched in schedules:
# Check if we should nag
should_nag, reason = adaptive_meds.should_send_nag(
user_uuid, med_id, sched.get("adjusted_time"), now
)
if not should_nag:
continue
# Get the time to display
adaptive_enabled = settings.get("adaptive_timing_enabled")
if adaptive_enabled:
display_time = sched.get("adjusted_time")
else:
display_time = sched.get("base_time")
# Normalize TIME objects for display
if isinstance(display_time, time_type):
display_time = display_time.strftime("%H:%M")
elif display_time is not None:
display_time = str(display_time)[:5]
# Send nag notification
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
nag_count = sched.get("nag_count", 0) + 1
max_nags = settings.get("max_nag_count", 4)
msg = f"🔔 {med['name']} reminder {nag_count}/{max_nags}: You missed your {display_time} dose. Please take it now!"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
# Record that we sent a nag
adaptive_meds.record_nag_sent(
user_uuid, med_id, sched.get("adjusted_time")
)
logger.info(
f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}"
)
# Check if we should snitch (max nags reached)
should_snitch, trigger_reason, snitch_settings = (
snitch.should_snitch(
user_uuid, med_id, nag_count, missed_doses=1
)
)
if should_snitch:
logger.info(
f"Triggering snitch for {med['name']} - {trigger_reason}"
)
results = snitch.send_snitch(
user_uuid=user_uuid,
med_id=med_id,
med_name=med["name"],
nag_count=nag_count,
missed_doses=1,
trigger_reason=trigger_reason,
)
# Log results
for result in results:
if result.get("delivered"):
logger.info(
f"Snitch sent to {result['contact_name']} via {result['contact_type']}"
)
else:
logger.error(
f"Failed to snitch to {result['contact_name']}: {result.get('error')}"
)
except Exception as e:
logger.error(f"Error checking nags: {e}")
def _get_distinct_user_uuids():
"""Return a set of user UUIDs that have active medications or routines."""
uuids = set()
try:
meds = postgres.select("medications", where={"active": True})
for m in meds:
uid = m.get("user_uuid")
if uid:
uuids.add(uid)
except Exception:
pass
try:
routines = postgres.select("routines")
for r in routines:
uid = r.get("user_uuid")
if uid:
uuids.add(uid)
except Exception:
pass
return uuids
def _is_med_due_today(med, today):
"""Check if a medication is due on the given date based on its frequency."""
from datetime import date as date_type
freq = med.get("frequency", "daily")
if freq == "as_needed":
return False
if freq == "specific_days":
current_day = today.strftime("%a").lower()
med_days = med.get("days_of_week", [])
if current_day not in med_days:
return False
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
days_since = (today - start_d).days
if days_since < 0 or days_since % interval != 0:
return False
else:
return False
return True
def _check_per_user_midnight_schedules():
"""Create daily adaptive schedules for each user when it's midnight in
their timezone (within the poll window)."""
for user_uuid in _get_distinct_user_uuids():
try:
now = _user_now_for(user_uuid)
if now.hour == 0 and now.minute < POLL_INTERVAL / 60:
today = now.date()
user_meds = postgres.select(
"medications", where={"user_uuid": user_uuid, "active": True}
)
for med in user_meds:
if not _is_med_due_today(med, today):
continue
times = med.get("times", [])
if times:
adaptive_meds.create_daily_schedule(user_uuid, med["id"], times)
except Exception as e:
logger.warning(
f"Could not create adaptive schedules for user {user_uuid}: {e}"
)
def check_task_reminders():
"""Check one-off tasks for advance and at-time reminders."""
from datetime import timedelta
try:
tasks = postgres.select("tasks", where={"status": "pending"})
if not tasks:
return
user_tasks = {}
for task in tasks:
uid = task.get("user_uuid")
user_tasks.setdefault(uid, []).append(task)
for user_uuid, task_list in user_tasks.items():
now = _user_now_for(user_uuid)
current_hhmm = now.strftime("%H:%M")
current_date = now.date()
user_settings = None # lazy-load once per user
for task in task_list:
raw_dt = task.get("scheduled_datetime")
if not raw_dt:
continue
sched_dt = (
raw_dt
if isinstance(raw_dt, datetime)
else datetime.fromisoformat(str(raw_dt))
)
sched_date = sched_dt.date()
sched_hhmm = sched_dt.strftime("%H:%M")
reminder_min = task.get("reminder_minutes_before") or 0
# Advance reminder
if reminder_min > 0 and not task.get("advance_notified"):
adv_dt = sched_dt - timedelta(minutes=reminder_min)
if (
adv_dt.date() == current_date
and adv_dt.strftime("%H:%M") == current_hhmm
):
if user_settings is None:
user_settings = notifications.getNotificationSettings(
user_uuid
)
if user_settings:
msg = f"⏰ In {reminder_min} min: {task['title']}"
if task.get("description"):
msg += f"{task['description']}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks", {"advance_notified": True}, {"id": task["id"]}
)
# At-time reminder
if sched_date == current_date and sched_hhmm == current_hhmm:
if user_settings is None:
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
msg = f"📋 Now: {task['title']}"
if task.get("description"):
msg += f"\n{task['description']}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
postgres.update(
"tasks",
{
"status": "notified",
"updated_at": datetime.utcnow().isoformat(),
},
{"id": task["id"]},
)
except Exception as e:
logger.error(f"Error checking task reminders: {e}")
def poll_callback():
"""Called every POLL_INTERVAL seconds."""
# Create daily schedules per-user at their local midnight
_check_per_user_midnight_schedules()
# Check medication reminders (adaptive path handles both adaptive and non-adaptive)
logger.info("Checking medication reminders")
try:
check_adaptive_medication_reminders()
except Exception as e:
logger.warning(f"Adaptive medication reminder check failed: {e}")
# Fall back to basic reminders if adaptive check fails entirely
check_medication_reminders()
# Check for nags - log as error to help with debugging
try:
check_nagging()
except Exception as e:
logger.error(f"Nagging check failed: {e}")
# Original checks
check_routine_reminders()
check_refills()
check_task_reminders()
def daemon_loop():
logger.info("Scheduler daemon starting")
while True:
try:
poll_callback()
except Exception as e:
logger.error(f"Poll callback error: {e}")
time_module.sleep(POLL_INTERVAL)
if __name__ == "__main__":
daemon_loop()