队列本来是完全不可见的:页面上一句「排队生成中」说不出自己是下一个、第二十 个,还是已经放弃了——网关挂掉的时候,「还在生成」和「永远不会好」长得一模 一样。今天排查就是这么排的。 - GET /analysis/insight/queue 返回队列(running 在前,其次按优先级和年龄, 和 worker 实际取任务的顺序一致)、已生成的解读、scope 名到中文标签的映射 (前端不必再抄一份),以及消费者的限流配置 - POST /analysis/insight/queue/retry:手动把「已放弃」的重新排队,不等冷却。 自动重试要等冷却是为了不去捶一个正在抽风的上游;人按下重试是他自己判断值得 再试一次 - 页面在 设置 → AI 生成队列。插队的任务标「插队」——这是整个界面最想让人看见 的一件事:为什么是它排在最前面 - 「已生成」单独列:队列空了意味着「没有待办」,不是「什么都没生成过」, 没有这一节这两件事在界面上没法区分 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
6.5 KiB
Python
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)})
|