feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot
数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推 z 分数,它算错的次数足以让简报引用图表反驳它的数字。 - services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个 值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率 (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量 对比。 - services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。 网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。 - services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(), 以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾 倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。 - 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。 - 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情 页归因面板。features.ai 打开。 实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报 绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零 输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。 顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读 到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差 时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
"""Analysis routes: trends + recommendations."""
|
||||
from flask import Blueprint, request, g, jsonify
|
||||
"""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
|
||||
|
||||
bp = Blueprint("analysis", __name__)
|
||||
|
||||
@@ -47,3 +50,88 @@ def ai_recommendations():
|
||||
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"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user