Files
GarminHealthLab/backend/services/coach.py
ericwyuan c57c930949 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>
2026-09-01 13:57:35 +08:00

410 lines
17 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.
"""
The AI coach: morning briefing, trend attribution, and the Copilot chat.
Division of labour with `insights.py`: every number quoted here was already
computed there. This module only turns a structured context into a prompt and
turns the reply back into a structured answer. Nothing asks the model to do
arithmetic, because a model asked to derive a z-score from a CSV gets it wrong
often enough that the briefing would quote figures the charts contradict.
Each feature has a rule-based counterpart. A model round-trip through the
gateway costs minutes (its primary upstream is a large reasoning model), and a
health screen that shows nothing when an upstream is rate-limited is worse than
one that shows a plainer answer — so `meta.source` says which one answered
rather than the failure being invisible.
"""
import json
from services import ai as ai_svc
from services import insights
SYSTEM = """# 角色
你是一名资深运动生理学专家与佳明Garmin数据分析教练。你解读用户的可穿戴设备
数据,输出严谨、精炼、无废话的生理状态解读与行动指导。
# 生理学原则
1. 训练准备度综合睡眠分数、HRV 状态、恢复时间、急性负荷与压力历史。
2. HRV 反映副交感神经活跃度HRV 高且静息心率低通常代表恢复良好。
3. 身体电量的充电量受睡眠质量与深睡/REM 比例影响:深睡负责肌肉与体力恢复,
REM 负责认知与精神修复。
4. 强度分钟与运动记录代表急性负荷;负荷骤增后 HRV 短暂下降属正常应激反应。
# 数据纪律
- 只使用输入 JSON 中出现的数值,禁止编造或估算任何未给出的数字。
- 字段为 null 表示该项未采集,要么略过,要么明确说明"未采集",不要当作 0。
- z 值z是该指标相对用户自身近 28 天基线的偏离程度,已经算好,直接引用即可,
不要自行重算。|z| < 1 属正常波动,不要渲染成异常。
- 你不是医生,不做医疗诊断;只从运动恢复、疲劳管理与作息角度给建议。发现明显
异常时提示用户咨询专业医师。
# 输出
- 简体中文。
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
JSON 之外的任何文字都会被丢弃。"""
BRIEFING_SCHEMA = """{
"status": "对整体恢复状态的定性,不超过 8 字,例如 '恢复良好' / '中等偏上' / '疲劳累积'",
"headline": "一句话总结今日身体状态,不超过 40 字",
"diagnosis": [
{"title": "维度名,如 睡眠结构 / 自主神经 / 电量与就绪度", "detail": "该维度的判断与依据,引用具体数值,不超过 60 字"}
],
"shortfall": "今日最主要的短板,一句话;若无明显短板则写 '无明显短板'",
"prescription": {
"intensity": "今日运动强度上限,如 低 / 中等 / 中等偏高 / 高",
"hrZone": "建议心率区间,如 'Zone 2~Zone 3';无法判断填 null",
"suggestion": "具体运动处方,含项目与时长,不超过 40 字",
"durationMin": 建议时长的分钟数(整数)或 null,
"avoid": "今日应避免的内容,不超过 20 字;无则填 null"
},
"actions": ["今日可执行的具体行动2~4 条,每条不超过 30 字"]
}"""
TREND_SCHEMA = """{
"summary": "这段区间内该指标发生了什么,一句话,不超过 50 字",
"drivers": [
{"factor": "关联因素名", "detail": "它与该指标的关系及依据,引用数值,不超过 60 字"}
],
"caution": "需要留意的风险或误读;没有则填 null",
"confidence": "high|medium|low —— 取决于样本量与关联证据强度"
}"""
def _payload(context):
"""The context as compact JSON.
`ensure_ascii=False` matters for size as much as readability: escaping
Chinese labels to \\uXXXX roughly triples their token cost.
"""
return json.dumps(context, ensure_ascii=False, separators=(",", ":"))
def briefing_messages(context):
return [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
"以下是我的健康数据快照。deviations 中的 z 值是相对我自身近 28 天\n"
"基线的偏离trends 是长周期走势activityShift 是近 7 天与之前的\n"
"活动量对比。\n\n"
f"```json\n{_payload(context)}\n```\n\n"
"请给出今日晨间简报与运动处方,严格按以下 JSON 结构输出:\n\n"
f"{BRIEFING_SCHEMA}"
),
},
]
def trend_messages(window):
return [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
f"以下是我 {window['label']} 指标在 {window['start']} ~ {window['end']}\n"
"区间的数据companions 是同区间内其它指标的均值activities 是该区间\n"
"内的运动记录baselineBefore 是该区间之前的基线。\n\n"
f"```json\n{_payload(window)}\n```\n\n"
"请解释这段区间内该指标的变化及其可能的驱动因素,严格按以下 JSON\n"
f"结构输出:\n\n{TREND_SCHEMA}"
),
},
]
COPILOT_SYSTEM = SYSTEM.replace(
"""# 输出
- 简体中文。
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
JSON 之外的任何文字都会被丢弃。""",
"""# 输出
- 简体中文Markdown 格式。
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
- 控制在 300 字以内,先给结论再给依据。
- 引用数值时写明是哪一天或哪个区间的值。
- 问题超出所给数据能回答的范围时,直接说明数据里没有,不要猜。""",
)
def copilot_messages(context, history, question):
"""Chat turns for the Copilot.
The health context rides in the system turn rather than being prepended to
the user's question: it stays out of the visible transcript, and the same
snapshot governs every turn instead of being re-sent (and re-charged) with
each follow-up.
"""
messages = [
{"role": "system", "content": COPILOT_SYSTEM},
{
"role": "system",
"content": (
"以下是提问者的健康数据快照,回答时以它为唯一事实来源:\n"
f"```json\n{_payload(context)}\n```"
),
},
]
# Filtered first, then capped: capping first lets a single unusable entry
# in the tail — a tool frame, an empty message — silently cost the model a
# remembered turn.
usable = [
{"role": t["role"], "content": (t.get("content") or "").strip()[:2000]}
for t in history
if t.get("role") in ("user", "assistant") and (t.get("content") or "").strip()
]
messages.extend(usable[-8:])
messages.append({"role": "user", "content": question[:2000]})
return messages
# --- reply validation -------------------------------------------------------
def _text(value, limit):
if value is None:
return None
text = str(value).strip()
return text[:limit] if text else None
def parse_briefing(reply):
data = ai_svc.extract_json(reply)
if not isinstance(data, dict):
raise ai_svc.AIError("模型未返回 JSON 对象")
prescription = data.get("prescription")
if not isinstance(prescription, dict):
prescription = {}
duration = prescription.get("durationMin")
try:
duration = int(duration) if duration is not None else None
except (TypeError, ValueError):
duration = None
diagnosis = []
for item in data.get("diagnosis") or []:
if isinstance(item, dict):
title = _text(item.get("title"), 20)
detail = _text(item.get("detail"), 200)
else:
title, detail = None, _text(item, 200)
if detail:
diagnosis.append({"title": title or "综合", "detail": detail})
actions = [
_text(a, 60) for a in (data.get("actions") or []) if _text(a, 60)
]
out = {
"status": _text(data.get("status"), 20) or "状态未定性",
"headline": _text(data.get("headline"), 120),
"diagnosis": diagnosis[:5],
"shortfall": _text(data.get("shortfall"), 120),
"prescription": {
"intensity": _text(prescription.get("intensity"), 20),
"hrZone": _text(prescription.get("hrZone"), 40),
"suggestion": _text(prescription.get("suggestion"), 120),
"durationMin": duration,
"avoid": _text(prescription.get("avoid"), 60),
},
"actions": actions[:4],
}
# A briefing with neither a headline nor any diagnosis is an empty card;
# rejecting it here lets the caller fall back to the rule engine instead
# of rendering blank space.
if not out["headline"] and not out["diagnosis"]:
raise ai_svc.AIError("模型返回的简报没有可用内容")
return out
def parse_trend_insight(reply):
data = ai_svc.extract_json(reply)
if not isinstance(data, dict):
raise ai_svc.AIError("模型未返回 JSON 对象")
drivers = []
for item in data.get("drivers") or []:
if isinstance(item, dict):
factor = _text(item.get("factor"), 30)
detail = _text(item.get("detail"), 200)
else:
factor, detail = None, _text(item, 200)
if detail:
drivers.append({"factor": factor or "关联因素", "detail": detail})
confidence = str(data.get("confidence", "medium")).lower()
if confidence not in ("high", "medium", "low"):
confidence = "medium"
summary = _text(data.get("summary"), 200)
if not summary and not drivers:
raise ai_svc.AIError("模型返回的归因没有可用内容")
return {
"summary": summary,
"drivers": drivers[:5],
"caution": _text(data.get("caution"), 200),
"confidence": confidence,
}
# --- rule-based counterparts ------------------------------------------------
def rule_briefing(context):
"""A briefing assembled from the computed features alone.
Deliberately quotes the same numbers the AI version would, so a fallback
reads as a plainer answer rather than a different one.
"""
today = context["todayMetrics"]
sleep = today["sleep"] or {}
nervous = today["autonomicNervous"]
recovery = today["recovery"]
activity = today["activityToday"]
by_metric = {d["metric"]: d for d in context["deviations"]}
diagnosis = []
concerns = []
duration = sleep.get("durationHours")
if duration is not None:
target = sleep.get("targetHours") or insights.SLEEP_TARGET_HOURS
parts = [f"睡眠 {duration:.1f} 小时(目标 {target:g}"]
rem = sleep.get("remPercent")
if rem is not None:
low, high = insights.REM_REFERENCE_PCT
parts.append(f"REM {rem:g}%{'(偏低)' if rem < low else ''}")
deep = sleep.get("deepPercent")
if deep is not None:
low, _ = insights.DEEP_REFERENCE_PCT
parts.append(f"深睡 {deep:g}%{'(偏低)' if deep < low else '(达标)'}")
diagnosis.append({"title": "睡眠结构", "detail": "".join(parts) + ""})
if duration < target:
concerns.append(f"睡眠比目标少 {target - duration:.1f} 小时")
hrv, rhr = nervous.get("hrvMs"), nervous.get("restingHr")
if hrv is not None or rhr is not None:
parts = []
if hrv is not None:
base = by_metric.get("heartRateVariability", {}).get("baselineMean")
parts.append(
f"HRV {hrv:g} ms" + (f"(基线 {base:g}" if base is not None else "")
)
if rhr is not None:
base = by_metric.get("heartRate", {}).get("baselineMean")
parts.append(
f"静息心率 {rhr:g} bpm" + (f"(基线 {base:g}" if base is not None else "")
)
diagnosis.append({"title": "自主神经", "detail": "".join(parts) + ""})
readiness = recovery.get("trainingReadiness")
battery = recovery.get("bodyBatteryPeak")
if readiness is not None or battery is not None:
parts = []
if readiness is not None:
parts.append(f"训练准备度 {readiness:g}/100")
if battery is not None:
parts.append(f"身体电量充至 {battery:g}")
diagnosis.append({"title": "恢复与就绪度", "detail": "".join(parts) + ""})
# Readiness is Garmin's own composite of sleep, HRV, recovery time and
# acute load, so it drives the prescription wherever it exists; the
# sleep/HRV fallback below is only for watches that do not report it.
if readiness is not None:
if readiness >= 75:
intensity, zone, suggestion = "", "Zone 3~Zone 4", "可安排高强度或长时间训练"
elif readiness >= 50:
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
else:
intensity, zone, suggestion = "", "Zone 1~Zone 2", "以走路或拉伸为主,优先恢复"
elif duration is not None and duration < (sleep.get("targetHours") or 7):
intensity, zone, suggestion = "中等偏低", "Zone 2", "30 分钟低强度有氧,避免加练"
else:
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
actions = []
steps, goal = activity.get("steps"), activity.get("stepGoal")
if steps is not None and goal and steps < goal:
actions.append(f"步数 {steps:,} / 目标 {goal:,},补一段快走")
elif steps is not None and steps < 6000:
actions.append(f"今日步数 {steps:,},偏低,安排一次散步")
if concerns:
actions.append("提前 30 分钟入睡,补回睡眠缺口")
sedentary = activity.get("sedentaryHours")
if sedentary and sedentary >= 8:
actions.append(f"久坐 {sedentary:g} 小时,每小时起身活动 3 分钟")
shift = context.get("activityShift", {}).get("steps")
if shift and shift.get("changePct") is not None and shift["changePct"] <= -20:
actions.append(f"近 7 天步数较此前下降 {abs(shift['changePct']):g}%,注意活动量")
if not actions:
actions.append("各项指标处于常态,保持当前作息与训练安排")
notable = [
d for d in context["deviations"]
if d.get("z") is not None and abs(d["z"]) >= insights.Z_NOTABLE
]
if notable:
top = notable[0]
status = "存在偏离"
headline = (
f"{top['label']} {top['value']:g}{top['unit']}"
f"偏离近 {top['baselineDays']} 天基线 {abs(top['z']):.1f} 个标准差。"
)
else:
status = "状态平稳"
headline = "各项指标均在个人基线的正常波动范围内。"
return {
"status": status,
"headline": headline,
"diagnosis": diagnosis,
"shortfall": "".join(concerns) if concerns else "无明显短板",
"prescription": {
"intensity": intensity,
"hrZone": zone,
"suggestion": suggestion,
"durationMin": None,
"avoid": None,
},
"actions": actions[:4],
}
def rule_trend_insight(window):
"""Trend attribution without a model: direction, size, and co-movement."""
slope = window.get("slopePer30d")
label, unit = window["label"], window["unit"]
if slope is None:
summary = f"{window['start']} ~ {window['end']} 区间内 {label} 样本不足,无法判断趋势。"
else:
direction = "上升" if slope > 0 else ("下降" if slope < 0 else "基本持平")
summary = (
f"{label} 在该区间{direction},拟合斜率约 {slope:g}{unit}/30 天,"
f"均值 {window['mean']:g}{unit}"
)
drivers = []
baseline = window.get("baselineBefore")
if baseline and window.get("mean") is not None:
delta = window["mean"] - baseline["mean"]
drivers.append({
"factor": "区间前基线",
"detail": (
f"区间前 {baseline['days']} 天均值 {baseline['mean']:g}{unit}"
f"区间内{'高出' if delta >= 0 else '低于'} {abs(delta):.2f}{unit}"
),
})
activities = window.get("activities") or []
if activities:
minutes = sum(a.get("durationMin") or 0 for a in activities)
drivers.append({
"factor": "运动负荷",
"detail": f"该区间共 {len(activities)} 次运动,合计约 {minutes} 分钟。",
})
return {
"summary": summary,
"drivers": drivers,
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
"confidence": "low",
}