From c57c93094920cdc99cf324dff51ad7996cc30c1c Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Tue, 1 Sep 2026 13:57:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20AI=20=E6=95=99=E7=BB=83=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=99=A8=E9=97=B4=E7=AE=80=E6=8A=A5=E3=80=81?= =?UTF-8?q?=E8=BF=90=E5=8A=A8=E5=A4=84=E6=96=B9=E3=80=81=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=BD=92=E5=9B=A0=E4=B8=8E=20Copilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数值全部在服务端算好再交给模型,模型只做解读。让模型从 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 --- PROGRESS.md | 21 +- README.md | 21 +- backend/.env.example | 16 +- backend/db.py | 19 + backend/routes/analysis.py | 92 +++- backend/services/ai.py | 279 +++++++++- backend/services/analysis.py | 261 +++++++++ backend/services/coach.py | 409 ++++++++++++++ backend/services/insights.py | 461 ++++++++++++++++ backend/tests/test_coach.py | 706 +++++++++++++++++++++++++ client/src/App.tsx | 7 + client/src/components/AiBriefing.css | 260 +++++++++ client/src/components/AiBriefing.tsx | 227 ++++++++ client/src/components/Copilot.css | 196 +++++++ client/src/components/Copilot.tsx | 185 +++++++ client/src/components/TrendInsight.css | 125 +++++ client/src/components/TrendInsight.tsx | 148 ++++++ client/src/features.ts | 12 +- client/src/pages/MetricDetailPage.tsx | 12 + client/src/pages/TodayPage.tsx | 9 + client/src/services/api.ts | 233 ++++++++ 21 files changed, 3677 insertions(+), 22 deletions(-) create mode 100644 backend/services/coach.py create mode 100644 backend/services/insights.py create mode 100644 backend/tests/test_coach.py create mode 100644 client/src/components/AiBriefing.css create mode 100644 client/src/components/AiBriefing.tsx create mode 100644 client/src/components/Copilot.css create mode 100644 client/src/components/Copilot.tsx create mode 100644 client/src/components/TrendInsight.css create mode 100644 client/src/components/TrendInsight.tsx diff --git a/PROGRESS.md b/PROGRESS.md index 4b1cb2c..965203c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -52,11 +52,30 @@ - [x] 完整表结构:`health_data` / `daily_series` / `activities` / `activity_details` / `users` / `user_settings` / `garmin_tokens` / `sync_status` 等 - [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 教练,见下) - [ ] 数据分析报告生成 - [ ] 多用户支持完善 diff --git a/README.md b/README.md index d6fd87e..26c9da1 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,26 @@ python tests/smoke.py ### 分析与建议 - `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` 会说明本次由谁作答。 ## 🔐 安全说明 diff --git a/backend/.env.example b/backend/.env.example index 1725d5d..e775e68 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -53,7 +53,9 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173 # absorbs single-vendor quota limits. Reached directly, bypassing any local # HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies # can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly. -AI_GATEWAY_BASE_URL=http://129.146.26.249:5100/v1 +# HTTPS (Caddy, strips the /ai prefix) rather than http://…:5100 — the token +# rides in an Authorization header and should not cross the internet in clear. +AI_GATEWAY_BASE_URL=https://oracle.zichuan.xyz/ai/v1 AI_GATEWAY_TOKEN= AI_GATEWAY_MODEL=ai-gateway-auto @@ -73,7 +75,17 @@ AI_MODEL_CHAIN=gateway,gemini-flash,llama-70b # payload always fits that model's own context window. AI_DAY_BUDGET=365 -AI_TIMEOUT_SECONDS=180 +# Measured against the gateway, not guessed: a trivial prompt took 138s end to +# end, because its primary upstream emits a full chain of thought before the +# answer. Nothing user-facing blocks on this (the briefing generates in a +# background thread), but the timeout still has to clear the real latency. +AI_TIMEOUT_SECONDS=300 # Output cap. Reasoning models spend part of it thinking before they answer; # entries that need more declare their own budget in services/ai.py. AI_MAX_TOKENS=1024 + +# Output cap for the AI coach (晨报 / 趋势归因 / Copilot). Larger than +# AI_MAX_TOKENS above: the same reasoning trace is spent from this budget +# before the answer starts, and at 1024 the reply was all thinking with the +# JSON truncated away. +AI_COACH_MAX_TOKENS=4000 diff --git a/backend/db.py b/backend/db.py index f922d6b..60852d4 100644 --- a/backend/db.py +++ b/backend/db.py @@ -296,6 +296,25 @@ CREATE TABLE IF NOT EXISTS ai_recommendations ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); + +-- Cached AI answers keyed by what they are about, so one stale entry cannot +-- evict another: `kind` separates the morning briefing from a chart-window +-- attribution, and `subject` is the day (briefing) or metric+range (trend). +-- Same reasoning as ai_recommendations above — a generation costs minutes, so +-- it can never sit inside a page load. +CREATE TABLE IF NOT EXISTS ai_insights ( + id VARCHAR(160) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + kind VARCHAR(32) NOT NULL, + subject VARCHAR(96) NOT NULL, + fingerprint VARCHAR(64) NOT NULL, + model VARCHAR(64), + upstream VARCHAR(64), + payload MEDIUMTEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, kind, subject), + FOREIGN KEY (user_id) REFERENCES users(id) +); """ # --- MariaDB pool (lazy) ---------------------------------------------------- diff --git a/backend/routes/analysis.py b/backend/routes/analysis.py index 1d34808..58be430 100644 --- a/backend/routes/analysis.py +++ b/backend/routes/analysis.py @@ -1,9 +1,12 @@ -"""Analysis routes: trends + recommendations.""" -from flask import Blueprint, request, g, jsonify +"""Analysis routes: trends, recommendations, and the AI coach.""" +import json + +from flask import Blueprint, Response, request, g, jsonify from auth import require_auth from services import analysis as analysis_svc from services import ai as ai_svc +from services import insights bp = Blueprint("analysis", __name__) @@ -47,3 +50,88 @@ def ai_recommendations(): return jsonify( analysis_svc.get_ai_recommendations(g.user_id, model, days, refresh) ) + + +def _flag(name): + return request.args.get(name) in ("1", "true", "yes") + + +@bp.route("/briefing", methods=["GET"]) +@require_auth +def briefing(): + """AI 晨间简报 + 今日运动处方, plus the computed context behind it. + + Answers immediately. When no cached model answer matches the current data + the rule-based briefing is returned with `meta.pending`, and a generation + runs in the background — a model round-trip costs minutes, which cannot + sit in the first paint of the 今日 screen. Poll the same URL to pick up + the model's version. + + `?wait=1` blocks for the model instead, for a deliberate regenerate. + """ + return jsonify(analysis_svc.get_briefing( + g.user_id, + date=request.args.get("date"), + model=request.args.get("model") or None, + refresh=_flag("refresh"), + wait=_flag("wait"), + )) + + +@bp.route("/trend-insight", methods=["GET"]) +@require_auth +def trend_insight(): + """Attribution for one metric over a selected span (chart brush).""" + metric = request.args.get("metric") + start = request.args.get("startDate") + end = request.args.get("endDate") + if not (metric and start and end): + return jsonify({"error": "缺少 metric / startDate / endDate 参数"}), 400 + if metric not in insights.METRICS: + return jsonify({ + "error": f"不支持的指标: {metric}", + "supported": sorted(insights.METRICS), + }), 400 + return jsonify(analysis_svc.get_trend_insight( + g.user_id, metric, start, end, + model=request.args.get("model") or None, + refresh=_flag("refresh"), + )) + + +@bp.route("/copilot", methods=["POST"]) +@require_auth +def copilot(): + """Health Copilot, streamed as server-sent events. + + Streaming is about keeping the connection honest as much as about speed: + the upstream can think for minutes before its first token, and a plain + JSON request that long is indistinguishable from a hang — to the user, to + a proxy, and to Gunicorn's worker timeout. + """ + body = request.get_json(silent=True) or {} + question = (body.get("question") or "").strip() + if not question: + return jsonify({"error": "缺少 question"}), 400 + + history = body.get("history") + history = history if isinstance(history, list) else [] + # Read off `g` here, not inside the generator: the request context is torn + # down before the first chunk is pulled, and touching g there raises. + user_id = g.user_id + date = body.get("date") + model = body.get("model") or None + + def events(): + for event, data in analysis_svc.copilot_stream( + user_id, question, history, date, model + ): + yield f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + return Response( + events(), + mimetype="text/event-stream", + # X-Accel-Buffering stops nginx-style proxies from holding the stream + # until it completes, which would undo the point of streaming it. + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/backend/services/ai.py b/backend/services/ai.py index 5fc3a36..c81b017 100644 --- a/backend/services/ai.py +++ b/backend/services/ai.py @@ -97,6 +97,12 @@ class Provider: name = "base" + # Whether this endpoint has an incremental transport of its own. False + # means `stream` is the blocking call in disguise, which `stream_chat` + # needs to know: retrying such a provider without streaming would just + # run the same request a second time. + streaming = False + def __init__( self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None ): @@ -131,9 +137,30 @@ class Provider: session.trust_env = self.use_proxy return session - def generate(self, prompt, timeout=None): + def chat(self, messages, timeout=None, max_tokens=None): + """Multi-turn completion. + + `messages` is the OpenAI shape — a list of + {"role": "system"|"user"|"assistant", "content": str}. Providers whose + wire format differs translate it themselves. + """ raise NotImplementedError + def generate(self, prompt, timeout=None): + """Single-turn convenience wrapper, kept for the recommendation path.""" + return self.chat([{"role": "user", "content": prompt}], timeout) + + def stream(self, messages, timeout=None, max_tokens=None): + """Yield Completion deltas as the reply arrives. + + The base implementation is not incremental: it waits for the whole + answer and emits it as a single delta. That keeps every entry in the + catalog streamable from the caller's point of view — a provider with + no SSE transport produces one late chunk rather than an error, so the + Copilot route does not need a per-provider branch. + """ + yield self.chat(messages, timeout, max_tokens) + class GeminiProvider(Provider): """Google AI Studio (generativelanguage.googleapis.com).""" @@ -141,18 +168,34 @@ class GeminiProvider(Provider): name = "gemini" BASE = "https://generativelanguage.googleapis.com/v1beta/models" - def generate(self, prompt, timeout=None): + def chat(self, messages, timeout=None, max_tokens=None): if not self.is_configured(): raise AIError(f"{self.api_key_env} 未配置") timeout = timeout or default_timeout() url = f"{self.BASE}/{self.model_id}:generateContent" + # Gemini splits what OpenAI keeps in one list: system turns move to + # `systemInstruction`, and the assistant role is spelled "model". + contents, system = [], [] + for message in messages: + role = message.get("role") + if role == "system": + system.append(message.get("content") or "") + continue + contents.append({ + "role": "model" if role == "assistant" else "user", + "parts": [{"text": message.get("content") or ""}], + }) payload = { - "contents": [{"parts": [{"text": prompt}]}], + "contents": contents, "generationConfig": { "temperature": 0.4, - "maxOutputTokens": self.max_tokens, + "maxOutputTokens": max_tokens or self.max_tokens, }, } + if system: + payload["systemInstruction"] = { + "parts": [{"text": "\n\n".join(system)}] + } try: resp = self._session().post( url, @@ -188,6 +231,7 @@ class OpenAICompatProvider(Provider): """ name = "openai-compat" + streaming = True def __init__( self, @@ -216,30 +260,35 @@ class OpenAICompatProvider(Provider): return False return bool(self.api_key) if self.requires_key else True - def generate(self, prompt, timeout=None): + def _request(self, messages, timeout, max_tokens, stream): if not self.is_configured(): raise AIError( f"{self.api_key_env} 未配置" if self.requires_key else f"{self.base_url_env} 未配置" ) - timeout = timeout or default_timeout() url = f"{self.base_url.rstrip('/')}/chat/completions" payload = { "model": self.model_id, - "messages": [{"role": "user", "content": prompt}], + "messages": messages, "temperature": 0.4, - "max_tokens": self.max_tokens, + "max_tokens": max_tokens or self.max_tokens, } + if stream: + payload["stream"] = True headers = {"Content-Type": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" try: - resp = self._session().post( - url, headers=headers, json=payload, timeout=timeout + return self._session().post( + url, headers=headers, json=payload, timeout=timeout, stream=stream ) except requests.RequestException as e: raise AIError(f"{self.model_id} 请求失败: {e}") from e + def chat(self, messages, timeout=None, max_tokens=None): + timeout = timeout or default_timeout() + resp = self._request(messages, timeout, max_tokens, stream=False) + if resp.status_code != 200: raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}") @@ -253,6 +302,51 @@ class OpenAICompatProvider(Provider): except (ValueError, KeyError, IndexError) as e: raise AIError(f"{self.model_id} 响应格式异常: {e}") from e + def stream(self, messages, timeout=None, max_tokens=None): + """Server-sent chunks in the OpenAI streaming schema. + + Note what "streaming" buys here in practice: the gateway forwards its + upstream's chunks, and its primary upstream is a reasoning model that + emits nothing until it has finished thinking. So this shortens the + wait to first text on some upstreams and not at all on others — it is + a transport, not a latency guarantee. + + A `data:` frame carrying an `error` object is the gateway's way of + reporting "no upstream answered" mid-stream, so it is raised rather + than yielded as content. + """ + timeout = timeout or default_timeout() + resp = self._request(messages, timeout, max_tokens, stream=True) + if resp.status_code != 200: + raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}") + + try: + for raw in resp.iter_lines(decode_unicode=True): + if not raw or not raw.startswith("data:"): + continue + data = raw[len("data:"):].strip() + if data == "[DONE]": + return + try: + chunk = json.loads(data) + except ValueError: + continue + if isinstance(chunk, dict) and chunk.get("error"): + message = chunk["error"] + if isinstance(message, dict): + message = message.get("message") or message + raise AIError(f"{self.model_id}: {message}") + choices = chunk.get("choices") or [] + if not choices: + continue + text = (choices[0].get("delta") or {}).get("content") + if text: + yield Completion(text, chunk.get("provider")) + except requests.RequestException as e: + raise AIError(f"{self.model_id} 流式中断: {e}") from e + finally: + resp.close() + # --- catalog ---------------------------------------------------------------- NVIDIA_BASE = "https://integrate.api.nvidia.com/v1" @@ -549,3 +643,168 @@ def generate(summary, activities=None, preferred_model=None, day_budget=None): detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors) raise AIError(f"所有模型均失败 -> {detail}") + + +# --- generic chat entry points ---------------------------------------------- +# Used by the coach (briefing / trend insight / Copilot), which needs multi-turn +# messages and a bigger output cap than the recommendation list: a reasoning +# upstream spends part of its budget thinking out loud before the answer, and a +# briefing is prose rather than five short strings. +FALLBACK_COACH_MAX_TOKENS = 4000 + + +def coach_max_tokens(): + return int(os.environ.get("AI_COACH_MAX_TOKENS") or FALLBACK_COACH_MAX_TOKENS) + + +def complete(messages, preferred_model=None, max_tokens=None, timeout=None): + """First healthy model in the chain answers. Returns (Completion, meta). + + Same fallback policy as `generate`, but the caller supplies the whole + message list and parses the reply itself. + """ + chain = resolve_chain(preferred_model) + max_tokens = max_tokens or coach_max_tokens() + errors = [] + + for model_id in chain: + provider = CATALOG[model_id] + try: + completion = provider.chat(messages, timeout, max_tokens) + except AIError as e: + errors.append({"model": model_id, "error": str(e)}) + continue + if not (completion.text or "").strip(): + errors.append({"model": model_id, "error": "空响应"}) + continue + return completion, { + "model": model_id, + "provider": provider.name, + "upstream": completion.upstream, + "fallbackFrom": [e["model"] for e in errors], + "errors": errors, + } + + detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors) + raise AIError(f"所有模型均失败 -> {detail}") + + +def stream_chat(messages, preferred_model=None, max_tokens=None, timeout=None): + """Stream a reply, yielding Completion deltas. + + Two failover rules, and the order matters: + + 1. **Same model, without streaming, before moving on.** Measured against + the gateway with a real briefing prompt: the streaming request came + back "所有模型均不可用" after 139s while the identical non-streaming + request answered in 273s. Its streaming path is simply less reliable + than its blocking one, so a stream that produces nothing is retried + blind before the model is written off. The reply then arrives as a + single late delta rather than not at all. + 2. **No failover once text has been yielded.** By then it has usually + reached the user's screen, and switching models mid-answer splices two + different replies together — the exact defect the gateway's own NVIDIA + adapter has (its README, known issue #1). A half-written answer that + fails visibly beats one finished in another voice. + """ + chain = resolve_chain(preferred_model) + max_tokens = max_tokens or coach_max_tokens() + errors = [] + + for model_id in chain: + provider = CATALOG[model_id] + started = False + try: + for delta in provider.stream(messages, timeout, max_tokens): + started = True + yield delta + except AIError as e: + if started: + raise + errors.append({"model": model_id, "error": f"流式: {e}"}) + if started: + return + + if not provider.streaming: + # Its `stream` was the blocking call already; retrying it here + # would spend a second full generation on the same failure. + continue + + try: + completion = provider.chat(messages, timeout, max_tokens) + except AIError as e: + errors.append({"model": model_id, "error": str(e)}) + continue + if (completion.text or "").strip(): + yield completion + return + errors.append({"model": model_id, "error": "空响应"}) + + detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors) + raise AIError(f"所有模型均失败 -> {detail}") + + +# --- JSON extraction -------------------------------------------------------- +def extract_json(text): + """Pull the last complete JSON object or array out of a model reply. + + The gateway's primary upstream is a reasoning model whose visible output + *begins* with its chain of thought ("The user wants ... so we must ..."), + with the real answer at the end. Scanning from the front therefore finds + prose, or a JSON fragment the model was merely considering. Scanning + backwards from the last closing brace finds the answer it settled on. + + Brace counting is string-aware, because a Chinese briefing routinely + contains a quoted `}` or an escaped quote and a naive count breaks on both. + """ + if not text or not text.strip(): + raise AIError("模型返回空响应") + + cleaned = _FENCE.sub("", text).strip() + try: + return json.loads(cleaned) + except ValueError: + pass + + for close, opener in (("}", "{"), ("]", "[")): + end = cleaned.rfind(close) + while end != -1: + start = _matching_open(cleaned, end, opener, close) + if start is not None: + try: + return json.loads(cleaned[start : end + 1]) + except ValueError: + pass + end = cleaned.rfind(close, 0, end) + + raise AIError(f"模型未返回可解析的 JSON: {text[-200:]}") + + +def _matching_open(text, end, opener, close): + """Index of the bracket that `text[end]` closes, or None if unbalanced.""" + depth = 0 + in_string = False + for i in range(end, -1, -1): + ch = text[i] + if in_string: + # Walking backwards, a quote ends the string only when it is not + # itself escaped — count the run of backslashes before it. + if ch == '"': + backslashes = 0 + j = i - 1 + while j >= 0 and text[j] == "\\": + backslashes += 1 + j -= 1 + if backslashes % 2 == 0: + in_string = False + continue + if ch == '"': + in_string = True + continue + if ch == close: + depth += 1 + elif ch == opener: + depth -= 1 + if depth == 0: + return i + return None diff --git a/backend/services/analysis.py b/backend/services/analysis.py index 0b97a82..324cb48 100644 --- a/backend/services/analysis.py +++ b/backend/services/analysis.py @@ -8,9 +8,12 @@ import datetime import hashlib import json import os +import threading from services import health from services import ai as ai_svc +from services import coach +from services import insights from db import query_all, query_one, execute from config import DB_TYPE @@ -256,3 +259,261 @@ def get_ai_recommendations(user_id, model=None, days=None, refresh=False): def clear_ai_cache(user_id): execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id]) + + +# --- AI coach: briefing, trend attribution, Copilot ------------------------- +# Same caching rationale as the recommendations above, with one addition: a +# briefing is the first thing on the 今日 screen, so it can never wait on a +# generation. The endpoint answers immediately from the rule engine and the +# model's version replaces it on a later poll. +_JOB_LOCK = threading.Lock() +_JOBS = set() + + +def _insight_key(kind, subject): + return f"{kind}:{subject}" + + +def _read_insight(user_id, kind, subject, fingerprint): + row = query_one( + "SELECT * FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?", + [user_id, kind, subject], + ) + if not row or row["fingerprint"] != fingerprint: + return None + try: + payload = json.loads(row["payload"]) + except (ValueError, TypeError): + return None + return payload, { + "source": "ai", + "model": row["model"], + "upstream": row["upstream"], + "cached": True, + "generatedAt": row.get("created_at"), + } + + +def _write_insight(user_id, kind, subject, fingerprint, payload, meta): + cols = ["id", "user_id", "kind", "subject", "fingerprint", "model", + "upstream", "payload", "created_at"] + placeholders = ", ".join(["?"] * len(cols)) + updatable = [c for c in cols if c != "id"] + if DB_TYPE == "mariadb": + updates = ", ".join(f"{c}=VALUES({c})" for c in updatable) + sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) " + f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}") + else: + updates = ", ".join(f"{c}=excluded.{c}" for c in updatable) + sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) " + f"VALUES ({placeholders}) ON CONFLICT(id) DO UPDATE SET {updates}") + # The id is derived rather than random so a re-generation overwrites the + # row it replaces instead of accumulating one per attempt. + row_id = hashlib.sha256( + f"{user_id}|{kind}|{subject}".encode("utf-8") + ).hexdigest()[:64] + execute(sql, [ + row_id, user_id, kind, subject, fingerprint, meta.get("model"), + meta.get("upstream"), json.dumps(payload, ensure_ascii=False), + datetime.datetime.utcnow().isoformat(timespec="seconds"), + ]) + + +def _context_fingerprint(context): + """Digest of everything the prompt will contain. + + The whole context rather than a chosen subset: a briefing is derived from + all of it, so any change to any field — a corrected sleep stage, a newly + synced activity — should expire the cached answer. + """ + blob = json.dumps(context, ensure_ascii=False, sort_keys=True) + return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64] + + +def _run_in_background(key, target): + """Start `target` once per key; a second caller joins the first one's run. + + Guards against the obvious failure mode of a poll-until-ready endpoint: + the client polls every few seconds while a generation takes minutes, and + without this every poll would start another one. + + The claim is per-process, not per-deployment: with Gunicorn's two workers + each can start one generation for the same key. That is deliberate rather + than overlooked — the `job_locks` table would make it exclusive, but the + cost here is a duplicate call, not a duplicate row (the cache id is derived + from user+kind+subject, so the second write lands on the first one's row). + A cross-process lock is worth adding only if the gateway's rate limits + start to bite. + """ + with _JOB_LOCK: + if key in _JOBS: + return False + _JOBS.add(key) + + def runner(): + try: + target() + except Exception as e: # noqa: BLE001 - a background job must not die silently + print(f"[analysis] background job {key} failed: {e}") + finally: + with _JOB_LOCK: + _JOBS.discard(key) + + threading.Thread(target=runner, name=f"ai-{key}", daemon=True).start() + return True + + +def _generating(key): + with _JOB_LOCK: + return key in _JOBS + + +def generate_briefing(user_id, context, model=None): + """Ask a model for the briefing and store it. Returns (briefing, meta).""" + completion, meta = ai_svc.complete(coach.briefing_messages(context), model) + briefing = coach.parse_briefing(completion.text) + _write_insight( + user_id, "briefing", context["snapshotDate"], + _context_fingerprint(context), briefing, meta, + ) + return briefing, meta + + +def get_briefing(user_id, date=None, model=None, refresh=False, wait=False): + """The morning briefing for one day. + + Non-blocking by default: a cached answer is returned if it matches the + current data, otherwise the rule-based briefing is returned straight away + and a model generation starts in the background. `wait=True` blocks for + the model instead — for callers that can afford minutes, such as a manual + "regenerate" or a scheduled pre-warm. + """ + context = insights.build_context(user_id, date) + if not context: + return { + "briefing": None, + "context": None, + "meta": {"source": "none", "reason": "无健康数据"}, + } + + fingerprint = _context_fingerprint(context) + subject = context["snapshotDate"] + key = _insight_key("briefing", subject) + + if not refresh: + cached = _read_insight(user_id, "briefing", subject, fingerprint) + if cached: + briefing, meta = cached + return {"briefing": briefing, "context": context, "meta": meta} + else: + # Drop the stored answer, not just skip it. Without this the poll that + # follows a regenerate reads the *old* row, sees `cached: true`, and + # stops polling — so the user keeps looking at the text they just + # asked to replace until something else expires it. + _delete_insight(user_id, "briefing", subject) + + if wait: + try: + briefing, meta = generate_briefing(user_id, context, model) + return { + "briefing": briefing, "context": context, + "meta": {**meta, "source": "ai", "cached": False}, + } + except ai_svc.AIError as e: + return { + "briefing": coach.rule_briefing(context), "context": context, + "meta": {"source": "rules", "reason": str(e)}, + } + + started = _run_in_background( + key, lambda: generate_briefing(user_id, context, model) + ) + return { + "briefing": coach.rule_briefing(context), + "context": context, + "meta": { + "source": "rules", + # `pending` is what tells the client to poll again: the card it is + # showing is the placeholder, not the final answer. + "pending": True, + "generating": started or _generating(key), + }, + } + + +def get_trend_insight(user_id, metric, start, end, model=None, refresh=False): + """Attribution for a user-selected span of one metric (chart brush). + + Blocking, unlike the briefing: this one is requested by an explicit + gesture on a chart, so there is a spinner to attach the wait to and no + useful placeholder to show in the meantime. + """ + window = insights.window_context(user_id, metric, start, end) + if not window: + return {"insight": None, "window": None, + "meta": {"source": "none", "reason": "所选区间没有数据"}} + + subject = f"{metric}:{start}:{end}" + fingerprint = _context_fingerprint(window) + if not refresh: + cached = _read_insight(user_id, "trend", subject, fingerprint) + if cached: + insight, meta = cached + return {"insight": insight, "window": window, "meta": meta} + + try: + completion, meta = ai_svc.complete(coach.trend_messages(window), model) + insight = coach.parse_trend_insight(completion.text) + except ai_svc.AIError as e: + return { + "insight": coach.rule_trend_insight(window), "window": window, + "meta": {"source": "rules", "reason": str(e)}, + } + + try: + _write_insight(user_id, "trend", subject, fingerprint, insight, meta) + except Exception as e: # noqa: BLE001 - a cache write must never fail the request + print(f"[analysis] failed to cache trend insight: {e}") + return {"insight": insight, "window": window, + "meta": {**meta, "source": "ai", "cached": False}} + + +def copilot_stream(user_id, question, history=None, date=None, model=None): + """Stream a Copilot answer, yielding (event, data) pairs. + + A generator rather than a return value so the route can forward each delta + as it arrives; the health context is assembled once, here, so the route + stays free of feature logic. + """ + context = insights.build_context(user_id, date) + if not context: + yield "error", {"message": "暂无健康数据,请先同步 Garmin 数据。"} + return + + messages = coach.copilot_messages(context, history or [], question) + yield "start", {"snapshotDate": context["snapshotDate"]} + upstream = None + try: + for delta in ai_svc.stream_chat(messages, model): + upstream = delta.upstream or upstream + yield "delta", {"text": delta.text} + except ai_svc.AIError as e: + yield "error", {"message": str(e)} + return + yield "done", {"upstream": upstream} + + +def _delete_insight(user_id, kind, subject): + execute( + "DELETE FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?", + [user_id, kind, subject], + ) + + +def clear_insight_cache(user_id, kind=None): + if kind: + execute( + "DELETE FROM ai_insights WHERE user_id = ? AND kind = ?", [user_id, kind] + ) + else: + execute("DELETE FROM ai_insights WHERE user_id = ?", [user_id]) diff --git a/backend/services/coach.py b/backend/services/coach.py new file mode 100644 index 0000000..a9edee1 --- /dev/null +++ b/backend/services/coach.py @@ -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", + } diff --git a/backend/services/insights.py b/backend/services/insights.py new file mode 100644 index 0000000..3ae51cc --- /dev/null +++ b/backend/services/insights.py @@ -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] + ], + } diff --git a/backend/tests/test_coach.py b/backend/tests/test_coach.py new file mode 100644 index 0000000..15d88c8 --- /dev/null +++ b/backend/tests/test_coach.py @@ -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 diff --git a/client/src/App.tsx b/client/src/App.tsx index 2410a9f..5d60c28 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,8 @@ import Framework7 from 'framework7/lite-bundle'; import Framework7React from 'framework7-react'; import routes from './routes'; +import Copilot from './components/Copilot'; +import { FEATURES } from './features'; import { apiClient, AUTH_EVENT } from './services/api'; import 'framework7/css/bundle'; @@ -314,6 +316,11 @@ function App() { )} + + {/* Outside 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 && } ); } diff --git a/client/src/components/AiBriefing.css b/client/src/components/AiBriefing.css new file mode 100644 index 0000000..4caca87 --- /dev/null +++ b/client/src/components/AiBriefing.css @@ -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); } diff --git a/client/src/components/AiBriefing.tsx b/client/src/components/AiBriefing.tsx new file mode 100644 index 0000000..9c4223f --- /dev/null +++ b/client/src/components/AiBriefing.tsx @@ -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 ( + + AI 生成{meta.upstream ? ` · ${meta.upstream}` : ''} + + ); + } + if (meta.pending) { + return ( + + + ); + } + return ( + + 规则引擎 + + ); +} + +/** + * 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 ( +
+

