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:
ericwyuan
2026-09-01 13:57:35 +08:00
parent a746327560
commit c57c930949
21 changed files with 3677 additions and 22 deletions

View File

@@ -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

View File

@@ -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) ----------------------------------------------------

View File

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

View File

@@ -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

View File

@@ -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
View 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",
}

View 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
View 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