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>
This commit is contained in:
601
backend/services/scopes.py
Normal file
601
backend/services/scopes.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
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 ",达标。"),
|
||||
})
|
||||
|
||||
return f"{window[-1]['date']}:{len(window)}", {
|
||||
"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 f"{rows[-1]['date']}:{len(rows)}", {
|
||||
"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')}。",
|
||||
})
|
||||
|
||||
return f"{rows[0].get('startDate')}:{len(rows)}", {
|
||||
"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")
|
||||
|
||||
def __init__(self, name, label, build, needs_subject=False):
|
||||
self.name = name
|
||||
self.label = label
|
||||
self.build = build
|
||||
self.needs_subject = needs_subject
|
||||
|
||||
|
||||
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),
|
||||
Scope("body", "身体成分", build_body),
|
||||
Scope("race", "成绩预测", build_race),
|
||||
Scope("bodyAge", "身体年龄", build_body_age),
|
||||
Scope("challenges", "挑战赛", build_challenges),
|
||||
Scope("activity", "运动详情", build_activity, needs_subject=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)
|
||||
Reference in New Issue
Block a user