Files
GarminHealthLab/backend/routes/analysis.py
ericwyuan 241ae0d6a3 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>
2026-09-01 15:17:17 +08:00

176 lines
5.9 KiB
Python

"""Analysis routes: trends, recommendations, and the AI coach."""
import json
from flask import Blueprint, Response, request, g, jsonify
from auth import require_auth
from services import analysis as analysis_svc
from services import ai as ai_svc
from services import insights
from services import jobs as ai_jobs
from services import scopes
bp = Blueprint("analysis", __name__)
@bp.route("/trends", methods=["GET"])
@require_auth
def trends():
metric = request.args.get("metricType", "steps")
s = request.args.get("startDate")
e = request.args.get("endDate")
return jsonify(analysis_svc.get_trends(metric, g.user_id, s, e))
@bp.route("/recommendations", methods=["GET"])
@require_auth
def recommendations():
return jsonify(analysis_svc.get_recommendations(g.user_id))
@bp.route("/models", methods=["GET"])
@require_auth
def models():
"""Available LLMs and whether each one has credentials configured."""
return jsonify(ai_svc.list_models())
@bp.route("/ai-recommendations", methods=["GET"])
@require_auth
def ai_recommendations():
"""LLM recommendations. `?model=` picks one; omit it to use the chain.
Served from cache unless `?refresh=1` or an explicit `model` is given —
a fresh generation can take minutes against a large reasoning model.
Always 200: when no model succeeds the rule engine answers instead, and
meta.source says which produced the result.
"""
model = request.args.get("model") or None
days = request.args.get("days", type=int)
refresh = request.args.get("refresh") in ("1", "true", "yes")
return jsonify(
analysis_svc.get_ai_recommendations(g.user_id, model, days, refresh)
)
def _flag(name):
return request.args.get(name) in ("1", "true", "yes")
@bp.route("/briefing", methods=["GET"])
@require_auth
def briefing():
"""AI 晨间简报 + 今日运动处方, plus the computed context behind it.
Answers immediately. When no cached model answer matches the current data
the rule-based briefing is returned with `meta.pending`, and a generation
runs in the background — a model round-trip costs minutes, which cannot
sit in the first paint of the 今日 screen. Poll the same URL to pick up
the model's version.
`?wait=1` blocks for the model instead, for a deliberate regenerate.
"""
return jsonify(analysis_svc.get_briefing(
g.user_id,
date=request.args.get("date"),
model=request.args.get("model") or None,
refresh=_flag("refresh"),
wait=_flag("wait"),
))
@bp.route("/trend-insight", methods=["GET"])
@require_auth
def trend_insight():
"""Attribution for one metric over a selected span (chart brush)."""
metric = request.args.get("metric")
start = request.args.get("startDate")
end = request.args.get("endDate")
if not (metric and start and end):
return jsonify({"error": "缺少 metric / startDate / endDate 参数"}), 400
if metric not in insights.METRICS:
return jsonify({
"error": f"不支持的指标: {metric}",
"supported": sorted(insights.METRICS),
}), 400
return jsonify(analysis_svc.get_trend_insight(
g.user_id, metric, start, end,
model=request.args.get("model") or None,
refresh=_flag("refresh"),
))
@bp.route("/copilot", methods=["POST"])
@require_auth
def copilot():
"""Health Copilot, streamed as server-sent events.
Streaming is about keeping the connection honest as much as about speed:
the upstream can think for minutes before its first token, and a plain
JSON request that long is indistinguishable from a hang — to the user, to
a proxy, and to Gunicorn's worker timeout.
"""
body = request.get_json(silent=True) or {}
question = (body.get("question") or "").strip()
if not question:
return jsonify({"error": "缺少 question"}), 400
history = body.get("history")
history = history if isinstance(history, list) else []
# Read off `g` here, not inside the generator: the request context is torn
# down before the first chunk is pulled, and touching g there raises.
user_id = g.user_id
date = body.get("date")
model = body.get("model") or None
def events():
for event, data in analysis_svc.copilot_stream(
user_id, question, history, date, model
):
yield f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
return Response(
events(),
mimetype="text/event-stream",
# X-Accel-Buffering stops nginx-style proxies from holding the stream
# until it completes, which would undo the point of streaming it.
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@bp.route("/insight", methods=["GET"])
@require_auth
def insight():
"""One screen's AI reading. `?scope=` names the screen.
Answers immediately, like the briefing: the computed highlights come back
with `meta.pending` while the model's version is generated. Opening a
screen queues it at interactive priority, ahead of any backfill still
running, so what you are looking at is what the queue works on next.
`?subject=` identifies the item for per-item screens (`activity` needs an
activity id, `daily` takes a date).
"""
scope = request.args.get("scope")
if scope not in scopes.SCOPES:
return jsonify({
"error": f"不支持的页面: {scope}",
"supported": sorted(scopes.SCOPES),
}), 400
return jsonify(analysis_svc.get_scope_insight(
g.user_id, scope,
subject=request.args.get("subject") or None,
refresh=_flag("refresh"),
))
@bp.route("/insight/queue", methods=["GET"])
@require_auth
def insight_queue():
"""What the coach still has to generate — for a progress indicator."""
return jsonify({
"pending": ai_jobs.pending_count(g.user_id),
"enabled": ai_jobs.ENABLED,
})