feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot
数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推 z 分数,它算错的次数足以让简报引用图表反驳它的数字。 - services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个 值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率 (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量 对比。 - services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。 网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。 - services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(), 以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾 倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。 - 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。 - 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情 页归因面板。features.ai 打开。 实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报 绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零 输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。 顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读 到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差 时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
461
backend/services/insights.py
Normal file
461
backend/services/insights.py
Normal file
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# 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 in New Issue
Block a user