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:
@@ -53,7 +53,9 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
||||
# absorbs single-vendor quota limits. Reached directly, bypassing any local
|
||||
# HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies
|
||||
# can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly.
|
||||
AI_GATEWAY_BASE_URL=http://129.146.26.249:5100/v1
|
||||
# HTTPS (Caddy, strips the /ai prefix) rather than http://…:5100 — the token
|
||||
# rides in an Authorization header and should not cross the internet in clear.
|
||||
AI_GATEWAY_BASE_URL=https://oracle.zichuan.xyz/ai/v1
|
||||
AI_GATEWAY_TOKEN=
|
||||
AI_GATEWAY_MODEL=ai-gateway-auto
|
||||
|
||||
@@ -73,7 +75,17 @@ AI_MODEL_CHAIN=gateway,gemini-flash,llama-70b
|
||||
# payload always fits that model's own context window.
|
||||
AI_DAY_BUDGET=365
|
||||
|
||||
AI_TIMEOUT_SECONDS=180
|
||||
# Measured against the gateway, not guessed: a trivial prompt took 138s end to
|
||||
# end, because its primary upstream emits a full chain of thought before the
|
||||
# answer. Nothing user-facing blocks on this (the briefing generates in a
|
||||
# background thread), but the timeout still has to clear the real latency.
|
||||
AI_TIMEOUT_SECONDS=300
|
||||
# Output cap. Reasoning models spend part of it thinking before they answer;
|
||||
# entries that need more declare their own budget in services/ai.py.
|
||||
AI_MAX_TOKENS=1024
|
||||
|
||||
# Output cap for the AI coach (晨报 / 趋势归因 / Copilot). Larger than
|
||||
# AI_MAX_TOKENS above: the same reasoning trace is spent from this budget
|
||||
# before the answer starts, and at 1024 the reply was all thinking with the
|
||||
# JSON truncated away.
|
||||
AI_COACH_MAX_TOKENS=4000
|
||||
|
||||
@@ -296,6 +296,25 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Cached AI answers keyed by what they are about, so one stale entry cannot
|
||||
-- evict another: `kind` separates the morning briefing from a chart-window
|
||||
-- attribution, and `subject` is the day (briefing) or metric+range (trend).
|
||||
-- Same reasoning as ai_recommendations above — a generation costs minutes, so
|
||||
-- it can never sit inside a page load.
|
||||
CREATE TABLE IF NOT EXISTS ai_insights (
|
||||
id VARCHAR(160) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(32) NOT NULL,
|
||||
subject VARCHAR(96) NOT NULL,
|
||||
fingerprint VARCHAR(64) NOT NULL,
|
||||
model VARCHAR(64),
|
||||
upstream VARCHAR(64),
|
||||
payload MEDIUMTEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, kind, subject),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
"""
|
||||
|
||||
# --- MariaDB pool (lazy) ----------------------------------------------------
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -97,6 +97,12 @@ class Provider:
|
||||
|
||||
name = "base"
|
||||
|
||||
# Whether this endpoint has an incremental transport of its own. False
|
||||
# means `stream` is the blocking call in disguise, which `stream_chat`
|
||||
# needs to know: retrying such a provider without streaming would just
|
||||
# run the same request a second time.
|
||||
streaming = False
|
||||
|
||||
def __init__(
|
||||
self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None
|
||||
):
|
||||
@@ -131,9 +137,30 @@ class Provider:
|
||||
session.trust_env = self.use_proxy
|
||||
return session
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
def chat(self, messages, timeout=None, max_tokens=None):
|
||||
"""Multi-turn completion.
|
||||
|
||||
`messages` is the OpenAI shape — a list of
|
||||
{"role": "system"|"user"|"assistant", "content": str}. Providers whose
|
||||
wire format differs translate it themselves.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
"""Single-turn convenience wrapper, kept for the recommendation path."""
|
||||
return self.chat([{"role": "user", "content": prompt}], timeout)
|
||||
|
||||
def stream(self, messages, timeout=None, max_tokens=None):
|
||||
"""Yield Completion deltas as the reply arrives.
|
||||
|
||||
The base implementation is not incremental: it waits for the whole
|
||||
answer and emits it as a single delta. That keeps every entry in the
|
||||
catalog streamable from the caller's point of view — a provider with
|
||||
no SSE transport produces one late chunk rather than an error, so the
|
||||
Copilot route does not need a per-provider branch.
|
||||
"""
|
||||
yield self.chat(messages, timeout, max_tokens)
|
||||
|
||||
|
||||
class GeminiProvider(Provider):
|
||||
"""Google AI Studio (generativelanguage.googleapis.com)."""
|
||||
@@ -141,18 +168,34 @@ class GeminiProvider(Provider):
|
||||
name = "gemini"
|
||||
BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
def chat(self, messages, timeout=None, max_tokens=None):
|
||||
if not self.is_configured():
|
||||
raise AIError(f"{self.api_key_env} 未配置")
|
||||
timeout = timeout or default_timeout()
|
||||
url = f"{self.BASE}/{self.model_id}:generateContent"
|
||||
# Gemini splits what OpenAI keeps in one list: system turns move to
|
||||
# `systemInstruction`, and the assistant role is spelled "model".
|
||||
contents, system = [], []
|
||||
for message in messages:
|
||||
role = message.get("role")
|
||||
if role == "system":
|
||||
system.append(message.get("content") or "")
|
||||
continue
|
||||
contents.append({
|
||||
"role": "model" if role == "assistant" else "user",
|
||||
"parts": [{"text": message.get("content") or ""}],
|
||||
})
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"contents": contents,
|
||||
"generationConfig": {
|
||||
"temperature": 0.4,
|
||||
"maxOutputTokens": self.max_tokens,
|
||||
"maxOutputTokens": max_tokens or self.max_tokens,
|
||||
},
|
||||
}
|
||||
if system:
|
||||
payload["systemInstruction"] = {
|
||||
"parts": [{"text": "\n\n".join(system)}]
|
||||
}
|
||||
try:
|
||||
resp = self._session().post(
|
||||
url,
|
||||
@@ -188,6 +231,7 @@ class OpenAICompatProvider(Provider):
|
||||
"""
|
||||
|
||||
name = "openai-compat"
|
||||
streaming = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -216,30 +260,35 @@ class OpenAICompatProvider(Provider):
|
||||
return False
|
||||
return bool(self.api_key) if self.requires_key else True
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
def _request(self, messages, timeout, max_tokens, stream):
|
||||
if not self.is_configured():
|
||||
raise AIError(
|
||||
f"{self.api_key_env} 未配置" if self.requires_key
|
||||
else f"{self.base_url_env} 未配置"
|
||||
)
|
||||
timeout = timeout or default_timeout()
|
||||
url = f"{self.base_url.rstrip('/')}/chat/completions"
|
||||
payload = {
|
||||
"model": self.model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"messages": messages,
|
||||
"temperature": 0.4,
|
||||
"max_tokens": self.max_tokens,
|
||||
"max_tokens": max_tokens or self.max_tokens,
|
||||
}
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
resp = self._session().post(
|
||||
url, headers=headers, json=payload, timeout=timeout
|
||||
return self._session().post(
|
||||
url, headers=headers, json=payload, timeout=timeout, stream=stream
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise AIError(f"{self.model_id} 请求失败: {e}") from e
|
||||
|
||||
def chat(self, messages, timeout=None, max_tokens=None):
|
||||
timeout = timeout or default_timeout()
|
||||
resp = self._request(messages, timeout, max_tokens, stream=False)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
@@ -253,6 +302,51 @@ class OpenAICompatProvider(Provider):
|
||||
except (ValueError, KeyError, IndexError) as e:
|
||||
raise AIError(f"{self.model_id} 响应格式异常: {e}") from e
|
||||
|
||||
def stream(self, messages, timeout=None, max_tokens=None):
|
||||
"""Server-sent chunks in the OpenAI streaming schema.
|
||||
|
||||
Note what "streaming" buys here in practice: the gateway forwards its
|
||||
upstream's chunks, and its primary upstream is a reasoning model that
|
||||
emits nothing until it has finished thinking. So this shortens the
|
||||
wait to first text on some upstreams and not at all on others — it is
|
||||
a transport, not a latency guarantee.
|
||||
|
||||
A `data:` frame carrying an `error` object is the gateway's way of
|
||||
reporting "no upstream answered" mid-stream, so it is raised rather
|
||||
than yielded as content.
|
||||
"""
|
||||
timeout = timeout or default_timeout()
|
||||
resp = self._request(messages, timeout, max_tokens, stream=True)
|
||||
if resp.status_code != 200:
|
||||
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
try:
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if not raw or not raw.startswith("data:"):
|
||||
continue
|
||||
data = raw[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(chunk, dict) and chunk.get("error"):
|
||||
message = chunk["error"]
|
||||
if isinstance(message, dict):
|
||||
message = message.get("message") or message
|
||||
raise AIError(f"{self.model_id}: {message}")
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
text = (choices[0].get("delta") or {}).get("content")
|
||||
if text:
|
||||
yield Completion(text, chunk.get("provider"))
|
||||
except requests.RequestException as e:
|
||||
raise AIError(f"{self.model_id} 流式中断: {e}") from e
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
|
||||
# --- catalog ----------------------------------------------------------------
|
||||
NVIDIA_BASE = "https://integrate.api.nvidia.com/v1"
|
||||
@@ -549,3 +643,168 @@ def generate(summary, activities=None, preferred_model=None, day_budget=None):
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
|
||||
|
||||
# --- generic chat entry points ----------------------------------------------
|
||||
# Used by the coach (briefing / trend insight / Copilot), which needs multi-turn
|
||||
# messages and a bigger output cap than the recommendation list: a reasoning
|
||||
# upstream spends part of its budget thinking out loud before the answer, and a
|
||||
# briefing is prose rather than five short strings.
|
||||
FALLBACK_COACH_MAX_TOKENS = 4000
|
||||
|
||||
|
||||
def coach_max_tokens():
|
||||
return int(os.environ.get("AI_COACH_MAX_TOKENS") or FALLBACK_COACH_MAX_TOKENS)
|
||||
|
||||
|
||||
def complete(messages, preferred_model=None, max_tokens=None, timeout=None):
|
||||
"""First healthy model in the chain answers. Returns (Completion, meta).
|
||||
|
||||
Same fallback policy as `generate`, but the caller supplies the whole
|
||||
message list and parses the reply itself.
|
||||
"""
|
||||
chain = resolve_chain(preferred_model)
|
||||
max_tokens = max_tokens or coach_max_tokens()
|
||||
errors = []
|
||||
|
||||
for model_id in chain:
|
||||
provider = CATALOG[model_id]
|
||||
try:
|
||||
completion = provider.chat(messages, timeout, max_tokens)
|
||||
except AIError as e:
|
||||
errors.append({"model": model_id, "error": str(e)})
|
||||
continue
|
||||
if not (completion.text or "").strip():
|
||||
errors.append({"model": model_id, "error": "空响应"})
|
||||
continue
|
||||
return completion, {
|
||||
"model": model_id,
|
||||
"provider": provider.name,
|
||||
"upstream": completion.upstream,
|
||||
"fallbackFrom": [e["model"] for e in errors],
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
|
||||
|
||||
def stream_chat(messages, preferred_model=None, max_tokens=None, timeout=None):
|
||||
"""Stream a reply, yielding Completion deltas.
|
||||
|
||||
Two failover rules, and the order matters:
|
||||
|
||||
1. **Same model, without streaming, before moving on.** Measured against
|
||||
the gateway with a real briefing prompt: the streaming request came
|
||||
back "所有模型均不可用" after 139s while the identical non-streaming
|
||||
request answered in 273s. Its streaming path is simply less reliable
|
||||
than its blocking one, so a stream that produces nothing is retried
|
||||
blind before the model is written off. The reply then arrives as a
|
||||
single late delta rather than not at all.
|
||||
2. **No failover once text has been yielded.** By then it has usually
|
||||
reached the user's screen, and switching models mid-answer splices two
|
||||
different replies together — the exact defect the gateway's own NVIDIA
|
||||
adapter has (its README, known issue #1). A half-written answer that
|
||||
fails visibly beats one finished in another voice.
|
||||
"""
|
||||
chain = resolve_chain(preferred_model)
|
||||
max_tokens = max_tokens or coach_max_tokens()
|
||||
errors = []
|
||||
|
||||
for model_id in chain:
|
||||
provider = CATALOG[model_id]
|
||||
started = False
|
||||
try:
|
||||
for delta in provider.stream(messages, timeout, max_tokens):
|
||||
started = True
|
||||
yield delta
|
||||
except AIError as e:
|
||||
if started:
|
||||
raise
|
||||
errors.append({"model": model_id, "error": f"流式: {e}"})
|
||||
if started:
|
||||
return
|
||||
|
||||
if not provider.streaming:
|
||||
# Its `stream` was the blocking call already; retrying it here
|
||||
# would spend a second full generation on the same failure.
|
||||
continue
|
||||
|
||||
try:
|
||||
completion = provider.chat(messages, timeout, max_tokens)
|
||||
except AIError as e:
|
||||
errors.append({"model": model_id, "error": str(e)})
|
||||
continue
|
||||
if (completion.text or "").strip():
|
||||
yield completion
|
||||
return
|
||||
errors.append({"model": model_id, "error": "空响应"})
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
|
||||
|
||||
# --- JSON extraction --------------------------------------------------------
|
||||
def extract_json(text):
|
||||
"""Pull the last complete JSON object or array out of a model reply.
|
||||
|
||||
The gateway's primary upstream is a reasoning model whose visible output
|
||||
*begins* with its chain of thought ("The user wants ... so we must ..."),
|
||||
with the real answer at the end. Scanning from the front therefore finds
|
||||
prose, or a JSON fragment the model was merely considering. Scanning
|
||||
backwards from the last closing brace finds the answer it settled on.
|
||||
|
||||
Brace counting is string-aware, because a Chinese briefing routinely
|
||||
contains a quoted `}` or an escaped quote and a naive count breaks on both.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
raise AIError("模型返回空响应")
|
||||
|
||||
cleaned = _FENCE.sub("", text).strip()
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for close, opener in (("}", "{"), ("]", "[")):
|
||||
end = cleaned.rfind(close)
|
||||
while end != -1:
|
||||
start = _matching_open(cleaned, end, opener, close)
|
||||
if start is not None:
|
||||
try:
|
||||
return json.loads(cleaned[start : end + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
end = cleaned.rfind(close, 0, end)
|
||||
|
||||
raise AIError(f"模型未返回可解析的 JSON: {text[-200:]}")
|
||||
|
||||
|
||||
def _matching_open(text, end, opener, close):
|
||||
"""Index of the bracket that `text[end]` closes, or None if unbalanced."""
|
||||
depth = 0
|
||||
in_string = False
|
||||
for i in range(end, -1, -1):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
# Walking backwards, a quote ends the string only when it is not
|
||||
# itself escaped — count the run of backslashes before it.
|
||||
if ch == '"':
|
||||
backslashes = 0
|
||||
j = i - 1
|
||||
while j >= 0 and text[j] == "\\":
|
||||
backslashes += 1
|
||||
j -= 1
|
||||
if backslashes % 2 == 0:
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
continue
|
||||
if ch == close:
|
||||
depth += 1
|
||||
elif ch == opener:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
return None
|
||||
|
||||
@@ -8,9 +8,12 @@ import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
from services import health
|
||||
from services import ai as ai_svc
|
||||
from services import coach
|
||||
from services import insights
|
||||
from db import query_all, query_one, execute
|
||||
from config import DB_TYPE
|
||||
|
||||
@@ -256,3 +259,261 @@ def get_ai_recommendations(user_id, model=None, days=None, refresh=False):
|
||||
|
||||
def clear_ai_cache(user_id):
|
||||
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])
|
||||
|
||||
|
||||
# --- AI coach: briefing, trend attribution, Copilot -------------------------
|
||||
# Same caching rationale as the recommendations above, with one addition: a
|
||||
# briefing is the first thing on the 今日 screen, so it can never wait on a
|
||||
# generation. The endpoint answers immediately from the rule engine and the
|
||||
# model's version replaces it on a later poll.
|
||||
_JOB_LOCK = threading.Lock()
|
||||
_JOBS = set()
|
||||
|
||||
|
||||
def _insight_key(kind, subject):
|
||||
return f"{kind}:{subject}"
|
||||
|
||||
|
||||
def _read_insight(user_id, kind, subject, fingerprint):
|
||||
row = query_one(
|
||||
"SELECT * FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
|
||||
[user_id, kind, subject],
|
||||
)
|
||||
if not row or row["fingerprint"] != fingerprint:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(row["payload"])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return payload, {
|
||||
"source": "ai",
|
||||
"model": row["model"],
|
||||
"upstream": row["upstream"],
|
||||
"cached": True,
|
||||
"generatedAt": row.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
def _write_insight(user_id, kind, subject, fingerprint, payload, meta):
|
||||
cols = ["id", "user_id", "kind", "subject", "fingerprint", "model",
|
||||
"upstream", "payload", "created_at"]
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
updatable = [c for c in cols if c != "id"]
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
||||
sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) "
|
||||
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
|
||||
else:
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
||||
sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) "
|
||||
f"VALUES ({placeholders}) ON CONFLICT(id) DO UPDATE SET {updates}")
|
||||
# The id is derived rather than random so a re-generation overwrites the
|
||||
# row it replaces instead of accumulating one per attempt.
|
||||
row_id = hashlib.sha256(
|
||||
f"{user_id}|{kind}|{subject}".encode("utf-8")
|
||||
).hexdigest()[:64]
|
||||
execute(sql, [
|
||||
row_id, user_id, kind, subject, fingerprint, meta.get("model"),
|
||||
meta.get("upstream"), json.dumps(payload, ensure_ascii=False),
|
||||
datetime.datetime.utcnow().isoformat(timespec="seconds"),
|
||||
])
|
||||
|
||||
|
||||
def _context_fingerprint(context):
|
||||
"""Digest of everything the prompt will contain.
|
||||
|
||||
The whole context rather than a chosen subset: a briefing is derived from
|
||||
all of it, so any change to any field — a corrected sleep stage, a newly
|
||||
synced activity — should expire the cached answer.
|
||||
"""
|
||||
blob = json.dumps(context, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64]
|
||||
|
||||
|
||||
def _run_in_background(key, target):
|
||||
"""Start `target` once per key; a second caller joins the first one's run.
|
||||
|
||||
Guards against the obvious failure mode of a poll-until-ready endpoint:
|
||||
the client polls every few seconds while a generation takes minutes, and
|
||||
without this every poll would start another one.
|
||||
|
||||
The claim is per-process, not per-deployment: with Gunicorn's two workers
|
||||
each can start one generation for the same key. That is deliberate rather
|
||||
than overlooked — the `job_locks` table would make it exclusive, but the
|
||||
cost here is a duplicate call, not a duplicate row (the cache id is derived
|
||||
from user+kind+subject, so the second write lands on the first one's row).
|
||||
A cross-process lock is worth adding only if the gateway's rate limits
|
||||
start to bite.
|
||||
"""
|
||||
with _JOB_LOCK:
|
||||
if key in _JOBS:
|
||||
return False
|
||||
_JOBS.add(key)
|
||||
|
||||
def runner():
|
||||
try:
|
||||
target()
|
||||
except Exception as e: # noqa: BLE001 - a background job must not die silently
|
||||
print(f"[analysis] background job {key} failed: {e}")
|
||||
finally:
|
||||
with _JOB_LOCK:
|
||||
_JOBS.discard(key)
|
||||
|
||||
threading.Thread(target=runner, name=f"ai-{key}", daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def _generating(key):
|
||||
with _JOB_LOCK:
|
||||
return key in _JOBS
|
||||
|
||||
|
||||
def generate_briefing(user_id, context, model=None):
|
||||
"""Ask a model for the briefing and store it. Returns (briefing, meta)."""
|
||||
completion, meta = ai_svc.complete(coach.briefing_messages(context), model)
|
||||
briefing = coach.parse_briefing(completion.text)
|
||||
_write_insight(
|
||||
user_id, "briefing", context["snapshotDate"],
|
||||
_context_fingerprint(context), briefing, meta,
|
||||
)
|
||||
return briefing, meta
|
||||
|
||||
|
||||
def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
|
||||
"""The morning briefing for one day.
|
||||
|
||||
Non-blocking by default: a cached answer is returned if it matches the
|
||||
current data, otherwise the rule-based briefing is returned straight away
|
||||
and a model generation starts in the background. `wait=True` blocks for
|
||||
the model instead — for callers that can afford minutes, such as a manual
|
||||
"regenerate" or a scheduled pre-warm.
|
||||
"""
|
||||
context = insights.build_context(user_id, date)
|
||||
if not context:
|
||||
return {
|
||||
"briefing": None,
|
||||
"context": None,
|
||||
"meta": {"source": "none", "reason": "无健康数据"},
|
||||
}
|
||||
|
||||
fingerprint = _context_fingerprint(context)
|
||||
subject = context["snapshotDate"]
|
||||
key = _insight_key("briefing", subject)
|
||||
|
||||
if not refresh:
|
||||
cached = _read_insight(user_id, "briefing", subject, fingerprint)
|
||||
if cached:
|
||||
briefing, meta = cached
|
||||
return {"briefing": briefing, "context": context, "meta": meta}
|
||||
else:
|
||||
# Drop the stored answer, not just skip it. Without this the poll that
|
||||
# follows a regenerate reads the *old* row, sees `cached: true`, and
|
||||
# stops polling — so the user keeps looking at the text they just
|
||||
# asked to replace until something else expires it.
|
||||
_delete_insight(user_id, "briefing", subject)
|
||||
|
||||
if wait:
|
||||
try:
|
||||
briefing, meta = generate_briefing(user_id, context, model)
|
||||
return {
|
||||
"briefing": briefing, "context": context,
|
||||
"meta": {**meta, "source": "ai", "cached": False},
|
||||
}
|
||||
except ai_svc.AIError as e:
|
||||
return {
|
||||
"briefing": coach.rule_briefing(context), "context": context,
|
||||
"meta": {"source": "rules", "reason": str(e)},
|
||||
}
|
||||
|
||||
started = _run_in_background(
|
||||
key, lambda: generate_briefing(user_id, context, model)
|
||||
)
|
||||
return {
|
||||
"briefing": coach.rule_briefing(context),
|
||||
"context": context,
|
||||
"meta": {
|
||||
"source": "rules",
|
||||
# `pending` is what tells the client to poll again: the card it is
|
||||
# showing is the placeholder, not the final answer.
|
||||
"pending": True,
|
||||
"generating": started or _generating(key),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_trend_insight(user_id, metric, start, end, model=None, refresh=False):
|
||||
"""Attribution for a user-selected span of one metric (chart brush).
|
||||
|
||||
Blocking, unlike the briefing: this one is requested by an explicit
|
||||
gesture on a chart, so there is a spinner to attach the wait to and no
|
||||
useful placeholder to show in the meantime.
|
||||
"""
|
||||
window = insights.window_context(user_id, metric, start, end)
|
||||
if not window:
|
||||
return {"insight": None, "window": None,
|
||||
"meta": {"source": "none", "reason": "所选区间没有数据"}}
|
||||
|
||||
subject = f"{metric}:{start}:{end}"
|
||||
fingerprint = _context_fingerprint(window)
|
||||
if not refresh:
|
||||
cached = _read_insight(user_id, "trend", subject, fingerprint)
|
||||
if cached:
|
||||
insight, meta = cached
|
||||
return {"insight": insight, "window": window, "meta": meta}
|
||||
|
||||
try:
|
||||
completion, meta = ai_svc.complete(coach.trend_messages(window), model)
|
||||
insight = coach.parse_trend_insight(completion.text)
|
||||
except ai_svc.AIError as e:
|
||||
return {
|
||||
"insight": coach.rule_trend_insight(window), "window": window,
|
||||
"meta": {"source": "rules", "reason": str(e)},
|
||||
}
|
||||
|
||||
try:
|
||||
_write_insight(user_id, "trend", subject, fingerprint, insight, meta)
|
||||
except Exception as e: # noqa: BLE001 - a cache write must never fail the request
|
||||
print(f"[analysis] failed to cache trend insight: {e}")
|
||||
return {"insight": insight, "window": window,
|
||||
"meta": {**meta, "source": "ai", "cached": False}}
|
||||
|
||||
|
||||
def copilot_stream(user_id, question, history=None, date=None, model=None):
|
||||
"""Stream a Copilot answer, yielding (event, data) pairs.
|
||||
|
||||
A generator rather than a return value so the route can forward each delta
|
||||
as it arrives; the health context is assembled once, here, so the route
|
||||
stays free of feature logic.
|
||||
"""
|
||||
context = insights.build_context(user_id, date)
|
||||
if not context:
|
||||
yield "error", {"message": "暂无健康数据,请先同步 Garmin 数据。"}
|
||||
return
|
||||
|
||||
messages = coach.copilot_messages(context, history or [], question)
|
||||
yield "start", {"snapshotDate": context["snapshotDate"]}
|
||||
upstream = None
|
||||
try:
|
||||
for delta in ai_svc.stream_chat(messages, model):
|
||||
upstream = delta.upstream or upstream
|
||||
yield "delta", {"text": delta.text}
|
||||
except ai_svc.AIError as e:
|
||||
yield "error", {"message": str(e)}
|
||||
return
|
||||
yield "done", {"upstream": upstream}
|
||||
|
||||
|
||||
def _delete_insight(user_id, kind, subject):
|
||||
execute(
|
||||
"DELETE FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
|
||||
[user_id, kind, subject],
|
||||
)
|
||||
|
||||
|
||||
def clear_insight_cache(user_id, kind=None):
|
||||
if kind:
|
||||
execute(
|
||||
"DELETE FROM ai_insights WHERE user_id = ? AND kind = ?", [user_id, kind]
|
||||
)
|
||||
else:
|
||||
execute("DELETE FROM ai_insights WHERE user_id = ?", [user_id])
|
||||
|
||||
409
backend/services/coach.py
Normal file
409
backend/services/coach.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
The AI coach: morning briefing, trend attribution, and the Copilot chat.
|
||||
|
||||
Division of labour with `insights.py`: every number quoted here was already
|
||||
computed there. This module only turns a structured context into a prompt and
|
||||
turns the reply back into a structured answer. Nothing asks the model to do
|
||||
arithmetic, because a model asked to derive a z-score from a CSV gets it wrong
|
||||
often enough that the briefing would quote figures the charts contradict.
|
||||
|
||||
Each feature has a rule-based counterpart. A model round-trip through the
|
||||
gateway costs minutes (its primary upstream is a large reasoning model), and a
|
||||
health screen that shows nothing when an upstream is rate-limited is worse than
|
||||
one that shows a plainer answer — so `meta.source` says which one answered
|
||||
rather than the failure being invisible.
|
||||
"""
|
||||
import json
|
||||
|
||||
from services import ai as ai_svc
|
||||
from services import insights
|
||||
|
||||
SYSTEM = """# 角色
|
||||
你是一名资深运动生理学专家与佳明(Garmin)数据分析教练。你解读用户的可穿戴设备
|
||||
数据,输出严谨、精炼、无废话的生理状态解读与行动指导。
|
||||
|
||||
# 生理学原则
|
||||
1. 训练准备度综合睡眠分数、HRV 状态、恢复时间、急性负荷与压力历史。
|
||||
2. HRV 反映副交感神经活跃度;HRV 高且静息心率低通常代表恢复良好。
|
||||
3. 身体电量的充电量受睡眠质量与深睡/REM 比例影响:深睡负责肌肉与体力恢复,
|
||||
REM 负责认知与精神修复。
|
||||
4. 强度分钟与运动记录代表急性负荷;负荷骤增后 HRV 短暂下降属正常应激反应。
|
||||
|
||||
# 数据纪律
|
||||
- 只使用输入 JSON 中出现的数值,禁止编造或估算任何未给出的数字。
|
||||
- 字段为 null 表示该项未采集,要么略过,要么明确说明"未采集",不要当作 0。
|
||||
- z 值(z)是该指标相对用户自身近 28 天基线的偏离程度,已经算好,直接引用即可,
|
||||
不要自行重算。|z| < 1 属正常波动,不要渲染成异常。
|
||||
- 你不是医生,不做医疗诊断;只从运动恢复、疲劳管理与作息角度给建议。发现明显
|
||||
异常时提示用户咨询专业医师。
|
||||
|
||||
# 输出
|
||||
- 简体中文。
|
||||
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
|
||||
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
|
||||
JSON 之外的任何文字都会被丢弃。"""
|
||||
|
||||
BRIEFING_SCHEMA = """{
|
||||
"status": "对整体恢复状态的定性,不超过 8 字,例如 '恢复良好' / '中等偏上' / '疲劳累积'",
|
||||
"headline": "一句话总结今日身体状态,不超过 40 字",
|
||||
"diagnosis": [
|
||||
{"title": "维度名,如 睡眠结构 / 自主神经 / 电量与就绪度", "detail": "该维度的判断与依据,引用具体数值,不超过 60 字"}
|
||||
],
|
||||
"shortfall": "今日最主要的短板,一句话;若无明显短板则写 '无明显短板'",
|
||||
"prescription": {
|
||||
"intensity": "今日运动强度上限,如 低 / 中等 / 中等偏高 / 高",
|
||||
"hrZone": "建议心率区间,如 'Zone 2~Zone 3';无法判断填 null",
|
||||
"suggestion": "具体运动处方,含项目与时长,不超过 40 字",
|
||||
"durationMin": 建议时长的分钟数(整数)或 null,
|
||||
"avoid": "今日应避免的内容,不超过 20 字;无则填 null"
|
||||
},
|
||||
"actions": ["今日可执行的具体行动,2~4 条,每条不超过 30 字"]
|
||||
}"""
|
||||
|
||||
TREND_SCHEMA = """{
|
||||
"summary": "这段区间内该指标发生了什么,一句话,不超过 50 字",
|
||||
"drivers": [
|
||||
{"factor": "关联因素名", "detail": "它与该指标的关系及依据,引用数值,不超过 60 字"}
|
||||
],
|
||||
"caution": "需要留意的风险或误读;没有则填 null",
|
||||
"confidence": "high|medium|low —— 取决于样本量与关联证据强度"
|
||||
}"""
|
||||
|
||||
|
||||
def _payload(context):
|
||||
"""The context as compact JSON.
|
||||
|
||||
`ensure_ascii=False` matters for size as much as readability: escaping
|
||||
Chinese labels to \\uXXXX roughly triples their token cost.
|
||||
"""
|
||||
return json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def briefing_messages(context):
|
||||
return [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"以下是我的健康数据快照。deviations 中的 z 值是相对我自身近 28 天\n"
|
||||
"基线的偏离,trends 是长周期走势,activityShift 是近 7 天与之前的\n"
|
||||
"活动量对比。\n\n"
|
||||
f"```json\n{_payload(context)}\n```\n\n"
|
||||
"请给出今日晨间简报与运动处方,严格按以下 JSON 结构输出:\n\n"
|
||||
f"{BRIEFING_SCHEMA}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def trend_messages(window):
|
||||
return [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"以下是我 {window['label']} 指标在 {window['start']} ~ {window['end']}\n"
|
||||
"区间的数据,companions 是同区间内其它指标的均值,activities 是该区间\n"
|
||||
"内的运动记录,baselineBefore 是该区间之前的基线。\n\n"
|
||||
f"```json\n{_payload(window)}\n```\n\n"
|
||||
"请解释这段区间内该指标的变化及其可能的驱动因素,严格按以下 JSON\n"
|
||||
f"结构输出:\n\n{TREND_SCHEMA}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
COPILOT_SYSTEM = SYSTEM.replace(
|
||||
"""# 输出
|
||||
- 简体中文。
|
||||
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
|
||||
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
|
||||
JSON 之外的任何文字都会被丢弃。""",
|
||||
"""# 输出
|
||||
- 简体中文,Markdown 格式。
|
||||
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
|
||||
- 控制在 300 字以内,先给结论再给依据。
|
||||
- 引用数值时写明是哪一天或哪个区间的值。
|
||||
- 问题超出所给数据能回答的范围时,直接说明数据里没有,不要猜。""",
|
||||
)
|
||||
|
||||
|
||||
def copilot_messages(context, history, question):
|
||||
"""Chat turns for the Copilot.
|
||||
|
||||
The health context rides in the system turn rather than being prepended to
|
||||
the user's question: it stays out of the visible transcript, and the same
|
||||
snapshot governs every turn instead of being re-sent (and re-charged) with
|
||||
each follow-up.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": COPILOT_SYSTEM},
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"以下是提问者的健康数据快照,回答时以它为唯一事实来源:\n"
|
||||
f"```json\n{_payload(context)}\n```"
|
||||
),
|
||||
},
|
||||
]
|
||||
# Filtered first, then capped: capping first lets a single unusable entry
|
||||
# in the tail — a tool frame, an empty message — silently cost the model a
|
||||
# remembered turn.
|
||||
usable = [
|
||||
{"role": t["role"], "content": (t.get("content") or "").strip()[:2000]}
|
||||
for t in history
|
||||
if t.get("role") in ("user", "assistant") and (t.get("content") or "").strip()
|
||||
]
|
||||
messages.extend(usable[-8:])
|
||||
messages.append({"role": "user", "content": question[:2000]})
|
||||
return messages
|
||||
|
||||
|
||||
# --- reply validation -------------------------------------------------------
|
||||
def _text(value, limit):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:limit] if text else None
|
||||
|
||||
|
||||
def parse_briefing(reply):
|
||||
data = ai_svc.extract_json(reply)
|
||||
if not isinstance(data, dict):
|
||||
raise ai_svc.AIError("模型未返回 JSON 对象")
|
||||
|
||||
prescription = data.get("prescription")
|
||||
if not isinstance(prescription, dict):
|
||||
prescription = {}
|
||||
|
||||
duration = prescription.get("durationMin")
|
||||
try:
|
||||
duration = int(duration) if duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
|
||||
diagnosis = []
|
||||
for item in data.get("diagnosis") or []:
|
||||
if isinstance(item, dict):
|
||||
title = _text(item.get("title"), 20)
|
||||
detail = _text(item.get("detail"), 200)
|
||||
else:
|
||||
title, detail = None, _text(item, 200)
|
||||
if detail:
|
||||
diagnosis.append({"title": title or "综合", "detail": detail})
|
||||
|
||||
actions = [
|
||||
_text(a, 60) for a in (data.get("actions") or []) if _text(a, 60)
|
||||
]
|
||||
|
||||
out = {
|
||||
"status": _text(data.get("status"), 20) or "状态未定性",
|
||||
"headline": _text(data.get("headline"), 120),
|
||||
"diagnosis": diagnosis[:5],
|
||||
"shortfall": _text(data.get("shortfall"), 120),
|
||||
"prescription": {
|
||||
"intensity": _text(prescription.get("intensity"), 20),
|
||||
"hrZone": _text(prescription.get("hrZone"), 40),
|
||||
"suggestion": _text(prescription.get("suggestion"), 120),
|
||||
"durationMin": duration,
|
||||
"avoid": _text(prescription.get("avoid"), 60),
|
||||
},
|
||||
"actions": actions[:4],
|
||||
}
|
||||
# A briefing with neither a headline nor any diagnosis is an empty card;
|
||||
# rejecting it here lets the caller fall back to the rule engine instead
|
||||
# of rendering blank space.
|
||||
if not out["headline"] and not out["diagnosis"]:
|
||||
raise ai_svc.AIError("模型返回的简报没有可用内容")
|
||||
return out
|
||||
|
||||
|
||||
def parse_trend_insight(reply):
|
||||
data = ai_svc.extract_json(reply)
|
||||
if not isinstance(data, dict):
|
||||
raise ai_svc.AIError("模型未返回 JSON 对象")
|
||||
|
||||
drivers = []
|
||||
for item in data.get("drivers") or []:
|
||||
if isinstance(item, dict):
|
||||
factor = _text(item.get("factor"), 30)
|
||||
detail = _text(item.get("detail"), 200)
|
||||
else:
|
||||
factor, detail = None, _text(item, 200)
|
||||
if detail:
|
||||
drivers.append({"factor": factor or "关联因素", "detail": detail})
|
||||
|
||||
confidence = str(data.get("confidence", "medium")).lower()
|
||||
if confidence not in ("high", "medium", "low"):
|
||||
confidence = "medium"
|
||||
|
||||
summary = _text(data.get("summary"), 200)
|
||||
if not summary and not drivers:
|
||||
raise ai_svc.AIError("模型返回的归因没有可用内容")
|
||||
return {
|
||||
"summary": summary,
|
||||
"drivers": drivers[:5],
|
||||
"caution": _text(data.get("caution"), 200),
|
||||
"confidence": confidence,
|
||||
}
|
||||
|
||||
|
||||
# --- rule-based counterparts ------------------------------------------------
|
||||
def rule_briefing(context):
|
||||
"""A briefing assembled from the computed features alone.
|
||||
|
||||
Deliberately quotes the same numbers the AI version would, so a fallback
|
||||
reads as a plainer answer rather than a different one.
|
||||
"""
|
||||
today = context["todayMetrics"]
|
||||
sleep = today["sleep"] or {}
|
||||
nervous = today["autonomicNervous"]
|
||||
recovery = today["recovery"]
|
||||
activity = today["activityToday"]
|
||||
by_metric = {d["metric"]: d for d in context["deviations"]}
|
||||
|
||||
diagnosis = []
|
||||
concerns = []
|
||||
|
||||
duration = sleep.get("durationHours")
|
||||
if duration is not None:
|
||||
target = sleep.get("targetHours") or insights.SLEEP_TARGET_HOURS
|
||||
parts = [f"睡眠 {duration:.1f} 小时(目标 {target:g})"]
|
||||
rem = sleep.get("remPercent")
|
||||
if rem is not None:
|
||||
low, high = insights.REM_REFERENCE_PCT
|
||||
parts.append(f"REM {rem:g}%{'(偏低)' if rem < low else ''}")
|
||||
deep = sleep.get("deepPercent")
|
||||
if deep is not None:
|
||||
low, _ = insights.DEEP_REFERENCE_PCT
|
||||
parts.append(f"深睡 {deep:g}%{'(偏低)' if deep < low else '(达标)'}")
|
||||
diagnosis.append({"title": "睡眠结构", "detail": ",".join(parts) + "。"})
|
||||
if duration < target:
|
||||
concerns.append(f"睡眠比目标少 {target - duration:.1f} 小时")
|
||||
|
||||
hrv, rhr = nervous.get("hrvMs"), nervous.get("restingHr")
|
||||
if hrv is not None or rhr is not None:
|
||||
parts = []
|
||||
if hrv is not None:
|
||||
base = by_metric.get("heartRateVariability", {}).get("baselineMean")
|
||||
parts.append(
|
||||
f"HRV {hrv:g} ms" + (f"(基线 {base:g})" if base is not None else "")
|
||||
)
|
||||
if rhr is not None:
|
||||
base = by_metric.get("heartRate", {}).get("baselineMean")
|
||||
parts.append(
|
||||
f"静息心率 {rhr:g} bpm" + (f"(基线 {base:g})" if base is not None else "")
|
||||
)
|
||||
diagnosis.append({"title": "自主神经", "detail": ",".join(parts) + "。"})
|
||||
|
||||
readiness = recovery.get("trainingReadiness")
|
||||
battery = recovery.get("bodyBatteryPeak")
|
||||
if readiness is not None or battery is not None:
|
||||
parts = []
|
||||
if readiness is not None:
|
||||
parts.append(f"训练准备度 {readiness:g}/100")
|
||||
if battery is not None:
|
||||
parts.append(f"身体电量充至 {battery:g}")
|
||||
diagnosis.append({"title": "恢复与就绪度", "detail": ",".join(parts) + "。"})
|
||||
|
||||
# Readiness is Garmin's own composite of sleep, HRV, recovery time and
|
||||
# acute load, so it drives the prescription wherever it exists; the
|
||||
# sleep/HRV fallback below is only for watches that do not report it.
|
||||
if readiness is not None:
|
||||
if readiness >= 75:
|
||||
intensity, zone, suggestion = "高", "Zone 3~Zone 4", "可安排高强度或长时间训练"
|
||||
elif readiness >= 50:
|
||||
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
|
||||
else:
|
||||
intensity, zone, suggestion = "低", "Zone 1~Zone 2", "以走路或拉伸为主,优先恢复"
|
||||
elif duration is not None and duration < (sleep.get("targetHours") or 7):
|
||||
intensity, zone, suggestion = "中等偏低", "Zone 2", "30 分钟低强度有氧,避免加练"
|
||||
else:
|
||||
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
|
||||
|
||||
actions = []
|
||||
steps, goal = activity.get("steps"), activity.get("stepGoal")
|
||||
if steps is not None and goal and steps < goal:
|
||||
actions.append(f"步数 {steps:,} / 目标 {goal:,},补一段快走")
|
||||
elif steps is not None and steps < 6000:
|
||||
actions.append(f"今日步数 {steps:,},偏低,安排一次散步")
|
||||
if concerns:
|
||||
actions.append("提前 30 分钟入睡,补回睡眠缺口")
|
||||
sedentary = activity.get("sedentaryHours")
|
||||
if sedentary and sedentary >= 8:
|
||||
actions.append(f"久坐 {sedentary:g} 小时,每小时起身活动 3 分钟")
|
||||
shift = context.get("activityShift", {}).get("steps")
|
||||
if shift and shift.get("changePct") is not None and shift["changePct"] <= -20:
|
||||
actions.append(f"近 7 天步数较此前下降 {abs(shift['changePct']):g}%,注意活动量")
|
||||
if not actions:
|
||||
actions.append("各项指标处于常态,保持当前作息与训练安排")
|
||||
|
||||
notable = [
|
||||
d for d in context["deviations"]
|
||||
if d.get("z") is not None and abs(d["z"]) >= insights.Z_NOTABLE
|
||||
]
|
||||
if notable:
|
||||
top = notable[0]
|
||||
status = "存在偏离"
|
||||
headline = (
|
||||
f"{top['label']} {top['value']:g}{top['unit']},"
|
||||
f"偏离近 {top['baselineDays']} 天基线 {abs(top['z']):.1f} 个标准差。"
|
||||
)
|
||||
else:
|
||||
status = "状态平稳"
|
||||
headline = "各项指标均在个人基线的正常波动范围内。"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"headline": headline,
|
||||
"diagnosis": diagnosis,
|
||||
"shortfall": ";".join(concerns) if concerns else "无明显短板",
|
||||
"prescription": {
|
||||
"intensity": intensity,
|
||||
"hrZone": zone,
|
||||
"suggestion": suggestion,
|
||||
"durationMin": None,
|
||||
"avoid": None,
|
||||
},
|
||||
"actions": actions[:4],
|
||||
}
|
||||
|
||||
|
||||
def rule_trend_insight(window):
|
||||
"""Trend attribution without a model: direction, size, and co-movement."""
|
||||
slope = window.get("slopePer30d")
|
||||
label, unit = window["label"], window["unit"]
|
||||
if slope is None:
|
||||
summary = f"{window['start']} ~ {window['end']} 区间内 {label} 样本不足,无法判断趋势。"
|
||||
else:
|
||||
direction = "上升" if slope > 0 else ("下降" if slope < 0 else "基本持平")
|
||||
summary = (
|
||||
f"{label} 在该区间{direction},拟合斜率约 {slope:g}{unit}/30 天,"
|
||||
f"均值 {window['mean']:g}{unit}。"
|
||||
)
|
||||
|
||||
drivers = []
|
||||
baseline = window.get("baselineBefore")
|
||||
if baseline and window.get("mean") is not None:
|
||||
delta = window["mean"] - baseline["mean"]
|
||||
drivers.append({
|
||||
"factor": "区间前基线",
|
||||
"detail": (
|
||||
f"区间前 {baseline['days']} 天均值 {baseline['mean']:g}{unit},"
|
||||
f"区间内{'高出' if delta >= 0 else '低于'} {abs(delta):.2f}{unit}。"
|
||||
),
|
||||
})
|
||||
activities = window.get("activities") or []
|
||||
if activities:
|
||||
minutes = sum(a.get("durationMin") or 0 for a in activities)
|
||||
drivers.append({
|
||||
"factor": "运动负荷",
|
||||
"detail": f"该区间共 {len(activities)} 次运动,合计约 {minutes} 分钟。",
|
||||
})
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"drivers": drivers,
|
||||
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
|
||||
"confidence": "low",
|
||||
}
|
||||
461
backend/services/insights.py
Normal file
461
backend/services/insights.py
Normal file
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Feature engineering for the AI coach.
|
||||
|
||||
Everything here is arithmetic over stored health data — no model calls. The
|
||||
split is deliberate: the numbers a briefing quotes (z-scores, baselines,
|
||||
trend slopes) must be reproducible and testable, and an LLM asked to compute
|
||||
them from a raw CSV gets them wrong often enough to matter. The model's job
|
||||
is to interpret figures that were already computed here, not to derive them.
|
||||
|
||||
Two windows are used throughout:
|
||||
|
||||
* **baseline** (default 28 days) — what "normal for this person, lately"
|
||||
means. Short enough to track a training block, long enough for a standard
|
||||
deviation to be worth quoting.
|
||||
* **trend** (default 395 days ≈ 13 months) — the long arc the product spec
|
||||
asks about, and long enough to contain a full season.
|
||||
"""
|
||||
import datetime
|
||||
import statistics
|
||||
|
||||
from services import health
|
||||
from services import settings as settings_svc
|
||||
from services import fitness_age
|
||||
|
||||
BASELINE_DAYS = 28
|
||||
TREND_DAYS = 395
|
||||
|
||||
# Sleep targets are personal, but Garmin's own coaching and the ACSM/AASM
|
||||
# adult guidance both land on 7 hours as the floor; the deep/REM shares are
|
||||
# the conventional adult reference bands.
|
||||
SLEEP_TARGET_HOURS = 7.0
|
||||
REM_REFERENCE_PCT = (20.0, 25.0)
|
||||
DEEP_REFERENCE_PCT = (13.0, 23.0)
|
||||
|
||||
# |z| beyond this counts as a departure from the personal baseline rather
|
||||
# than day-to-day noise. 1.0 rather than the textbook 2.0: with a 28-day
|
||||
# window a 2-sigma day is roughly a once-a-month event, which is too rare to
|
||||
# drive a daily briefing.
|
||||
Z_NOTABLE = 1.0
|
||||
|
||||
|
||||
def _flatten(day):
|
||||
"""One day as a flat metric -> value mapping.
|
||||
|
||||
`get_summary` nests sleep and omits missing metrics entirely; both are
|
||||
inconvenient for statistics, so sleep is lifted to the top level and
|
||||
derived shares (deep/REM percent) are computed once here.
|
||||
"""
|
||||
flat = {k: v for k, v in day.items() if k != "sleep"}
|
||||
sleep = day.get("sleep") or {}
|
||||
duration = sleep.get("duration") or day.get("sleepDuration")
|
||||
if duration:
|
||||
flat["sleepDuration"] = duration
|
||||
seconds = duration * 3600.0
|
||||
for src, dest in (
|
||||
("deepSeconds", "sleepDeepPct"),
|
||||
("remSeconds", "sleepRemPct"),
|
||||
("lightSeconds", "sleepLightPct"),
|
||||
("awakeSeconds", "sleepAwakePct"),
|
||||
):
|
||||
value = sleep.get(src)
|
||||
if value is not None and seconds > 0:
|
||||
flat[dest] = round(value / seconds * 100, 1)
|
||||
if sleep.get("quality") is not None:
|
||||
flat["sleepQuality"] = sleep["quality"]
|
||||
sedentary = day.get("sedentarySeconds")
|
||||
if sedentary is not None:
|
||||
flat["sedentaryHours"] = round(sedentary / 3600.0, 1)
|
||||
return flat
|
||||
|
||||
|
||||
# Metrics the briefing reasons about. `higher_better` drives the plain-language
|
||||
# verdict; None means the direction is not meaningful on its own (steps on a
|
||||
# rest day are not a failure).
|
||||
METRICS = {
|
||||
"sleepDuration": ("睡眠时长", "小时", True),
|
||||
"sleepQuality": ("睡眠评分", "分", True),
|
||||
"sleepDeepPct": ("深睡占比", "%", True),
|
||||
"sleepRemPct": ("REM 占比", "%", True),
|
||||
"heartRate": ("静息心率", "bpm", False),
|
||||
"heartRateVariability": ("HRV", "ms", True),
|
||||
"stress": ("压力均值", "", False),
|
||||
"bodyBatteryHigh": ("身体电量峰值", "", True),
|
||||
"bodyBatteryLow": ("身体电量谷值", "", True),
|
||||
"trainingReadiness": ("训练准备度", "分", True),
|
||||
"enduranceScore": ("耐力分", "分", True),
|
||||
"vo2max": ("最大摄氧量", "ml/kg/min", True),
|
||||
"steps": ("步数", "步", None),
|
||||
"intensityMinutes": ("强度分钟", "分钟", None),
|
||||
"respirationAvg": ("呼吸频率", "次/分", None),
|
||||
"spo2Avg": ("血氧", "%", True),
|
||||
}
|
||||
|
||||
|
||||
def _series(rows, metric):
|
||||
"""(date, value) pairs where the metric was actually recorded."""
|
||||
return [(r["date"], r[metric]) for r in rows if r.get(metric) is not None]
|
||||
|
||||
|
||||
def _stats(values):
|
||||
if not values:
|
||||
return None
|
||||
mean = statistics.fmean(values)
|
||||
# pstdev, not stdev: these are all the observations in the window, not a
|
||||
# sample drawn from it, and stdev raises on a single point.
|
||||
sd = statistics.pstdev(values) if len(values) > 1 else 0.0
|
||||
return {"mean": mean, "sd": sd, "n": len(values)}
|
||||
|
||||
|
||||
def _verdict(z, higher_better):
|
||||
if higher_better is None or abs(z) < Z_NOTABLE:
|
||||
return "正常"
|
||||
if (z > 0) == bool(higher_better):
|
||||
return "偏好"
|
||||
return "偏差"
|
||||
|
||||
|
||||
def deviations(rows, today, baseline_days=BASELINE_DAYS):
|
||||
"""How far each of today's metrics sits from its own recent baseline.
|
||||
|
||||
The baseline deliberately excludes today: comparing a value against a mean
|
||||
it helped produce shrinks its own z-score, and with a 28-day window that
|
||||
bias is large enough to hide a genuine outlier.
|
||||
"""
|
||||
history = [r for r in rows if r["date"] < today.get("date", "")]
|
||||
window = history[-baseline_days:]
|
||||
out = []
|
||||
for metric, (label, unit, higher_better) in METRICS.items():
|
||||
value = today.get(metric)
|
||||
if value is None:
|
||||
continue
|
||||
values = [v for _, v in _series(window, metric)]
|
||||
stats = _stats(values)
|
||||
if not stats or stats["n"] < 5:
|
||||
# Too little history for a standard deviation to mean anything.
|
||||
out.append({
|
||||
"metric": metric, "label": label, "unit": unit,
|
||||
"value": round(float(value), 2), "baselineMean": None,
|
||||
"sd": None, "z": None, "verdict": "基线不足",
|
||||
})
|
||||
continue
|
||||
sd = stats["sd"]
|
||||
if sd > 0:
|
||||
z = round((float(value) - stats["mean"]) / sd, 2)
|
||||
verdict = _verdict(z, higher_better)
|
||||
else:
|
||||
# A baseline with no spread cannot scale a departure. Reporting
|
||||
# z = 0 here would label a value that differs from every single
|
||||
# observation as perfectly typical, which is the opposite of true.
|
||||
z = None
|
||||
verdict = "正常" if float(value) == stats["mean"] else "基线无波动"
|
||||
out.append({
|
||||
"metric": metric, "label": label, "unit": unit,
|
||||
"value": round(float(value), 2),
|
||||
"baselineMean": round(stats["mean"], 2),
|
||||
"sd": round(sd, 2),
|
||||
"baselineDays": stats["n"],
|
||||
"z": z,
|
||||
"verdict": verdict,
|
||||
})
|
||||
# Biggest departures first: that ordering is what the prompt relies on to
|
||||
# keep the interesting metrics inside the model's attention span.
|
||||
out.sort(key=lambda d: abs(d["z"]) if d["z"] is not None else -1, reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _slope_per_30d(points):
|
||||
"""Least-squares slope in units per 30 days.
|
||||
|
||||
Ordinal dates rather than array indices: gaps in the record (a watch left
|
||||
on the charger for a week) would otherwise compress the x-axis and inflate
|
||||
the slope.
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return None
|
||||
xs = [datetime.date.fromisoformat(d).toordinal() for d, _ in points]
|
||||
ys = [float(v) for _, v in points]
|
||||
mx, my = statistics.fmean(xs), statistics.fmean(ys)
|
||||
denom = sum((x - mx) ** 2 for x in xs)
|
||||
if denom == 0:
|
||||
return None
|
||||
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom
|
||||
return round(slope * 30, 3)
|
||||
|
||||
|
||||
def trends(rows, window_days=TREND_DAYS, edge=30):
|
||||
"""Long-arc movement per metric: endpoint means plus a fitted slope.
|
||||
|
||||
Endpoint means (first `edge` days vs last `edge` days) answer "where did
|
||||
this end up"; the slope answers "was it a trend or two different plateaus".
|
||||
Reporting only one of them has misled us before — a metric can finish
|
||||
higher after months of decline if it spikes in the final week.
|
||||
"""
|
||||
if not rows:
|
||||
return []
|
||||
cutoff = (
|
||||
datetime.date.fromisoformat(rows[-1]["date"])
|
||||
- datetime.timedelta(days=window_days)
|
||||
).isoformat()
|
||||
window = [r for r in rows if r["date"] >= cutoff]
|
||||
|
||||
out = []
|
||||
for metric, (label, unit, higher_better) in METRICS.items():
|
||||
points = _series(window, metric)
|
||||
if len(points) < 10:
|
||||
continue
|
||||
# With fewer than two edges' worth of points the two windows would
|
||||
# overlap and both converge on the overall mean — reporting delta 0
|
||||
# for a series that visibly moved. Split it in half instead.
|
||||
span = min(edge, len(points) // 2)
|
||||
head = [v for _, v in points[:span]]
|
||||
tail = [v for _, v in points[-span:]]
|
||||
first, last = statistics.fmean(head), statistics.fmean(tail)
|
||||
delta = last - first
|
||||
entry = {
|
||||
"metric": metric, "label": label, "unit": unit,
|
||||
"days": (
|
||||
datetime.date.fromisoformat(points[-1][0])
|
||||
- datetime.date.fromisoformat(points[0][0])
|
||||
).days,
|
||||
"samples": len(points),
|
||||
"firstMean": round(first, 2),
|
||||
"lastMean": round(last, 2),
|
||||
"delta": round(delta, 2),
|
||||
"slopePer30d": _slope_per_30d(points),
|
||||
}
|
||||
if higher_better is not None and abs(delta) > 0:
|
||||
entry["direction"] = "改善" if (delta > 0) == bool(higher_better) else "退步"
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def activity_shift(rows, recent=7, prior=30):
|
||||
"""Recent activity volume against the weeks before it.
|
||||
|
||||
Separate from `deviations` because the question is different: not "is today
|
||||
unusual" but "has the last week as a whole dropped off" — the drop that a
|
||||
single quiet day cannot show.
|
||||
"""
|
||||
out = {}
|
||||
for metric in ("steps", "intensityMinutes", "sleepDuration", "bodyBatteryHigh"):
|
||||
points = _series(rows, metric)
|
||||
if len(points) < recent + 5:
|
||||
continue
|
||||
recent_values = [v for _, v in points[-recent:]]
|
||||
prior_values = [v for _, v in points[-(recent + prior):-recent]]
|
||||
if not prior_values:
|
||||
continue
|
||||
r_mean, p_mean = statistics.fmean(recent_values), statistics.fmean(prior_values)
|
||||
out[metric] = {
|
||||
"label": METRICS[metric][0],
|
||||
"recentMean": round(r_mean, 2),
|
||||
"priorMean": round(p_mean, 2),
|
||||
"changePct": round((r_mean - p_mean) / p_mean * 100, 1) if p_mean else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _sleep_block(today):
|
||||
duration = today.get("sleepDuration")
|
||||
if duration is None:
|
||||
return None
|
||||
block = {
|
||||
"durationHours": round(float(duration), 2),
|
||||
"targetHours": SLEEP_TARGET_HOURS,
|
||||
"score": today.get("sleepQuality"),
|
||||
"deepPercent": today.get("sleepDeepPct"),
|
||||
"remPercent": today.get("sleepRemPct"),
|
||||
"lightPercent": today.get("sleepLightPct"),
|
||||
"awakePercent": today.get("sleepAwakePct"),
|
||||
"remReference": list(REM_REFERENCE_PCT),
|
||||
"deepReference": list(DEEP_REFERENCE_PCT),
|
||||
}
|
||||
return block
|
||||
|
||||
|
||||
def latest_of(rows, metric, within=180):
|
||||
"""Most recent recorded value, for metrics that only refresh occasionally.
|
||||
|
||||
VO2max and endurance score update after a qualifying outdoor session, so
|
||||
reading them off "today" yields None on any indoor or rest day even though
|
||||
the last measured value is still the current one.
|
||||
"""
|
||||
for row in reversed(rows[-within:] if within else rows):
|
||||
if row.get(metric) is not None:
|
||||
return row[metric]
|
||||
return None
|
||||
|
||||
|
||||
def build_context(user_id, date=None, rows=None):
|
||||
"""The structured payload every coach prompt is assembled from.
|
||||
|
||||
`date` selects the snapshot day; the default is the newest day on record
|
||||
rather than the calendar date, because a sync may not have run yet today
|
||||
and an empty snapshot produces a briefing about nothing.
|
||||
"""
|
||||
rows = rows if rows is not None else health.get_summary(user_id)
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
flat = [_flatten(r) for r in rows]
|
||||
if date:
|
||||
matches = [r for r in flat if r["date"] == date]
|
||||
if not matches:
|
||||
return None
|
||||
today = matches[0]
|
||||
history = [r for r in flat if r["date"] <= date]
|
||||
else:
|
||||
today = flat[-1]
|
||||
history = flat
|
||||
|
||||
profile = settings_svc.get_raw(user_id)
|
||||
age = settings_svc.age_from(profile["birth_date"])
|
||||
bmi = settings_svc.bmi_from(profile["height_cm"], profile["weight_kg"])
|
||||
vo2max = latest_of(history, "vo2max")
|
||||
body_age = fitness_age.estimate(
|
||||
age=age, sex=profile["sex"], vo2max=vo2max,
|
||||
resting_hr=latest_of(history, "heartRate", within=30), bmi=bmi,
|
||||
)
|
||||
|
||||
start = (
|
||||
datetime.date.fromisoformat(today["date"]) - datetime.timedelta(days=14)
|
||||
).isoformat()
|
||||
recent_activities = health.get_activities(user_id, start, today["date"])
|
||||
|
||||
sedentary = today.get("sedentaryHours")
|
||||
return {
|
||||
"snapshotDate": today["date"],
|
||||
"userProfile": {
|
||||
"age": age,
|
||||
"sex": profile["sex"],
|
||||
# `estimate` returns {"value": None, "missing": [...]} when the
|
||||
# profile is incomplete, so this is None rather than a number
|
||||
# until a birth date, sex and a VO2max reading all exist.
|
||||
"fitnessAge": (body_age or {}).get("value"),
|
||||
"vo2max": vo2max,
|
||||
"enduranceScore": latest_of(history, "enduranceScore"),
|
||||
"heightCm": profile["height_cm"],
|
||||
"weightKg": profile["weight_kg"],
|
||||
"bmi": bmi,
|
||||
},
|
||||
"todayMetrics": {
|
||||
"sleep": _sleep_block(today),
|
||||
"autonomicNervous": {
|
||||
"restingHr": today.get("heartRate"),
|
||||
"hrvMs": today.get("heartRateVariability"),
|
||||
"stressAvg": today.get("stress"),
|
||||
"stressMax": today.get("stressMax"),
|
||||
"respirationAvg": today.get("respirationAvg"),
|
||||
"spo2Avg": today.get("spo2Avg"),
|
||||
},
|
||||
"recovery": {
|
||||
"bodyBatteryPeak": today.get("bodyBatteryHigh"),
|
||||
"bodyBatteryLow": today.get("bodyBatteryLow"),
|
||||
"bodyBatteryCharged": today.get("bodyBatteryCharged"),
|
||||
"bodyBatteryDrained": today.get("bodyBatteryDrained"),
|
||||
"trainingReadiness": today.get("trainingReadiness"),
|
||||
},
|
||||
"activityToday": {
|
||||
"steps": today.get("steps"),
|
||||
"stepGoal": today.get("stepGoal"),
|
||||
"intensityMinutes": today.get("intensityMinutes"),
|
||||
"sedentaryHours": sedentary,
|
||||
"floorsAscended": today.get("floorsAscended"),
|
||||
"caloriesBurned": today.get("caloriesBurned"),
|
||||
"activeCalories": today.get("activeCalories"),
|
||||
},
|
||||
},
|
||||
"deviations": deviations(history, today),
|
||||
"trends": trends(history),
|
||||
"activityShift": activity_shift(history),
|
||||
"recentActivities": [
|
||||
{
|
||||
"date": a.get("start_time"),
|
||||
"sport": a.get("activity_type"),
|
||||
"durationMin": round((a.get("duration") or 0) / 60) or None,
|
||||
"distanceKm": (
|
||||
round(a["distance"] / 1000, 2) if a.get("distance") else None
|
||||
),
|
||||
"calories": a.get("calories"),
|
||||
"avgHr": a.get("heart_rate_average"),
|
||||
"maxHr": a.get("heart_rate_max"),
|
||||
}
|
||||
for a in recent_activities[-15:]
|
||||
],
|
||||
"dataQuality": {
|
||||
"totalDays": len(history),
|
||||
"firstDate": history[0]["date"],
|
||||
"lastDate": history[-1]["date"],
|
||||
"staleDays": (
|
||||
datetime.date.today()
|
||||
- datetime.date.fromisoformat(history[-1]["date"])
|
||||
).days,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def window_context(user_id, metric, start, end, rows=None):
|
||||
"""Context for one metric over a user-selected span (chart brush).
|
||||
|
||||
Narrower than `build_context` on purpose: the question being answered is
|
||||
"what happened to this line here", so the payload carries the selected
|
||||
series plus whatever else moved alongside it in the same window.
|
||||
"""
|
||||
# An unknown metric would otherwise produce a well-formed window with an
|
||||
# empty series, and the model would dutifully write an attribution for a
|
||||
# line that does not exist.
|
||||
if metric not in METRICS:
|
||||
return None
|
||||
|
||||
rows = rows if rows is not None else health.get_summary(user_id)
|
||||
flat = [_flatten(r) for r in rows]
|
||||
window = [r for r in flat if start <= r["date"] <= end]
|
||||
if not window:
|
||||
return None
|
||||
|
||||
label, unit, _ = METRICS[metric]
|
||||
points = _series(window, metric)
|
||||
before = [r for r in flat if r["date"] < start][-BASELINE_DAYS:]
|
||||
baseline = _stats([v for _, v in _series(before, metric)])
|
||||
|
||||
companions = {}
|
||||
for other in METRICS:
|
||||
if other == metric:
|
||||
continue
|
||||
values = [v for _, v in _series(window, other)]
|
||||
stats = _stats(values)
|
||||
if stats and stats["n"] >= 3:
|
||||
companions[other] = {
|
||||
"label": METRICS[other][0],
|
||||
"mean": round(stats["mean"], 2),
|
||||
"n": stats["n"],
|
||||
}
|
||||
|
||||
return {
|
||||
"metric": metric,
|
||||
"label": label,
|
||||
"unit": unit,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"points": [{"date": d, "value": v} for d, v in points],
|
||||
"mean": round(statistics.fmean([v for _, v in points]), 2) if points else None,
|
||||
"min": min((v for _, v in points), default=None),
|
||||
"max": max((v for _, v in points), default=None),
|
||||
"slopePer30d": _slope_per_30d(points),
|
||||
"baselineBefore": (
|
||||
{"mean": round(baseline["mean"], 2), "days": baseline["n"]}
|
||||
if baseline else None
|
||||
),
|
||||
"companions": companions,
|
||||
"activities": [
|
||||
{
|
||||
"date": a.get("start_time"),
|
||||
"sport": a.get("activity_type"),
|
||||
"durationMin": round((a.get("duration") or 0) / 60) or None,
|
||||
"calories": a.get("calories"),
|
||||
"avgHr": a.get("heart_rate_average"),
|
||||
}
|
||||
for a in health.get_activities(user_id, start, end)[:40]
|
||||
],
|
||||
}
|
||||
706
backend/tests/test_coach.py
Normal file
706
backend/tests/test_coach.py
Normal file
@@ -0,0 +1,706 @@
|
||||
"""
|
||||
Unit tests for the AI coach: feature engineering, prompt parsing, and the
|
||||
briefing / trend-insight / Copilot endpoints.
|
||||
|
||||
Every model call is mocked. The suite never reaches the ai-gateway, so it is
|
||||
neither slow nor dependent on that box being up.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services import ai as ai_svc
|
||||
from services import analysis as analysis_svc
|
||||
from services import coach
|
||||
from services import insights
|
||||
|
||||
|
||||
def day(date, **metrics):
|
||||
"""One row in the shape `health.get_summary` returns."""
|
||||
sleep = metrics.pop("sleep", None)
|
||||
row = {"date": date, **metrics}
|
||||
row["sleep"] = sleep
|
||||
return row
|
||||
|
||||
|
||||
def flat_days(n, start=1, **series):
|
||||
"""`n` consecutive days from 2026-08-01, each metric a constant or list."""
|
||||
rows = []
|
||||
for i in range(n):
|
||||
values = {}
|
||||
for key, value in series.items():
|
||||
values[key] = value[i] if isinstance(value, list) else value
|
||||
rows.append(day(f"2026-08-{start + i:02d}", **values))
|
||||
return rows
|
||||
|
||||
|
||||
# --- feature engineering ----------------------------------------------------
|
||||
class TestFlatten:
|
||||
def test_sleep_stages_become_percentages_of_time_asleep(self):
|
||||
row = day("2026-08-01", sleep={
|
||||
"duration": 8.0, "quality": 80, "deepSeconds": 3600,
|
||||
"remSeconds": 7200, "lightSeconds": None, "awakeSeconds": None,
|
||||
})
|
||||
flat = insights._flatten(row)
|
||||
assert flat["sleepDeepPct"] == 12.5
|
||||
assert flat["sleepRemPct"] == 25.0
|
||||
|
||||
def test_missing_stage_is_absent_not_zero(self):
|
||||
flat = insights._flatten(day("2026-08-01", sleep={"duration": 7.0}))
|
||||
assert "sleepDeepPct" not in flat
|
||||
|
||||
def test_no_sleep_record_leaves_no_sleep_fields(self):
|
||||
flat = insights._flatten(day("2026-08-01", steps=100))
|
||||
assert "sleepDuration" not in flat
|
||||
|
||||
def test_sedentary_seconds_become_hours(self):
|
||||
flat = insights._flatten(day("2026-08-01", sedentarySeconds=5400))
|
||||
assert flat["sedentaryHours"] == 1.5
|
||||
|
||||
|
||||
class TestDeviations:
|
||||
def test_z_score_measures_departure_from_the_personal_baseline(self):
|
||||
# A baseline that varies, as real data does: mean 1000, sd 100.
|
||||
baseline = [900, 1000, 1100, 900, 1000, 1100, 900, 1000, 1100, 1000]
|
||||
history = [
|
||||
insights._flatten(r) for r in flat_days(11, steps=baseline + [1800])
|
||||
]
|
||||
result = {d["metric"]: d for d in insights.deviations(history, history[-1])}
|
||||
assert result["steps"]["baselineMean"] == 1000
|
||||
assert result["steps"]["z"] > 3
|
||||
|
||||
def test_today_is_excluded_from_its_own_baseline(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
8, heartRate=[60, 60, 60, 60, 60, 60, 60, 70]
|
||||
)]
|
||||
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||
# Including today would pull the mean up to 61.25 and shrink the z.
|
||||
assert result["heartRate"]["baselineMean"] == 60
|
||||
|
||||
def test_too_little_history_reports_insufficient_baseline(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(3, steps=5000)]
|
||||
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||
assert result["steps"]["verdict"] == "基线不足"
|
||||
assert result["steps"]["z"] is None
|
||||
|
||||
def test_a_flat_baseline_reports_no_z_rather_than_a_fabricated_zero(self):
|
||||
"""Dividing by a zero standard deviation is undefined; calling the day
|
||||
'z = 0' would label a genuine departure as perfectly typical."""
|
||||
rows = [insights._flatten(r) for r in flat_days(8, steps=[5000] * 7 + [9000])]
|
||||
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||
assert result["steps"]["z"] is None
|
||||
assert result["steps"]["verdict"] == "基线无波动"
|
||||
|
||||
def test_a_flat_baseline_matched_exactly_is_just_normal(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(8, steps=5000)]
|
||||
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||
assert result["steps"]["verdict"] == "正常"
|
||||
|
||||
def test_direction_is_judged_per_metric_not_by_sign(self):
|
||||
wobble = [58, 60, 62, 58, 60, 62, 60]
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
8,
|
||||
heartRate=wobble + [75],
|
||||
heartRateVariability=[38, 40, 42, 38, 40, 42, 40] + [55],
|
||||
)]
|
||||
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||
# Both moved up; only one of them is good news.
|
||||
assert result["heartRate"]["verdict"] == "偏差"
|
||||
assert result["heartRateVariability"]["verdict"] == "偏好"
|
||||
|
||||
def test_largest_departure_comes_first(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
8, steps=[5000] * 7 + [5100], heartRate=[60] * 7 + [80]
|
||||
)]
|
||||
result = insights.deviations(rows, rows[-1])
|
||||
assert result[0]["metric"] == "heartRate"
|
||||
|
||||
def test_metrics_absent_today_are_omitted(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(8, steps=5000)]
|
||||
assert all(d["metric"] == "steps" for d in insights.deviations(rows, rows[-1]))
|
||||
|
||||
|
||||
class TestTrends:
|
||||
def test_slope_is_reported_per_thirty_days(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
30, heartRateVariability=[40 + i for i in range(30)]
|
||||
)]
|
||||
entry = {t["metric"]: t for t in insights.trends(rows)}
|
||||
# One unit a day is 30 per 30 days.
|
||||
assert entry["heartRateVariability"]["slopePer30d"] == pytest.approx(30, abs=0.5)
|
||||
|
||||
def test_endpoint_windows_do_not_overlap_on_short_histories(self):
|
||||
"""A 30-day history must not report delta 0 for a line that moved."""
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
30, steps=[1000 + i * 100 for i in range(30)]
|
||||
)]
|
||||
entry = {t["metric"]: t for t in insights.trends(rows)}["steps"]
|
||||
assert entry["firstMean"] < entry["lastMean"]
|
||||
assert entry["delta"] > 0
|
||||
|
||||
def test_direction_respects_which_way_is_better(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
30, heartRate=[80 - i for i in range(30)]
|
||||
)]
|
||||
entry = {t["metric"]: t for t in insights.trends(rows)}["heartRate"]
|
||||
assert entry["direction"] == "改善"
|
||||
|
||||
def test_metrics_with_too_few_samples_are_skipped(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(5, steps=1000)]
|
||||
assert insights.trends(rows) == []
|
||||
|
||||
def test_gaps_do_not_compress_the_x_axis(self):
|
||||
"""Ordinal dates, not indices: the same rise spread over a longer span
|
||||
is a gentler slope, and indices would score the two identically."""
|
||||
values = [1000 + i * 100 for i in range(10)]
|
||||
dense = [(f"2026-08-{1 + i:02d}", v) for i, v in enumerate(values)]
|
||||
# Same ten readings, but the last five sit a month later.
|
||||
gapped = dense[:5] + [
|
||||
(f"2026-09-{6 + i:02d}", v) for i, v in enumerate(values[5:])
|
||||
]
|
||||
assert insights._slope_per_30d(gapped) < insights._slope_per_30d(dense)
|
||||
|
||||
|
||||
class TestActivityShift:
|
||||
def test_compares_the_last_week_with_the_weeks_before_it(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(
|
||||
20, steps=[10000] * 13 + [5000] * 7
|
||||
)]
|
||||
shift = insights.activity_shift(rows)
|
||||
assert shift["steps"]["recentMean"] == 5000
|
||||
assert shift["steps"]["priorMean"] == 10000
|
||||
assert shift["steps"]["changePct"] == -50.0
|
||||
|
||||
def test_absent_when_there_is_not_enough_history(self):
|
||||
rows = [insights._flatten(r) for r in flat_days(6, steps=8000)]
|
||||
assert "steps" not in insights.activity_shift(rows)
|
||||
|
||||
|
||||
class TestWindowContext:
|
||||
def test_unknown_metric_returns_nothing(self, db, user):
|
||||
assert insights.window_context(
|
||||
user["id"], "notAMetric", "2026-08-01", "2026-08-30"
|
||||
) is None
|
||||
|
||||
def test_empty_span_returns_nothing(self, db, user):
|
||||
assert insights.window_context(
|
||||
user["id"], "steps", "2020-01-01", "2020-01-31"
|
||||
) is None
|
||||
|
||||
|
||||
class TestBuildContext:
|
||||
def test_returns_nothing_without_data(self, db, user):
|
||||
assert insights.build_context(user["id"]) is None
|
||||
|
||||
def test_defaults_to_the_newest_recorded_day(self, db, user, seed_health):
|
||||
seed_health([
|
||||
{"date": "2026-08-01", "steps": 5000},
|
||||
{"date": "2026-08-02", "steps": 6000},
|
||||
])
|
||||
context = insights.build_context(user["id"])
|
||||
assert context["snapshotDate"] == "2026-08-02"
|
||||
|
||||
def test_an_unknown_date_is_not_silently_replaced(self, db, user, seed_health):
|
||||
seed_health([{"date": "2026-08-01", "steps": 5000}])
|
||||
assert insights.build_context(user["id"], "2026-08-09") is None
|
||||
|
||||
def test_stays_small_enough_to_prompt_with(self, db, user, seed_health):
|
||||
seed_health([
|
||||
{"date": f"2026-08-{d:02d}", "steps": 8000 + d, "heart_rate": 60,
|
||||
"hrv": 45, "sleep_duration": 7, "stress": 30}
|
||||
for d in range(1, 31)
|
||||
])
|
||||
context = insights.build_context(user["id"])
|
||||
blob = json.dumps(context, ensure_ascii=False)
|
||||
# A month of history has to cost thousands of characters, not tens of
|
||||
# thousands — the whole point of computing features server-side.
|
||||
assert len(blob) < 20_000
|
||||
|
||||
|
||||
# --- reply parsing ----------------------------------------------------------
|
||||
GOOD_BRIEFING = {
|
||||
"status": "中等偏上",
|
||||
"headline": "恢复尚可,睡眠偏短。",
|
||||
"diagnosis": [{"title": "睡眠结构", "detail": "睡眠 6 小时,低于目标。"}],
|
||||
"shortfall": "睡眠不足",
|
||||
"prescription": {
|
||||
"intensity": "中等", "hrZone": "Zone 2~Zone 3",
|
||||
"suggestion": "40 分钟慢跑", "durationMin": 40, "avoid": "高强度间歇",
|
||||
},
|
||||
"actions": ["提前 30 分钟入睡", "午后避免咖啡因"],
|
||||
}
|
||||
|
||||
|
||||
class TestParseBriefing:
|
||||
def test_plain_json(self):
|
||||
out = coach.parse_briefing(json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
assert out["status"] == "中等偏上"
|
||||
assert out["prescription"]["durationMin"] == 40
|
||||
assert out["actions"] == ["提前 30 分钟入睡", "午后避免咖啡因"]
|
||||
|
||||
def test_answer_is_taken_from_after_a_reasoning_preamble(self):
|
||||
"""The gateway's primary upstream narrates its thinking first."""
|
||||
reply = (
|
||||
'The user wants a briefing. Let me consider {"draft": true} first.\n'
|
||||
"Actually I should output the final object now:\n"
|
||||
+ json.dumps(GOOD_BRIEFING, ensure_ascii=False)
|
||||
)
|
||||
assert coach.parse_briefing(reply)["status"] == "中等偏上"
|
||||
|
||||
def test_markdown_fences_are_tolerated(self):
|
||||
reply = "```json\n" + json.dumps(GOOD_BRIEFING, ensure_ascii=False) + "\n```"
|
||||
assert coach.parse_briefing(reply)["headline"] == "恢复尚可,睡眠偏短。"
|
||||
|
||||
def test_missing_prescription_does_not_raise(self):
|
||||
payload = {k: v for k, v in GOOD_BRIEFING.items() if k != "prescription"}
|
||||
out = coach.parse_briefing(json.dumps(payload, ensure_ascii=False))
|
||||
assert out["prescription"]["suggestion"] is None
|
||||
|
||||
def test_non_numeric_duration_becomes_none(self):
|
||||
payload = json.loads(json.dumps(GOOD_BRIEFING))
|
||||
payload["prescription"]["durationMin"] = "四十分钟"
|
||||
assert coach.parse_briefing(json.dumps(payload))["prescription"]["durationMin"] is None
|
||||
|
||||
def test_diagnosis_written_as_plain_strings_is_accepted(self):
|
||||
payload = json.loads(json.dumps(GOOD_BRIEFING))
|
||||
payload["diagnosis"] = ["睡眠偏短。"]
|
||||
out = coach.parse_briefing(json.dumps(payload, ensure_ascii=False))
|
||||
assert out["diagnosis"][0]["detail"] == "睡眠偏短。"
|
||||
|
||||
def test_an_empty_briefing_is_rejected_rather_than_rendered_blank(self):
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
coach.parse_briefing(json.dumps({"status": "好"}))
|
||||
|
||||
def test_prose_without_json_raises(self):
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
coach.parse_briefing("今天状态不错,可以正常训练。")
|
||||
|
||||
|
||||
class TestParseTrendInsight:
|
||||
def test_valid_reply(self):
|
||||
reply = json.dumps({
|
||||
"summary": "HRV 稳步上升。",
|
||||
"drivers": [{"factor": "有氧负荷", "detail": "区间内 8 次有氧。"}],
|
||||
"caution": None, "confidence": "high",
|
||||
}, ensure_ascii=False)
|
||||
out = coach.parse_trend_insight(reply)
|
||||
assert out["confidence"] == "high"
|
||||
assert out["drivers"][0]["factor"] == "有氧负荷"
|
||||
|
||||
def test_unknown_confidence_falls_back_to_medium(self):
|
||||
reply = json.dumps({"summary": "上升。", "confidence": "很高"}, ensure_ascii=False)
|
||||
assert coach.parse_trend_insight(reply)["confidence"] == "medium"
|
||||
|
||||
def test_empty_reply_raises(self):
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
coach.parse_trend_insight(json.dumps({"confidence": "high"}))
|
||||
|
||||
|
||||
class TestExtractJson:
|
||||
def test_last_object_wins_over_an_earlier_draft(self):
|
||||
assert ai_svc.extract_json('{"a": 1} then {"a": 2}') == {"a": 2}
|
||||
|
||||
def test_braces_inside_strings_do_not_break_the_scan(self):
|
||||
assert ai_svc.extract_json('思考 } 中。{"t": "含 } 的文本"}')["t"] == "含 } 的文本"
|
||||
|
||||
def test_escaped_quote_inside_a_string(self):
|
||||
assert ai_svc.extract_json(r'x {"t": "a \" b"}')["t"] == 'a " b'
|
||||
|
||||
def test_arrays_are_extracted_too(self):
|
||||
assert ai_svc.extract_json("preamble [1, 2, 3]") == [1, 2, 3]
|
||||
|
||||
def test_empty_reply_raises(self):
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
ai_svc.extract_json(" ")
|
||||
|
||||
|
||||
# --- prompt assembly --------------------------------------------------------
|
||||
class TestPrompts:
|
||||
def test_system_prompt_forbids_inventing_numbers(self):
|
||||
assert "禁止编造" in coach.SYSTEM
|
||||
|
||||
def test_system_prompt_disclaims_medical_diagnosis(self):
|
||||
assert "不做医疗诊断" in coach.SYSTEM
|
||||
|
||||
def test_model_is_told_not_to_recompute_the_z_scores(self):
|
||||
assert "不要自行重算" in coach.SYSTEM
|
||||
|
||||
def test_briefing_prompt_carries_the_context_as_json(self):
|
||||
messages = coach.briefing_messages({"snapshotDate": "2026-08-01", "x": 1})
|
||||
assert messages[0]["role"] == "system"
|
||||
assert '"snapshotDate":"2026-08-01"' in messages[1]["content"]
|
||||
|
||||
def test_context_is_not_ascii_escaped(self):
|
||||
"""Escaping Chinese to \\uXXXX roughly triples its token cost."""
|
||||
assert "睡眠" in coach._payload({"label": "睡眠"})
|
||||
|
||||
def test_copilot_keeps_the_context_out_of_the_visible_transcript(self):
|
||||
messages = coach.copilot_messages(
|
||||
{"snapshotDate": "2026-08-01"}, [], "我今天能练吗?"
|
||||
)
|
||||
assert [m["role"] for m in messages] == ["system", "system", "user"]
|
||||
assert messages[-1]["content"] == "我今天能练吗?"
|
||||
|
||||
def test_copilot_history_is_capped_and_role_filtered(self):
|
||||
history = [{"role": "user", "content": f"q{i}"} for i in range(20)]
|
||||
history.append({"role": "tool", "content": "ignored"})
|
||||
messages = coach.copilot_messages({}, history, "最后一问")
|
||||
turns = [m for m in messages if m["role"] != "system"]
|
||||
assert len(turns) == 9 # eight remembered turns plus the new question
|
||||
assert "ignored" not in json.dumps(messages, ensure_ascii=False)
|
||||
|
||||
def test_copilot_asks_for_markdown_not_json(self):
|
||||
assert "Markdown" in coach.COPILOT_SYSTEM
|
||||
assert "最后出现的 JSON" not in coach.COPILOT_SYSTEM
|
||||
|
||||
|
||||
# --- rule-based counterparts ------------------------------------------------
|
||||
def context_with(**today):
|
||||
base = {
|
||||
"snapshotDate": "2026-08-30",
|
||||
"todayMetrics": {
|
||||
"sleep": None,
|
||||
"autonomicNervous": {},
|
||||
"recovery": {},
|
||||
"activityToday": {},
|
||||
},
|
||||
"deviations": [],
|
||||
"trends": [],
|
||||
"activityShift": {},
|
||||
}
|
||||
base["todayMetrics"].update(today)
|
||||
return base
|
||||
|
||||
|
||||
class TestRuleBriefing:
|
||||
def test_readiness_drives_the_prescription(self):
|
||||
low = coach.rule_briefing(context_with(recovery={"trainingReadiness": 30}))
|
||||
high = coach.rule_briefing(context_with(recovery={"trainingReadiness": 85}))
|
||||
assert low["prescription"]["intensity"] == "低"
|
||||
assert high["prescription"]["intensity"] == "高"
|
||||
|
||||
def test_short_sleep_is_named_as_the_shortfall(self):
|
||||
out = coach.rule_briefing(context_with(
|
||||
sleep={"durationHours": 5.0, "targetHours": 7.0}
|
||||
))
|
||||
assert "睡眠" in out["shortfall"]
|
||||
|
||||
def test_no_shortfall_is_stated_explicitly(self):
|
||||
out = coach.rule_briefing(context_with(
|
||||
sleep={"durationHours": 8.0, "targetHours": 7.0}
|
||||
))
|
||||
assert out["shortfall"] == "无明显短板"
|
||||
|
||||
def test_it_never_invents_a_metric_the_watch_did_not_record(self):
|
||||
out = coach.rule_briefing(context_with())
|
||||
assert out["diagnosis"] == []
|
||||
assert out["actions"]
|
||||
|
||||
def test_the_headline_names_the_largest_departure(self):
|
||||
context = context_with(autonomicNervous={"restingHr": 80})
|
||||
context["deviations"] = [{
|
||||
"metric": "heartRate", "label": "静息心率", "unit": "bpm",
|
||||
"value": 80, "baselineMean": 60, "sd": 5, "baselineDays": 28,
|
||||
"z": 4.0, "verdict": "偏差",
|
||||
}]
|
||||
assert "静息心率" in coach.rule_briefing(context)["headline"]
|
||||
|
||||
def test_a_sustained_drop_in_steps_becomes_an_action(self):
|
||||
context = context_with()
|
||||
context["activityShift"] = {
|
||||
"steps": {"label": "步数", "recentMean": 4000,
|
||||
"priorMean": 10000, "changePct": -60.0}
|
||||
}
|
||||
assert any("60" in a for a in coach.rule_briefing(context)["actions"])
|
||||
|
||||
|
||||
# --- orchestration ----------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def gateway(monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_TOKEN", "test-token")
|
||||
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gateway.test/v1")
|
||||
monkeypatch.setenv("AI_MODEL_CHAIN", "gateway")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def month(db, user, seed_health):
|
||||
seed_health([
|
||||
{"date": f"2026-08-{d:02d}", "steps": 8000, "heart_rate": 60, "hrv": 45,
|
||||
"sleep_duration": 7, "sleep_quality": 80, "stress": 30}
|
||||
for d in range(1, 31)
|
||||
])
|
||||
return user
|
||||
|
||||
|
||||
def answer(monkeypatch, text):
|
||||
"""Make every model reply with `text`, and count the calls."""
|
||||
calls = []
|
||||
|
||||
def fake_chat(self, messages, timeout=None, max_tokens=None):
|
||||
calls.append(messages)
|
||||
return ai_svc.Completion(text, "nvidia")
|
||||
|
||||
monkeypatch.setattr(ai_svc.Provider, "chat", fake_chat, raising=False)
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", fake_chat)
|
||||
return calls
|
||||
|
||||
|
||||
class TestGetBriefing:
|
||||
def test_no_data_says_so_rather_than_guessing(self, db, user, gateway):
|
||||
out = analysis_svc.get_briefing(user["id"])
|
||||
assert out["meta"]["source"] == "none"
|
||||
assert out["briefing"] is None
|
||||
|
||||
def test_blocking_mode_returns_the_model_answer(self, month, gateway, monkeypatch):
|
||||
answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
out = analysis_svc.get_briefing(month["id"], wait=True)
|
||||
assert out["meta"]["source"] == "ai"
|
||||
assert out["briefing"]["status"] == "中等偏上"
|
||||
|
||||
def test_a_stored_answer_is_reused(self, month, gateway, monkeypatch):
|
||||
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
analysis_svc.get_briefing(month["id"], wait=True)
|
||||
out = analysis_svc.get_briefing(month["id"])
|
||||
assert out["meta"]["cached"] is True
|
||||
assert len(calls) == 1, "the cached answer must not trigger a second call"
|
||||
|
||||
def test_new_health_data_expires_the_stored_answer(
|
||||
self, month, gateway, monkeypatch, seed_health
|
||||
):
|
||||
answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
analysis_svc.get_briefing(month["id"], wait=True)
|
||||
seed_health([{"date": "2026-08-31", "steps": 12000}])
|
||||
out = analysis_svc.get_briefing(month["id"])
|
||||
assert out["meta"].get("cached") is not True
|
||||
|
||||
def test_a_failing_model_degrades_to_the_rule_engine(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
def boom(self, messages, timeout=None, max_tokens=None):
|
||||
raise ai_svc.AIError("upstream down")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
|
||||
out = analysis_svc.get_briefing(month["id"], wait=True)
|
||||
assert out["meta"]["source"] == "rules"
|
||||
assert out["briefing"] is not None
|
||||
|
||||
def test_the_non_blocking_path_answers_without_calling_a_model(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||
out = analysis_svc.get_briefing(month["id"])
|
||||
assert out["meta"]["pending"] is True
|
||||
assert out["briefing"]["status"]
|
||||
assert calls == []
|
||||
|
||||
def test_one_generation_per_key_no_matter_how_often_it_is_polled(self):
|
||||
"""The poll runs every few seconds; a generation takes minutes."""
|
||||
started = []
|
||||
# A job that never finishes, so the key stays claimed across polls.
|
||||
blocked = analysis_svc.threading.Event()
|
||||
analysis_svc._run_in_background("test-key", lambda: (
|
||||
started.append(1), blocked.wait(5)
|
||||
))
|
||||
try:
|
||||
for _ in range(5):
|
||||
analysis_svc._run_in_background("test-key", lambda: started.append(1))
|
||||
assert len(started) == 1
|
||||
finally:
|
||||
blocked.set()
|
||||
|
||||
|
||||
class TestGetTrendInsight:
|
||||
def test_empty_span_is_reported_not_analysed(self, month, gateway):
|
||||
out = analysis_svc.get_trend_insight(
|
||||
month["id"], "steps", "2020-01-01", "2020-01-31"
|
||||
)
|
||||
assert out["meta"]["source"] == "none"
|
||||
|
||||
def test_model_answer_is_returned_and_cached(self, month, gateway, monkeypatch):
|
||||
reply = json.dumps({
|
||||
"summary": "步数稳定。", "drivers": [], "caution": None,
|
||||
"confidence": "medium",
|
||||
}, ensure_ascii=False)
|
||||
calls = answer(monkeypatch, reply)
|
||||
first = analysis_svc.get_trend_insight(
|
||||
month["id"], "steps", "2026-08-01", "2026-08-30"
|
||||
)
|
||||
second = analysis_svc.get_trend_insight(
|
||||
month["id"], "steps", "2026-08-01", "2026-08-30"
|
||||
)
|
||||
assert first["insight"]["summary"] == "步数稳定。"
|
||||
assert second["meta"]["cached"] is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_a_failing_model_degrades_to_the_rule_engine(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
def boom(self, messages, timeout=None, max_tokens=None):
|
||||
raise ai_svc.AIError("down")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
|
||||
out = analysis_svc.get_trend_insight(
|
||||
month["id"], "steps", "2026-08-01", "2026-08-30"
|
||||
)
|
||||
assert out["meta"]["source"] == "rules"
|
||||
assert out["insight"]["summary"]
|
||||
|
||||
|
||||
class TestStreamChat:
|
||||
"""The gateway's streaming path is measurably less reliable than its
|
||||
blocking one, so a stream that produces nothing must not end the attempt."""
|
||||
|
||||
def test_deltas_are_forwarded(self, gateway, monkeypatch):
|
||||
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||
yield ai_svc.Completion("你好", "nvidia")
|
||||
yield ai_svc.Completion(",世界", "nvidia")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||
text = "".join(d.text for d in ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||
assert text == "你好,世界"
|
||||
|
||||
def test_a_failed_stream_retries_the_same_model_without_streaming(
|
||||
self, gateway, monkeypatch
|
||||
):
|
||||
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||
raise ai_svc.AIError("所有模型均不可用")
|
||||
yield # pragma: no cover - generator marker
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||
answer(monkeypatch, "完整回答")
|
||||
deltas = list(ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||
assert "".join(d.text for d in deltas) == "完整回答"
|
||||
|
||||
def test_no_model_switch_once_text_has_been_sent(self, gateway, monkeypatch):
|
||||
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||
yield ai_svc.Completion("半句", "nvidia")
|
||||
raise ai_svc.AIError("断流")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
list(ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||
|
||||
|
||||
class TestCopilotStream:
|
||||
def test_no_data_yields_an_error_event(self, db, user, gateway):
|
||||
events = list(analysis_svc.copilot_stream(user["id"], "我今天能练吗"))
|
||||
assert events[0][0] == "error"
|
||||
|
||||
def test_a_successful_answer_is_framed_start_delta_done(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||
yield ai_svc.Completion("可以。", "nvidia")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||
events = list(analysis_svc.copilot_stream(month["id"], "我今天能练吗"))
|
||||
assert [e for e, _ in events] == ["start", "delta", "done"]
|
||||
assert events[-1][1]["upstream"] == "nvidia"
|
||||
|
||||
|
||||
# --- endpoints --------------------------------------------------------------
|
||||
class TestEndpoints:
|
||||
def test_briefing_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/briefing").status_code == 401
|
||||
|
||||
def test_trend_insight_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/trend-insight").status_code == 401
|
||||
|
||||
def test_copilot_requires_auth(self, client):
|
||||
assert client.post("/api/analysis/copilot", json={}).status_code == 401
|
||||
|
||||
def test_briefing_answers_even_with_no_model_configured(self, client, auth, month):
|
||||
resp = client.get("/api/analysis/briefing", headers=auth)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["briefing"] is not None
|
||||
|
||||
def test_trend_insight_rejects_an_unknown_metric(self, client, auth, month):
|
||||
resp = client.get(
|
||||
"/api/analysis/trend-insight",
|
||||
query_string={"metric": "nope", "startDate": "2026-08-01",
|
||||
"endDate": "2026-08-30"},
|
||||
headers=auth,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "supported" in resp.get_json()
|
||||
|
||||
def test_trend_insight_requires_a_range(self, client, auth, month):
|
||||
resp = client.get(
|
||||
"/api/analysis/trend-insight",
|
||||
query_string={"metric": "steps"}, headers=auth,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_copilot_requires_a_question(self, client, auth, month):
|
||||
resp = client.post("/api/analysis/copilot", json={}, headers=auth)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_copilot_streams_server_sent_events(
|
||||
self, client, auth, month, gateway, monkeypatch
|
||||
):
|
||||
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||
yield ai_svc.Completion("可以,注意强度。", "nvidia")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||
resp = client.post(
|
||||
"/api/analysis/copilot",
|
||||
json={"question": "我今天能练吗"}, headers=auth,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "text/event-stream"
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "event: delta" in body
|
||||
assert "可以,注意强度。" in body
|
||||
|
||||
def test_briefing_never_leaks_the_gateway_token(
|
||||
self, client, auth, month, gateway, monkeypatch
|
||||
):
|
||||
# No real background generation: this suite must not reach the network.
|
||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||
body = client.get("/api/analysis/briefing", headers=auth).get_data(as_text=True)
|
||||
assert "test-token" not in body
|
||||
|
||||
|
||||
class TestStreamRetryScope:
|
||||
"""The blind non-streaming retry is only worth doing for endpoints that
|
||||
actually have a separate streaming transport."""
|
||||
|
||||
def test_a_provider_without_streaming_is_not_called_twice(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
||||
monkeypatch.setenv("AI_MODEL_CHAIN", "gemini-flash")
|
||||
calls = []
|
||||
|
||||
def boom(self, messages, timeout=None, max_tokens=None):
|
||||
calls.append(1)
|
||||
raise ai_svc.AIError("down")
|
||||
|
||||
monkeypatch.setattr(ai_svc.GeminiProvider, "chat", boom)
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
list(ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_the_gateway_declares_a_streaming_transport(self):
|
||||
assert ai_svc.CATALOG["gateway"].streaming is True
|
||||
assert ai_svc.CATALOG["gemini-flash"].streaming is False
|
||||
|
||||
|
||||
class TestRegenerate:
|
||||
def test_refresh_evicts_the_stored_answer_so_the_poll_can_see_the_new_one(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
"""Without eviction the poll after 重新生成 reads the row it was asked
|
||||
to replace, reports `cached`, and stops — leaving the old text on
|
||||
screen."""
|
||||
answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
analysis_svc.get_briefing(month["id"], wait=True)
|
||||
assert analysis_svc.get_briefing(month["id"])["meta"]["cached"] is True
|
||||
|
||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||
analysis_svc.get_briefing(month["id"], refresh=True)
|
||||
|
||||
after = analysis_svc.get_briefing(month["id"])
|
||||
assert after["meta"].get("cached") is not True
|
||||
assert after["meta"]["pending"] is True
|
||||
Reference in New Issue
Block a user