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

@@ -52,11 +52,30 @@
- [x] 完整表结构:`health_data` / `daily_series` / `activities` / `activity_details` / `users` / `user_settings` / `garmin_tokens` / `sync_status` - [x] 完整表结构:`health_data` / `daily_series` / `activities` / `activity_details` / `users` / `user_settings` / `garmin_tokens` / `sync_status`
- [x] 257 天健康数据已同步2025-12-19 ~ 2026-09-01 - [x] 257 天健康数据已同步2025-12-19 ~ 2026-09-01
### AI 教练2026-09-01
- [x] 特征工程层 `services/insights.py`z 分数28 天个人基线、13 个月趋势
斜率、近 7 天活动量对比,全部服务端算好再交给模型
- [x] 提示词与解析层 `services/coach.py`:晨报 / 趋势归因 / Copilot 三套提示词,
每套都有对应的规则引擎兜底版本
- [x] `services/ai.py` 扩展:多轮 `chat()`、SSE `stream()``extract_json()`
(从推理模型的思维链里取最后一个 JSON
- [x] 接口:`GET /analysis/briefing``GET /analysis/trend-insight`
`POST /analysis/copilot`SSE
- [x] 缓存表 `ai_insights`(按 user + kind + subject数据指纹失效
- [x] 前端:今日页 AI 晨报卡片(后台生成 + 轮询升级)、全局 Copilot 浮窗、
指标详情页 AI 归因面板;`features.ts``ai` 开关已打开
- [x] 接入自建 ai-gateway`https://oracle.zichuan.xyz/ai/v1`),实测走通
> **实测数据2026-09-01**:网关一次晨报生成 **273 秒**(上游 nvidia
> 缓存命中 18 毫秒。网关的**流式**通道比阻塞通道更不可靠——同一条提示词
> 流式 139 秒后返回「所有模型均不可用」,阻塞则成功,因此 `stream_chat()`
> 在流式无输出时会对同一模型退回非流式重试。
## 待办 ## 待办
### 功能完善 ### 功能完善
- [ ] 仪表板数据可视化组件完善 - [ ] 仪表板数据可视化组件完善
- [ ] 健康建议 / AI 解读功能 - [x] 健康建议 / AI 解读功能AI 教练,见下)
- [ ] 数据分析报告生成 - [ ] 数据分析报告生成
- [ ] 多用户支持完善 - [ ] 多用户支持完善

View File

@@ -153,7 +153,26 @@ python tests/smoke.py
### 分析与建议 ### 分析与建议
- `GET /api/analysis/trends` - 获取数据趋势 - `GET /api/analysis/trends` - 获取数据趋势
- `GET /api/analysis/recommendations` - 获取健康建议 - `GET /api/analysis/recommendations` - 规则引擎健康建议
- `GET /api/analysis/models` - 可用大模型及其配置状态
- `GET /api/analysis/ai-recommendations` - 大模型健康建议(带缓存)
### AI 教练
- `GET /api/analysis/briefing` - 晨间简报 + 今日运动处方,附计算出的特征上下文
- 立即返回。若没有匹配当前数据的模型答案,先返回规则版并带上
`meta.pending`,模型版本在后台生成,再次请求即可取到
- `?date=` 指定日期(默认最新有数据的一天)、`?refresh=1` 忽略缓存、
`?wait=1` 阻塞等待模型(一次生成 2~5 分钟)
- `GET /api/analysis/trend-insight?metric=&startDate=&endDate=` - 对选定区间内
单个指标的变化做归因分析(阻塞,未知指标返回 400 并附 `supported` 列表)
- `POST /api/analysis/copilot` - 健康 Copilot 问答SSE 流式返回
- 请求体 `{question, history?, date?, model?}`
- 事件序列 `start``delta`* → `done`,失败时为 `error`
> AI 相关接口全部经由自建 **ai-gateway**OpenAI 兼容,见 `AI_GATEWAY_BASE_URL`)。
> 该网关的主上游是大型推理模型,一次生成实测需 2~5 分钟,因此简报走后台生成 +
> 轮询,趋势归因与 Copilot 走显式触发;任一模型失败时降级为规则引擎,
> `meta.source` 会说明本次由谁作答。
## 🔐 安全说明 ## 🔐 安全说明

View File

@@ -53,7 +53,9 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# absorbs single-vendor quota limits. Reached directly, bypassing any local # absorbs single-vendor quota limits. Reached directly, bypassing any local
# HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies # HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies
# can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly. # 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_TOKEN=
AI_GATEWAY_MODEL=ai-gateway-auto 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. # payload always fits that model's own context window.
AI_DAY_BUDGET=365 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; # Output cap. Reasoning models spend part of it thinking before they answer;
# entries that need more declare their own budget in services/ai.py. # entries that need more declare their own budget in services/ai.py.
AI_MAX_TOKENS=1024 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, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) 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) ---------------------------------------------------- # --- MariaDB pool (lazy) ----------------------------------------------------

View File

