Files
GarminHealthLab/backend/services/insights.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

502 lines
20 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.
"""
Feature engineering for the AI coach.
Everything here is arithmetic over stored health data — no model calls. The
split is deliberate: the numbers a briefing quotes (z-scores, baselines,
trend slopes) must be reproducible and testable, and an LLM asked to compute
them from a raw CSV gets them wrong often enough to matter. The model's job
is to interpret figures that were already computed here, not to derive them.
Two windows are used throughout:
* **baseline** (default 28 days) — what "normal for this person, lately"
means. Short enough to track a training block, long enough for a standard
deviation to be worth quoting.
* **trend** (default 395 days ≈ 13 months) — the long arc the product spec
asks about, and long enough to contain a full season.
"""
import datetime
import statistics
from services import health
from services import settings as settings_svc
from services import fitness_age
BASELINE_DAYS = 28
TREND_DAYS = 395
# Sleep targets are personal, but Garmin's own coaching and the ACSM/AASM
# adult guidance both land on 7 hours as the floor; the deep/REM shares are
# the conventional adult reference bands.
SLEEP_TARGET_HOURS = 7.0
REM_REFERENCE_PCT = (20.0, 25.0)
DEEP_REFERENCE_PCT = (13.0, 23.0)
# |z| beyond this counts as a departure from the personal baseline rather
# than day-to-day noise. 1.0 rather than the textbook 2.0: with a 28-day
# window a 2-sigma day is roughly a once-a-month event, which is too rare to
# drive a daily briefing.
Z_NOTABLE = 1.0
def _flatten(day):
"""One day as a flat metric -> value mapping.
`get_summary` nests sleep and omits missing metrics entirely; both are
inconvenient for statistics, so sleep is lifted to the top level and
derived shares (deep/REM percent) are computed once here.
"""
flat = {k: v for k, v in day.items() if k != "sleep"}
sleep = day.get("sleep") or {}
duration = sleep.get("duration") or day.get("sleepDuration")
if duration:
flat["sleepDuration"] = duration
seconds = duration * 3600.0
for src, dest in (
("deepSeconds", "sleepDeepPct"),
("remSeconds", "sleepRemPct"),
("lightSeconds", "sleepLightPct"),
("awakeSeconds", "sleepAwakePct"),
):
value = sleep.get(src)
if value is not None and seconds > 0:
flat[dest] = round(value / seconds * 100, 1)
if sleep.get("quality") is not None:
flat["sleepQuality"] = sleep["quality"]
sedentary = day.get("sedentarySeconds")
if sedentary is not None:
flat["sedentaryHours"] = round(sedentary / 3600.0, 1)
return flat
def flatten_days(rows):
"""`get_summary` output as flat metric maps, with derived shares filled in.
Public counterpart of `_flatten` for the scope builders, which all need the
same normalisation before they can compute anything.
"""
return [_flatten(r) for r in rows]
# Metrics the briefing reasons about. `higher_better` drives the plain-language
# verdict; None means the direction is not meaningful on its own (steps on a
# rest day are not a failure).
METRICS = {
"sleepDuration": ("睡眠时长", "小时", True),
"sleepQuality": ("睡眠评分", "", True),
"sleepDeepPct": ("深睡占比", "%", True),
"sleepRemPct": ("REM 占比", "%", True),
"heartRate": ("静息心率", "bpm", False),
"heartRateVariability": ("HRV", "ms", True),
"stress": ("压力均值", "", False),
"bodyBatteryHigh": ("身体电量峰值", "", True),
"bodyBatteryLow": ("身体电量谷值", "", True),
"trainingReadiness": ("训练准备度", "", True),
"enduranceScore": ("耐力分", "", True),
"vo2max": ("最大摄氧量", "ml/kg/min", True),
"steps": ("步数", "", None),
"intensityMinutes": ("强度分钟", "分钟", None),
"respirationAvg": ("呼吸频率", "次/分", None),
"spo2Avg": ("血氧", "%", True),
}
def _series(rows, metric):
"""(date, value) pairs where the metric was actually recorded."""
return [(r["date"], r[metric]) for r in rows if r.get(metric) is not None]
def _stats(values):
if not values:
return None
mean = statistics.fmean(values)
# pstdev, not stdev: these are all the observations in the window, not a
# sample drawn from it, and stdev raises on a single point.
sd = statistics.pstdev(values) if len(values) > 1 else 0.0
return {"mean": mean, "sd": sd, "n": len(values)}
def _verdict(z, higher_better):
if higher_better is None or abs(z) < Z_NOTABLE:
return "正常"
if (z > 0) == bool(higher_better):
return "偏好"
return "偏差"
def deviations(rows, today, baseline_days=BASELINE_DAYS):
"""How far each of today's metrics sits from its own recent baseline.
The baseline deliberately excludes today: comparing a value against a mean
it helped produce shrinks its own z-score, and with a 28-day window that
bias is large enough to hide a genuine outlier.
"""
history = [r for r in rows if r["date"] < today.get("date", "")]
window = history[-baseline_days:]
out = []
for metric, (label, unit, higher_better) in METRICS.items():
value = today.get(metric)
if value is None:
continue
values = [v for _, v in _series(window, metric)]
stats = _stats(values)
if not stats or stats["n"] < 5:
# Too little history for a standard deviation to mean anything.
out.append({
"metric": metric, "label": label, "unit": unit,
"value": round(float(value), 2), "baselineMean": None,
"sd": None, "z": None, "verdict": "基线不足",
})
continue
sd = stats["sd"]
if sd > 0:
z = round((float(value) - stats["mean"]) / sd, 2)
verdict = _verdict(z, higher_better)
else:
# A baseline with no spread cannot scale a departure. Reporting
# z = 0 here would label a value that differs from every single
# observation as perfectly typical, which is the opposite of true.
z = None
verdict = "正常" if float(value) == stats["mean"] else "基线无波动"
out.append({
"metric": metric, "label": label, "unit": unit,
"value": round(float(value), 2),
"baselineMean": round(stats["mean"], 2),
"sd": round(sd, 2),
"baselineDays": stats["n"],
"z": z,
"verdict": verdict,
})
# Biggest departures first: that ordering is what the prompt relies on to
# keep the interesting metrics inside the model's attention span.
out.sort(key=lambda d: abs(d["z"]) if d["z"] is not None else -1, reverse=True)
return out
def _slope_per_30d(points):
"""Least-squares slope in units per 30 days.
Ordinal dates rather than array indices: gaps in the record (a watch left
on the charger for a week) would otherwise compress the x-axis and inflate
the slope.
"""
if len(points) < 3:
return None
xs = [datetime.date.fromisoformat(d).toordinal() for d, _ in points]
ys = [float(v) for _, v in points]
mx, my = statistics.fmean(xs), statistics.fmean(ys)
denom = sum((x - mx) ** 2 for x in xs)
if denom == 0:
return None
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom
return round(slope * 30, 3)
def trends(rows, window_days=TREND_DAYS, edge=30):
"""Long-arc movement per metric: endpoint means plus a fitted slope.
Endpoint means (first `edge` days vs last `edge` days) answer "where did
this end up"; the slope answers "was it a trend or two different plateaus".
Reporting only one of them has misled us before — a metric can finish
higher after months of decline if it spikes in the final week.
"""
if not rows:
return []
cutoff = (
datetime.date.fromisoformat(rows[-1]["date"])
- datetime.timedelta(days=window_days)
).isoformat()
window = [r for r in rows if r["date"] >= cutoff]
out = []
for metric, (label, unit, higher_better) in METRICS.items():
points = _series(window, metric)
if len(points) < 10:
continue
# With fewer than two edges' worth of points the two windows would
# overlap and both converge on the overall mean — reporting delta 0
# for a series that visibly moved. Split it in half instead.
span = min(edge, len(points) // 2)
head = [v for _, v in points[:span]]
tail = [v for _, v in points[-span:]]
first, last = statistics.fmean(head), statistics.fmean(tail)
delta = last - first
entry = {
"metric": metric, "label": label, "unit": unit,
"days": (
datetime.date.fromisoformat(points[-1][0])
- datetime.date.fromisoformat(points[0][0])
).days,
"samples": len(points),
"firstMean": round(first, 2),
"lastMean": round(last, 2),
"delta": round(delta, 2),
"slopePer30d": _slope_per_30d(points),
}
if higher_better is not None and abs(delta) > 0:
entry["direction"] = "改善" if (delta > 0) == bool(higher_better) else "退步"
out.append(entry)
return out
def activity_shift(rows, recent=7, prior=30):
"""Recent activity volume against the weeks before it.
Separate from `deviations` because the question is different: not "is today
unusual" but "has the last week as a whole dropped off" — the drop that a
single quiet day cannot show.
"""
out = {}
for metric in ("steps", "intensityMinutes", "sleepDuration", "bodyBatteryHigh"):
points = _series(rows, metric)
if len(points) < recent + 5:
continue
recent_values = [v for _, v in points[-recent:]]
prior_values = [v for _, v in points[-(recent + prior):-recent]]
if not prior_values:
continue
r_mean, p_mean = statistics.fmean(recent_values), statistics.fmean(prior_values)
out[metric] = {
"label": METRICS[metric][0],
"recentMean": round(r_mean, 2),
"priorMean": round(p_mean, 2),
"changePct": round((r_mean - p_mean) / p_mean * 100, 1) if p_mean else None,
}
return out
def _sleep_block(today):
duration = today.get("sleepDuration")
if duration is None:
return None
block = {
"durationHours": round(float(duration), 2),
"targetHours": SLEEP_TARGET_HOURS,
"score": today.get("sleepQuality"),
"deepPercent": today.get("sleepDeepPct"),
"remPercent": today.get("sleepRemPct"),
"lightPercent": today.get("sleepLightPct"),
"awakePercent": today.get("sleepAwakePct"),
"remReference": list(REM_REFERENCE_PCT),
"deepReference": list(DEEP_REFERENCE_PCT),
}
return block
def latest_of(rows, metric, within=180):
"""Most recent recorded value, for metrics that only refresh occasionally.
VO2max and endurance score update after a qualifying outdoor session, so
reading them off "today" yields None on any indoor or rest day even though
the last measured value is still the current one.
"""
for row in reversed(rows[-within:] if within else rows):
if row.get(metric) is not None:
return row[metric]
return None
def build_context(user_id, date=None, rows=None):
"""The structured payload every coach prompt is assembled from.
`date` selects the snapshot day; the default is the newest day on record
rather than the calendar date, because a sync may not have run yet today
and an empty snapshot produces a briefing about nothing.
"""
rows = rows if rows is not None else health.get_summary(user_id)
if not rows:
return None
flat = [_flatten(r) for r in rows]
if date:
matches = [r for r in flat if r["date"] == date]
if not matches:
return None
today = matches[0]
history = [r for r in flat if r["date"] <= date]
else:
today = flat[-1]
history = flat
profile = settings_svc.get_raw(user_id)
age = settings_svc.age_from(profile["birth_date"])
bmi = settings_svc.bmi_from(profile["height_cm"], profile["weight_kg"])
vo2max = latest_of(history, "vo2max")
body_age = fitness_age.estimate(
age=age, sex=profile["sex"], vo2max=vo2max,
resting_hr=latest_of(history, "heartRate", within=30), bmi=bmi,
)
start = (
datetime.date.fromisoformat(today["date"]) - datetime.timedelta(days=14)
).isoformat()
recent_activities = health.get_activities(user_id, start, today["date"])
sedentary = today.get("sedentaryHours")
return {
"snapshotDate": today["date"],
"userProfile": {
"age": age,
"sex": profile["sex"],
# `estimate` returns {"value": None, "missing": [...]} when the
# profile is incomplete, so this is None rather than a number
# until a birth date, sex and a VO2max reading all exist.
"fitnessAge": (body_age or {}).get("value"),
"vo2max": vo2max,
"enduranceScore": latest_of(history, "enduranceScore"),
"heightCm": profile["height_cm"],
"weightKg": profile["weight_kg"],
"bmi": bmi,
},
"todayMetrics": {
"sleep": _sleep_block(today),
"autonomicNervous": {
"restingHr": today.get("heartRate"),
"hrvMs": today.get("heartRateVariability"),
"stressAvg": today.get("stress"),
"stressMax": today.get("stressMax"),
"respirationAvg": today.get("respirationAvg"),
"spo2Avg": today.get("spo2Avg"),
},
"recovery": {
"bodyBatteryPeak": today.get("bodyBatteryHigh"),
"bodyBatteryLow": today.get("bodyBatteryLow"),
"bodyBatteryCharged": today.get("bodyBatteryCharged"),
"bodyBatteryDrained": today.get("bodyBatteryDrained"),
"trainingReadiness": today.get("trainingReadiness"),
},
"activityToday": {
"steps": today.get("steps"),
"stepGoal": today.get("stepGoal"),
"intensityMinutes": today.get("intensityMinutes"),
"sedentaryHours": sedentary,
"floorsAscended": today.get("floorsAscended"),
"caloriesBurned": today.get("caloriesBurned"),
"activeCalories": today.get("activeCalories"),
},
},
"deviations": deviations(history, today),
"trends": trends(history),
"activityShift": activity_shift(history),
"recentActivities": [
{
"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, 2) 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 recent_activities[-15:]
],
"dataQuality": {
"totalDays": len(history),
"firstDate": history[0]["date"],
"lastDate": history[-1]["date"],
"staleDays": (
datetime.date.today()
- datetime.date.fromisoformat(history[-1]["date"])
).days,
},
}
def window_context(user_id, metric, start, end, rows=None):
"""Context for one metric over a user-selected span (chart brush).
Narrower than `build_context` on purpose: the question being answered is
"what happened to this line here", so the payload carries the selected
series plus whatever else moved alongside it in the same window.
"""
# An unknown metric would otherwise produce a well-formed window with an
# empty series, and the model would dutifully write an attribution for a
# line that does not exist.
if metric not in METRICS:
return None
rows = rows if rows is not None else health.get_summary(user_id)
flat = [_flatten(r) for r in rows]
window = [r for r in flat if start <= r["date"] <= end]
if not window:
return None
label, unit, _ = METRICS[metric]
points = _series(window, metric)
before = [r for r in flat if r["date"] < start][-BASELINE_DAYS:]
baseline = _stats([v for _, v in _series(before, metric)])
companions = {}
for other in METRICS:
if other == metric:
continue
values = [v for _, v in _series(window, other)]
stats = _stats(values)
if stats and stats["n"] >= 3:
companions[other] = {
"label": METRICS[other][0],
"mean": round(stats["mean"], 2),
"n": stats["n"],
}
return {
"metric": metric,
"label": label,
"unit": unit,
"start": start,
"end": end,
"points": [{"date": d, "value": v} for d, v in points],
"mean": round(statistics.fmean([v for _, v in points]), 2) if points else None,
"min": min((v for _, v in points), default=None),
"max": max((v for _, v in points), default=None),
"slopePer30d": _slope_per_30d(points),
"baselineBefore": (
{"mean": round(baseline["mean"], 2), "days": baseline["n"]}
if baseline else None
),
"companions": companions,
"activities": [
{
"date": a.get("start_time"),
"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, start, end)[:40]
],
}
# Reference bands, kept beside the UI's RANGES table (client/src/lib/ranges.ts).
# Each entry says where that metric's band edges came from — the honest answer
# for several of them is "general-population orientation figures", and it says
# so. Lives here rather than in routes/: the 评分依据 screen serves it, but the
# coach also reasons against it, and services must not import from routes.
BAND_SOURCES = [
{"metric": "步数", "bands": "<5k 偏低 · 58k 一般 · 812k 达标 · >12k 优秀",
"source": "步数与死亡率的队列研究(约 8000 步起获益明显12000 步后趋平)"},
{"metric": "静息心率", "bands": "<50 很低 · 5065 正常 · 6575 偏高 · >75 较高",
"source": "健康成人静息心率 60100 bpm 为正常范围,规律运动者常低于 60"},
{"metric": "心率变异性", "bands": "<25 偏低 · 2540 一般 · 4070 良好 · >70 很好",
"source": "夜间 RMSSD 的一般人群分布;个体差异极大,趋势比绝对值更有意义"},
{"metric": "睡眠时长", "bands": "<6h 不足 · 67h 偏少 · 79h 充足 · >9h 偏多",
"source": "美国睡眠医学会 / 睡眠研究会成人 79 小时建议"},
{"metric": "压力", "bands": "025 休息 · 2650 偏低 · 5175 中等 · >75 偏高",
"source": "Garmin 官方压力分级,与手表显示一致"},
{"metric": "身体电量", "bands": "025 很低 · 2650 偏低 · 5175 良好 · >75 充足",
"source": "Garmin 官方 Body Battery 分级"},
{"metric": "血氧", "bands": "<90 偏低 · 9094 略低 · ≥95 正常",
"source": "静息血氧饱和度常用临床参考;腕表光学测量误差较大,仅供趋势参考"},
{"metric": "呼吸频率", "bands": "<12 偏低 · 1220 正常 · >20 偏高",
"source": "成人静息呼吸频率 1220 次/分"},
{"metric": "强度分钟", "bands": "<10 偏少 · 1021 一般 · ≥21 达标",
"source": "WHO 每周 150 分钟中等强度活动,折合每天约 21 分钟"},
{"metric": "训练准备度", "bands": "025 很低 · 2650 偏低 · 5175 就绪 · >75 很好",
"source": "Garmin 官方 Training Readiness 分级"},
{"metric": "爬楼", "bands": "<5 偏少 · 510 达标 · >10 优秀",
"source": "一般性活动量参考,无权威标准"},
]