Files
mop-skills/skills/datastore/scripts/morning_briefing.py
T

204 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "pytz>=2024.1",
# "python-ulid>=3.0.0",
# ]
# ///
"""
morning_briefing.py — Generates the daily morning Hebamme sleep roundup for Mila,
computes 7d vs 30d trends, renders a visual trend chart via chart-to-chat,
and returns the message with MEDIA:<path> and the permanent website URL.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import subprocess
import sys
from pathlib import Path
from zoneinfo import ZoneInfo
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from datastore import DatastoreEngine, find_data_dir
BERLIN_TZ = ZoneInfo("Europe/Berlin")
MILA_BIRTHDATE = datetime.date(2026, 3, 14)
WEEKDAYS_SHORT = {
0: "Mo",
1: "Di",
2: "Mi",
3: "Do",
4: "Fr",
5: "Sa",
6: "So",
}
def find_chart_script() -> Optional[Path]:
candidates = [
SCRIPT_DIR.parent.parent / "chart-to-chat" / "scripts" / "chart.py",
Path("/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py"),
Path("/Users/moritz/src/mop-skills/skills/chart-to-chat/scripts/chart.py"),
]
for c in candidates:
if c.is_file():
return c
return None
def generate_morning_briefing(force: bool = False) -> Dict[str, Any]:
data_dir = find_data_dir()
state_file = data_dir / ".mila_morning_state.json"
now_berlin = datetime.datetime.now(BERLIN_TZ)
today = now_berlin.date()
today_str = today.strftime("%Y-%m-%d")
# State check: only brief once per day unless forced
if not force and state_file.is_file():
try:
state = json.loads(state_file.read_text(encoding="utf-8"))
if state.get("last_briefed_date") == today_str:
return {
"already_briefed": True,
"date": today_str,
"message": None,
}
except Exception:
pass
engine = DatastoreEngine(data_dir)
con = engine.con
yesterday = today - datetime.timedelta(days=1)
yesterday_str = yesterday.strftime("%Y-%m-%d")
# 1. Yesterday's sleep
y_agg = con.execute(
f"""
SELECT total_sleep_hours, night_sleep_minutes, nap_count, nap_sleep_minutes
FROM v_sleep_daily
WHERE subject = 'mila' AND log_date = '{yesterday_str}'
"""
).fetchone()
y_tot = y_agg[0] if y_agg and y_agg[0] is not None else 0.0
y_night_min = y_agg[1] if y_agg and y_agg[1] is not None else 0
y_night_h, y_night_m = divmod(y_night_min, 60)
y_naps = y_agg[2] if y_agg and y_agg[2] is not None else 0
y_naps_min = y_agg[3] if y_agg and y_agg[3] is not None else 0
y_naps_h, y_naps_m = divmod(y_naps_min, 60)
# 2. 7-Day & 30-Day Averages
avg_7 = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
avg_7_tot = avg_7[0] if avg_7 and avg_7[0] is not None else y_tot
avg_7_night = avg_7[1] if avg_7 and avg_7[1] is not None else 0.0
avg_7_naps = avg_7[2] if avg_7 and avg_7[2] is not None else 0.0
avg_30 = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2)
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
"""
).fetchone()
avg_30_tot = avg_30[0] if avg_30 and avg_30[0] is not None else avg_7_tot
# 3. 7-Day History Data for Chart
recent_7_rows = con.execute(
"""
SELECT log_date, ROUND(total_sleep_hours, 1)
FROM v_sleep_daily
WHERE subject = 'mila'
ORDER BY log_date ASC
LIMIT 7
"""
).fetchall()
chart_media_tag = ""
chart_script = find_chart_script()
if chart_script and len(recent_7_rows) >= 3:
chart_data = []
for r in recent_7_rows:
d_obj = datetime.date.fromisoformat(str(r[0]))
label = f"{WEEKDAYS_SHORT[d_obj.weekday()]} {d_obj.strftime('%d.%m.')}"
chart_data.append({"day": label, "hours": float(r[1])})
try:
chart_cmd = [
sys.executable,
str(chart_script),
"--title", "Mila Schlaf letzte 7 Tage (Stunden)",
"--type", "bar",
"--data", json.dumps(chart_data),
]
res = subprocess.run(chart_cmd, capture_output=True, text=True, timeout=15)
for line in res.stdout.splitlines():
if line.startswith("MEDIA:"):
chart_media_tag = "\n\n" + line.strip()
break
except Exception:
pass
# 4. Mila's exact age & pediatric reflection
days_old = (today - MILA_BIRTHDATE).days
months_old = days_old // 30.4375
trend_reflection = (
f"Mila schläft im 7-Tage-Schnitt **{avg_7_tot:.1f} h** (30-Tage-Basis: **{avg_30_tot:.1f} h**). "
f"Mit rund {int(months_old)} Monaten liegt das wunderbar im altersgerechten Entwicklungsfenster (12–15 Std/24h). "
f"Die Hauptschlafphase nachts konsolidiert sich stabil (Ø {avg_7_night:.1f} h), während tagsüber typischerweise 2–3 Schläfchen den Rhythmus tragen."
)
briefing_text = (
f"Guten Morgen! ☀️ Hier ist euer morgendlicher Schlafrückblick für Mila:\n\n"
f"**Gestern ({yesterday.strftime('%d.%m.')}):**\n"
f"• 24h Gesamtschlaf: **{y_tot:.1f} h**\n"
f"• Nachtschlaf: **{y_night_h} h {y_night_m:02d} min**\n"
f"• Tagesschläfchen: **{y_naps} Naps** ({y_naps_h} h {y_naps_m:02d} min)\n\n"
f"**Entwicklung & Trend:**\n"
f"• {trend_reflection}\n\n"
f"🔗 **Live-Dashboard:** https://family.haumdaucher.de/tracking/mila/\n"
f"{chart_media_tag}"
)
# Save state
try:
state_file.write_text(json.dumps({"last_briefed_date": today_str, "updated_at": now_berlin.isoformat()}), encoding="utf-8")
except Exception:
pass
return {
"already_briefed": False,
"date": today_str,
"message": briefing_text,
}
def main():
parser = argparse.ArgumentParser(description="Mila Morning Sleep Briefing")
parser.add_argument("--force", action="store_true", help="Force briefing even if already generated today")
args = parser.parse_args()
result = generate_morning_briefing(force=args.force)
if result.get("already_briefed"):
print(f"Already briefed for today ({result.get('date')}). Use --force to override.")
else:
print(result.get("message"))
if __name__ == "__main__":
main()