@@ -1,9 +1,12 @@
"""Analysis routes: trends + recommendations.""" """Analysis routes: trends, recommendations, and the AI coach."""
from flask import Blueprint, request, g, jsonify import json
from flask import Blueprint, Response, request, g, jsonify
from auth import require_auth from auth import require_auth
from services import analysis as analysis_svc from services import analysis as analysis_svc
from services import ai as ai_svc from services import ai as ai_svc
from services import insights
bp = Blueprint("analysis", __name__) bp = Blueprint("analysis", __name__)
@@ -47,3 +50,88 @@ def ai_recommendations():
return jsonify( return jsonify(
analysis_svc.get_ai_recommendations(g.user_id, model, days, refresh) 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" 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__( def __init__(
self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None 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 session.trust_env = self.use_proxy
return session 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 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): class GeminiProvider(Provider):
"""Google AI Studio (generativelanguage.googleapis.com).""" """Google AI Studio (generativelanguage.googleapis.com)."""
@@ -141,18 +168,34 @@ class GeminiProvider(Provider):
name = "gemini" name = "gemini"
BASE = "https://generativelanguage.googleapis.com/v1beta/models" 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(): if not self.is_configured():
raise AIError(f"{self.api_key_env} 未配置") raise AIError(f"{self.api_key_env} 未配置")
timeout = timeout or default_timeout() timeout = timeout or default_timeout()
url = f"{self.BASE}/{self.model_id}:generateContent" 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 = { payload = {
"contents": [{"parts": [{"text": prompt}]}], "contents": contents,
"generationConfig": { "generationConfig": {
"temperature": 0.4, "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: try:
resp = self._session().post( resp = self._session().post(
url, url,
@@ -188,6 +231,7 @@ class OpenAICompatProvider(Provider):
""" """
name = "openai-compat" name = "openai-compat"
streaming = True
def __init__( def __init__(
self, self,
@@ -216,30 +260,35 @@ class OpenAICompatProvider(Provider):
return False return False
return bool(self.api_key) if self.requires_key else True 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(): if not self.is_configured():
raise AIError( raise AIError(
f"{self.api_key_env} 未配置" if self.requires_key f"{self.api_key_env} 未配置" if self.requires_key
else f"{self.base_url_env} 未配置" else f"{self.base_url_env} 未配置"
) )
timeout = timeout or default_timeout()
url = f"{self.base_url.rstrip('/')}/chat/completions" url = f"{self.base_url.rstrip('/')}/chat/completions"
payload = { payload = {
"model": self.model_id, "model": self.model_id,
"messages": [{"role": "user", "content": prompt}], "messages": messages,
"temperature": 0.4, "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"} headers = {"Content-Type": "application/json"}
if self.api_key: if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
try: try:
resp = self._session().post( return self._session().post(
url, headers=headers, json=payload, timeout=timeout url, headers=headers, json=payload, timeout=timeout, stream=stream
) )
except requests.RequestException as e: except requests.RequestException as e:
raise AIError(f"{self.model_id} 请求失败: {e}") from 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: if resp.status_code != 200:
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[: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: except (ValueError, KeyError, IndexError) as e:
raise AIError(f"{self.model_id} 响应格式异常: {e}") from 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 ---------------------------------------------------------------- # --- catalog ----------------------------------------------------------------
NVIDIA_BASE = "https://integrate.api.nvidia.com/v1" 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) detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
raise AIError(f"所有模型均失败 -> {detail}") 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 hashlib
import json import json
import os import os
import threading
from services import health from services import health
from services import ai as ai_svc from services import ai as ai_svc
from services import coach
from services import insights
from db import query_all, query_one, execute from db import query_all, query_one, execute
from config import DB_TYPE 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): def clear_ai_cache(user_id):
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [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

View File

@@ -5,6 +5,8 @@ import Framework7 from 'framework7/lite-bundle';
import Framework7React from 'framework7-react'; import Framework7React from 'framework7-react';
import routes from './routes'; import routes from './routes';
import Copilot from './components/Copilot';
import { FEATURES } from './features';
import { apiClient, AUTH_EVENT } from './services/api'; import { apiClient, AUTH_EVENT } from './services/api';
import 'framework7/css/bundle'; import 'framework7/css/bundle';
@@ -314,6 +316,11 @@ function App() {
</Views> </Views>
)} )}
</F7App> </F7App>
{/* Outside <F7App> for the same reason NavProgress is: a stray child of
the Framework7 root breaks its initialisation. Only rendered with a
session — there is nothing to ask about on the login screen. */}
{authed && FEATURES.ai && <Copilot />}
</> </>
); );
} }

View File

@@ -0,0 +1,260 @@
/* AI 晨间简报 — the hero card above the metric grid.
Shares the surface, radius and lift of .hero in Today.css so the two read as
one stack rather than two competing headers. */
.brief-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 1.1rem 1rem 0.65rem;
margin-bottom: 1.25rem;
animation: hero-in 0.5s var(--ease) both;
}
.brief-skeleton {
height: 132px;
border-radius: 16px;
margin-bottom: 1.25rem;
background: linear-gradient(
100deg,
var(--surface-1) 30%,
var(--surface-2) 50%,
var(--surface-1) 70%
);
background-size: 220% 100%;
animation: brief-shimmer 1.4s linear infinite;
}
@keyframes brief-shimmer {
from { background-position: 180% 0; }
to { background-position: -80% 0; }
}
.brief-error,
.brief-empty {
color: var(--text-secondary);
font-size: 0.88rem;
padding-bottom: 1.1rem;
}
.brief-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.brief-status {
font-size: 0.95rem;
font-weight: 680;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* Provenance is deliberately always visible: a model answer and a rule-engine
stand-in look alike on the page, and which one is on screen changes how much
weight the reader should give it. */
.brief-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.66rem;
font-weight: 600;
padding: 0.16rem 0.44rem;
border-radius: 999px;
color: var(--text-muted);
background: var(--surface-0);
border: 1px solid var(--border);
white-space: nowrap;
}
.brief-badge-ai {
color: var(--accent);
background: var(--accent-soft);
border-color: transparent;
}
.brief-badge-pending { color: var(--text-secondary); }
.brief-spinner {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
border-top-color: var(--accent);
animation: brief-spin 0.8s linear infinite;
}
@keyframes brief-spin { to { transform: rotate(360deg); } }
.brief-headline {
margin: 0 0 0.7rem;
font-size: 0.92rem;
line-height: 1.5;
color: var(--text-primary);
}
.brief-rx {
background: var(--surface-0);
border-radius: 12px;
padding: 0.6rem 0.7rem;
margin-bottom: 0.55rem;
}
.brief-rx-label {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.brief-rx-body {
margin: 0.2rem 0 0;
font-size: 0.86rem;
line-height: 1.5;
color: var(--text-primary);
}
.brief-tag {
display: inline-block;
margin-left: 0.35rem;
font-size: 0.68rem;
font-weight: 600;
padding: 0.1rem 0.38rem;
border-radius: 6px;
color: var(--accent);
background: var(--accent-soft);
vertical-align: 0.06em;
}
.brief-avoid,
.brief-shortfall {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--text-secondary);
}
.brief-shortfall { margin-bottom: 0.5rem; }
.brief-detail {
border-top: 1px solid var(--border);
padding-top: 0.75rem;
margin-top: 0.4rem;
animation: brief-open 0.28s var(--ease) both;
}
@keyframes brief-open {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.brief-sub {
margin: 0 0 0.2rem;
font-size: 0.72rem;
font-weight: 700;
color: var(--text-muted);
}
.brief-diag { margin-bottom: 0.6rem; }
.brief-diag p {
margin: 0;
font-size: 0.84rem;
line-height: 1.5;
color: var(--text-secondary);
}
.brief-actions ul {
margin: 0 0 0.6rem;
padding-left: 1.05rem;
}
.brief-actions li {
font-size: 0.84rem;
line-height: 1.55;
color: var(--text-secondary);
}
.brief-dev-list {
list-style: none;
margin: 0 0 0.6rem;
padding: 0;
}
.brief-dev-list li {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.22rem 0;
border-bottom: 1px solid var(--grid);
font-size: 0.8rem;
}
.brief-dev-list li:last-child { border-bottom: none; }
.brief-dev-label {
flex: 1;
color: var(--text-secondary);
}
/* Tabular figures here, unlike the display numbers in the rings: these are a
column meant to be compared down the list. */
.brief-dev-value {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--text-primary);
}
/* Direction only — not good/bad. A high z on HRV is welcome and a high z on
resting heart rate is not, so colouring by sign would mislead; the status
tokens stay reserved for judgements the card actually makes. */
.brief-dev-z {
font-variant-numeric: tabular-nums;
font-size: 0.72rem;
font-weight: 600;
min-width: 3.1rem;
text-align: right;
color: var(--text-muted);
}
.brief-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.2rem;
}
.brief-link {
background: none;
border: none;
padding: 0;
font-size: 0.78rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.brief-link:disabled { color: var(--text-muted); cursor: default; }
.brief-time {
font-size: 0.68rem;
color: var(--text-muted);
}
.brief-toggle {
display: block;
width: 100%;
background: none;
border: none;
border-top: 1px solid var(--border);
margin-top: 0.5rem;
padding: 0.55rem 0 0.15rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.brief-toggle:active { color: var(--accent); }

View File

@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, Briefing, BriefingContext, InsightMeta,
} from '../services/api';
import './AiBriefing.css';
/* A generation runs for minutes on the gateway's reasoning upstream, so the
card shows the rule-based briefing immediately and polls for the model's
version. The interval is a compromise: often enough that the swap feels
like it belongs to this visit, rare enough that a five-minute generation
costs a few dozen requests rather than a few hundred. */
const POLL_MS = 8000;
const POLL_LIMIT_MS = 6 * 60 * 1000;
interface Props {
/** Day to brief on. Omit for the newest day on record. */
date?: string;
}
function SourceBadge({ meta }: { meta: InsightMeta }) {
if (meta.source === 'ai') {
return (
<span className="brief-badge brief-badge-ai" title={meta.model ?? undefined}>
AI {meta.upstream ? ` · ${meta.upstream}` : ''}
</span>
);
}
if (meta.pending) {
return (
<span className="brief-badge brief-badge-pending">
<span className="brief-spinner" aria-hidden="true" />
· AI
</span>
);
}
return (
<span className="brief-badge" title={meta.reason}>
</span>
);
}
/**
* The metrics that moved furthest from the user's own baseline.
*
* Shown alongside the prose because the briefing quotes these figures: the
* card should let the reader check the claim rather than take it on trust.
* Only departures are listed — a row saying a metric is normal is noise.
*/
function Deviations({ context }: { context: BriefingContext }) {
const notable = context.deviations
.filter((d) => d.z !== null && Math.abs(d.z) >= 1)
.slice(0, 4);
if (!notable.length) return null;
return (
<div className="brief-dev">
<h4 className="brief-sub">线</h4>
<ul className="brief-dev-list">
{notable.map((d) => (
<li key={d.metric}>
<span className="brief-dev-label">{d.label}</span>
<span className="brief-dev-value">
{d.value}
{d.unit}
</span>
<span
className={`brief-dev-z ${d.z! > 0 ? 'up' : 'down'}`}
title={`${d.baselineDays} 天基线 ${d.baselineMean}${d.unit}`}
>
{d.z! > 0 ? '+' : ''}
{d.z!.toFixed(1)}σ
</span>
</li>
))}
</ul>
</div>
);
}
function AiBriefing({ date }: Props) {
const [briefing, setBriefing] = useState<Briefing | null>(null);
const [context, setContext] = useState<BriefingContext | null>(null);
const [meta, setMeta] = useState<InsightMeta | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
/* The poll is cleared on unmount and whenever the day changes, so stepping
back through dates cannot leave a timer writing into a stale card. */
const timer = useRef<number>();
const startedAt = useRef(0);
const load = useCallback(async (refresh?: boolean) => {
try {
const data = await apiClient.getBriefing({ date, refresh });
setBriefing(data.briefing);
setContext(data.context);
setMeta(data.meta);
setError('');
return data.meta;
} catch (err: any) {
setError(errorMessage(err, '获取简报失败'));
return null;
} finally {
setLoading(false);
}
}, [date]);
const cancelled = useRef(false);
/* One poll loop, shared by the first load and by 重新生成. Each tick asks
without `refresh` — only the first request may bypass the cache, or every
tick would restart the generation it is waiting for. */
const poll = useCallback(async (refresh?: boolean) => {
window.clearTimeout(timer.current);
startedAt.current = Date.now();
setLoading(true);
const tick = async (first: boolean) => {
const result = await load(first && refresh);
if (cancelled.current) return;
// Stop as soon as a model answer lands, and give up after the window a
// generation realistically needs — an upstream that has gone quiet
// should not leave the tab polling for the rest of the session.
if (result?.pending && Date.now() - startedAt.current < POLL_LIMIT_MS) {
timer.current = window.setTimeout(() => tick(false), POLL_MS);
}
};
await tick(true);
}, [load]);
useEffect(() => {
cancelled.current = false;
poll();
return () => {
cancelled.current = true;
window.clearTimeout(timer.current);
};
}, [poll]);
if (loading && !briefing) {
return <div className="brief-skeleton" aria-label="正在生成简报" />;
}
if (error) return <div className="brief-card brief-error">{error}</div>;
if (!briefing || !meta) return null;
if (meta.source === 'none') {
return <div className="brief-card brief-empty">{meta.reason ?? '暂无可分析的数据'}</div>;
}
const rx = briefing.prescription;
return (
<section className="brief-card">
<header className="brief-head">
<span className="brief-status">{briefing.status}</span>
<SourceBadge meta={meta} />
</header>
{briefing.headline && <p className="brief-headline">{briefing.headline}</p>}
{(rx.suggestion || rx.intensity) && (
<div className="brief-rx">
<span className="brief-rx-label"></span>
<p className="brief-rx-body">
{rx.suggestion}
{rx.intensity && <span className="brief-tag"> {rx.intensity}</span>}
{rx.hrZone && <span className="brief-tag">{rx.hrZone}</span>}
{rx.durationMin != null && <span className="brief-tag">{rx.durationMin} </span>}
</p>
{rx.avoid && <p className="brief-avoid">{rx.avoid}</p>}
</div>
)}
{briefing.shortfall && briefing.shortfall !== '无明显短板' && (
<p className="brief-shortfall">{briefing.shortfall}</p>
)}
{open && (
<div className="brief-detail">
{briefing.diagnosis.map((d) => (
<div className="brief-diag" key={d.title + d.detail}>
<h4 className="brief-sub">{d.title}</h4>
<p>{d.detail}</p>
</div>
))}
{!!briefing.actions.length && (
<div className="brief-actions">
<h4 className="brief-sub"></h4>
<ul>
{briefing.actions.map((a) => <li key={a}>{a}</li>)}
</ul>
</div>
)}
{context && <Deviations context={context} />}
<div className="brief-foot">
<button
className="brief-link"
onClick={() => poll(true)}
disabled={loading}
>
{loading ? '重新生成中…' : '重新生成'}
</button>
{meta.generatedAt && (
<span className="brief-time">
{meta.generatedAt.replace('T', ' ')} UTC
</span>
)}
</div>
</div>
)}
<button
className="brief-toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
{open ? '收起' : '展开深度报告'}
</button>
</section>
);
}
export default AiBriefing;

View File

@@ -0,0 +1,196 @@
/* Health Copilot — a floating dock, portalled to <body>.
The z-index clears Framework7's navbar (500) and tab bar but stays under its
modals (10500-13500), so a dialog is never trapped behind the panel. */
.copilot-wrap {
position: fixed;
right: max(0.9rem, env(safe-area-inset-right));
/* Above the tab bar, which is 50px plus the home indicator. */
bottom: calc(50px + max(0.9rem, env(safe-area-inset-bottom)));
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.6rem;
pointer-events: none;
}
.copilot-wrap > * { pointer-events: auto; }
.copilot-fab {
width: 3rem;
height: 3rem;
border-radius: 50%;
border: none;
background: var(--accent-solid);
color: #fff;
font-size: 0.86rem;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: var(--shadow-lift);
cursor: pointer;
transition: transform 0.18s var(--ease);
}
.copilot-fab:active { transform: scale(0.94); }
.copilot-fab.open { font-size: 1rem; font-weight: 500; }
.copilot-panel {
display: flex;
flex-direction: column;
width: min(23rem, calc(100vw - 1.8rem));
height: min(30rem, calc(100vh - 11rem));
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lift);
overflow: hidden;
animation: copilot-in 0.24s var(--ease) both;
}
@keyframes copilot-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.copilot-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.copilot-title {
font-size: 0.86rem;
font-weight: 680;
color: var(--text-primary);
}
.copilot-close {
background: none;
border: none;
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
padding: 0 0.2rem;
}
.copilot-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 0.8rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.copilot-intro p {
margin: 0 0 0.6rem;
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-muted);
}
.copilot-starter {
display: block;
width: 100%;
text-align: left;
margin-bottom: 0.4rem;
padding: 0.5rem 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text-secondary);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
}
.copilot-starter:active { border-color: var(--accent); color: var(--accent); }
.copilot-msg {
max-width: 88%;
padding: 0.5rem 0.65rem;
border-radius: 12px;
font-size: 0.84rem;
line-height: 1.55;
/* Model replies arrive as Markdown-ish plain text; preserving the newlines
keeps its lists and paragraphs readable without a renderer. */
white-space: pre-wrap;
word-break: break-word;
}
.copilot-user {
align-self: flex-end;
background: var(--accent-solid);
color: #fff;
}
.copilot-assistant {
align-self: flex-start;
background: var(--surface-0);
color: var(--text-primary);
border: 1px solid var(--border);
}
.copilot-failed { color: var(--status-critical); }
.copilot-caret {
display: inline-block;
width: 0.42rem;
height: 0.85em;
margin-left: 0.12rem;
background: var(--accent);
vertical-align: -0.12em;
animation: copilot-blink 1s steps(2) infinite;
}
@keyframes copilot-blink { 50% { opacity: 0; } }
.copilot-note {
margin: 0.1rem 0 0;
font-size: 0.7rem;
line-height: 1.5;
color: var(--text-muted);
}
.copilot-compose {
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.6rem;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.copilot-compose input {
flex: 1;
min-width: 0;
padding: 0.45rem 0.6rem;
font-size: 0.84rem;
color: var(--text-primary);
background: var(--surface-0);
border: 1px solid var(--border);
border-radius: 999px;
outline: none;
}
.copilot-compose input:focus { border-color: var(--accent); }
.copilot-compose button {
flex: none;
padding: 0.45rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background: var(--accent-solid);
border: none;
border-radius: 999px;
cursor: pointer;
}
.copilot-compose button:disabled {
background: var(--border-strong);
cursor: default;
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { apiClient, CopilotTurn } from '../services/api';
import './Copilot.css';
/* Openers, phrased as the questions this data can actually answer. An empty
chat box gets asked nothing; these also teach the shape of question that
works — one about today's state, one about a specific reading, one about
the long arc. */
const STARTERS = [
'我昨晚的睡眠够支撑今天一次高强度训练吗?',
'为什么我的身体电量没有充满?',
'过去一年我的耐力变化,主要是什么驱动的?',
];
interface Message extends CopilotTurn {
/** Set while this answer is still arriving, so the bubble can show a caret
* and the composer can stay disabled. */
streaming?: boolean;
error?: boolean;
}
function Copilot({ date }: { date?: string }) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const abort = useRef<AbortController>();
const scroller = useRef<HTMLDivElement>(null);
/* Follow the tail as text streams in, but only that: jumping the view on
every delta while the user has scrolled up to re-read would fight them. */
const pinned = useRef(true);
useEffect(() => {
const el = scroller.current;
if (el && pinned.current) el.scrollTop = el.scrollHeight;
}, [messages]);
useEffect(() => () => abort.current?.abort(), []);
const onScroll = () => {
const el = scroller.current;
if (!el) return;
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
};
const ask = async (question: string) => {
const text = question.trim();
if (!text || busy) return;
// The history sent upstream is the conversation *before* this question,
// and only completed turns: a half-streamed or failed answer would teach
// the model to continue its own broken reply.
const history = messages
.filter((m) => !m.streaming && !m.error)
.map(({ role, content }) => ({ role, content }));
setDraft('');
setBusy(true);
pinned.current = true;
setMessages((prev) => [
...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: '', streaming: true },
]);
const controller = new AbortController();
abort.current = controller;
const appendToLast = (updater: (m: Message) => Message) =>
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = updater(next[next.length - 1]);
return next;
});
try {
await apiClient.streamCopilot(text, {
history,
date,
signal: controller.signal,
onDelta: (chunk) =>
appendToLast((m) => ({ ...m, content: m.content + chunk })),
});
appendToLast((m) => ({ ...m, streaming: false }));
} catch (err: any) {
const aborted = controller.signal.aborted;
appendToLast((m) => ({
...m,
streaming: false,
error: !aborted,
content: aborted
? m.content || '(已停止)'
: m.content || err?.message || '生成失败',
}));
} finally {
setBusy(false);
abort.current = undefined;
}
};
const panel = (
<div className="copilot-wrap">
{open && (
<div className="copilot-panel" role="dialog" aria-label="健康 Copilot">
<header className="copilot-head">
<span className="copilot-title"> Copilot</span>
<button
className="copilot-close"
onClick={() => setOpen(false)}
aria-label="关闭"
>
</button>
</header>
<div className="copilot-body" ref={scroller} onScroll={onScroll}>
{!messages.length && (
<div className="copilot-intro">
<p>西</p>
{STARTERS.map((s) => (
<button key={s} className="copilot-starter" onClick={() => ask(s)}>
{s}
</button>
))}
</div>
)}
{messages.map((m, i) => (
<div
key={i}
className={`copilot-msg copilot-${m.role}${m.error ? ' copilot-failed' : ''}`}
>
{m.content}
{m.streaming && <span className="copilot-caret" aria-hidden="true" />}
</div>
))}
{busy && (
<p className="copilot-note">
</p>
)}
</div>
<form
className="copilot-compose"
onSubmit={(e) => { e.preventDefault(); ask(draft); }}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="问点关于你自己数据的问题"
disabled={busy}
aria-label="输入问题"
/>
{busy ? (
<button type="button" onClick={() => abort.current?.abort()}>
</button>
) : (
<button type="submit" disabled={!draft.trim()}></button>
)}
</form>
</div>
)}
<button
className={`copilot-fab${open ? ' open' : ''}`}
onClick={() => setOpen((v) => !v)}
aria-label={open ? '收起 Copilot' : '打开健康 Copilot'}
>
{open ? '✕' : 'AI'}
</button>
</div>
);
/* Rendered onto document.body rather than inside the page. Framework7 pages
are transformed during navigation, and a `position: fixed` child of a
transformed ancestor is positioned against that ancestor instead of the
viewport — the button would slide away with the page. */
return createPortal(panel, document.body);
}
export default Copilot;

