Files
GarminHealthLab/backend/routes/analysis.py
ericwyuan 05f55e695b feat(ai): 设置里加「AI 生成队列」,看得见后台在算什么
队列本来是完全不可见的:页面上一句「排队生成中」说不出自己是下一个、第二十
个,还是已经放弃了——网关挂掉的时候,「还在生成」和「永远不会好」长得一模
一样。今天排查就是这么排的。

- GET /analysis/insight/queue 返回队列(running 在前,其次按优先级和年龄,
  和 worker 实际取任务的顺序一致)、已生成的解读、scope 名到中文标签的映射
  (前端不必再抄一份),以及消费者的限流配置
- POST /analysis/insight/queue/retry:手动把「已放弃」的重新排队,不等冷却。
  自动重试要等冷却是为了不去捶一个正在抽风的上游;人按下重试是他自己判断值得
  再试一次
- 页面在 设置 → AI 生成队列。插队的任务标「插队」——这是整个界面最想让人看见
  的一件事:为什么是它排在最前面
- 「已生成」单独列:队列空了意味着「没有待办」,不是「什么都没生成过」,
  没有这一节这两件事在界面上没法区分

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:02:05 +08:00

191 lines
6.5 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():
"""The coach's queue: what is running, what is waiting, what gave up.
Also lists what has already been generated, because "nothing queued" and
"nothing generated" look the same from the queue alone and mean opposite
things.
"""
return jsonify({
"pending": ai_jobs.pending_count(g.user_id),
"jobs": ai_jobs.list_jobs(g.user_id),
"insights": analysis_svc.list_insights(g.user_id),
"scopes": {name: scope.label for name, scope in scopes.SCOPES.items()},
"settings": ai_jobs.settings(),
})
@bp.route("/insight/queue/retry", methods=["POST"])
@require_auth
def insight_queue_retry():
"""Re-queue everything that gave up, without waiting out the cooldown."""
return jsonify({"pending": ai_jobs.retry_failed(g.user_id)})