偏离基线的指标

+
    + {notable.map((d) => ( +
  • + {d.label} + + {d.value} + {d.unit} + + 0 ? 'up' : 'down'}`} + title={`近 ${d.baselineDays} 天基线 ${d.baselineMean}${d.unit}`} + > + {d.z! > 0 ? '+' : ''} + {d.z!.toFixed(1)}σ + +
  • + ))} +
+
+ ); +} + +function AiBriefing({ date }: Props) { + const [briefing, setBriefing] = useState(null); + const [context, setContext] = useState(null); + const [meta, setMeta] = useState(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(); + 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
; + } + if (error) return
{error}
; + if (!briefing || !meta) return null; + if (meta.source === 'none') { + return
{meta.reason ?? '暂无可分析的数据'}
; + } + + const rx = briefing.prescription; + + return ( +
+
+ {briefing.status} + +
+ + {briefing.headline &&

{briefing.headline}

} + + {(rx.suggestion || rx.intensity) && ( +
+ 今日处方 +

+ {rx.suggestion} + {rx.intensity && 强度 {rx.intensity}} + {rx.hrZone && {rx.hrZone}} + {rx.durationMin != null && {rx.durationMin} 分钟} +

+ {rx.avoid &&

避免:{rx.avoid}

} +
+ )} + + {briefing.shortfall && briefing.shortfall !== '无明显短板' && ( +

短板:{briefing.shortfall}

+ )} + + {open && ( +
+ {briefing.diagnosis.map((d) => ( +
+

{d.title}

+

{d.detail}

+
+ ))} + + {!!briefing.actions.length && ( +
+

今日行动

+
    + {briefing.actions.map((a) =>
  • {a}
  • )} +
+
+ )} + + {context && } + +
+ + {meta.generatedAt && ( + + 生成于 {meta.generatedAt.replace('T', ' ')} UTC + + )} +
+
+ )} + + +
+ ); +} + +export default AiBriefing; diff --git a/client/src/components/Copilot.css b/client/src/components/Copilot.css new file mode 100644 index 0000000..5aa55d5 --- /dev/null +++ b/client/src/components/Copilot.css @@ -0,0 +1,196 @@ +/* Health Copilot — a floating dock, portalled to . + 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; +} diff --git a/client/src/components/Copilot.tsx b/client/src/components/Copilot.tsx new file mode 100644 index 0000000..6c1b6a3 --- /dev/null +++ b/client/src/components/Copilot.tsx @@ -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([]); + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const abort = useRef(); + const scroller = useRef(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 = ( +
+ {open && ( +
+
+ 健康 Copilot + +
+ +
+ {!messages.length && ( +
+

基于你已同步的佳明数据回答。它只看得到数据里有的东西。

+ {STARTERS.map((s) => ( + + ))} +
+ )} + + {messages.map((m, i) => ( +
+ {m.content} + {m.streaming &&
+ ))} + + {busy && ( +

+ 模型需要一到几分钟才会开始输出,这段等待是正常的。 +

+ )} +
+ +
{ e.preventDefault(); ask(draft); }} + > + setDraft(e.target.value)} + placeholder="问点关于你自己数据的问题" + disabled={busy} + aria-label="输入问题" + /> + {busy ? ( + + ) : ( + + )} +
+
+ )} + + +
+ ); + + /* 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; diff --git a/client/src/components/TrendInsight.css b/client/src/components/TrendInsight.css new file mode 100644 index 0000000..f15dd1e --- /dev/null +++ b/client/src/components/TrendInsight.css @@ -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); +} diff --git a/client/src/components/TrendInsight.tsx b/client/src/components/TrendInsight.tsx new file mode 100644 index 0000000..a67e431 --- /dev/null +++ b/client/src/components/TrendInsight.tsx @@ -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 = { + 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 = { + 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(null); + const [meta, setMeta] = useState(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 ( +
+
+

AI 归因

+ {insight && !loading && ( + + )} +
+ + {!insight && !loading && !error && ( + <> +

+ 分析 {startDate} ~ {endDate} 这段区间内该指标的变化及其关联因素。 +

+ + + )} + + {loading && ( +
+
+ )} + + {error &&

{error}

} + + {insight && ( +
+ {insight.summary &&

{insight.summary}

} + + {insight.drivers.map((d) => ( +
+ {d.factor} +

{d.detail}

+
+ ))} + + {insight.caution &&

{insight.caution}

} + +
+ + {meta?.source === 'ai' ? `AI 生成${meta.upstream ? ` · ${meta.upstream}` : ''}` : '规则引擎'} + + {CONFIDENCE_LABEL[insight.confidence]} +
+
+ )} +
+ ); +} + +export default TrendInsight; diff --git a/client/src/features.ts b/client/src/features.ts index c6ddf8a..d04d3c3 100644 --- a/client/src/features.ts +++ b/client/src/features.ts @@ -1,12 +1,12 @@ /** * Feature switches. * - * `ai` is off while the recommendation module is being reworked: the pages and - * the backend endpoints still exist, so turning it back on is a one-line - * change rather than a rebuild. Nothing links to the route while it is off, - * and the route itself is not registered — a hidden nav entry with a live URL - * would still be reachable by typing it. + * `ai` covers the whole coach surface: the 晨间简报 card on 今日, the Copilot + * dock, and the trend attribution panel. It is one switch rather than three + * because they share a backend that depends on the ai-gateway being reachable + * — when that box is down, all three degrade together and turning the set off + * is one edit. */ export const FEATURES = { - ai: false, + ai: true, }; diff --git a/client/src/pages/MetricDetailPage.tsx b/client/src/pages/MetricDetailPage.tsx index 21a3693..c99a9ef 100644 --- a/client/src/pages/MetricDetailPage.tsx +++ b/client/src/pages/MetricDetailPage.tsx @@ -7,7 +7,9 @@ import { daysAgo, today as todayIso } from '../lib/day'; import Chart from '../components/charts/Chart'; import BandBar from '../components/charts/BandBar'; import Skeleton from '../components/Skeleton'; +import TrendInsight from '../components/TrendInsight'; import { useCountUp } from '../lib/motion'; +import { FEATURES } from '../features'; import './MetricDetail.css'; const WINDOWS = [7, 30, 90, 365]; @@ -183,6 +185,16 @@ function MetricDetailPage({ id, f7route }: Props) { /> + {/* Directly under the chart it explains, and scoped to the same + window the range selector is showing. */} + {FEATURES.ai && ( + + )} +

这个指标是什么

{def.about}

diff --git a/client/src/pages/TodayPage.tsx b/client/src/pages/TodayPage.tsx index d28800a..c437c16 100644 --- a/client/src/pages/TodayPage.tsx +++ b/client/src/pages/TodayPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Link, f7 } from 'framework7-react'; import { apiClient, errorMessage, HealthDay } from '../services/api'; import Screen from '../components/Screen'; +import AiBriefing from '../components/AiBriefing'; import Ring from '../components/charts/Ring'; import MetricCard from '../components/charts/MetricCard'; import MetricStrip from '../components/charts/MetricStrip'; @@ -10,6 +11,7 @@ import { useCountUp } from '../lib/motion'; import { RANGES } from '../lib/ranges'; import { METRICS, metricHref } from '../lib/metrics'; import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day'; +import { FEATURES } from '../features'; import './Today.css'; /* Enough history for the cards' sparklines and a few weeks of stepping back @@ -283,6 +285,13 @@ function TodayPage() { <> + {/* 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 && } + {SECTIONS.map((section) => (

{section.title}

diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 255237f..e3028d5 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -332,6 +332,106 @@ export interface TrendPoint { 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; + todayMetrics: { + sleep: Record | null; + autonomicNervous: Record; + recovery: Record; + activityToday: Record; + }; + deviations: Deviation[]; + trends: TrendSummary[]; + activityShift: Record; + recentActivities: Array>; + 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 | null; + meta: InsightMeta; +} + +export interface CopilotTurn { + role: 'user' | 'assistant'; + content: string; +} + /** * 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, @@ -675,6 +775,139 @@ class ApiClient { ); 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('/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( + '/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();