""" Per-screen contexts for the AI coach. One builder per screen, all returning the same envelope, so the queue, the prompt, the cache and the UI component stay generic — adding a screen means adding an entry here, not another endpoint and another card. Every builder returns `(subject, context)`: * `subject` identifies *what* the insight is about (a date, an activity id, a window). Together with the scope name it keys the cache and the job queue, so 睡眠 for last week and 睡眠 for today are separate entries rather than one overwriting the other. * `context["highlights"]` is the plain-language facts, already computed. The model interprets them; the rule engine renders them verbatim when no model answers. Both versions therefore quote the same numbers, which is what keeps a fallback from reading like a different app. Builders return None when the screen has nothing to say about — no nights recorded, no scale readings — so the caller can stay quiet instead of asking a model to comment on an empty table. """ import datetime import statistics from services import garmin as garmin_svc from services import garmin_extras as extras from services import fitness_age from services import health from services import insights from services import settings as settings_svc # The same reference bands the 评分依据 screen shows. Passed to the model so it # cannot call a number 偏高 that the card beside it labels 正常. from services.insights import BAND_SOURCES def _round(value, digits=2): return None if value is None else round(float(value), digits) def _mean(values): values = [v for v in values if v is not None] return round(statistics.fmean(values), 2) if values else None def _recent(rows, days): """The last `days` calendar days of flattened rows, relative to the newest.""" if not rows: return [] end = datetime.date.fromisoformat(rows[-1]["date"]) cutoff = (end - datetime.timedelta(days=days - 1)).isoformat() return [r for r in rows if r["date"] >= cutoff] def _summary(user_id): return insights.flatten_days(health.get_summary(user_id)) def _deviations_for(rows, metrics): """Today's departures, narrowed to the metrics this screen is about.""" if not rows: return [] picked = set(metrics) return [ d for d in insights.deviations(rows, rows[-1]) if d["metric"] in picked ] def _trends_for(rows, metrics): picked = set(metrics) return [t for t in insights.trends(rows) if t["metric"] in picked] # --- 健康 -------------------------------------------------------------------- def build_health(user_id, subject=None): rows = _summary(user_id) if not rows: return None today = rows[-1] deviations = insights.deviations(rows, today) notable = [d for d in deviations if d["z"] is not None and abs(d["z"]) >= insights.Z_NOTABLE] highlights = [{ "title": d["label"], # The label is repeated inside the sentence because the first # highlight is also used as a standalone headline, where the title # beside it is not shown. "detail": ( f"{d['label']} {d['value']}{d['unit']},近 {d['baselineDays']} 天基线 " f"{d['baselineMean']}{d['unit']},偏离 {abs(d['z']):.1f} 个标准差。" ), } for d in notable[:5]] if not highlights: highlights = [{"title": "整体", "detail": "今日各项指标均在个人基线的正常波动范围内。"}] return today["date"], { "scope": "health", "label": "健康总览", "snapshotDate": today["date"], "highlights": highlights, "deviations": deviations, "trends": insights.trends(rows)[:8], "referenceBands": BAND_SOURCES, "profile": _profile(user_id, rows), } def _profile(user_id, rows): raw = settings_svc.get_raw(user_id) age = settings_svc.age_from(raw["birth_date"]) return { "age": age, "sex": raw["sex"], "bmi": settings_svc.bmi_from(raw["height_cm"], raw["weight_kg"]), "vo2max": insights.latest_of(rows, "vo2max"), "enduranceScore": insights.latest_of(rows, "enduranceScore"), } # --- 睡眠 -------------------------------------------------------------------- SLEEP_METRICS = ("sleepDuration", "sleepQuality", "sleepDeepPct", "sleepRemPct") NIGHTS = 14 def build_sleep(user_id, subject=None): rows = _summary(user_id) nights = [r for r in rows if r.get("sleepDuration") is not None] if not nights: return None window = _recent(nights, NIGHTS) durations = [n["sleepDuration"] for n in window] avg = _mean(durations) debt = round(sum(insights.SLEEP_TARGET_HOURS - d for d in durations if d < insights.SLEEP_TARGET_HOURS), 1) deep = _mean([n.get("sleepDeepPct") for n in window]) rem = _mean([n.get("sleepRemPct") for n in window]) rem_low, rem_high = insights.REM_REFERENCE_PCT deep_low, _ = insights.DEEP_REFERENCE_PCT highlights = [{ "title": f"近 {len(window)} 晚", "detail": ( f"平均 {avg} 小时(目标 {insights.SLEEP_TARGET_HOURS:g})," f"累计缺口 {debt} 小时,平均评分 " f"{_mean([n.get('sleepQuality') for n in window])}。" ), }] if rem is not None: highlights.append({ "title": "REM 占比", "detail": f"平均 {rem}%(参考 {rem_low:g}~{rem_high:g}%)" + (",偏低。" if rem < rem_low else "。"), }) if deep is not None: highlights.append({ "title": "深睡占比", "detail": f"平均 {deep}%(参考 ≥{deep_low:g}%)" + (",偏低。" if deep < deep_low else ",达标。"), }) # The configured window, not `len(window)`: a night missing from the record # would otherwise change the key and queue a second job for the same screen. return f"{window[-1]['date']}:{NIGHTS}", { "scope": "sleep", "label": "睡眠", "windowNights": len(window), "highlights": highlights, "nights": [{ "date": n["date"], "hours": _round(n.get("sleepDuration")), "score": n.get("sleepQuality"), "deepPct": n.get("sleepDeepPct"), "remPct": n.get("sleepRemPct"), "awakePct": n.get("sleepAwakePct"), "sleepHr": n.get("sleepRespirationAvg"), "sleepStress": n.get("sleepStressAvg"), "spo2": n.get("sleepSpo2Avg"), } for n in window], "averages": { "hours": avg, "debtHours": debt, "deepPct": deep, "remPct": rem, "targetHours": insights.SLEEP_TARGET_HOURS, }, "deviations": _deviations_for(rows, SLEEP_METRICS), "trends": _trends_for(rows, SLEEP_METRICS), # The night's autonomic readings are what tell recovery apart from # merely lying down for eight hours. "autonomic": { "restingHr": rows[-1].get("heartRate"), "hrv": rows[-1].get("heartRateVariability"), }, } # --- 运动 -------------------------------------------------------------------- EXERCISE_METRICS = ("intensityMinutes", "steps", "trainingReadiness", "enduranceScore", "vo2max") EXERCISE_DAYS = 30 def build_exercise(user_id, subject=None): rows = _summary(user_id) if not rows: return None end = rows[-1]["date"] start = (datetime.date.fromisoformat(end) - datetime.timedelta(days=EXERCISE_DAYS - 1)).isoformat() activities = health.get_activities(user_id, start, end) by_sport = {} for a in activities: sport = a.get("activity_type") or "其他" entry = by_sport.setdefault(sport, {"sessions": 0, "minutes": 0, "calories": 0}) entry["sessions"] += 1 entry["minutes"] += round((a.get("duration") or 0) / 60) entry["calories"] += a.get("calories") or 0 window = _recent(rows, EXERCISE_DAYS) intensity = [r.get("intensityMinutes") for r in window] weekly = round((sum(v for v in intensity if v) / max(len(window), 1)) * 7) highlights = [{ "title": f"近 {EXERCISE_DAYS} 天", "detail": ( f"共 {len(activities)} 次运动,合计 " f"{sum(e['minutes'] for e in by_sport.values())} 分钟;" f"强度分钟周均约 {weekly}(WHO 建议 150)。" ), }] if by_sport: top = max(by_sport.items(), key=lambda kv: kv[1]["minutes"]) highlights.append({ "title": "主要项目", "detail": f"{top[0]},{top[1]['sessions']} 次 / {top[1]['minutes']} 分钟。", }) readiness = insights.latest_of(rows, "trainingReadiness", within=7) if readiness is not None: highlights.append({"title": "训练准备度", "detail": f"最近一次 {readiness:g}/100。"}) return f"{end}:{EXERCISE_DAYS}", { "scope": "exercise", "label": "运动", "windowDays": EXERCISE_DAYS, "highlights": highlights, "bySport": by_sport, "weeklyIntensityMinutes": weekly, "sessions": [{ "date": a.get("start_time"), "sport": a.get("activity_type"), "durationMin": round((a.get("duration") or 0) / 60) or None, "distanceKm": _round((a["distance"] / 1000) if a.get("distance") else None), "calories": a.get("calories"), "avgHr": a.get("heart_rate_average"), "maxHr": a.get("heart_rate_max"), } for a in activities[-30:]], "deviations": _deviations_for(rows, EXERCISE_METRICS), "trends": _trends_for(rows, EXERCISE_METRICS), "activityShift": insights.activity_shift(rows), "personalRecords": health.get_personal_records(user_id)[:10], "profile": _profile(user_id, rows), } # --- 趋势 -------------------------------------------------------------------- def build_trends(user_id, subject=None): rows = _summary(user_id) if len(rows) < 20: return None trends = insights.trends(rows) moved = sorted( [t for t in trends if t.get("direction")], key=lambda t: abs(t["slopePer30d"] or 0), reverse=True, ) highlights = [{ "title": t["label"], "detail": ( f"{t['days']} 天内由 {t['firstMean']}{t['unit']} 到 " f"{t['lastMean']}{t['unit']}({t['direction']})," f"斜率约 {t['slopePer30d']}{t['unit']}/30 天。" ), } for t in moved[:5]] if not highlights: highlights = [{"title": "整体", "detail": "各项指标长期走势平稳,无明显方向性变化。"}] return rows[-1]["date"], { "scope": "trends", "label": "长期趋势", "highlights": highlights, "trends": trends, "activityShift": insights.activity_shift(rows), "coverage": { "days": len(rows), "first": rows[0]["date"], "last": rows[-1]["date"], }, "profile": _profile(user_id, rows), } # --- 每日 -------------------------------------------------------------------- def build_daily(user_id, subject=None): """One calendar day, including the within-day curves the screen plots.""" rows = _summary(user_id) if not rows: return None date = subject or rows[-1]["date"] match = [r for r in rows if r["date"] == date] if not match: return None day = match[0] history = [r for r in rows if r["date"] <= date] series = extras.get_daily_series(user_id, date) or {} curves = {} for kind, points in series.items(): values = [p[1] for p in points if isinstance(p, (list, tuple)) and p[1] is not None] if values: # The curve itself is thousands of points; its shape is what the # reader can see on the chart, so only the summary is sent. curves[kind] = {"min": min(values), "max": max(values), "mean": _mean(values), "samples": len(values)} deviations = insights.deviations(history, day) notable = [d for d in deviations if d["z"] is not None and abs(d["z"]) >= insights.Z_NOTABLE] highlights = [{ "title": d["label"], "detail": f"{d['label']} {d['value']}{d['unit']},偏离基线 " f"{abs(d['z']):.1f} 个标准差。", } for d in notable[:4]] or [ {"title": date, "detail": "这一天各项指标都在常态范围内。"} ] return date, { "scope": "daily", "label": f"{date} 当日", "date": date, "highlights": highlights, "metrics": {k: v for k, v in day.items() if k != "date"}, "curves": curves, "activities": [{ "sport": a.get("activity_type"), "durationMin": round((a.get("duration") or 0) / 60) or None, "calories": a.get("calories"), "avgHr": a.get("heart_rate_average"), } for a in health.get_activities(user_id, date, date)], "deviations": deviations, } # --- 身体成分 ---------------------------------------------------------------- def build_body(user_id, subject=None): series = extras.get_body_composition(user_id) pressure = extras.get_blood_pressure(user_id) if not series and not pressure: return None highlights = [] if len(series) >= 2: first, last = series[0], series[-1] for key, label, unit in (("weightKg", "体重", "kg"), ("bodyFatPct", "体脂率", "%"), ("muscleMassKg", "肌肉量", "kg")): a, b = first.get(key), last.get(key) if a is None or b is None: continue highlights.append({ "title": label, "detail": f"{first['date']} {a}{unit} → {last['date']} {b}{unit}" f"({'+' if b >= a else ''}{round(b - a, 1)}{unit})。", }) elif series: last = series[-1] highlights.append({ "title": "最近一次测量", "detail": f"{last['date']}:体重 {last.get('weightKg')} kg," f"体脂 {last.get('bodyFatPct')}%。", }) if pressure: p = pressure[0] highlights.append({ "title": "血压", "detail": f"最近一次 {p['systolic']}/{p['diastolic']} mmHg" f"({str(p['measuredAt'])[:16]})。", }) subject_key = (series[-1]["date"] if series else str(pressure[0]["measuredAt"])[:10]) return subject_key, { "scope": "body", "label": "身体成分", "highlights": highlights, "composition": series[-60:], "bloodPressure": pressure[:20], "profile": _profile(user_id, _summary(user_id)), } # --- 成绩预测 ---------------------------------------------------------------- def build_race(user_id, subject=None): predictions = extras.get_race_predictions(user_id) if not predictions: return None rows = _summary(user_id) latest = predictions[-1] def mmss(seconds): if not seconds: return None return f"{int(seconds) // 60}:{int(seconds) % 60:02d}" highlights = [{ "title": "当前预测", "detail": " · ".join( f"{label} {mmss(latest.get(key))}" for key, label in (("time5k", "5K"), ("time10k", "10K"), ("timeHalf", "半马"), ("timeMarathon", "全马")) if latest.get(key) ) + f"({latest['date']})。", }] if len(predictions) >= 2: first = predictions[0] a, b = first.get("time5k"), latest.get("time5k") if a and b: delta = int(a - b) highlights.append({ "title": "5K 变化", "detail": f"自 {first['date']} 起{'快了' if delta > 0 else '慢了'} " f"{abs(delta)} 秒。", }) return latest["date"], { "scope": "race", "label": "成绩预测", "highlights": highlights, "predictions": predictions[-40:], "trends": _trends_for(rows, ("vo2max", "enduranceScore", "heartRate")), "profile": _profile(user_id, rows), } # --- 身体年龄 ---------------------------------------------------------------- def build_body_age(user_id, subject=None): rows = _summary(user_id) if not rows: return None raw = settings_svc.get_raw(user_id) age = settings_svc.age_from(raw["birth_date"]) estimate = fitness_age.estimate( age=age, sex=raw["sex"], vo2max=insights.latest_of(rows, "vo2max"), resting_hr=insights.latest_of(rows, "heartRate", within=30), bmi=settings_svc.bmi_from(raw["height_cm"], raw["weight_kg"]), ) if not estimate or estimate.get("value") is None: return None highlights = [{ "title": "身体年龄", "detail": f"{estimate['value']} 岁,实际 {estimate['chronologicalAge']} 岁" f"({'+' if estimate['delta'] >= 0 else ''}{estimate['delta']} 年)。", }] + [{ "title": step["label"], "detail": f"输入 {step['input']},贡献 {step['years']} 年。", } for step in estimate.get("steps", [])] return rows[-1]["date"], { "scope": "bodyAge", "label": "身体年龄", "highlights": highlights, "estimate": {k: v for k, v in estimate.items() if k != "basis"}, "trends": _trends_for(rows, ("vo2max", "heartRate", "enduranceScore")), "profile": _profile(user_id, rows), } # --- 挑战赛 ------------------------------------------------------------------ def build_challenges(user_id, subject=None): rows = extras.get_challenges(user_id) if not rows: return None active = [c for c in rows if (c.get("status") or "").lower() in ("active", "in_progress", "inprogress")] highlights = [{ "title": "进行中", "detail": f"共 {len(active)} 项进行中,历史累计 {len(rows)} 项。", }] for c in active[:3]: highlights.append({ "title": c.get("name") or "挑战", "detail": f"{c.get('startDate')} ~ {c.get('endDate')},状态 {c.get('status')}。", }) # One entry per account: which challenges exist is what the fingerprint # tracks, so the key does not need to encode how many there are. return "all", { "scope": "challenges", "label": "挑战赛", "highlights": highlights, "challenges": [{k: v for k, v in c.items() if k != "payload"} for c in rows[:40]], "activityShift": insights.activity_shift(_summary(user_id)), } # --- 运动详情 ---------------------------------------------------------------- def build_activity(user_id, subject=None): """One session. `subject` is the activity id.""" if not subject: return None detail = garmin_svc.read_activity_detail(user_id, subject) listed = [a for a in health.get_activities(user_id) if str(a.get("id")) == str(subject)] if not detail and not listed: return None summary = (detail or {}).get("summary") or {} base = listed[0] if listed else {} zones = (detail or {}).get("hrZones") or [] total_zone = sum(z.get("seconds") or 0 for z in zones) minutes = round((base.get("duration") or summary.get("duration") or 0) / 60) highlights = [{ "title": base.get("activity_type") or (detail or {}).get("activityType") or "运动", "detail": f"{minutes} 分钟" + (f",{round((base.get('distance') or 0) / 1000, 2)} km" if base.get("distance") else "") + (f",平均心率 {base.get('heart_rate_average')}" if base.get("heart_rate_average") else "") + "。", }] if total_zone: share = " · ".join( f"Z{z['zone']} {round((z.get('seconds') or 0) / total_zone * 100)}%" for z in zones if z.get("seconds") ) highlights.append({"title": "心率区间分布", "detail": share + "。"}) return str(subject), { "scope": "activity", "label": "运动详情", "activityId": str(subject), "highlights": highlights, "session": { "sport": base.get("activity_type"), "startTime": base.get("start_time"), "durationMin": minutes or None, "distanceKm": _round((base["distance"] / 1000) if base.get("distance") else None), "calories": base.get("calories"), "avgHr": base.get("heart_rate_average"), "maxHr": base.get("heart_rate_max"), }, "hrZones": zones, # The full summary carries dozens of sport-specific fields; only the # numeric ones are useful to reason over and they are already small. "detailSummary": {k: v for k, v in summary.items() if isinstance(v, (int, float))}, "laps": ((detail or {}).get("laps") or [])[:20], } # --- registry ---------------------------------------------------------------- class Scope: __slots__ = ("name", "label", "build", "needs_subject", "per_item") def __init__(self, name, label, build, needs_subject=False, per_item=False): self.name = name self.label = label self.build = build self.needs_subject = needs_subject # `per_item` scopes legitimately have one entry per date or per # session. Every other scope has exactly one live entry, so an older # subject sitting in the queue is stale work — see `supersede`. self.per_item = per_item SCOPES = { s.name: s for s in ( Scope("health", "健康总览", build_health), Scope("sleep", "睡眠", build_sleep), Scope("exercise", "运动", build_exercise), Scope("trends", "长期趋势", build_trends), Scope("daily", "每日数据", build_daily, per_item=True), Scope("body", "身体成分", build_body), Scope("race", "成绩预测", build_race), Scope("bodyAge", "身体年龄", build_body_age), Scope("challenges", "挑战赛", build_challenges), Scope("activity", "运动详情", build_activity, needs_subject=True, per_item=True), ) } # Scopes worth generating ahead of the user asking, after a sync brings new # data in. `activity` and `daily` are excluded on purpose: they are per-item, # so pre-warming them would queue one job per session or per day rather than # one job. PREFETCH_SCOPES = ("health", "sleep", "exercise", "trends", "body", "race", "bodyAge", "challenges") def build(user_id, scope, subject=None): """`(subject, context)` for one screen, or None when there is nothing to say.""" entry = SCOPES.get(scope) if not entry: raise KeyError(scope) if entry.needs_subject and not subject: return None return entry.build(user_id, subject)