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:
21
PROGRESS.md
21
PROGRESS.md
@@ -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 教练,见下)
|
||||||
- [ ] 数据分析报告生成
|
- [ ] 数据分析报告生成
|
||||||
- [ ] 多用户支持完善
|
- [ ] 多用户支持完善
|
||||||
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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` 会说明本次由谁作答。
|
||||||
|
|
||||||
## 🔐 安全说明
|
## 🔐 安全说明
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ----------------------------------------------------
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
409
backend/services/coach.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
The AI coach: morning briefing, trend attribution, and the Copilot chat.
|
||||||
|
|
||||||
|
Division of labour with `insights.py`: every number quoted here was already
|
||||||
|
computed there. This module only turns a structured context into a prompt and
|
||||||
|
turns the reply back into a structured answer. Nothing asks the model to do
|
||||||
|
arithmetic, because a model asked to derive a z-score from a CSV gets it wrong
|
||||||
|
often enough that the briefing would quote figures the charts contradict.
|
||||||
|
|
||||||
|
Each feature has a rule-based counterpart. A model round-trip through the
|
||||||
|
gateway costs minutes (its primary upstream is a large reasoning model), and a
|
||||||
|
health screen that shows nothing when an upstream is rate-limited is worse than
|
||||||
|
one that shows a plainer answer — so `meta.source` says which one answered
|
||||||
|
rather than the failure being invisible.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from services import ai as ai_svc
|
||||||
|
from services import insights
|
||||||
|
|
||||||
|
SYSTEM = """# 角色
|
||||||
|
你是一名资深运动生理学专家与佳明(Garmin)数据分析教练。你解读用户的可穿戴设备
|
||||||
|
数据,输出严谨、精炼、无废话的生理状态解读与行动指导。
|
||||||
|
|
||||||
|
# 生理学原则
|
||||||
|
1. 训练准备度综合睡眠分数、HRV 状态、恢复时间、急性负荷与压力历史。
|
||||||
|
2. HRV 反映副交感神经活跃度;HRV 高且静息心率低通常代表恢复良好。
|
||||||
|
3. 身体电量的充电量受睡眠质量与深睡/REM 比例影响:深睡负责肌肉与体力恢复,
|
||||||
|
REM 负责认知与精神修复。
|
||||||
|
4. 强度分钟与运动记录代表急性负荷;负荷骤增后 HRV 短暂下降属正常应激反应。
|
||||||
|
|
||||||
|
# 数据纪律
|
||||||
|
- 只使用输入 JSON 中出现的数值,禁止编造或估算任何未给出的数字。
|
||||||
|
- 字段为 null 表示该项未采集,要么略过,要么明确说明"未采集",不要当作 0。
|
||||||
|
- z 值(z)是该指标相对用户自身近 28 天基线的偏离程度,已经算好,直接引用即可,
|
||||||
|
不要自行重算。|z| < 1 属正常波动,不要渲染成异常。
|
||||||
|
- 你不是医生,不做医疗诊断;只从运动恢复、疲劳管理与作息角度给建议。发现明显
|
||||||
|
异常时提示用户咨询专业医师。
|
||||||
|
|
||||||
|
# 输出
|
||||||
|
- 简体中文。
|
||||||
|
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
|
||||||
|
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
|
||||||
|
JSON 之外的任何文字都会被丢弃。"""
|
||||||
|
|
||||||
|
BRIEFING_SCHEMA = """{
|
||||||
|
"status": "对整体恢复状态的定性,不超过 8 字,例如 '恢复良好' / '中等偏上' / '疲劳累积'",
|
||||||
|
"headline": "一句话总结今日身体状态,不超过 40 字",
|
||||||
|
"diagnosis": [
|
||||||
|
{"title": "维度名,如 睡眠结构 / 自主神经 / 电量与就绪度", "detail": "该维度的判断与依据,引用具体数值,不超过 60 字"}
|
||||||
|
],
|
||||||
|
"shortfall": "今日最主要的短板,一句话;若无明显短板则写 '无明显短板'",
|
||||||
|
"prescription": {
|
||||||
|
"intensity": "今日运动强度上限,如 低 / 中等 / 中等偏高 / 高",
|
||||||
|
"hrZone": "建议心率区间,如 'Zone 2~Zone 3';无法判断填 null",
|
||||||
|
"suggestion": "具体运动处方,含项目与时长,不超过 40 字",
|
||||||
|
"durationMin": 建议时长的分钟数(整数)或 null,
|
||||||
|
"avoid": "今日应避免的内容,不超过 20 字;无则填 null"
|
||||||
|
},
|
||||||
|
"actions": ["今日可执行的具体行动,2~4 条,每条不超过 30 字"]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
TREND_SCHEMA = """{
|
||||||
|
"summary": "这段区间内该指标发生了什么,一句话,不超过 50 字",
|
||||||
|
"drivers": [
|
||||||
|
{"factor": "关联因素名", "detail": "它与该指标的关系及依据,引用数值,不超过 60 字"}
|
||||||
|
],
|
||||||
|
"caution": "需要留意的风险或误读;没有则填 null",
|
||||||
|
"confidence": "high|medium|low —— 取决于样本量与关联证据强度"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(context):
|
||||||
|
"""The context as compact JSON.
|
||||||
|
|
||||||
|
`ensure_ascii=False` matters for size as much as readability: escaping
|
||||||
|
Chinese labels to \\uXXXX roughly triples their token cost.
|
||||||
|
"""
|
||||||
|
return json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def briefing_messages(context):
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"以下是我的健康数据快照。deviations 中的 z 值是相对我自身近 28 天\n"
|
||||||
|
"基线的偏离,trends 是长周期走势,activityShift 是近 7 天与之前的\n"
|
||||||
|
"活动量对比。\n\n"
|
||||||
|
f"```json\n{_payload(context)}\n```\n\n"
|
||||||
|
"请给出今日晨间简报与运动处方,严格按以下 JSON 结构输出:\n\n"
|
||||||
|
f"{BRIEFING_SCHEMA}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def trend_messages(window):
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"以下是我 {window['label']} 指标在 {window['start']} ~ {window['end']}\n"
|
||||||
|
"区间的数据,companions 是同区间内其它指标的均值,activities 是该区间\n"
|
||||||
|
"内的运动记录,baselineBefore 是该区间之前的基线。\n\n"
|
||||||
|
f"```json\n{_payload(window)}\n```\n\n"
|
||||||
|
"请解释这段区间内该指标的变化及其可能的驱动因素,严格按以下 JSON\n"
|
||||||
|
f"结构输出:\n\n{TREND_SCHEMA}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
COPILOT_SYSTEM = SYSTEM.replace(
|
||||||
|
"""# 输出
|
||||||
|
- 简体中文。
|
||||||
|
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
|
||||||
|
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
|
||||||
|
JSON 之外的任何文字都会被丢弃。""",
|
||||||
|
"""# 输出
|
||||||
|
- 简体中文,Markdown 格式。
|
||||||
|
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
|
||||||
|
- 控制在 300 字以内,先给结论再给依据。
|
||||||
|
- 引用数值时写明是哪一天或哪个区间的值。
|
||||||
|
- 问题超出所给数据能回答的范围时,直接说明数据里没有,不要猜。""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copilot_messages(context, history, question):
|
||||||
|
"""Chat turns for the Copilot.
|
||||||
|
|
||||||
|
The health context rides in the system turn rather than being prepended to
|
||||||
|
the user's question: it stays out of the visible transcript, and the same
|
||||||
|
snapshot governs every turn instead of being re-sent (and re-charged) with
|
||||||
|
each follow-up.
|
||||||
|
"""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": COPILOT_SYSTEM},
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"以下是提问者的健康数据快照,回答时以它为唯一事实来源:\n"
|
||||||
|
f"```json\n{_payload(context)}\n```"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
# Filtered first, then capped: capping first lets a single unusable entry
|
||||||
|
# in the tail — a tool frame, an empty message — silently cost the model a
|
||||||
|
# remembered turn.
|
||||||
|
usable = [
|
||||||
|
{"role": t["role"], "content": (t.get("content") or "").strip()[:2000]}
|
||||||
|
for t in history
|
||||||
|
if t.get("role") in ("user", "assistant") and (t.get("content") or "").strip()
|
||||||
|
]
|
||||||
|
messages.extend(usable[-8:])
|
||||||
|
messages.append({"role": "user", "content": question[:2000]})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
# --- reply validation -------------------------------------------------------
|
||||||
|
def _text(value, limit):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text[:limit] if text else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_briefing(reply):
|
||||||
|
data = ai_svc.extract_json(reply)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ai_svc.AIError("模型未返回 JSON 对象")
|
||||||
|
|
||||||
|
prescription = data.get("prescription")
|
||||||
|
if not isinstance(prescription, dict):
|
||||||
|
prescription = {}
|
||||||
|
|
||||||
|
duration = prescription.get("durationMin")
|
||||||
|
try:
|
||||||
|
duration = int(duration) if duration is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = None
|
||||||
|
|
||||||
|
diagnosis = []
|
||||||
|
for item in data.get("diagnosis") or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
title = _text(item.get("title"), 20)
|
||||||
|
detail = _text(item.get("detail"), 200)
|
||||||
|
else:
|
||||||
|
title, detail = None, _text(item, 200)
|
||||||
|
if detail:
|
||||||
|
diagnosis.append({"title": title or "综合", "detail": detail})
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
_text(a, 60) for a in (data.get("actions") or []) if _text(a, 60)
|
||||||
|
]
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"status": _text(data.get("status"), 20) or "状态未定性",
|
||||||
|
"headline": _text(data.get("headline"), 120),
|
||||||
|
"diagnosis": diagnosis[:5],
|
||||||
|
"shortfall": _text(data.get("shortfall"), 120),
|
||||||
|
"prescription": {
|
||||||
|
"intensity": _text(prescription.get("intensity"), 20),
|
||||||
|
"hrZone": _text(prescription.get("hrZone"), 40),
|
||||||
|
"suggestion": _text(prescription.get("suggestion"), 120),
|
||||||
|
"durationMin": duration,
|
||||||
|
"avoid": _text(prescription.get("avoid"), 60),
|
||||||
|
},
|
||||||
|
"actions": actions[:4],
|
||||||
|
}
|
||||||
|
# A briefing with neither a headline nor any diagnosis is an empty card;
|
||||||
|
# rejecting it here lets the caller fall back to the rule engine instead
|
||||||
|
# of rendering blank space.
|
||||||
|
if not out["headline"] and not out["diagnosis"]:
|
||||||
|
raise ai_svc.AIError("模型返回的简报没有可用内容")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_trend_insight(reply):
|
||||||
|
data = ai_svc.extract_json(reply)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ai_svc.AIError("模型未返回 JSON 对象")
|
||||||
|
|
||||||
|
drivers = []
|
||||||
|
for item in data.get("drivers") or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
factor = _text(item.get("factor"), 30)
|
||||||
|
detail = _text(item.get("detail"), 200)
|
||||||
|
else:
|
||||||
|
factor, detail = None, _text(item, 200)
|
||||||
|
if detail:
|
||||||
|
drivers.append({"factor": factor or "关联因素", "detail": detail})
|
||||||
|
|
||||||
|
confidence = str(data.get("confidence", "medium")).lower()
|
||||||
|
if confidence not in ("high", "medium", "low"):
|
||||||
|
confidence = "medium"
|
||||||
|
|
||||||
|
summary = _text(data.get("summary"), 200)
|
||||||
|
if not summary and not drivers:
|
||||||
|
raise ai_svc.AIError("模型返回的归因没有可用内容")
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"drivers": drivers[:5],
|
||||||
|
"caution": _text(data.get("caution"), 200),
|
||||||
|
"confidence": confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- rule-based counterparts ------------------------------------------------
|
||||||
|
def rule_briefing(context):
|
||||||
|
"""A briefing assembled from the computed features alone.
|
||||||
|
|
||||||
|
Deliberately quotes the same numbers the AI version would, so a fallback
|
||||||
|
reads as a plainer answer rather than a different one.
|
||||||
|
"""
|
||||||
|
today = context["todayMetrics"]
|
||||||
|
sleep = today["sleep"] or {}
|
||||||
|
nervous = today["autonomicNervous"]
|
||||||
|
recovery = today["recovery"]
|
||||||
|
activity = today["activityToday"]
|
||||||
|
by_metric = {d["metric"]: d for d in context["deviations"]}
|
||||||
|
|
||||||
|
diagnosis = []
|
||||||
|
concerns = []
|
||||||
|
|
||||||
|
duration = sleep.get("durationHours")
|
||||||
|
if duration is not None:
|
||||||
|
target = sleep.get("targetHours") or insights.SLEEP_TARGET_HOURS
|
||||||
|
parts = [f"睡眠 {duration:.1f} 小时(目标 {target:g})"]
|
||||||
|
rem = sleep.get("remPercent")
|
||||||
|
if rem is not None:
|
||||||
|
low, high = insights.REM_REFERENCE_PCT
|
||||||
|
parts.append(f"REM {rem:g}%{'(偏低)' if rem < low else ''}")
|
||||||
|
deep = sleep.get("deepPercent")
|
||||||
|
if deep is not None:
|
||||||
|
low, _ = insights.DEEP_REFERENCE_PCT
|
||||||
|
parts.append(f"深睡 {deep:g}%{'(偏低)' if deep < low else '(达标)'}")
|
||||||
|
diagnosis.append({"title": "睡眠结构", "detail": ",".join(parts) + "。"})
|
||||||
|
if duration < target:
|
||||||
|
concerns.append(f"睡眠比目标少 {target - duration:.1f} 小时")
|
||||||
|
|
||||||
|
hrv, rhr = nervous.get("hrvMs"), nervous.get("restingHr")
|
||||||
|
if hrv is not None or rhr is not None:
|
||||||
|
parts = []
|
||||||
|
if hrv is not None:
|
||||||
|
base = by_metric.get("heartRateVariability", {}).get("baselineMean")
|
||||||
|
parts.append(
|
||||||
|
f"HRV {hrv:g} ms" + (f"(基线 {base:g})" if base is not None else "")
|
||||||
|
)
|
||||||
|
if rhr is not None:
|
||||||
|
base = by_metric.get("heartRate", {}).get("baselineMean")
|
||||||
|
parts.append(
|
||||||
|
f"静息心率 {rhr:g} bpm" + (f"(基线 {base:g})" if base is not None else "")
|
||||||
|
)
|
||||||
|
diagnosis.append({"title": "自主神经", "detail": ",".join(parts) + "。"})
|
||||||
|
|
||||||
|
readiness = recovery.get("trainingReadiness")
|
||||||
|
battery = recovery.get("bodyBatteryPeak")
|
||||||
|
if readiness is not None or battery is not None:
|
||||||
|
parts = []
|
||||||
|
if readiness is not None:
|
||||||
|
parts.append(f"训练准备度 {readiness:g}/100")
|
||||||
|
if battery is not None:
|
||||||
|
parts.append(f"身体电量充至 {battery:g}")
|
||||||
|
diagnosis.append({"title": "恢复与就绪度", "detail": ",".join(parts) + "。"})
|
||||||
|
|
||||||
|
# Readiness is Garmin's own composite of sleep, HRV, recovery time and
|
||||||
|
# acute load, so it drives the prescription wherever it exists; the
|
||||||
|
# sleep/HRV fallback below is only for watches that do not report it.
|
||||||
|
if readiness is not None:
|
||||||
|
if readiness >= 75:
|
||||||
|
intensity, zone, suggestion = "高", "Zone 3~Zone 4", "可安排高强度或长时间训练"
|
||||||
|
elif readiness >= 50:
|
||||||
|
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
|
||||||
|
else:
|
||||||
|
intensity, zone, suggestion = "低", "Zone 1~Zone 2", "以走路或拉伸为主,优先恢复"
|
||||||
|
elif duration is not None and duration < (sleep.get("targetHours") or 7):
|
||||||
|
intensity, zone, suggestion = "中等偏低", "Zone 2", "30 分钟低强度有氧,避免加练"
|
||||||
|
else:
|
||||||
|
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
|
||||||
|
|
||||||
|
actions = []
|
||||||
|
steps, goal = activity.get("steps"), activity.get("stepGoal")
|
||||||
|
if steps is not None and goal and steps < goal:
|
||||||
|
actions.append(f"步数 {steps:,} / 目标 {goal:,},补一段快走")
|
||||||
|
elif steps is not None and steps < 6000:
|
||||||
|
actions.append(f"今日步数 {steps:,},偏低,安排一次散步")
|
||||||
|
if concerns:
|
||||||
|
actions.append("提前 30 分钟入睡,补回睡眠缺口")
|
||||||
|
sedentary = activity.get("sedentaryHours")
|
||||||
|
if sedentary and sedentary >= 8:
|
||||||
|
actions.append(f"久坐 {sedentary:g} 小时,每小时起身活动 3 分钟")
|
||||||
|
shift = context.get("activityShift", {}).get("steps")
|
||||||
|
if shift and shift.get("changePct") is not None and shift["changePct"] <= -20:
|
||||||
|
actions.append(f"近 7 天步数较此前下降 {abs(shift['changePct']):g}%,注意活动量")
|
||||||
|
if not actions:
|
||||||
|
actions.append("各项指标处于常态,保持当前作息与训练安排")
|
||||||
|
|
||||||
|
notable = [
|
||||||
|
d for d in context["deviations"]
|
||||||
|
if d.get("z") is not None and abs(d["z"]) >= insights.Z_NOTABLE
|
||||||
|
]
|
||||||
|
if notable:
|
||||||
|
top = notable[0]
|
||||||
|
status = "存在偏离"
|
||||||
|
headline = (
|
||||||
|
f"{top['label']} {top['value']:g}{top['unit']},"
|
||||||
|
f"偏离近 {top['baselineDays']} 天基线 {abs(top['z']):.1f} 个标准差。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "状态平稳"
|
||||||
|
headline = "各项指标均在个人基线的正常波动范围内。"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"headline": headline,
|
||||||
|
"diagnosis": diagnosis,
|
||||||
|
"shortfall": ";".join(concerns) if concerns else "无明显短板",
|
||||||
|
"prescription": {
|
||||||
|
"intensity": intensity,
|
||||||
|
"hrZone": zone,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
"durationMin": None,
|
||||||
|
"avoid": None,
|
||||||
|
},
|
||||||
|
"actions": actions[:4],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rule_trend_insight(window):
|
||||||
|
"""Trend attribution without a model: direction, size, and co-movement."""
|
||||||
|
slope = window.get("slopePer30d")
|
||||||
|
label, unit = window["label"], window["unit"]
|
||||||
|
if slope is None:
|
||||||
|
summary = f"{window['start']} ~ {window['end']} 区间内 {label} 样本不足,无法判断趋势。"
|
||||||
|
else:
|
||||||
|
direction = "上升" if slope > 0 else ("下降" if slope < 0 else "基本持平")
|
||||||
|
summary = (
|
||||||
|
f"{label} 在该区间{direction},拟合斜率约 {slope:g}{unit}/30 天,"
|
||||||
|
f"均值 {window['mean']:g}{unit}。"
|
||||||
|
)
|
||||||
|
|
||||||
|
drivers = []
|
||||||
|
baseline = window.get("baselineBefore")
|
||||||
|
if baseline and window.get("mean") is not None:
|
||||||
|
delta = window["mean"] - baseline["mean"]
|
||||||
|
drivers.append({
|
||||||
|
"factor": "区间前基线",
|
||||||
|
"detail": (
|
||||||
|
f"区间前 {baseline['days']} 天均值 {baseline['mean']:g}{unit},"
|
||||||
|
f"区间内{'高出' if delta >= 0 else '低于'} {abs(delta):.2f}{unit}。"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
activities = window.get("activities") or []
|
||||||
|
if activities:
|
||||||
|
minutes = sum(a.get("durationMin") or 0 for a in activities)
|
||||||
|
drivers.append({
|
||||||
|
"factor": "运动负荷",
|
||||||
|
"detail": f"该区间共 {len(activities)} 次运动,合计约 {minutes} 分钟。",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"drivers": drivers,
|
||||||
|
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
|
||||||
|
"confidence": "low",
|
||||||
|
}
|
||||||
461
backend/services/insights.py
Normal file
461
backend/services/insights.py
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
"""
|
||||||
|
Feature engineering for the AI coach.
|
||||||
|
|
||||||
|
Everything here is arithmetic over stored health data — no model calls. The
|
||||||
|
split is deliberate: the numbers a briefing quotes (z-scores, baselines,
|
||||||
|
trend slopes) must be reproducible and testable, and an LLM asked to compute
|
||||||
|
them from a raw CSV gets them wrong often enough to matter. The model's job
|
||||||
|
is to interpret figures that were already computed here, not to derive them.
|
||||||
|
|
||||||
|
Two windows are used throughout:
|
||||||
|
|
||||||
|
* **baseline** (default 28 days) — what "normal for this person, lately"
|
||||||
|
means. Short enough to track a training block, long enough for a standard
|
||||||
|
deviation to be worth quoting.
|
||||||
|
* **trend** (default 395 days ≈ 13 months) — the long arc the product spec
|
||||||
|
asks about, and long enough to contain a full season.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
from services import health
|
||||||
|
from services import settings as settings_svc
|
||||||
|
from services import fitness_age
|
||||||
|
|
||||||
|
BASELINE_DAYS = 28
|
||||||
|
TREND_DAYS = 395
|
||||||
|
|
||||||
|
# Sleep targets are personal, but Garmin's own coaching and the ACSM/AASM
|
||||||
|
# adult guidance both land on 7 hours as the floor; the deep/REM shares are
|
||||||
|
# the conventional adult reference bands.
|
||||||
|
SLEEP_TARGET_HOURS = 7.0
|
||||||
|
REM_REFERENCE_PCT = (20.0, 25.0)
|
||||||
|
DEEP_REFERENCE_PCT = (13.0, 23.0)
|
||||||
|
|
||||||
|
# |z| beyond this counts as a departure from the personal baseline rather
|
||||||
|
# than day-to-day noise. 1.0 rather than the textbook 2.0: with a 28-day
|
||||||
|
# window a 2-sigma day is roughly a once-a-month event, which is too rare to
|
||||||
|
# drive a daily briefing.
|
||||||
|
Z_NOTABLE = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(day):
|
||||||
|
"""One day as a flat metric -> value mapping.
|
||||||
|
|
||||||
|
`get_summary` nests sleep and omits missing metrics entirely; both are
|
||||||
|
inconvenient for statistics, so sleep is lifted to the top level and
|
||||||
|
derived shares (deep/REM percent) are computed once here.
|
||||||
|
"""
|
||||||
|
flat = {k: v for k, v in day.items() if k != "sleep"}
|
||||||
|
sleep = day.get("sleep") or {}
|
||||||
|
duration = sleep.get("duration") or day.get("sleepDuration")
|
||||||
|
if duration:
|
||||||
|
flat["sleepDuration"] = duration
|
||||||
|
seconds = duration * 3600.0
|
||||||
|
for src, dest in (
|
||||||
|
("deepSeconds", "sleepDeepPct"),
|
||||||
|
("remSeconds", "sleepRemPct"),
|
||||||
|
("lightSeconds", "sleepLightPct"),
|
||||||
|
("awakeSeconds", "sleepAwakePct"),
|
||||||
|
):
|
||||||
|
value = sleep.get(src)
|
||||||
|
if value is not None and seconds > 0:
|
||||||
|
flat[dest] = round(value / seconds * 100, 1)
|
||||||
|
if sleep.get("quality") is not None:
|
||||||
|
flat["sleepQuality"] = sleep["quality"]
|
||||||
|
sedentary = day.get("sedentarySeconds")
|
||||||
|
if sedentary is not None:
|
||||||
|
flat["sedentaryHours"] = round(sedentary / 3600.0, 1)
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
# Metrics the briefing reasons about. `higher_better` drives the plain-language
|
||||||
|
# verdict; None means the direction is not meaningful on its own (steps on a
|
||||||
|
# rest day are not a failure).
|
||||||
|
METRICS = {
|
||||||
|
"sleepDuration": ("睡眠时长", "小时", True),
|
||||||
|
"sleepQuality": ("睡眠评分", "分", True),
|
||||||
|
"sleepDeepPct": ("深睡占比", "%", True),
|
||||||
|
"sleepRemPct": ("REM 占比", "%", True),
|
||||||
|
"heartRate": ("静息心率", "bpm", False),
|
||||||
|
"heartRateVariability": ("HRV", "ms", True),
|
||||||
|
"stress": ("压力均值", "", False),
|
||||||
|
"bodyBatteryHigh": ("身体电量峰值", "", True),
|
||||||
|
"bodyBatteryLow": ("身体电量谷值", "", True),
|
||||||
|
"trainingReadiness": ("训练准备度", "分", True),
|
||||||
|
"enduranceScore": ("耐力分", "分", True),
|
||||||
|
"vo2max": ("最大摄氧量", "ml/kg/min", True),
|
||||||
|
"steps": ("步数", "步", None),
|
||||||
|
"intensityMinutes": ("强度分钟", "分钟", None),
|
||||||
|
"respirationAvg": ("呼吸频率", "次/分", None),
|
||||||
|
"spo2Avg": ("血氧", "%", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _series(rows, metric):
|
||||||
|
"""(date, value) pairs where the metric was actually recorded."""
|
||||||
|
return [(r["date"], r[metric]) for r in rows if r.get(metric) is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _stats(values):
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
mean = statistics.fmean(values)
|
||||||
|
# pstdev, not stdev: these are all the observations in the window, not a
|
||||||
|
# sample drawn from it, and stdev raises on a single point.
|
||||||
|
sd = statistics.pstdev(values) if len(values) > 1 else 0.0
|
||||||
|
return {"mean": mean, "sd": sd, "n": len(values)}
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(z, higher_better):
|
||||||
|
if higher_better is None or abs(z) < Z_NOTABLE:
|
||||||
|
return "正常"
|
||||||
|
if (z > 0) == bool(higher_better):
|
||||||
|
return "偏好"
|
||||||
|
return "偏差"
|
||||||
|
|
||||||
|
|
||||||
|
def deviations(rows, today, baseline_days=BASELINE_DAYS):
|
||||||
|
"""How far each of today's metrics sits from its own recent baseline.
|
||||||
|
|
||||||
|
The baseline deliberately excludes today: comparing a value against a mean
|
||||||
|
it helped produce shrinks its own z-score, and with a 28-day window that
|
||||||
|
bias is large enough to hide a genuine outlier.
|
||||||
|
"""
|
||||||
|
history = [r for r in rows if r["date"] < today.get("date", "")]
|
||||||
|
window = history[-baseline_days:]
|
||||||
|
out = []
|
||||||
|
for metric, (label, unit, higher_better) in METRICS.items():
|
||||||
|
value = today.get(metric)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
values = [v for _, v in _series(window, metric)]
|
||||||
|
stats = _stats(values)
|
||||||
|
if not stats or stats["n"] < 5:
|
||||||
|
# Too little history for a standard deviation to mean anything.
|
||||||
|
out.append({
|
||||||
|
"metric": metric, "label": label, "unit": unit,
|
||||||
|
"value": round(float(value), 2), "baselineMean": None,
|
||||||
|
"sd": None, "z": None, "verdict": "基线不足",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
sd = stats["sd"]
|
||||||
|
if sd > 0:
|
||||||
|
z = round((float(value) - stats["mean"]) / sd, 2)
|
||||||
|
verdict = _verdict(z, higher_better)
|
||||||
|
else:
|
||||||
|
# A baseline with no spread cannot scale a departure. Reporting
|
||||||
|
# z = 0 here would label a value that differs from every single
|
||||||
|
# observation as perfectly typical, which is the opposite of true.
|
||||||
|
z = None
|
||||||
|
verdict = "正常" if float(value) == stats["mean"] else "基线无波动"
|
||||||
|
out.append({
|
||||||
|
"metric": metric, "label": label, "unit": unit,
|
||||||
|
"value": round(float(value), 2),
|
||||||
|
"baselineMean": round(stats["mean"], 2),
|
||||||
|
"sd": round(sd, 2),
|
||||||
|
"baselineDays": stats["n"],
|
||||||
|
"z": z,
|
||||||
|
"verdict": verdict,
|
||||||
|
})
|
||||||
|
# Biggest departures first: that ordering is what the prompt relies on to
|
||||||
|
# keep the interesting metrics inside the model's attention span.
|
||||||
|
out.sort(key=lambda d: abs(d["z"]) if d["z"] is not None else -1, reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slope_per_30d(points):
|
||||||
|
"""Least-squares slope in units per 30 days.
|
||||||
|
|
||||||
|
Ordinal dates rather than array indices: gaps in the record (a watch left
|
||||||
|
on the charger for a week) would otherwise compress the x-axis and inflate
|
||||||
|
the slope.
|
||||||
|
"""
|
||||||
|
if len(points) < 3:
|
||||||
|
return None
|
||||||
|
xs = [datetime.date.fromisoformat(d).toordinal() for d, _ in points]
|
||||||
|
ys = [float(v) for _, v in points]
|
||||||
|
mx, my = statistics.fmean(xs), statistics.fmean(ys)
|
||||||
|
denom = sum((x - mx) ** 2 for x in xs)
|
||||||
|
if denom == 0:
|
||||||
|
return None
|
||||||
|
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom
|
||||||
|
return round(slope * 30, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def trends(rows, window_days=TREND_DAYS, edge=30):
|
||||||
|
"""Long-arc movement per metric: endpoint means plus a fitted slope.
|
||||||
|
|
||||||
|
Endpoint means (first `edge` days vs last `edge` days) answer "where did
|
||||||
|
this end up"; the slope answers "was it a trend or two different plateaus".
|
||||||
|
Reporting only one of them has misled us before — a metric can finish
|
||||||
|
higher after months of decline if it spikes in the final week.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
cutoff = (
|
||||||
|
datetime.date.fromisoformat(rows[-1]["date"])
|
||||||
|
- datetime.timedelta(days=window_days)
|
||||||
|
).isoformat()
|
||||||
|
window = [r for r in rows if r["date"] >= cutoff]
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for metric, (label, unit, higher_better) in METRICS.items():
|
||||||
|
points = _series(window, metric)
|
||||||
|
if len(points) < 10:
|
||||||
|
continue
|
||||||
|
# With fewer than two edges' worth of points the two windows would
|
||||||
|
# overlap and both converge on the overall mean — reporting delta 0
|
||||||
|
# for a series that visibly moved. Split it in half instead.
|
||||||
|
span = min(edge, len(points) // 2)
|
||||||
|
head = [v for _, v in points[:span]]
|
||||||
|
tail = [v for _, v in points[-span:]]
|
||||||
|
first, last = statistics.fmean(head), statistics.fmean(tail)
|
||||||
|
delta = last - first
|
||||||
|
entry = {
|
||||||
|
"metric": metric, "label": label, "unit": unit,
|
||||||
|
"days": (
|
||||||
|
datetime.date.fromisoformat(points[-1][0])
|
||||||
|
- datetime.date.fromisoformat(points[0][0])
|
||||||
|
).days,
|
||||||
|
"samples": len(points),
|
||||||
|
"firstMean": round(first, 2),
|
||||||
|
"lastMean": round(last, 2),
|
||||||
|
"delta": round(delta, 2),
|
||||||
|
"slopePer30d": _slope_per_30d(points),
|
||||||
|
}
|
||||||
|
if higher_better is not None and abs(delta) > 0:
|
||||||
|
entry["direction"] = "改善" if (delta > 0) == bool(higher_better) else "退步"
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def activity_shift(rows, recent=7, prior=30):
|
||||||
|
"""Recent activity volume against the weeks before it.
|
||||||
|
|
||||||
|
Separate from `deviations` because the question is different: not "is today
|
||||||
|
unusual" but "has the last week as a whole dropped off" — the drop that a
|
||||||
|
single quiet day cannot show.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for metric in ("steps", "intensityMinutes", "sleepDuration", "bodyBatteryHigh"):
|
||||||
|
points = _series(rows, metric)
|
||||||
|
if len(points) < recent + 5:
|
||||||
|
continue
|
||||||
|
recent_values = [v for _, v in points[-recent:]]
|
||||||
|
prior_values = [v for _, v in points[-(recent + prior):-recent]]
|
||||||
|
if not prior_values:
|
||||||
|
continue
|
||||||
|
r_mean, p_mean = statistics.fmean(recent_values), statistics.fmean(prior_values)
|
||||||
|
out[metric] = {
|
||||||
|
"label": METRICS[metric][0],
|
||||||
|
"recentMean": round(r_mean, 2),
|
||||||
|
"priorMean": round(p_mean, 2),
|
||||||
|
"changePct": round((r_mean - p_mean) / p_mean * 100, 1) if p_mean else None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sleep_block(today):
|
||||||
|
duration = today.get("sleepDuration")
|
||||||
|
if duration is None:
|
||||||
|
return None
|
||||||
|
block = {
|
||||||
|
"durationHours": round(float(duration), 2),
|
||||||
|
"targetHours": SLEEP_TARGET_HOURS,
|
||||||
|
"score": today.get("sleepQuality"),
|
||||||
|
"deepPercent": today.get("sleepDeepPct"),
|
||||||
|
"remPercent": today.get("sleepRemPct"),
|
||||||
|
"lightPercent": today.get("sleepLightPct"),
|
||||||
|
"awakePercent": today.get("sleepAwakePct"),
|
||||||
|
"remReference": list(REM_REFERENCE_PCT),
|
||||||
|
"deepReference": list(DEEP_REFERENCE_PCT),
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def latest_of(rows, metric, within=180):
|
||||||
|
"""Most recent recorded value, for metrics that only refresh occasionally.
|
||||||
|
|
||||||
|
VO2max and endurance score update after a qualifying outdoor session, so
|
||||||
|
reading them off "today" yields None on any indoor or rest day even though
|
||||||
|
the last measured value is still the current one.
|
||||||
|
"""
|
||||||
|
for row in reversed(rows[-within:] if within else rows):
|
||||||
|
if row.get(metric) is not None:
|
||||||
|
return row[metric]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(user_id, date=None, rows=None):
|
||||||
|
"""The structured payload every coach prompt is assembled from.
|
||||||
|
|
||||||
|
`date` selects the snapshot day; the default is the newest day on record
|
||||||
|
rather than the calendar date, because a sync may not have run yet today
|
||||||
|
and an empty snapshot produces a briefing about nothing.
|
||||||
|
"""
|
||||||
|
rows = rows if rows is not None else health.get_summary(user_id)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
flat = [_flatten(r) for r in rows]
|
||||||
|
if date:
|
||||||
|
matches = [r for r in flat if r["date"] == date]
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
today = matches[0]
|
||||||
|
history = [r for r in flat if r["date"] <= date]
|
||||||
|
else:
|
||||||
|
today = flat[-1]
|
||||||
|
history = flat
|
||||||
|
|
||||||
|
profile = settings_svc.get_raw(user_id)
|
||||||
|
age = settings_svc.age_from(profile["birth_date"])
|
||||||
|
bmi = settings_svc.bmi_from(profile["height_cm"], profile["weight_kg"])
|
||||||
|
vo2max = latest_of(history, "vo2max")
|
||||||
|
body_age = fitness_age.estimate(
|
||||||
|
age=age, sex=profile["sex"], vo2max=vo2max,
|
||||||
|
resting_hr=latest_of(history, "heartRate", within=30), bmi=bmi,
|
||||||
|
)
|
||||||
|
|
||||||
|
start = (
|
||||||
|
datetime.date.fromisoformat(today["date"]) - datetime.timedelta(days=14)
|
||||||
|
).isoformat()
|
||||||
|
recent_activities = health.get_activities(user_id, start, today["date"])
|
||||||
|
|
||||||
|
sedentary = today.get("sedentaryHours")
|
||||||
|
return {
|
||||||
|
"snapshotDate": today["date"],
|
||||||
|
"userProfile": {
|
||||||
|
"age": age,
|
||||||
|
"sex": profile["sex"],
|
||||||
|
# `estimate` returns {"value": None, "missing": [...]} when the
|
||||||
|
# profile is incomplete, so this is None rather than a number
|
||||||
|
# until a birth date, sex and a VO2max reading all exist.
|
||||||
|
"fitnessAge": (body_age or {}).get("value"),
|
||||||
|
"vo2max": vo2max,
|
||||||
|
"enduranceScore": latest_of(history, "enduranceScore"),
|
||||||
|
"heightCm": profile["height_cm"],
|
||||||
|
"weightKg": profile["weight_kg"],
|
||||||
|
"bmi": bmi,
|
||||||
|
},
|
||||||
|
"todayMetrics": {
|
||||||
|
"sleep": _sleep_block(today),
|
||||||
|
"autonomicNervous": {
|
||||||
|
"restingHr": today.get("heartRate"),
|
||||||
|
"hrvMs": today.get("heartRateVariability"),
|
||||||
|
"stressAvg": today.get("stress"),
|
||||||
|
"stressMax": today.get("stressMax"),
|
||||||
|
"respirationAvg": today.get("respirationAvg"),
|
||||||
|
"spo2Avg": today.get("spo2Avg"),
|
||||||
|
},
|
||||||
|
"recovery": {
|
||||||
|
"bodyBatteryPeak": today.get("bodyBatteryHigh"),
|
||||||
|
"bodyBatteryLow": today.get("bodyBatteryLow"),
|
||||||
|
"bodyBatteryCharged": today.get("bodyBatteryCharged"),
|
||||||
|
"bodyBatteryDrained": today.get("bodyBatteryDrained"),
|
||||||
|
"trainingReadiness": today.get("trainingReadiness"),
|
||||||
|
},
|
||||||
|
"activityToday": {
|
||||||
|
"steps": today.get("steps"),
|
||||||
|
"stepGoal": today.get("stepGoal"),
|
||||||
|
"intensityMinutes": today.get("intensityMinutes"),
|
||||||
|
"sedentaryHours": sedentary,
|
||||||
|
"floorsAscended": today.get("floorsAscended"),
|
||||||
|
"caloriesBurned": today.get("caloriesBurned"),
|
||||||
|
"activeCalories": today.get("activeCalories"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"deviations": deviations(history, today),
|
||||||
|
"trends": trends(history),
|
||||||
|
"activityShift": activity_shift(history),
|
||||||
|
"recentActivities": [
|
||||||
|
{
|
||||||
|
"date": a.get("start_time"),
|
||||||
|
"sport": a.get("activity_type"),
|
||||||
|
"durationMin": round((a.get("duration") or 0) / 60) or None,
|
||||||
|
"distanceKm": (
|
||||||
|
round(a["distance"] / 1000, 2) if a.get("distance") else None
|
||||||
|
),
|
||||||
|
"calories": a.get("calories"),
|
||||||
|
"avgHr": a.get("heart_rate_average"),
|
||||||
|
"maxHr": a.get("heart_rate_max"),
|
||||||
|
}
|
||||||
|
for a in recent_activities[-15:]
|
||||||
|
],
|
||||||
|
"dataQuality": {
|
||||||
|
"totalDays": len(history),
|
||||||
|
"firstDate": history[0]["date"],
|
||||||
|
"lastDate": history[-1]["date"],
|
||||||
|
"staleDays": (
|
||||||
|
datetime.date.today()
|
||||||
|
- datetime.date.fromisoformat(history[-1]["date"])
|
||||||
|
).days,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def window_context(user_id, metric, start, end, rows=None):
|
||||||
|
"""Context for one metric over a user-selected span (chart brush).
|
||||||
|
|
||||||
|
Narrower than `build_context` on purpose: the question being answered is
|
||||||
|
"what happened to this line here", so the payload carries the selected
|
||||||
|
series plus whatever else moved alongside it in the same window.
|
||||||
|
"""
|
||||||
|
# An unknown metric would otherwise produce a well-formed window with an
|
||||||
|
# empty series, and the model would dutifully write an attribution for a
|
||||||
|
# line that does not exist.
|
||||||
|
if metric not in METRICS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rows = rows if rows is not None else health.get_summary(user_id)
|
||||||
|
flat = [_flatten(r) for r in rows]
|
||||||
|
window = [r for r in flat if start <= r["date"] <= end]
|
||||||
|
if not window:
|
||||||
|
return None
|
||||||
|
|
||||||
|
label, unit, _ = METRICS[metric]
|
||||||
|
points = _series(window, metric)
|
||||||
|
before = [r for r in flat if r["date"] < start][-BASELINE_DAYS:]
|
||||||
|
baseline = _stats([v for _, v in _series(before, metric)])
|
||||||
|
|
||||||
|
companions = {}
|
||||||
|
for other in METRICS:
|
||||||
|
if other == metric:
|
||||||
|
continue
|
||||||
|
values = [v for _, v in _series(window, other)]
|
||||||
|
stats = _stats(values)
|
||||||
|
if stats and stats["n"] >= 3:
|
||||||
|
companions[other] = {
|
||||||
|
"label": METRICS[other][0],
|
||||||
|
"mean": round(stats["mean"], 2),
|
||||||
|
"n": stats["n"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metric": metric,
|
||||||
|
"label": label,
|
||||||
|
"unit": unit,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"points": [{"date": d, "value": v} for d, v in points],
|
||||||
|
"mean": round(statistics.fmean([v for _, v in points]), 2) if points else None,
|
||||||
|
"min": min((v for _, v in points), default=None),
|
||||||
|
"max": max((v for _, v in points), default=None),
|
||||||
|
"slopePer30d": _slope_per_30d(points),
|
||||||
|
"baselineBefore": (
|
||||||
|
{"mean": round(baseline["mean"], 2), "days": baseline["n"]}
|
||||||
|
if baseline else None
|
||||||
|
),
|
||||||
|
"companions": companions,
|
||||||
|
"activities": [
|
||||||
|
{
|
||||||
|
"date": a.get("start_time"),
|
||||||
|
"sport": a.get("activity_type"),
|
||||||
|
"durationMin": round((a.get("duration") or 0) / 60) or None,
|
||||||
|
"calories": a.get("calories"),
|
||||||
|
"avgHr": a.get("heart_rate_average"),
|
||||||
|
}
|
||||||
|
for a in health.get_activities(user_id, start, end)[:40]
|
||||||
|
],
|
||||||
|
}
|
||||||
706
backend/tests/test_coach.py
Normal file
706
backend/tests/test_coach.py
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the AI coach: feature engineering, prompt parsing, and the
|
||||||
|
briefing / trend-insight / Copilot endpoints.
|
||||||
|
|
||||||
|
Every model call is mocked. The suite never reaches the ai-gateway, so it is
|
||||||
|
neither slow nor dependent on that box being up.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services import ai as ai_svc
|
||||||
|
from services import analysis as analysis_svc
|
||||||
|
from services import coach
|
||||||
|
from services import insights
|
||||||
|
|
||||||
|
|
||||||
|
def day(date, **metrics):
|
||||||
|
"""One row in the shape `health.get_summary` returns."""
|
||||||
|
sleep = metrics.pop("sleep", None)
|
||||||
|
row = {"date": date, **metrics}
|
||||||
|
row["sleep"] = sleep
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def flat_days(n, start=1, **series):
|
||||||
|
"""`n` consecutive days from 2026-08-01, each metric a constant or list."""
|
||||||
|
rows = []
|
||||||
|
for i in range(n):
|
||||||
|
values = {}
|
||||||
|
for key, value in series.items():
|
||||||
|
values[key] = value[i] if isinstance(value, list) else value
|
||||||
|
rows.append(day(f"2026-08-{start + i:02d}", **values))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# --- feature engineering ----------------------------------------------------
|
||||||
|
class TestFlatten:
|
||||||
|
def test_sleep_stages_become_percentages_of_time_asleep(self):
|
||||||
|
row = day("2026-08-01", sleep={
|
||||||
|
"duration": 8.0, "quality": 80, "deepSeconds": 3600,
|
||||||
|
"remSeconds": 7200, "lightSeconds": None, "awakeSeconds": None,
|
||||||
|
})
|
||||||
|
flat = insights._flatten(row)
|
||||||
|
assert flat["sleepDeepPct"] == 12.5
|
||||||
|
assert flat["sleepRemPct"] == 25.0
|
||||||
|
|
||||||
|
def test_missing_stage_is_absent_not_zero(self):
|
||||||
|
flat = insights._flatten(day("2026-08-01", sleep={"duration": 7.0}))
|
||||||
|
assert "sleepDeepPct" not in flat
|
||||||
|
|
||||||
|
def test_no_sleep_record_leaves_no_sleep_fields(self):
|
||||||
|
flat = insights._flatten(day("2026-08-01", steps=100))
|
||||||
|
assert "sleepDuration" not in flat
|
||||||
|
|
||||||
|
def test_sedentary_seconds_become_hours(self):
|
||||||
|
flat = insights._flatten(day("2026-08-01", sedentarySeconds=5400))
|
||||||
|
assert flat["sedentaryHours"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviations:
|
||||||
|
def test_z_score_measures_departure_from_the_personal_baseline(self):
|
||||||
|
# A baseline that varies, as real data does: mean 1000, sd 100.
|
||||||
|
baseline = [900, 1000, 1100, 900, 1000, 1100, 900, 1000, 1100, 1000]
|
||||||
|
history = [
|
||||||
|
insights._flatten(r) for r in flat_days(11, steps=baseline + [1800])
|
||||||
|
]
|
||||||
|
result = {d["metric"]: d for d in insights.deviations(history, history[-1])}
|
||||||
|
assert result["steps"]["baselineMean"] == 1000
|
||||||
|
assert result["steps"]["z"] > 3
|
||||||
|
|
||||||
|
def test_today_is_excluded_from_its_own_baseline(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
8, heartRate=[60, 60, 60, 60, 60, 60, 60, 70]
|
||||||
|
)]
|
||||||
|
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||||
|
# Including today would pull the mean up to 61.25 and shrink the z.
|
||||||
|
assert result["heartRate"]["baselineMean"] == 60
|
||||||
|
|
||||||
|
def test_too_little_history_reports_insufficient_baseline(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(3, steps=5000)]
|
||||||
|
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||||
|
assert result["steps"]["verdict"] == "基线不足"
|
||||||
|
assert result["steps"]["z"] is None
|
||||||
|
|
||||||
|
def test_a_flat_baseline_reports_no_z_rather_than_a_fabricated_zero(self):
|
||||||
|
"""Dividing by a zero standard deviation is undefined; calling the day
|
||||||
|
'z = 0' would label a genuine departure as perfectly typical."""
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(8, steps=[5000] * 7 + [9000])]
|
||||||
|
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||||
|
assert result["steps"]["z"] is None
|
||||||
|
assert result["steps"]["verdict"] == "基线无波动"
|
||||||
|
|
||||||
|
def test_a_flat_baseline_matched_exactly_is_just_normal(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(8, steps=5000)]
|
||||||
|
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||||
|
assert result["steps"]["verdict"] == "正常"
|
||||||
|
|
||||||
|
def test_direction_is_judged_per_metric_not_by_sign(self):
|
||||||
|
wobble = [58, 60, 62, 58, 60, 62, 60]
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
8,
|
||||||
|
heartRate=wobble + [75],
|
||||||
|
heartRateVariability=[38, 40, 42, 38, 40, 42, 40] + [55],
|
||||||
|
)]
|
||||||
|
result = {d["metric"]: d for d in insights.deviations(rows, rows[-1])}
|
||||||
|
# Both moved up; only one of them is good news.
|
||||||
|
assert result["heartRate"]["verdict"] == "偏差"
|
||||||
|
assert result["heartRateVariability"]["verdict"] == "偏好"
|
||||||
|
|
||||||
|
def test_largest_departure_comes_first(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
8, steps=[5000] * 7 + [5100], heartRate=[60] * 7 + [80]
|
||||||
|
)]
|
||||||
|
result = insights.deviations(rows, rows[-1])
|
||||||
|
assert result[0]["metric"] == "heartRate"
|
||||||
|
|
||||||
|
def test_metrics_absent_today_are_omitted(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(8, steps=5000)]
|
||||||
|
assert all(d["metric"] == "steps" for d in insights.deviations(rows, rows[-1]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrends:
|
||||||
|
def test_slope_is_reported_per_thirty_days(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
30, heartRateVariability=[40 + i for i in range(30)]
|
||||||
|
)]
|
||||||
|
entry = {t["metric"]: t for t in insights.trends(rows)}
|
||||||
|
# One unit a day is 30 per 30 days.
|
||||||
|
assert entry["heartRateVariability"]["slopePer30d"] == pytest.approx(30, abs=0.5)
|
||||||
|
|
||||||
|
def test_endpoint_windows_do_not_overlap_on_short_histories(self):
|
||||||
|
"""A 30-day history must not report delta 0 for a line that moved."""
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
30, steps=[1000 + i * 100 for i in range(30)]
|
||||||
|
)]
|
||||||
|
entry = {t["metric"]: t for t in insights.trends(rows)}["steps"]
|
||||||
|
assert entry["firstMean"] < entry["lastMean"]
|
||||||
|
assert entry["delta"] > 0
|
||||||
|
|
||||||
|
def test_direction_respects_which_way_is_better(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
30, heartRate=[80 - i for i in range(30)]
|
||||||
|
)]
|
||||||
|
entry = {t["metric"]: t for t in insights.trends(rows)}["heartRate"]
|
||||||
|
assert entry["direction"] == "改善"
|
||||||
|
|
||||||
|
def test_metrics_with_too_few_samples_are_skipped(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(5, steps=1000)]
|
||||||
|
assert insights.trends(rows) == []
|
||||||
|
|
||||||
|
def test_gaps_do_not_compress_the_x_axis(self):
|
||||||
|
"""Ordinal dates, not indices: the same rise spread over a longer span
|
||||||
|
is a gentler slope, and indices would score the two identically."""
|
||||||
|
values = [1000 + i * 100 for i in range(10)]
|
||||||
|
dense = [(f"2026-08-{1 + i:02d}", v) for i, v in enumerate(values)]
|
||||||
|
# Same ten readings, but the last five sit a month later.
|
||||||
|
gapped = dense[:5] + [
|
||||||
|
(f"2026-09-{6 + i:02d}", v) for i, v in enumerate(values[5:])
|
||||||
|
]
|
||||||
|
assert insights._slope_per_30d(gapped) < insights._slope_per_30d(dense)
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivityShift:
|
||||||
|
def test_compares_the_last_week_with_the_weeks_before_it(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(
|
||||||
|
20, steps=[10000] * 13 + [5000] * 7
|
||||||
|
)]
|
||||||
|
shift = insights.activity_shift(rows)
|
||||||
|
assert shift["steps"]["recentMean"] == 5000
|
||||||
|
assert shift["steps"]["priorMean"] == 10000
|
||||||
|
assert shift["steps"]["changePct"] == -50.0
|
||||||
|
|
||||||
|
def test_absent_when_there_is_not_enough_history(self):
|
||||||
|
rows = [insights._flatten(r) for r in flat_days(6, steps=8000)]
|
||||||
|
assert "steps" not in insights.activity_shift(rows)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWindowContext:
|
||||||
|
def test_unknown_metric_returns_nothing(self, db, user):
|
||||||
|
assert insights.window_context(
|
||||||
|
user["id"], "notAMetric", "2026-08-01", "2026-08-30"
|
||||||
|
) is None
|
||||||
|
|
||||||
|
def test_empty_span_returns_nothing(self, db, user):
|
||||||
|
assert insights.window_context(
|
||||||
|
user["id"], "steps", "2020-01-01", "2020-01-31"
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildContext:
|
||||||
|
def test_returns_nothing_without_data(self, db, user):
|
||||||
|
assert insights.build_context(user["id"]) is None
|
||||||
|
|
||||||
|
def test_defaults_to_the_newest_recorded_day(self, db, user, seed_health):
|
||||||
|
seed_health([
|
||||||
|
{"date": "2026-08-01", "steps": 5000},
|
||||||
|
{"date": "2026-08-02", "steps": 6000},
|
||||||
|
])
|
||||||
|
context = insights.build_context(user["id"])
|
||||||
|
assert context["snapshotDate"] == "2026-08-02"
|
||||||
|
|
||||||
|
def test_an_unknown_date_is_not_silently_replaced(self, db, user, seed_health):
|
||||||
|
seed_health([{"date": "2026-08-01", "steps": 5000}])
|
||||||
|
assert insights.build_context(user["id"], "2026-08-09") is None
|
||||||
|
|
||||||
|
def test_stays_small_enough_to_prompt_with(self, db, user, seed_health):
|
||||||
|
seed_health([
|
||||||
|
{"date": f"2026-08-{d:02d}", "steps": 8000 + d, "heart_rate": 60,
|
||||||
|
"hrv": 45, "sleep_duration": 7, "stress": 30}
|
||||||
|
for d in range(1, 31)
|
||||||
|
])
|
||||||
|
context = insights.build_context(user["id"])
|
||||||
|
blob = json.dumps(context, ensure_ascii=False)
|
||||||
|
# A month of history has to cost thousands of characters, not tens of
|
||||||
|
# thousands — the whole point of computing features server-side.
|
||||||
|
assert len(blob) < 20_000
|
||||||
|
|
||||||
|
|
||||||
|
# --- reply parsing ----------------------------------------------------------
|
||||||
|
GOOD_BRIEFING = {
|
||||||
|
"status": "中等偏上",
|
||||||
|
"headline": "恢复尚可,睡眠偏短。",
|
||||||
|
"diagnosis": [{"title": "睡眠结构", "detail": "睡眠 6 小时,低于目标。"}],
|
||||||
|
"shortfall": "睡眠不足",
|
||||||
|
"prescription": {
|
||||||
|
"intensity": "中等", "hrZone": "Zone 2~Zone 3",
|
||||||
|
"suggestion": "40 分钟慢跑", "durationMin": 40, "avoid": "高强度间歇",
|
||||||
|
},
|
||||||
|
"actions": ["提前 30 分钟入睡", "午后避免咖啡因"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseBriefing:
|
||||||
|
def test_plain_json(self):
|
||||||
|
out = coach.parse_briefing(json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
|
assert out["status"] == "中等偏上"
|
||||||
|
assert out["prescription"]["durationMin"] == 40
|
||||||
|
assert out["actions"] == ["提前 30 分钟入睡", "午后避免咖啡因"]
|
||||||
|
|
||||||
|
def test_answer_is_taken_from_after_a_reasoning_preamble(self):
|
||||||
|
"""The gateway's primary upstream narrates its thinking first."""
|
||||||
|
reply = (
|
||||||
|
'The user wants a briefing. Let me consider {"draft": true} first.\n'
|
||||||
|
"Actually I should output the final object now:\n"
|
||||||
|
+ json.dumps(GOOD_BRIEFING, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
assert coach.parse_briefing(reply)["status"] == "中等偏上"
|
||||||
|
|
||||||
|
def test_markdown_fences_are_tolerated(self):
|
||||||
|
reply = "```json\n" + json.dumps(GOOD_BRIEFING, ensure_ascii=False) + "\n```"
|
||||||
|
assert coach.parse_briefing(reply)["headline"] == "恢复尚可,睡眠偏短。"
|
||||||
|
|
||||||
|
def test_missing_prescription_does_not_raise(self):
|
||||||
|
payload = {k: v for k, v in GOOD_BRIEFING.items() if k != "prescription"}
|
||||||
|
out = coach.parse_briefing(json.dumps(payload, ensure_ascii=False))
|
||||||
|
assert out["prescription"]["suggestion"] is None
|
||||||
|
|
||||||
|
def test_non_numeric_duration_becomes_none(self):
|
||||||
|
payload = json.loads(json.dumps(GOOD_BRIEFING))
|
||||||
|
payload["prescription"]["durationMin"] = "四十分钟"
|
||||||
|
assert coach.parse_briefing(json.dumps(payload))["prescription"]["durationMin"] is None
|
||||||
|
|
||||||
|
def test_diagnosis_written_as_plain_strings_is_accepted(self):
|
||||||
|
payload = json.loads(json.dumps(GOOD_BRIEFING))
|
||||||
|
payload["diagnosis"] = ["睡眠偏短。"]
|
||||||
|
out = coach.parse_briefing(json.dumps(payload, ensure_ascii=False))
|
||||||
|
assert out["diagnosis"][0]["detail"] == "睡眠偏短。"
|
||||||
|
|
||||||
|
def test_an_empty_briefing_is_rejected_rather_than_rendered_blank(self):
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
coach.parse_briefing(json.dumps({"status": "好"}))
|
||||||
|
|
||||||
|
def test_prose_without_json_raises(self):
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
coach.parse_briefing("今天状态不错,可以正常训练。")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTrendInsight:
|
||||||
|
def test_valid_reply(self):
|
||||||
|
reply = json.dumps({
|
||||||
|
"summary": "HRV 稳步上升。",
|
||||||
|
"drivers": [{"factor": "有氧负荷", "detail": "区间内 8 次有氧。"}],
|
||||||
|
"caution": None, "confidence": "high",
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
out = coach.parse_trend_insight(reply)
|
||||||
|
assert out["confidence"] == "high"
|
||||||
|
assert out["drivers"][0]["factor"] == "有氧负荷"
|
||||||
|
|
||||||
|
def test_unknown_confidence_falls_back_to_medium(self):
|
||||||
|
reply = json.dumps({"summary": "上升。", "confidence": "很高"}, ensure_ascii=False)
|
||||||
|
assert coach.parse_trend_insight(reply)["confidence"] == "medium"
|
||||||
|
|
||||||
|
def test_empty_reply_raises(self):
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
coach.parse_trend_insight(json.dumps({"confidence": "high"}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractJson:
|
||||||
|
def test_last_object_wins_over_an_earlier_draft(self):
|
||||||
|
assert ai_svc.extract_json('{"a": 1} then {"a": 2}') == {"a": 2}
|
||||||
|
|
||||||
|
def test_braces_inside_strings_do_not_break_the_scan(self):
|
||||||
|
assert ai_svc.extract_json('思考 } 中。{"t": "含 } 的文本"}')["t"] == "含 } 的文本"
|
||||||
|
|
||||||
|
def test_escaped_quote_inside_a_string(self):
|
||||||
|
assert ai_svc.extract_json(r'x {"t": "a \" b"}')["t"] == 'a " b'
|
||||||
|
|
||||||
|
def test_arrays_are_extracted_too(self):
|
||||||
|
assert ai_svc.extract_json("preamble [1, 2, 3]") == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_empty_reply_raises(self):
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
ai_svc.extract_json(" ")
|
||||||
|
|
||||||
|
|
||||||
|
# --- prompt assembly --------------------------------------------------------
|
||||||
|
class TestPrompts:
|
||||||
|
def test_system_prompt_forbids_inventing_numbers(self):
|
||||||
|
assert "禁止编造" in coach.SYSTEM
|
||||||
|
|
||||||
|
def test_system_prompt_disclaims_medical_diagnosis(self):
|
||||||
|
assert "不做医疗诊断" in coach.SYSTEM
|
||||||
|
|
||||||
|
def test_model_is_told_not_to_recompute_the_z_scores(self):
|
||||||
|
assert "不要自行重算" in coach.SYSTEM
|
||||||
|
|
||||||
|
def test_briefing_prompt_carries_the_context_as_json(self):
|
||||||
|
messages = coach.briefing_messages({"snapshotDate": "2026-08-01", "x": 1})
|
||||||
|
assert messages[0]["role"] == "system"
|
||||||
|
assert '"snapshotDate":"2026-08-01"' in messages[1]["content"]
|
||||||
|
|
||||||
|
def test_context_is_not_ascii_escaped(self):
|
||||||
|
"""Escaping Chinese to \\uXXXX roughly triples its token cost."""
|
||||||
|
assert "睡眠" in coach._payload({"label": "睡眠"})
|
||||||
|
|
||||||
|
def test_copilot_keeps_the_context_out_of_the_visible_transcript(self):
|
||||||
|
messages = coach.copilot_messages(
|
||||||
|
{"snapshotDate": "2026-08-01"}, [], "我今天能练吗?"
|
||||||
|
)
|
||||||
|
assert [m["role"] for m in messages] == ["system", "system", "user"]
|
||||||
|
assert messages[-1]["content"] == "我今天能练吗?"
|
||||||
|
|
||||||
|
def test_copilot_history_is_capped_and_role_filtered(self):
|
||||||
|
history = [{"role": "user", "content": f"q{i}"} for i in range(20)]
|
||||||
|
history.append({"role": "tool", "content": "ignored"})
|
||||||
|
messages = coach.copilot_messages({}, history, "最后一问")
|
||||||
|
turns = [m for m in messages if m["role"] != "system"]
|
||||||
|
assert len(turns) == 9 # eight remembered turns plus the new question
|
||||||
|
assert "ignored" not in json.dumps(messages, ensure_ascii=False)
|
||||||
|
|
||||||
|
def test_copilot_asks_for_markdown_not_json(self):
|
||||||
|
assert "Markdown" in coach.COPILOT_SYSTEM
|
||||||
|
assert "最后出现的 JSON" not in coach.COPILOT_SYSTEM
|
||||||
|
|
||||||
|
|
||||||
|
# --- rule-based counterparts ------------------------------------------------
|
||||||
|
def context_with(**today):
|
||||||
|
base = {
|
||||||
|
"snapshotDate": "2026-08-30",
|
||||||
|
"todayMetrics": {
|
||||||
|
"sleep": None,
|
||||||
|
"autonomicNervous": {},
|
||||||
|
"recovery": {},
|
||||||
|
"activityToday": {},
|
||||||
|
},
|
||||||
|
"deviations": [],
|
||||||
|
"trends": [],
|
||||||
|
"activityShift": {},
|
||||||
|
}
|
||||||
|
base["todayMetrics"].update(today)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleBriefing:
|
||||||
|
def test_readiness_drives_the_prescription(self):
|
||||||
|
low = coach.rule_briefing(context_with(recovery={"trainingReadiness": 30}))
|
||||||
|
high = coach.rule_briefing(context_with(recovery={"trainingReadiness": 85}))
|
||||||
|
assert low["prescription"]["intensity"] == "低"
|
||||||
|
assert high["prescription"]["intensity"] == "高"
|
||||||
|
|
||||||
|
def test_short_sleep_is_named_as_the_shortfall(self):
|
||||||
|
out = coach.rule_briefing(context_with(
|
||||||
|
sleep={"durationHours": 5.0, "targetHours": 7.0}
|
||||||
|
))
|
||||||
|
assert "睡眠" in out["shortfall"]
|
||||||
|
|
||||||
|
def test_no_shortfall_is_stated_explicitly(self):
|
||||||
|
out = coach.rule_briefing(context_with(
|
||||||
|
sleep={"durationHours": 8.0, "targetHours": 7.0}
|
||||||
|
))
|
||||||
|
assert out["shortfall"] == "无明显短板"
|
||||||
|
|
||||||
|
def test_it_never_invents_a_metric_the_watch_did_not_record(self):
|
||||||
|
out = coach.rule_briefing(context_with())
|
||||||
|
assert out["diagnosis"] == []
|
||||||
|
assert out["actions"]
|
||||||
|
|
||||||
|
def test_the_headline_names_the_largest_departure(self):
|
||||||
|
context = context_with(autonomicNervous={"restingHr": 80})
|
||||||
|
context["deviations"] = [{
|
||||||
|
"metric": "heartRate", "label": "静息心率", "unit": "bpm",
|
||||||
|
"value": 80, "baselineMean": 60, "sd": 5, "baselineDays": 28,
|
||||||
|
"z": 4.0, "verdict": "偏差",
|
||||||
|
}]
|
||||||
|
assert "静息心率" in coach.rule_briefing(context)["headline"]
|
||||||
|
|
||||||
|
def test_a_sustained_drop_in_steps_becomes_an_action(self):
|
||||||
|
context = context_with()
|
||||||
|
context["activityShift"] = {
|
||||||
|
"steps": {"label": "步数", "recentMean": 4000,
|
||||||
|
"priorMean": 10000, "changePct": -60.0}
|
||||||
|
}
|
||||||
|
assert any("60" in a for a in coach.rule_briefing(context)["actions"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- orchestration ----------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def gateway(monkeypatch):
|
||||||
|
monkeypatch.setenv("AI_GATEWAY_TOKEN", "test-token")
|
||||||
|
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gateway.test/v1")
|
||||||
|
monkeypatch.setenv("AI_MODEL_CHAIN", "gateway")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def month(db, user, seed_health):
|
||||||
|
seed_health([
|
||||||
|
{"date": f"2026-08-{d:02d}", "steps": 8000, "heart_rate": 60, "hrv": 45,
|
||||||
|
"sleep_duration": 7, "sleep_quality": 80, "stress": 30}
|
||||||
|
for d in range(1, 31)
|
||||||
|
])
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def answer(monkeypatch, text):
|
||||||
|
"""Make every model reply with `text`, and count the calls."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_chat(self, messages, timeout=None, max_tokens=None):
|
||||||
|
calls.append(messages)
|
||||||
|
return ai_svc.Completion(text, "nvidia")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.Provider, "chat", fake_chat, raising=False)
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", fake_chat)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetBriefing:
|
||||||
|
def test_no_data_says_so_rather_than_guessing(self, db, user, gateway):
|
||||||
|
out = analysis_svc.get_briefing(user["id"])
|
||||||
|
assert out["meta"]["source"] == "none"
|
||||||
|
assert out["briefing"] is None
|
||||||
|
|
||||||
|
def test_blocking_mode_returns_the_model_answer(self, month, gateway, monkeypatch):
|
||||||
|
answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
|
out = analysis_svc.get_briefing(month["id"], wait=True)
|
||||||
|
assert out["meta"]["source"] == "ai"
|
||||||
|
assert out["briefing"]["status"] == "中等偏上"
|
||||||
|
|
||||||
|
def test_a_stored_answer_is_reused(self, month, gateway, monkeypatch):
|
||||||
|
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
|
analysis_svc.get_briefing(month["id"], wait=True)
|
||||||
|
out = analysis_svc.get_briefing(month["id"])
|
||||||
|
assert out["meta"]["cached"] is True
|
||||||
|
assert len(calls) == 1, "the cached answer must not trigger a second call"
|
||||||
|
|
||||||
|
def test_new_health_data_expires_the_stored_answer(
|
||||||
|
self, month, gateway, monkeypatch, seed_health
|
||||||
|
):
|
||||||
|
answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
|
analysis_svc.get_briefing(month["id"], wait=True)
|
||||||
|
seed_health([{"date": "2026-08-31", "steps": 12000}])
|
||||||
|
out = analysis_svc.get_briefing(month["id"])
|
||||||
|
assert out["meta"].get("cached") is not True
|
||||||
|
|
||||||
|
def test_a_failing_model_degrades_to_the_rule_engine(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
def boom(self, messages, timeout=None, max_tokens=None):
|
||||||
|
raise ai_svc.AIError("upstream down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
|
||||||
|
out = analysis_svc.get_briefing(month["id"], wait=True)
|
||||||
|
assert out["meta"]["source"] == "rules"
|
||||||
|
assert out["briefing"] is not None
|
||||||
|
|
||||||
|
def test_the_non_blocking_path_answers_without_calling_a_model(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
|
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||||
|
out = analysis_svc.get_briefing(month["id"])
|
||||||
|
assert out["meta"]["pending"] is True
|
||||||
|
assert out["briefing"]["status"]
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
def test_one_generation_per_key_no_matter_how_often_it_is_polled(self):
|
||||||
|
"""The poll runs every few seconds; a generation takes minutes."""
|
||||||
|
started = []
|
||||||
|
# A job that never finishes, so the key stays claimed across polls.
|
||||||
|
blocked = analysis_svc.threading.Event()
|
||||||
|
analysis_svc._run_in_background("test-key", lambda: (
|
||||||
|
started.append(1), blocked.wait(5)
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
for _ in range(5):
|
||||||
|
analysis_svc._run_in_background("test-key", lambda: started.append(1))
|
||||||
|
assert len(started) == 1
|
||||||
|
finally:
|
||||||
|
blocked.set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTrendInsight:
|
||||||
|
def test_empty_span_is_reported_not_analysed(self, month, gateway):
|
||||||
|
out = analysis_svc.get_trend_insight(
|
||||||
|
month["id"], "steps", "2020-01-01", "2020-01-31"
|
||||||
|
)
|
||||||
|
assert out["meta"]["source"] == "none"
|
||||||
|
|
||||||
|
def test_model_answer_is_returned_and_cached(self, month, gateway, monkeypatch):
|
||||||
|
reply = json.dumps({
|
||||||
|
"summary": "步数稳定。", "drivers": [], "caution": None,
|
||||||
|
"confidence": "medium",
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
calls = answer(monkeypatch, reply)
|
||||||
|
first = analysis_svc.get_trend_insight(
|
||||||
|
month["id"], "steps", "2026-08-01", "2026-08-30"
|
||||||
|
)
|
||||||
|
second = analysis_svc.get_trend_insight(
|
||||||
|
month["id"], "steps", "2026-08-01", "2026-08-30"
|
||||||
|
)
|
||||||
|
assert first["insight"]["summary"] == "步数稳定。"
|
||||||
|
assert second["meta"]["cached"] is True
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
def test_a_failing_model_degrades_to_the_rule_engine(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
def boom(self, messages, timeout=None, max_tokens=None):
|
||||||
|
raise ai_svc.AIError("down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
|
||||||
|
out = analysis_svc.get_trend_insight(
|
||||||
|
month["id"], "steps", "2026-08-01", "2026-08-30"
|
||||||
|
)
|
||||||
|
assert out["meta"]["source"] == "rules"
|
||||||
|
assert out["insight"]["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamChat:
|
||||||
|
"""The gateway's streaming path is measurably less reliable than its
|
||||||
|
blocking one, so a stream that produces nothing must not end the attempt."""
|
||||||
|
|
||||||
|
def test_deltas_are_forwarded(self, gateway, monkeypatch):
|
||||||
|
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||||
|
yield ai_svc.Completion("你好", "nvidia")
|
||||||
|
yield ai_svc.Completion(",世界", "nvidia")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||||
|
text = "".join(d.text for d in ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||||
|
assert text == "你好,世界"
|
||||||
|
|
||||||
|
def test_a_failed_stream_retries_the_same_model_without_streaming(
|
||||||
|
self, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||||
|
raise ai_svc.AIError("所有模型均不可用")
|
||||||
|
yield # pragma: no cover - generator marker
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||||
|
answer(monkeypatch, "完整回答")
|
||||||
|
deltas = list(ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||||
|
assert "".join(d.text for d in deltas) == "完整回答"
|
||||||
|
|
||||||
|
def test_no_model_switch_once_text_has_been_sent(self, gateway, monkeypatch):
|
||||||
|
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||||
|
yield ai_svc.Completion("半句", "nvidia")
|
||||||
|
raise ai_svc.AIError("断流")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
list(ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotStream:
|
||||||
|
def test_no_data_yields_an_error_event(self, db, user, gateway):
|
||||||
|
events = list(analysis_svc.copilot_stream(user["id"], "我今天能练吗"))
|
||||||
|
assert events[0][0] == "error"
|
||||||
|
|
||||||
|
def test_a_successful_answer_is_framed_start_delta_done(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||||
|
yield ai_svc.Completion("可以。", "nvidia")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||||
|
events = list(analysis_svc.copilot_stream(month["id"], "我今天能练吗"))
|
||||||
|
assert [e for e, _ in events] == ["start", "delta", "done"]
|
||||||
|
assert events[-1][1]["upstream"] == "nvidia"
|
||||||
|
|
||||||
|
|
||||||
|
# --- endpoints --------------------------------------------------------------
|
||||||
|
class TestEndpoints:
|
||||||
|
def test_briefing_requires_auth(self, client):
|
||||||
|
assert client.get("/api/analysis/briefing").status_code == 401
|
||||||
|
|
||||||
|
def test_trend_insight_requires_auth(self, client):
|
||||||
|
assert client.get("/api/analysis/trend-insight").status_code == 401
|
||||||
|
|
||||||
|
def test_copilot_requires_auth(self, client):
|
||||||
|
assert client.post("/api/analysis/copilot", json={}).status_code == 401
|
||||||
|
|
||||||
|
def test_briefing_answers_even_with_no_model_configured(self, client, auth, month):
|
||||||
|
resp = client.get("/api/analysis/briefing", headers=auth)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["briefing"] is not None
|
||||||
|
|
||||||
|
def test_trend_insight_rejects_an_unknown_metric(self, client, auth, month):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/analysis/trend-insight",
|
||||||
|
query_string={"metric": "nope", "startDate": "2026-08-01",
|
||||||
|
"endDate": "2026-08-30"},
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "supported" in resp.get_json()
|
||||||
|
|
||||||
|
def test_trend_insight_requires_a_range(self, client, auth, month):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/analysis/trend-insight",
|
||||||
|
query_string={"metric": "steps"}, headers=auth,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_copilot_requires_a_question(self, client, auth, month):
|
||||||
|
resp = client.post("/api/analysis/copilot", json={}, headers=auth)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_copilot_streams_server_sent_events(
|
||||||
|
self, client, auth, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
def fake_stream(self, messages, timeout=None, max_tokens=None):
|
||||||
|
yield ai_svc.Completion("可以,注意强度。", "nvidia")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", fake_stream)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analysis/copilot",
|
||||||
|
json={"question": "我今天能练吗"}, headers=auth,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.mimetype == "text/event-stream"
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
assert "event: delta" in body
|
||||||
|
assert "可以,注意强度。" in body
|
||||||
|
|
||||||
|
def test_briefing_never_leaks_the_gateway_token(
|
||||||
|
self, client, auth, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
# No real background generation: this suite must not reach the network.
|
||||||
|
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||||
|
body = client.get("/api/analysis/briefing", headers=auth).get_data(as_text=True)
|
||||||
|
assert "test-token" not in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamRetryScope:
|
||||||
|
"""The blind non-streaming retry is only worth doing for endpoints that
|
||||||
|
actually have a separate streaming transport."""
|
||||||
|
|
||||||
|
def test_a_provider_without_streaming_is_not_called_twice(
|
||||||
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
||||||
|
monkeypatch.setenv("AI_MODEL_CHAIN", "gemini-flash")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def boom(self, messages, timeout=None, max_tokens=None):
|
||||||
|
calls.append(1)
|
||||||
|
raise ai_svc.AIError("down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_svc.GeminiProvider, "chat", boom)
|
||||||
|
with pytest.raises(ai_svc.AIError):
|
||||||
|
list(ai_svc.stream_chat([{"role": "user", "content": "hi"}]))
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
def test_the_gateway_declares_a_streaming_transport(self):
|
||||||
|
assert ai_svc.CATALOG["gateway"].streaming is True
|
||||||
|
assert ai_svc.CATALOG["gemini-flash"].streaming is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegenerate:
|
||||||
|
def test_refresh_evicts_the_stored_answer_so_the_poll_can_see_the_new_one(
|
||||||
|
self, month, gateway, monkeypatch
|
||||||
|
):
|
||||||
|
"""Without eviction the poll after 重新生成 reads the row it was asked
|
||||||
|
to replace, reports `cached`, and stops — leaving the old text on
|
||||||
|
screen."""
|
||||||
|
answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||||
|
analysis_svc.get_briefing(month["id"], wait=True)
|
||||||
|
assert analysis_svc.get_briefing(month["id"])["meta"]["cached"] is True
|
||||||
|
|
||||||
|
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||||
|
analysis_svc.get_briefing(month["id"], refresh=True)
|
||||||
|
|
||||||
|
after = analysis_svc.get_briefing(month["id"])
|
||||||
|
assert after["meta"].get("cached") is not True
|
||||||
|
assert after["meta"]["pending"] is True
|
||||||
@@ -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 />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
260
client/src/components/AiBriefing.css
Normal file
260
client/src/components/AiBriefing.css
Normal 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); }
|
||||||
227
client/src/components/AiBriefing.tsx
Normal file
227
client/src/components/AiBriefing.tsx
Normal 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;
|
||||||
196
client/src/components/Copilot.css
Normal file
196
client/src/components/Copilot.css
Normal 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;
|
||||||
|
}
|
||||||
185
client/src/components/Copilot.tsx
Normal file
185
client/src/components/Copilot.tsx
Normal 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;
|
||||||
125
client/src/components/TrendInsight.css
Normal file
125
client/src/components/TrendInsight.css
Normal 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);
|
||||||
|
}
|
||||||
148
client/src/components/TrendInsight.tsx
Normal file
148
client/src/components/TrendInsight.tsx
Normal 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" />
|
||||||
|
正在分析,通常需要 2–5 分钟
|
||||||
|
</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;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user