View File

@@ -0,0 +1,125 @@
/* AI attribution panel, shown under a metric's chart. */
.ti {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.85rem 0.9rem;
margin-bottom: 1.25rem;
}
.ti-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.ti-head .sec-title { margin: 0; }
.ti-hint {
margin: 0.35rem 0 0.6rem;
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-muted);
}
.ti-run {
padding: 0.42rem 0.9rem;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background: var(--accent-solid);
border: none;
border-radius: 999px;
cursor: pointer;
}
.ti-link {
background: none;
border: none;
padding: 0;
font-size: 0.76rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.ti-loading {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.ti-spinner {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
border: 1.6px solid var(--border-strong);
border-top-color: var(--accent);
animation: ti-spin 0.8s linear infinite;
}
@keyframes ti-spin { to { transform: rotate(360deg); } }
.ti-error {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--status-critical);
}
.ti-body { animation: ti-in 0.3s var(--ease) both; }
@keyframes ti-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.ti-summary {
margin: 0.4rem 0 0.7rem;
font-size: 0.86rem;
line-height: 1.55;
color: var(--text-primary);
}
.ti-driver { margin-bottom: 0.55rem; }
.ti-factor {
display: inline-block;
margin-bottom: 0.14rem;
font-size: 0.7rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 6px;
color: var(--accent);
background: var(--accent-soft);
}
.ti-driver p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--text-secondary);
}
.ti-caution {
margin: 0.5rem 0 0;
padding-left: 0.55rem;
border-left: 2px solid var(--status-warning);
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-secondary);
}
.ti-foot {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.7rem;
padding-top: 0.5rem;
border-top: 1px solid var(--grid);
font-size: 0.68rem;
color: var(--text-muted);
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, InsightMeta, TrendInsight as Insight,
} from '../services/api';
import './TrendInsight.css';
/**
* Metric ids the backend's feature engineering knows, keyed by the id this app
* uses. Two names differ (the sleep shares), the rest are identical.
*
* Kept as an explicit list rather than sent optimistically: the endpoint 400s
* on an unknown metric, and a button that reliably fails is worse than no
* button on the metrics this cannot explain.
*/
const BACKEND_METRIC: Record<string, string> = {
steps: 'steps',
intensityMinutes: 'intensityMinutes',
heartRate: 'heartRate',
heartRateVariability: 'heartRateVariability',
stress: 'stress',
bodyBatteryHigh: 'bodyBatteryHigh',
respirationAvg: 'respirationAvg',
sleepDuration: 'sleepDuration',
sleepQuality: 'sleepQuality',
deepShare: 'sleepDeepPct',
remShare: 'sleepRemPct',
trainingReadiness: 'trainingReadiness',
enduranceScore: 'enduranceScore',
};
export function supportsInsight(metricId: string) {
return metricId in BACKEND_METRIC;
}
const CONFIDENCE_LABEL: Record<string, string> = {
high: '证据充分', medium: '证据一般', low: '证据薄弱',
};
interface Props {
/** This app's metric id, e.g. `heartRateVariability`. */
metricId: string;
startDate: string;
endDate: string;
}
/**
* AI attribution for the span currently on the chart.
*
* The product spec asks for this on a brush selection over a desktop chart.
* On a phone the equivalent gesture is the range selector that is already
* there, so the panel explains whatever window the user has selected rather
* than adding a drag interaction that fights the page's own scrolling.
*
* On demand, never on load: one generation takes minutes on the gateway, so
* running it for every metric a user browses past would spend that on nothing.
*/
function TrendInsight({ metricId, startDate, endDate }: Props) {
const [insight, setInsight] = useState<Insight | null>(null);
const [meta, setMeta] = useState<InsightMeta | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const live = useRef(true);
useEffect(() => {
live.current = true;
return () => { live.current = false; };
}, []);
// A new window is a different question; clear the old answer rather than
// leaving it under a chart it no longer describes.
useEffect(() => {
setInsight(null);
setMeta(null);
setError('');
}, [metricId, startDate, endDate]);
const run = async (refresh?: boolean) => {
const backend = BACKEND_METRIC[metricId];
if (!backend || loading) return;
setLoading(true);
setError('');
try {
const data = await apiClient.getTrendInsight(backend, startDate, endDate, refresh);
if (!live.current) return;
setInsight(data.insight);
setMeta(data.meta);
} catch (err: any) {
if (live.current) setError(errorMessage(err, '归因分析失败'));
} finally {
if (live.current) setLoading(false);
}
};
if (!supportsInsight(metricId)) return null;
return (
<section className="ti">
<div className="ti-head">
<h3 className="sec-title">AI </h3>
{insight && !loading && (
<button className="ti-link" onClick={() => run(true)}></button>
)}
</div>
{!insight && !loading && !error && (
<>
<p className="ti-hint">
{startDate} ~ {endDate}
</p>
<button className="ti-run" onClick={() => run()}></button>
</>
)}
{loading && (
<div className="ti-loading">
<span className="ti-spinner" aria-hidden="true" />
25
</div>
)}
{error && <p className="ti-error">{error}</p>}
{insight && (
<div className="ti-body">
{insight.summary && <p className="ti-summary">{insight.summary}</p>}
{insight.drivers.map((d) => (
<div className="ti-driver" key={d.factor + d.detail}>
<span className="ti-factor">{d.factor}</span>
<p>{d.detail}</p>
</div>
))}
{insight.caution && <p className="ti-caution">{insight.caution}</p>}
<div className="ti-foot">
<span>
{meta?.source === 'ai' ? `AI 生成${meta.upstream ? ` · ${meta.upstream}` : ''}` : '规则引擎'}
</span>
<span>{CONFIDENCE_LABEL[insight.confidence]}</span>
</div>
</div>
)}
</section>
);
}
export default TrendInsight;

