Files
GarminHealthLab/backend/services/scopes.py
ericwyuan 241ae0d6a3 feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列
原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外
的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、
身体年龄、挑战赛、运动详情。

不是给每个页面写一套,而是一个通用管线:
- services/scopes.py:一个页面一个 context builder,返回同一个信封。
  context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不
  可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。
  没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。
- coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面
  的差异全在 context 里,加页面 = 加一个 builder。
- 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成
  lib/insight.ts 的 usePolledInsight,晨报卡也改用它。

## 队列

一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队,
worker 负责消费(services/jobs.py)。

优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按
背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务
立刻提到队首、下一个就跑。你在看什么,队列就在算什么。

队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder
声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由
user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一
个任务。

## 网关中断时踩到的两个坑(当场修了)

写完正好赶上 oracle 那台机器不通,于是看到:
- 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把
  那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置
  尝试次数再排一次。
- 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending
  现在跟着队列状态走,并把失败原因带给卡片。

顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py:
教练要拿它做参照,而 services 不该反向依赖 routes。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:17:17 +08:00

602 lines
22 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.
"""
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 ",达标。"),
})
return f"{window[-1]['date']}:{len(window)}", {
"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 f"{rows[-1]['date']}:{len(rows)}", {
"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')}",
})
return f"{rows[0].get('startDate')}:{len(rows)}", {
"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")
def __init__(self, name, label, build, needs_subject=False):
self.name = name
self.label = label
self.build = build
self.needs_subject = needs_subject
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),
Scope("body", "身体成分", build_body),
Scope("race", "成绩预测", build_race),
Scope("bodyAge", "身体年龄", build_body_age),
Scope("challenges", "挑战赛", build_challenges),
Scope("activity", "运动详情", build_activity, needs_subject=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)