Files
GarminHealthLab/backend/services/scopes.py
ericwyuan 2488956f36 fix(ai): 折叠态只显示一句话,那句话得说清是哪个指标
精简版的卡片正文就是第一条 highlight,标题不渲染。于是趋势页折叠起来是
「395 天内由 4376.0分 到 4905.0分(改善)」——什么的 4376 分?身体成分、
运动详情同样。

每个 builder 的第一条 detail 现在都自带主语,并加了不变量测试:detail 必须
包含它自己的 title(纯汇总性标题除外)。

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

614 lines
23 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 ",达标。"),
})
# 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['label']} {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": f"{date} 各项指标都在常态范围内。"}
]
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"{label}{first['date']}{a}{unit}"
f"{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)
sport = (base.get("activity_type") or (detail or {}).get("activityType")
or "运动")
highlights = [{
"title": sport,
"detail": f"{sport} {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)