View File

@@ -1,12 +1,12 @@
/** /**
* Feature switches. * Feature switches.
* *
* `ai` is off while the recommendation module is being reworked: the pages and * `ai` covers the whole coach surface: the 晨间简报 card on 今日, the Copilot
* the backend endpoints still exist, so turning it back on is a one-line * dock, and the trend attribution panel. It is one switch rather than three
* change rather than a rebuild. Nothing links to the route while it is off, * because they share a backend that depends on the ai-gateway being reachable
* and the route itself is not registered — a hidden nav entry with a live URL * — when that box is down, all three degrade together and turning the set off
* would still be reachable by typing it. * is one edit.
*/ */
export const FEATURES = { export const FEATURES = {
ai: false, ai: true,
}; };

View File

@@ -7,7 +7,9 @@ import { daysAgo, today as todayIso } from '../lib/day';
import Chart from '../components/charts/Chart'; import Chart from '../components/charts/Chart';
import BandBar from '../components/charts/BandBar'; import BandBar from '../components/charts/BandBar';
import Skeleton from '../components/Skeleton'; import Skeleton from '../components/Skeleton';
import TrendInsight from '../components/TrendInsight';
import { useCountUp } from '../lib/motion'; import { useCountUp } from '../lib/motion';
import { FEATURES } from '../features';
import './MetricDetail.css'; import './MetricDetail.css';
const WINDOWS = [7, 30, 90, 365]; const WINDOWS = [7, 30, 90, 365];
@@ -183,6 +185,16 @@ function MetricDetailPage({ id, f7route }: Props) {
/> />
</section> </section>
{/* Directly under the chart it explains, and scoped to the same
window the range selector is showing. */}
{FEATURES.ai && (
<TrendInsight
metricId={key}
startDate={daysAgo(window - 1)}
endDate={todayIso()}
/>
)}
<section className="md-about"> <section className="md-about">
<h3 className="sec-title"></h3> <h3 className="sec-title"></h3>
<p>{def.about}</p> <p>{def.about}</p>

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Link, f7 } from 'framework7-react'; import { Link, f7 } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api'; import { apiClient, errorMessage, HealthDay } from '../services/api';
import Screen from '../components/Screen'; import Screen from '../components/Screen';
import AiBriefing from '../components/AiBriefing';
import Ring from '../components/charts/Ring'; import Ring from '../components/charts/Ring';
import MetricCard from '../components/charts/MetricCard'; import MetricCard from '../components/charts/MetricCard';
import MetricStrip from '../components/charts/MetricStrip'; import MetricStrip from '../components/charts/MetricStrip';
@@ -10,6 +11,7 @@ import { useCountUp } from '../lib/motion';
import { RANGES } from '../lib/ranges'; import { RANGES } from '../lib/ranges';
import { METRICS, metricHref } from '../lib/metrics'; import { METRICS, metricHref } from '../lib/metrics';
import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day'; import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day';
import { FEATURES } from '../features';
import './Today.css'; import './Today.css';
/* Enough history for the cards' sparklines and a few weeks of stepping back /* Enough history for the cards' sparklines and a few weeks of stepping back
@@ -283,6 +285,13 @@ function TodayPage() {
<> <>
<RingRow today={today} history={history} /> <RingRow today={today} history={history} />
{/* Under the rings, not above them: the rings are the day's
facts and load instantly, while the briefing is an
interpretation of those facts that may still be generating.
Putting a card that can say "生成中" at the very top would
make the whole screen look unready. */}
{FEATURES.ai && <AiBriefing date={date} />}
{SECTIONS.map((section) => ( {SECTIONS.map((section) => (
<section className="sec" key={section.title}> <section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3> <h3 className="sec-title">{section.title}</h3>

View File

@@ -332,6 +332,106 @@ export interface TrendPoint {
value: number; value: number;
} }
// --- AI coach -------------------------------------------------------------
/** How far one of today's metrics sits from the user's own recent baseline. */
export interface Deviation {
metric: string;
label: string;
unit: string;
value: number;
baselineMean: number | null;
sd: number | null;
baselineDays?: number;
z: number | null;
verdict: string;
}
export interface TrendSummary {
metric: string;
label: string;
unit: string;
days: number;
samples: number;
firstMean: number;
lastMean: number;
delta: number;
slopePer30d: number | null;
direction?: string;
}
/** The computed features a briefing was derived from — the same numbers the
* card quotes, so the UI can show them without a second request. */
export interface BriefingContext {
snapshotDate: string;
userProfile: Record<string, number | string | null>;
todayMetrics: {
sleep: Record<string, number | number[] | null> | null;
autonomicNervous: Record<string, number | null>;
recovery: Record<string, number | null>;
activityToday: Record<string, number | null>;
};
deviations: Deviation[];
trends: TrendSummary[];
activityShift: Record<string, {
label: string; recentMean: number; priorMean: number; changePct: number | null;
}>;
recentActivities: Array<Record<string, string | number | null>>;
dataQuality: { totalDays: number; firstDate: string; lastDate: string; staleDays: number };
}
export interface Briefing {
status: string;
headline: string | null;
diagnosis: Array<{ title: string; detail: string }>;
shortfall: string | null;
prescription: {
intensity: string | null;
hrZone: string | null;
suggestion: string | null;
durationMin: number | null;
avoid: string | null;
};
actions: string[];
}
/** `source` says who answered: the model, or the rule engine standing in for
* it. `pending` means the card on screen is the placeholder and the model's
* version is still being generated — poll again. */
export interface InsightMeta {
source: 'ai' | 'rules' | 'none';
model?: string | null;
upstream?: string | null;
cached?: boolean;
pending?: boolean;
generating?: boolean;
generatedAt?: string | null;
reason?: string;
}
export interface BriefingResponse {
briefing: Briefing | null;
context: BriefingContext | null;
meta: InsightMeta;
}
export interface TrendInsight {
summary: string | null;
drivers: Array<{ factor: string; detail: string }>;
caution: string | null;
confidence: 'high' | 'medium' | 'low';
}
export interface TrendInsightResponse {
insight: TrendInsight | null;
window: Record<string, any> | null;
meta: InsightMeta;
}
export interface CopilotTurn {
role: 'user' | 'assistant';
content: string;
}
/** /**
* Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but * Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but
* with no offset in the string. JavaScript reads such a value as *local* time, * with no offset in the string. JavaScript reads such a value as *local* time,
@@ -675,6 +775,139 @@ class ApiClient {
); );
return data; return data;
} }
// --- AI coach ---
/**
* 晨间简报 for one day, plus the computed context behind it.
*
* Returns immediately. When no stored model answer matches the current data
* the reply is the rule-based briefing with `meta.pending`, and the model's
* version is generated in the background — call again to pick it up.
*/
async getBriefing(opts: { date?: string; refresh?: boolean; model?: string } = {}) {
const { data } = await this.client.get<BriefingResponse>('/analysis/briefing', {
params: {
date: opts.date,
model: opts.model,
...(opts.refresh ? { refresh: 1 } : {}),
},
});
return data;
}
/** Attribution for one metric over a selected span. Blocking: a cold
* generation runs well past axios's default timeout. */
async getTrendInsight(
metric: string, startDate: string, endDate: string, refresh?: boolean
) {
const { data } = await this.client.get<TrendInsightResponse>(
'/analysis/trend-insight',
{
params: { metric, startDate, endDate, ...(refresh ? { refresh: 1 } : {}) },
timeout: 300_000,
}
);
return data;
}
/**
* Ask the Copilot, streamed.
*
* `fetch` rather than axios or EventSource: axios buffers the whole body
* before resolving, and EventSource cannot send an Authorization header —
* which would mean moving the JWT into the query string, where it would be
* logged by every proxy in the path.
*
* `onDelta` is called with each fragment as it arrives. Pass `signal` to
* abort; the promise resolves with the full text.
*/
async streamCopilot(
question: string,
opts: {
history?: CopilotTurn[];
date?: string;
model?: string;
signal?: AbortSignal;
onDelta?: (text: string) => void;
} = {}
): Promise<{ text: string; upstream: string | null }> {
const token = localStorage.getItem(TOKEN_KEY);
const resp = await fetch(`${API_BASE_URL}/analysis/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
question,
history: opts.history ?? [],
date: opts.date,
model: opts.model,
}),
signal: opts.signal,
});
if (!resp.ok || !resp.body) {
let message = `请求失败 (${resp.status})`;
try {
message = (await resp.json()).error || message;
} catch {
// A non-JSON error body (a proxy's HTML 502) leaves the status text.
}
throw new Error(message);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let upstream: string | null = null;
let failure: string | null = null;
// SSE frames are separated by a blank line and can split across chunks,
// so the tail of the buffer is kept until its terminator arrives.
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let split = buffer.indexOf('\n\n');
while (split !== -1) {
const frame = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
split = buffer.indexOf('\n\n');
let event = 'message';
const dataLines: string[] = [];
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
}
if (!dataLines.length) continue;
let payload: any;
try {
payload = JSON.parse(dataLines.join('\n'));
} catch {
continue;
}
if (event === 'delta' && payload.text) {
text += payload.text;
opts.onDelta?.(payload.text);
} else if (event === 'done') {
upstream = payload.upstream ?? null;
} else if (event === 'error') {
// Recorded rather than thrown here: the stream still has to be
// drained, and the server closes it right after this frame.
failure = payload.message || '生成失败';
}
}
}
if (failure) throw new Error(failure);
return { text, upstream };
}
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();