"""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)})