生产上 trends 队列里积了 36 个任务,subject 是 2026-09-01:1033、:1039、 :1044……一路涨。这台账号当时正在补历史,get_summary 的行数每隔几分钟就变, 而我把 len(rows) 写进了 subject——subject 同时是缓存键和任务队列的键,一变 就是一条全新的任务,轮询几次就刷出十几条。 subject 该回答的是「这条解读是关于什么的」,不是「当时有多少行数据」。 数据变化本来就由 fingerprint 负责。 - trends 的 subject 改成快照日期;sleep 用配置的窗口常量而不是实际夜数 (缺一晚也不该换键);challenges 用固定键 - 加了不变量测试:补一天历史数据后 subject 不许变;任何 subject 段都不许 长得像行数 顺带加一层兜底 jobs.supersede():单实例 scope 只该有一个在跑的 subject, 队列里同 kind 的其它 pending 任务是关于已经不存在的快照的,跑完也没人看。 per_item 的 daily / activity 不受影响——它们本来就一天一条、一次运动一条。 兜底不是机制,机制是 subject 稳定;它存在只是因为这次 subject 不稳定,而 36 条任务堆在那里之前没人发现。 顺带按要求把 AiPanel 改成默认精简:只显示标题、来源和一句话结论,点「展开 详细」才出要点/建议/依据,可再收起——和今日晨报卡片一致。这些面板压在本来 就很密的图表页上面,全部默认展开会把真正的数据一次性挤到屏幕外。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
611 lines
23 KiB
Python
611 lines
23 KiB
Python
"""
|
||
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['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": "这一天各项指标都在常态范围内。"}
|
||
]
|
||
|
||
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')}。",
|
||
})
|
||
|
||
# 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)
|
||
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", "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)
|