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