feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot

数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推
z 分数,它算错的次数足以让简报引用图表反驳它的数字。

- services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个
  值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率
  (按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量
  对比。
- services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。
  网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。
- services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(),
  以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾
  倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。
- 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。
- 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情
  页归因面板。features.ai 打开。

实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报
绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式
139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零
输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。

顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读
到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差
时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 13:57:35 +08:00
parent a746327560
commit c57c930949
21 changed files with 3677 additions and 22 deletions

View File

@@ -97,6 +97,12 @@ class Provider:
name = "base"
# Whether this endpoint has an incremental transport of its own. False
# means `stream` is the blocking call in disguise, which `stream_chat`
# needs to know: retrying such a provider without streaming would just
# run the same request a second time.
streaming = False
def __init__(
self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None
):
@@ -131,9 +137,30 @@ class Provider:
session.trust_env = self.use_proxy
return session
def generate(self, prompt, timeout=None):
def chat(self, messages, timeout=None, max_tokens=None):
"""Multi-turn completion.
`messages` is the OpenAI shape — a list of
{"role": "system"|"user"|"assistant", "content": str}. Providers whose
wire format differs translate it themselves.
"""
raise NotImplementedError
def generate(self, prompt, timeout=None):
"""Single-turn convenience wrapper, kept for the recommendation path."""
return self.chat([{"role": "user", "content": prompt}], timeout)
def stream(self, messages, timeout=None, max_tokens=None):
"""Yield Completion deltas as the reply arrives.
The base implementation is not incremental: it waits for the whole
answer and emits it as a single delta. That keeps every entry in the
catalog streamable from the caller's point of view — a provider with
no SSE transport produces one late chunk rather than an error, so the
Copilot route does not need a per-provider branch.
"""
yield self.chat(messages, timeout, max_tokens)
class GeminiProvider(Provider):
"""Google AI Studio (generativelanguage.googleapis.com)."""
@@ -141,18 +168,34 @@ class GeminiProvider(Provider):
name = "gemini"
BASE = "https://generativelanguage.googleapis.com/v1beta/models"
def generate(self, prompt, timeout=None):
def chat(self, messages, timeout=None, max_tokens=None):
if not self.is_configured():
raise AIError(f"{self.api_key_env} 未配置")
timeout = timeout or default_timeout()
url = f"{self.BASE}/{self.model_id}:generateContent"
# Gemini splits what OpenAI keeps in one list: system turns move to
# `systemInstruction`, and the assistant role is spelled "model".
contents, system = [], []
for message in messages:
role = message.get("role")
if role == "system":
system.append(message.get("content") or "")
continue
contents.append({
"role": "model" if role == "assistant" else "user",
"parts": [{"text": message.get("content") or ""}],
})
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"contents": contents,
"generationConfig": {
"temperature": 0.4,
"maxOutputTokens": self.max_tokens,
"maxOutputTokens": max_tokens or self.max_tokens,
},
}
if system:
payload["systemInstruction"] = {
"parts": [{"text": "\n\n".join(system)}]
}
try:
resp = self._session().post(
url,
@@ -188,6 +231,7 @@ class OpenAICompatProvider(Provider):
"""
name = "openai-compat"
streaming = True
def __init__(
self,
@@ -216,30 +260,35 @@ class OpenAICompatProvider(Provider):
return False
return bool(self.api_key) if self.requires_key else True
def generate(self, prompt, timeout=None):
def _request(self, messages, timeout, max_tokens, stream):
if not self.is_configured():
raise AIError(
f"{self.api_key_env} 未配置" if self.requires_key
else f"{self.base_url_env} 未配置"
)
timeout = timeout or default_timeout()
url = f"{self.base_url.rstrip('/')}/chat/completions"
payload = {
"model": self.model_id,
"messages": [{"role": "user", "content": prompt}],
"messages": messages,
"temperature": 0.4,
"max_tokens": self.max_tokens,
"max_tokens": max_tokens or self.max_tokens,
}
if stream:
payload["stream"] = True
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
try:
resp = self._session().post(
url, headers=headers, json=payload, timeout=timeout
return self._session().post(
url, headers=headers, json=payload, timeout=timeout, stream=stream
)
except requests.RequestException as e:
raise AIError(f"{self.model_id} 请求失败: {e}") from e
def chat(self, messages, timeout=None, max_tokens=None):
timeout = timeout or default_timeout()
resp = self._request(messages, timeout, max_tokens, stream=False)
if resp.status_code != 200:
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
@@ -253,6 +302,51 @@ class OpenAICompatProvider(Provider):
except (ValueError, KeyError, IndexError) as e:
raise AIError(f"{self.model_id} 响应格式异常: {e}") from e
def stream(self, messages, timeout=None, max_tokens=None):
"""Server-sent chunks in the OpenAI streaming schema.
Note what "streaming" buys here in practice: the gateway forwards its
upstream's chunks, and its primary upstream is a reasoning model that
emits nothing until it has finished thinking. So this shortens the
wait to first text on some upstreams and not at all on others — it is
a transport, not a latency guarantee.
A `data:` frame carrying an `error` object is the gateway's way of
reporting "no upstream answered" mid-stream, so it is raised rather
than yielded as content.
"""
timeout = timeout or default_timeout()
resp = self._request(messages, timeout, max_tokens, stream=True)
if resp.status_code != 200:
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
try:
for raw in resp.iter_lines(decode_unicode=True):
if not raw or not raw.startswith("data:"):
continue
data = raw[len("data:"):].strip()
if data == "[DONE]":
return
try:
chunk = json.loads(data)
except ValueError:
continue
if isinstance(chunk, dict) and chunk.get("error"):
message = chunk["error"]
if isinstance(message, dict):
message = message.get("message") or message
raise AIError(f"{self.model_id}: {message}")
choices = chunk.get("choices") or []
if not choices:
continue
text = (choices[0].get("delta") or {}).get("content")
if text:
yield Completion(text, chunk.get("provider"))
except requests.RequestException as e:
raise AIError(f"{self.model_id} 流式中断: {e}") from e
finally:
resp.close()
# --- catalog ----------------------------------------------------------------
NVIDIA_BASE = "https://integrate.api.nvidia.com/v1"
@@ -549,3 +643,168 @@ def generate(summary, activities=None, preferred_model=None, day_budget=None):
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
raise AIError(f"所有模型均失败 -> {detail}")
# --- generic chat entry points ----------------------------------------------
# Used by the coach (briefing / trend insight / Copilot), which needs multi-turn
# messages and a bigger output cap than the recommendation list: a reasoning
# upstream spends part of its budget thinking out loud before the answer, and a
# briefing is prose rather than five short strings.
FALLBACK_COACH_MAX_TOKENS = 4000
def coach_max_tokens():
return int(os.environ.get("AI_COACH_MAX_TOKENS") or FALLBACK_COACH_MAX_TOKENS)
def complete(messages, preferred_model=None, max_tokens=None, timeout=None):
"""First healthy model in the chain answers. Returns (Completion, meta).
Same fallback policy as `generate`, but the caller supplies the whole
message list and parses the reply itself.
"""
chain = resolve_chain(preferred_model)
max_tokens = max_tokens or coach_max_tokens()
errors = []
for model_id in chain:
provider = CATALOG[model_id]
try:
completion = provider.chat(messages, timeout, max_tokens)
except AIError as e:
errors.append({"model": model_id, "error": str(e)})
continue
if not (completion.text or "").strip():
errors.append({"model": model_id, "error": "空响应"})
continue
return completion, {
"model": model_id,
"provider": provider.name,
"upstream": completion.upstream,
"fallbackFrom": [e["model"] for e in errors],
"errors": errors,
}
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
raise AIError(f"所有模型均失败 -> {detail}")
def stream_chat(messages, preferred_model=None, max_tokens=None, timeout=None):
"""Stream a reply, yielding Completion deltas.
Two failover rules, and the order matters:
1. **Same model, without streaming, before moving on.** Measured against
the gateway with a real briefing prompt: the streaming request came
back "所有模型均不可用" after 139s while the identical non-streaming
request answered in 273s. Its streaming path is simply less reliable
than its blocking one, so a stream that produces nothing is retried
blind before the model is written off. The reply then arrives as a
single late delta rather than not at all.
2. **No failover once text has been yielded.** By then it has usually
reached the user's screen, and switching models mid-answer splices two
different replies together — the exact defect the gateway's own NVIDIA
adapter has (its README, known issue #1). A half-written answer that
fails visibly beats one finished in another voice.
"""
chain = resolve_chain(preferred_model)
max_tokens = max_tokens or coach_max_tokens()
errors = []
for model_id in chain:
provider = CATALOG[model_id]
started = False
try:
for delta in provider.stream(messages, timeout, max_tokens):
started = True
yield delta
except AIError as e:
if started:
raise
errors.append({"model": model_id, "error": f"流式: {e}"})
if started:
return
if not provider.streaming:
# Its `stream` was the blocking call already; retrying it here
# would spend a second full generation on the same failure.
continue
try:
completion = provider.chat(messages, timeout, max_tokens)
except AIError as e:
errors.append({"model": model_id, "error": str(e)})
continue
if (completion.text or "").strip():
yield completion
return
errors.append({"model": model_id, "error": "空响应"})
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
raise AIError(f"所有模型均失败 -> {detail}")
# --- JSON extraction --------------------------------------------------------
def extract_json(text):
"""Pull the last complete JSON object or array out of a model reply.
The gateway's primary upstream is a reasoning model whose visible output
*begins* with its chain of thought ("The user wants ... so we must ..."),
with the real answer at the end. Scanning from the front therefore finds
prose, or a JSON fragment the model was merely considering. Scanning
backwards from the last closing brace finds the answer it settled on.
Brace counting is string-aware, because a Chinese briefing routinely
contains a quoted `}` or an escaped quote and a naive count breaks on both.
"""
if not text or not text.strip():
raise AIError("模型返回空响应")
cleaned = _FENCE.sub("", text).strip()
try:
return json.loads(cleaned)
except ValueError:
pass
for close, opener in (("}", "{"), ("]", "[")):
end = cleaned.rfind(close)
while end != -1:
start = _matching_open(cleaned, end, opener, close)
if start is not None:
try:
return json.loads(cleaned[start : end + 1])
except ValueError:
pass
end = cleaned.rfind(close, 0, end)
raise AIError(f"模型未返回可解析的 JSON: {text[-200:]}")
def _matching_open(text, end, opener, close):
"""Index of the bracket that `text[end]` closes, or None if unbalanced."""
depth = 0
in_string = False
for i in range(end, -1, -1):
ch = text[i]
if in_string:
# Walking backwards, a quote ends the string only when it is not
# itself escaped — count the run of backslashes before it.
if ch == '"':
backslashes = 0
j = i - 1
while j >= 0 and text[j] == "\\":
backslashes += 1
j -= 1
if backslashes % 2 == 0:
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == close:
depth += 1
elif ch == opener:
depth -= 1
if depth == 0:
return i
return None

View File

@@ -8,9 +8,12 @@ import datetime
import hashlib
import json
import os
import threading
from services import health
from services import ai as ai_svc
from services import coach
from services import insights
from db import query_all, query_one, execute
from config import DB_TYPE
@@ -256,3 +259,261 @@ def get_ai_recommendations(user_id, model=None, days=None, refresh=False):
def clear_ai_cache(user_id):
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])
# --- AI coach: briefing, trend attribution, Copilot -------------------------
# Same caching rationale as the recommendations above, with one addition: a
# briefing is the first thing on the 今日 screen, so it can never wait on a
# generation. The endpoint answers immediately from the rule engine and the
# model's version replaces it on a later poll.
_JOB_LOCK = threading.Lock()
_JOBS = set()
def _insight_key(kind, subject):
return f"{kind}:{subject}"
def _read_insight(user_id, kind, subject, fingerprint):
row = query_one(
"SELECT * FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
[user_id, kind, subject],
)
if not row or row["fingerprint"] != fingerprint:
return None
try:
payload = json.loads(row["payload"])
except (ValueError, TypeError):
return None
return payload, {
"source": "ai",
"model": row["model"],
"upstream": row["upstream"],
"cached": True,
"generatedAt": row.get("created_at"),
}
def _write_insight(user_id, kind, subject, fingerprint, payload, meta):
cols = ["id", "user_id", "kind", "subject", "fingerprint", "model",
"upstream", "payload", "created_at"]
placeholders = ", ".join(["?"] * len(cols))
updatable = [c for c in cols if c != "id"]
if DB_TYPE == "mariadb":
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) "
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
else:
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
sql = (f"INSERT INTO ai_insights ({', '.join(cols)}) "
f"VALUES ({placeholders}) ON CONFLICT(id) DO UPDATE SET {updates}")
# The id is derived rather than random so a re-generation overwrites the
# row it replaces instead of accumulating one per attempt.
row_id = hashlib.sha256(
f"{user_id}|{kind}|{subject}".encode("utf-8")
).hexdigest()[:64]
execute(sql, [
row_id, user_id, kind, subject, fingerprint, meta.get("model"),
meta.get("upstream"), json.dumps(payload, ensure_ascii=False),
datetime.datetime.utcnow().isoformat(timespec="seconds"),
])
def _context_fingerprint(context):
"""Digest of everything the prompt will contain.
The whole context rather than a chosen subset: a briefing is derived from
all of it, so any change to any field — a corrected sleep stage, a newly
synced activity — should expire the cached answer.
"""
blob = json.dumps(context, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:64]
def _run_in_background(key, target):
"""Start `target` once per key; a second caller joins the first one's run.
Guards against the obvious failure mode of a poll-until-ready endpoint:
the client polls every few seconds while a generation takes minutes, and
without this every poll would start another one.
The claim is per-process, not per-deployment: with Gunicorn's two workers
each can start one generation for the same key. That is deliberate rather
than overlooked — the `job_locks` table would make it exclusive, but the
cost here is a duplicate call, not a duplicate row (the cache id is derived
from user+kind+subject, so the second write lands on the first one's row).
A cross-process lock is worth adding only if the gateway's rate limits
start to bite.
"""
with _JOB_LOCK:
if key in _JOBS:
return False
_JOBS.add(key)
def runner():
try:
target()
except Exception as e: # noqa: BLE001 - a background job must not die silently
print(f"[analysis] background job {key} failed: {e}")
finally:
with _JOB_LOCK:
_JOBS.discard(key)
threading.Thread(target=runner, name=f"ai-{key}", daemon=True).start()
return True
def _generating(key):
with _JOB_LOCK:
return key in _JOBS
def generate_briefing(user_id, context, model=None):
"""Ask a model for the briefing and store it. Returns (briefing, meta)."""
completion, meta = ai_svc.complete(coach.briefing_messages(context), model)
briefing = coach.parse_briefing(completion.text)
_write_insight(
user_id, "briefing", context["snapshotDate"],
_context_fingerprint(context), briefing, meta,
)
return briefing, meta
def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
"""The morning briefing for one day.
Non-blocking by default: a cached answer is returned if it matches the
current data, otherwise the rule-based briefing is returned straight away
and a model generation starts in the background. `wait=True` blocks for
the model instead — for callers that can afford minutes, such as a manual
"regenerate" or a scheduled pre-warm.
"""
context = insights.build_context(user_id, date)
if not context:
return {
"briefing": None,
"context": None,
"meta": {"source": "none", "reason": "无健康数据"},
}
fingerprint = _context_fingerprint(context)
subject = context["snapshotDate"]
key = _insight_key("briefing", subject)
if not refresh:
cached = _read_insight(user_id, "briefing", subject, fingerprint)
if cached:
briefing, meta = cached
return {"briefing": briefing, "context": context, "meta": meta}
else:
# Drop the stored answer, not just skip it. Without this the poll that
# follows a regenerate reads the *old* row, sees `cached: true`, and
# stops polling — so the user keeps looking at the text they just
# asked to replace until something else expires it.
_delete_insight(user_id, "briefing", subject)
if wait:
try:
briefing, meta = generate_briefing(user_id, context, model)
return {
"briefing": briefing, "context": context,
"meta": {**meta, "source": "ai", "cached": False},
}
except ai_svc.AIError as e:
return {
"briefing": coach.rule_briefing(context), "context": context,
"meta": {"source": "rules", "reason": str(e)},
}
started = _run_in_background(
key, lambda: generate_briefing(user_id, context, model)
)
return {
"briefing": coach.rule_briefing(context),
"context": context,
"meta": {
"source": "rules",
# `pending` is what tells the client to poll again: the card it is
# showing is the placeholder, not the final answer.
"pending": True,
"generating": started or _generating(key),
},
}
def get_trend_insight(user_id, metric, start, end, model=None, refresh=False):
"""Attribution for a user-selected span of one metric (chart brush).
Blocking, unlike the briefing: this one is requested by an explicit
gesture on a chart, so there is a spinner to attach the wait to and no
useful placeholder to show in the meantime.
"""
window = insights.window_context(user_id, metric, start, end)
if not window:
return {"insight": None, "window": None,
"meta": {"source": "none", "reason": "所选区间没有数据"}}
subject = f"{metric}:{start}:{end}"
fingerprint = _context_fingerprint(window)
if not refresh:
cached = _read_insight(user_id, "trend", subject, fingerprint)
if cached:
insight, meta = cached
return {"insight": insight, "window": window, "meta": meta}
try:
completion, meta = ai_svc.complete(coach.trend_messages(window), model)
insight = coach.parse_trend_insight(completion.text)
except ai_svc.AIError as e:
return {
"insight": coach.rule_trend_insight(window), "window": window,
"meta": {"source": "rules", "reason": str(e)},
}
try:
_write_insight(user_id, "trend", subject, fingerprint, insight, meta)
except Exception as e: # noqa: BLE001 - a cache write must never fail the request
print(f"[analysis] failed to cache trend insight: {e}")
return {"insight": insight, "window": window,
"meta": {**meta, "source": "ai", "cached": False}}
def copilot_stream(user_id, question, history=None, date=None, model=None):
"""Stream a Copilot answer, yielding (event, data) pairs.
A generator rather than a return value so the route can forward each delta
as it arrives; the health context is assembled once, here, so the route
stays free of feature logic.
"""
context = insights.build_context(user_id, date)
if not context:
yield "error", {"message": "暂无健康数据,请先同步 Garmin 数据。"}
return
messages = coach.copilot_messages(context, history or [], question)
yield "start", {"snapshotDate": context["snapshotDate"]}
upstream = None
try:
for delta in ai_svc.stream_chat(messages, model):
upstream = delta.upstream or upstream
yield "delta", {"text": delta.text}
except ai_svc.AIError as e:
yield "error", {"message": str(e)}
return
yield "done", {"upstream": upstream}
def _delete_insight(user_id, kind, subject):
execute(
"DELETE FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
[user_id, kind, subject],
)
def clear_insight_cache(user_id, kind=None):
if kind:
execute(
"DELETE FROM ai_insights WHERE user_id = ? AND kind = ?", [user_id, kind]
)
else:
execute("DELETE FROM ai_insights WHERE user_id = ?", [user_id])

409
backend/services/coach.py Normal file
View File

@@ -0,0 +1,409 @@
"""
The AI coach: morning briefing, trend attribution, and the Copilot chat.
Division of labour with `insights.py`: every number quoted here was already
computed there. This module only turns a structured context into a prompt and
turns the reply back into a structured answer. Nothing asks the model to do
arithmetic, because a model asked to derive a z-score from a CSV gets it wrong
often enough that the briefing would quote figures the charts contradict.
Each feature has a rule-based counterpart. A model round-trip through the
gateway costs minutes (its primary upstream is a large reasoning model), and a
health screen that shows nothing when an upstream is rate-limited is worse than
one that shows a plainer answer — so `meta.source` says which one answered
rather than the failure being invisible.
"""
import json
from services import ai as ai_svc
from services import insights
SYSTEM = """# 角色
你是一名资深运动生理学专家与佳明Garmin数据分析教练。你解读用户的可穿戴设备
数据,输出严谨、精炼、无废话的生理状态解读与行动指导。
# 生理学原则
1. 训练准备度综合睡眠分数、HRV 状态、恢复时间、急性负荷与压力历史。
2. HRV 反映副交感神经活跃度HRV 高且静息心率低通常代表恢复良好。
3. 身体电量的充电量受睡眠质量与深睡/REM 比例影响:深睡负责肌肉与体力恢复,
REM 负责认知与精神修复。
4. 强度分钟与运动记录代表急性负荷;负荷骤增后 HRV 短暂下降属正常应激反应。
# 数据纪律
- 只使用输入 JSON 中出现的数值,禁止编造或估算任何未给出的数字。
- 字段为 null 表示该项未采集,要么略过,要么明确说明"未采集",不要当作 0。
- z 值z是该指标相对用户自身近 28 天基线的偏离程度,已经算好,直接引用即可,
不要自行重算。|z| < 1 属正常波动,不要渲染成异常。
- 你不是医生,不做医疗诊断;只从运动恢复、疲劳管理与作息角度给建议。发现明显
异常时提示用户咨询专业医师。
# 输出
- 简体中文。
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
JSON 之外的任何文字都会被丢弃。"""
BRIEFING_SCHEMA = """{
"status": "对整体恢复状态的定性,不超过 8 字,例如 '恢复良好' / '中等偏上' / '疲劳累积'",
"headline": "一句话总结今日身体状态,不超过 40 字",
"diagnosis": [
{"title": "维度名,如 睡眠结构 / 自主神经 / 电量与就绪度", "detail": "该维度的判断与依据,引用具体数值,不超过 60 字"}
],
"shortfall": "今日最主要的短板,一句话;若无明显短板则写 '无明显短板'",
"prescription": {
"intensity": "今日运动强度上限,如 低 / 中等 / 中等偏高 / 高",
"hrZone": "建议心率区间,如 'Zone 2~Zone 3';无法判断填 null",
"suggestion": "具体运动处方,含项目与时长,不超过 40 字",
"durationMin": 建议时长的分钟数(整数)或 null,
"avoid": "今日应避免的内容,不超过 20 字;无则填 null"
},
"actions": ["今日可执行的具体行动2~4 条,每条不超过 30 字"]
}"""
TREND_SCHEMA = """{
"summary": "这段区间内该指标发生了什么,一句话,不超过 50 字",
"drivers": [
{"factor": "关联因素名", "detail": "它与该指标的关系及依据,引用数值,不超过 60 字"}
],
"caution": "需要留意的风险或误读;没有则填 null",
"confidence": "high|medium|low —— 取决于样本量与关联证据强度"
}"""
def _payload(context):
"""The context as compact JSON.
`ensure_ascii=False` matters for size as much as readability: escaping
Chinese labels to \\uXXXX roughly triples their token cost.
"""
return json.dumps(context, ensure_ascii=False, separators=(",", ":"))
def briefing_messages(context):
return [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
"以下是我的健康数据快照。deviations 中的 z 值是相对我自身近 28 天\n"
"基线的偏离trends 是长周期走势activityShift 是近 7 天与之前的\n"
"活动量对比。\n\n"
f"```json\n{_payload(context)}\n```\n\n"
"请给出今日晨间简报与运动处方,严格按以下 JSON 结构输出:\n\n"
f"{BRIEFING_SCHEMA}"
),
},
]
def trend_messages(window):
return [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
f"以下是我 {window['label']} 指标在 {window['start']} ~ {window['end']}\n"
"区间的数据companions 是同区间内其它指标的均值activities 是该区间\n"
"内的运动记录baselineBefore 是该区间之前的基线。\n\n"
f"```json\n{_payload(window)}\n```\n\n"
"请解释这段区间内该指标的变化及其可能的驱动因素,严格按以下 JSON\n"
f"结构输出:\n\n{TREND_SCHEMA}"
),
},
]
COPILOT_SYSTEM = SYSTEM.replace(
"""# 输出
- 简体中文。
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
- 最终答案必须是一个 JSON 对象,且是你整段输出中最后出现的 JSON。
JSON 之外的任何文字都会被丢弃。""",
"""# 输出
- 简体中文Markdown 格式。
- 逻辑严谨、直接明确,禁止客套、禁止情绪化修辞。
- 控制在 300 字以内,先给结论再给依据。
- 引用数值时写明是哪一天或哪个区间的值。
- 问题超出所给数据能回答的范围时,直接说明数据里没有,不要猜。""",
)
def copilot_messages(context, history, question):
"""Chat turns for the Copilot.
The health context rides in the system turn rather than being prepended to
the user's question: it stays out of the visible transcript, and the same
snapshot governs every turn instead of being re-sent (and re-charged) with
each follow-up.
"""
messages = [
{"role": "system", "content": COPILOT_SYSTEM},
{
"role": "system",
"content": (
"以下是提问者的健康数据快照,回答时以它为唯一事实来源:\n"
f"```json\n{_payload(context)}\n```"
),
},
]
# Filtered first, then capped: capping first lets a single unusable entry
# in the tail — a tool frame, an empty message — silently cost the model a
# remembered turn.
usable = [
{"role": t["role"], "content": (t.get("content") or "").strip()[:2000]}
for t in history
if t.get("role") in ("user", "assistant") and (t.get("content") or "").strip()
]
messages.extend(usable[-8:])
messages.append({"role": "user", "content": question[:2000]})
return messages
# --- reply validation -------------------------------------------------------
def _text(value, limit):
if value is None:
return None
text = str(value).strip()
return text[:limit] if text else None
def parse_briefing(reply):
data = ai_svc.extract_json(reply)
if not isinstance(data, dict):
raise ai_svc.AIError("模型未返回 JSON 对象")
prescription = data.get("prescription")
if not isinstance(prescription, dict):
prescription = {}
duration = prescription.get("durationMin")
try:
duration = int(duration) if duration is not None else None
except (TypeError, ValueError):
duration = None
diagnosis = []
for item in data.get("diagnosis") or []:
if isinstance(item, dict):
title = _text(item.get("title"), 20)
detail = _text(item.get("detail"), 200)
else:
title, detail = None, _text(item, 200)
if detail:
diagnosis.append({"title": title or "综合", "detail": detail})
actions = [
_text(a, 60) for a in (data.get("actions") or []) if _text(a, 60)
]
out = {
"status": _text(data.get("status"), 20) or "状态未定性",
"headline": _text(data.get("headline"), 120),
"diagnosis": diagnosis[:5],
"shortfall": _text(data.get("shortfall"), 120),
"prescription": {
"intensity": _text(prescription.get("intensity"), 20),
"hrZone": _text(prescription.get("hrZone"), 40),
"suggestion": _text(prescription.get("suggestion"), 120),
"durationMin": duration,
"avoid": _text(prescription.get("avoid"), 60),
},
"actions": actions[:4],
}
# A briefing with neither a headline nor any diagnosis is an empty card;
# rejecting it here lets the caller fall back to the rule engine instead
# of rendering blank space.
if not out["headline"] and not out["diagnosis"]:
raise ai_svc.AIError("模型返回的简报没有可用内容")
return out
def parse_trend_insight(reply):
data = ai_svc.extract_json(reply)
if not isinstance(data, dict):
raise ai_svc.AIError("模型未返回 JSON 对象")
drivers = []
for item in data.get("drivers") or []:
if isinstance(item, dict):
factor = _text(item.get("factor"), 30)
detail = _text(item.get("detail"), 200)
else:
factor, detail = None, _text(item, 200)
if detail:
drivers.append({"factor": factor or "关联因素", "detail": detail})
confidence = str(data.get("confidence", "medium")).lower()
if confidence not in ("high", "medium", "low"):
confidence = "medium"
summary = _text(data.get("summary"), 200)
if not summary and not drivers:
raise ai_svc.AIError("模型返回的归因没有可用内容")
return {
"summary": summary,
"drivers": drivers[:5],
"caution": _text(data.get("caution"), 200),
"confidence": confidence,
}
# --- rule-based counterparts ------------------------------------------------
def rule_briefing(context):
"""A briefing assembled from the computed features alone.
Deliberately quotes the same numbers the AI version would, so a fallback
reads as a plainer answer rather than a different one.
"""
today = context["todayMetrics"]
sleep = today["sleep"] or {}
nervous = today["autonomicNervous"]
recovery = today["recovery"]
activity = today["activityToday"]
by_metric = {d["metric"]: d for d in context["deviations"]}
diagnosis = []
concerns = []
duration = sleep.get("durationHours")
if duration is not None:
target = sleep.get("targetHours") or insights.SLEEP_TARGET_HOURS
parts = [f"睡眠 {duration:.1f} 小时(目标 {target:g}"]
rem = sleep.get("remPercent")
if rem is not None:
low, high = insights.REM_REFERENCE_PCT
parts.append(f"REM {rem:g}%{'(偏低)' if rem < low else ''}")
deep = sleep.get("deepPercent")
if deep is not None:
low, _ = insights.DEEP_REFERENCE_PCT
parts.append(f"深睡 {deep:g}%{'(偏低)' if deep < low else '(达标)'}")
diagnosis.append({"title": "睡眠结构", "detail": "".join(parts) + ""})
if duration < target:
concerns.append(f"睡眠比目标少 {target - duration:.1f} 小时")
hrv, rhr = nervous.get("hrvMs"), nervous.get("restingHr")
if hrv is not None or rhr is not None:
parts = []
if hrv is not None:
base = by_metric.get("heartRateVariability", {}).get("baselineMean")
parts.append(
f"HRV {hrv:g} ms" + (f"(基线 {base:g}" if base is not None else "")
)
if rhr is not None:
base = by_metric.get("heartRate", {}).get("baselineMean")
parts.append(
f"静息心率 {rhr:g} bpm" + (f"(基线 {base:g}" if base is not None else "")
)
diagnosis.append({"title": "自主神经", "detail": "".join(parts) + ""})
readiness = recovery.get("trainingReadiness")
battery = recovery.get("bodyBatteryPeak")
if readiness is not None or battery is not None:
parts = []
if readiness is not None:
parts.append(f"训练准备度 {readiness:g}/100")
if battery is not None:
parts.append(f"身体电量充至 {battery:g}")
diagnosis.append({"title": "恢复与就绪度", "detail": "".join(parts) + ""})
# Readiness is Garmin's own composite of sleep, HRV, recovery time and
# acute load, so it drives the prescription wherever it exists; the
# sleep/HRV fallback below is only for watches that do not report it.
if readiness is not None:
if readiness >= 75:
intensity, zone, suggestion = "", "Zone 3~Zone 4", "可安排高强度或长时间训练"
elif readiness >= 50:
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
else:
intensity, zone, suggestion = "", "Zone 1~Zone 2", "以走路或拉伸为主,优先恢复"
elif duration is not None and duration < (sleep.get("targetHours") or 7):
intensity, zone, suggestion = "中等偏低", "Zone 2", "30 分钟低强度有氧,避免加练"
else:
intensity, zone, suggestion = "中等", "Zone 2~Zone 3", "30-45 分钟中低强度有氧"
actions = []
steps, goal = activity.get("steps"), activity.get("stepGoal")
if steps is not None and goal and steps < goal:
actions.append(f"步数 {steps:,} / 目标 {goal:,},补一段快走")
elif steps is not None and steps < 6000:
actions.append(f"今日步数 {steps:,},偏低,安排一次散步")
if concerns:
actions.append("提前 30 分钟入睡,补回睡眠缺口")
sedentary = activity.get("sedentaryHours")
if sedentary and sedentary >= 8:
actions.append(f"久坐 {sedentary:g} 小时,每小时起身活动 3 分钟")
shift = context.get("activityShift", {}).get("steps")
if shift and shift.get("changePct") is not None and shift["changePct"] <= -20:
actions.append(f"近 7 天步数较此前下降 {abs(shift['changePct']):g}%,注意活动量")
if not actions:
actions.append("各项指标处于常态,保持当前作息与训练安排")
notable = [
d for d in context["deviations"]
if d.get("z") is not None and abs(d["z"]) >= insights.Z_NOTABLE
]
if notable:
top = notable[0]
status = "存在偏离"
headline = (
f"{top['label']} {top['value']:g}{top['unit']}"
f"偏离近 {top['baselineDays']} 天基线 {abs(top['z']):.1f} 个标准差。"
)
else:
status = "状态平稳"
headline = "各项指标均在个人基线的正常波动范围内。"
return {
"status": status,
"headline": headline,
"diagnosis": diagnosis,
"shortfall": "".join(concerns) if concerns else "无明显短板",
"prescription": {
"intensity": intensity,
"hrZone": zone,
"suggestion": suggestion,
"durationMin": None,
"avoid": None,
},
"actions": actions[:4],
}
def rule_trend_insight(window):
"""Trend attribution without a model: direction, size, and co-movement."""
slope = window.get("slopePer30d")
label, unit = window["label"], window["unit"]
if slope is None:
summary = f"{window['start']} ~ {window['end']} 区间内 {label} 样本不足,无法判断趋势。"
else:
direction = "上升" if slope > 0 else ("下降" if slope < 0 else "基本持平")
summary = (
f"{label} 在该区间{direction},拟合斜率约 {slope:g}{unit}/30 天,"
f"均值 {window['mean']:g}{unit}"
)
drivers = []
baseline = window.get("baselineBefore")
if baseline and window.get("mean") is not None:
delta = window["mean"] - baseline["mean"]
drivers.append({
"factor": "区间前基线",
"detail": (
f"区间前 {baseline['days']} 天均值 {baseline['mean']:g}{unit}"
f"区间内{'高出' if delta >= 0 else '低于'} {abs(delta):.2f}{unit}"
),
})
activities = window.get("activities") or []
if activities:
minutes = sum(a.get("durationMin") or 0 for a in activities)
drivers.append({
"factor": "运动负荷",
"detail": f"该区间共 {len(activities)} 次运动,合计约 {minutes} 分钟。",
})
return {
"summary": summary,
"drivers": drivers,
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
"confidence": "low",
}

View File

@@ -0,0 +1,461 @@
"""
Feature engineering for the AI coach.
Everything here is arithmetic over stored health data — no model calls. The
split is deliberate: the numbers a briefing quotes (z-scores, baselines,
trend slopes) must be reproducible and testable, and an LLM asked to compute
them from a raw CSV gets them wrong often enough to matter. The model's job
is to interpret figures that were already computed here, not to derive them.
Two windows are used throughout:
* **baseline** (default 28 days) — what "normal for this person, lately"
means. Short enough to track a training block, long enough for a standard
deviation to be worth quoting.
* **trend** (default 395 days ≈ 13 months) — the long arc the product spec
asks about, and long enough to contain a full season.
"""
import datetime
import statistics
from services import health
from services import settings as settings_svc
from services import fitness_age
BASELINE_DAYS = 28
TREND_DAYS = 395
# Sleep targets are personal, but Garmin's own coaching and the ACSM/AASM
# adult guidance both land on 7 hours as the floor; the deep/REM shares are
# the conventional adult reference bands.
SLEEP_TARGET_HOURS = 7.0
REM_REFERENCE_PCT = (20.0, 25.0)
DEEP_REFERENCE_PCT = (13.0, 23.0)
# |z| beyond this counts as a departure from the personal baseline rather
# than day-to-day noise. 1.0 rather than the textbook 2.0: with a 28-day
# window a 2-sigma day is roughly a once-a-month event, which is too rare to
# drive a daily briefing.
Z_NOTABLE = 1.0
def _flatten(day):
"""One day as a flat metric -> value mapping.
`get_summary` nests sleep and omits missing metrics entirely; both are
inconvenient for statistics, so sleep is lifted to the top level and
derived shares (deep/REM percent) are computed once here.
"""
flat = {k: v for k, v in day.items() if k != "sleep"}
sleep = day.get("sleep") or {}
duration = sleep.get("duration") or day.get("sleepDuration")
if duration:
flat["sleepDuration"] = duration
seconds = duration * 3600.0
for src, dest in (
("deepSeconds", "sleepDeepPct"),
("remSeconds", "sleepRemPct"),
("lightSeconds", "sleepLightPct"),
("awakeSeconds", "sleepAwakePct"),
):
value = sleep.get(src)
if value is not None and seconds > 0:
flat[dest] = round(value / seconds * 100, 1)
if sleep.get("quality") is not None:
flat["sleepQuality"] = sleep["quality"]
sedentary = day.get("sedentarySeconds")
if sedentary is not None:
flat["sedentaryHours"] = round(sedentary / 3600.0, 1)
return flat
# Metrics the briefing reasons about. `higher_better` drives the plain-language
# verdict; None means the direction is not meaningful on its own (steps on a
# rest day are not a failure).
METRICS = {
"sleepDuration": ("睡眠时长", "小时", True),
"sleepQuality": ("睡眠评分", "", True),
"sleepDeepPct": ("深睡占比", "%", True),
"sleepRemPct": ("REM 占比", "%", True),
"heartRate": ("静息心率", "bpm", False),
"heartRateVariability": ("HRV", "ms", True),
"stress": ("压力均值", "", False),
"bodyBatteryHigh": ("身体电量峰值", "", True),
"bodyBatteryLow": ("身体电量谷值", "", True),
"trainingReadiness": ("训练准备度", "", True),
"enduranceScore": ("耐力分", "", True),
"vo2max": ("最大摄氧量", "ml/kg/min", True),
"steps": ("步数", "", None),
"intensityMinutes": ("强度分钟", "分钟", None),
"respirationAvg": ("呼吸频率", "次/分", None),
"spo2Avg": ("血氧", "%", True),
}
def _series(rows, metric):
"""(date, value) pairs where the metric was actually recorded."""
return [(r["date"], r[metric]) for r in rows if r.get(metric) is not None]
def _stats(values):
if not values:
return None
mean = statistics.fmean(values)
# pstdev, not stdev: these are all the observations in the window, not a
# sample drawn from it, and stdev raises on a single point.
sd = statistics.pstdev(values) if len(values) > 1 else 0.0
return {"mean": mean, "sd": sd, "n": len(values)}
def _verdict(z, higher_better):
if higher_better is None or abs(z) < Z_NOTABLE:
return "正常"
if (z > 0) == bool(higher_better):
return "偏好"
return "偏差"
def deviations(rows, today, baseline_days=BASELINE_DAYS):
"""How far each of today's metrics sits from its own recent baseline.
The baseline deliberately excludes today: comparing a value against a mean
it helped produce shrinks its own z-score, and with a 28-day window that
bias is large enough to hide a genuine outlier.
"""
history = [r for r in rows if r["date"] < today.get("date", "")]
window = history[-baseline_days:]
out = []
for metric, (label, unit, higher_better) in METRICS.items():
value = today.get(metric)
if value is None:
continue
values = [v for _, v in _series(window, metric)]
stats = _stats(values)
if not stats or stats["n"] < 5:
# Too little history for a standard deviation to mean anything.
out.append({
"metric": metric, "label": label, "unit": unit,
"value": round(float(value), 2), "baselineMean": None,
"sd": None, "z": None, "verdict": "基线不足",
})
continue
sd = stats["sd"]
if sd > 0:
z = round((float(value) - stats["mean"]) / sd, 2)
verdict = _verdict(z, higher_better)
else:
# A baseline with no spread cannot scale a departure. Reporting
# z = 0 here would label a value that differs from every single
# observation as perfectly typical, which is the opposite of true.
z = None
verdict = "正常" if float(value) == stats["mean"] else "基线无波动"
out.append({
"metric": metric, "label": label, "unit": unit,
"value": round(float(value), 2),
"baselineMean": round(stats["mean"], 2),
"sd": round(sd, 2),
"baselineDays": stats["n"],
"z": z,
"verdict": verdict,
})
# Biggest departures first: that ordering is what the prompt relies on to
# keep the interesting metrics inside the model's attention span.
out.sort(key=lambda d: abs(d["z"]) if d["z"] is not None else -1, reverse=True)
return out
def _slope_per_30d(points):
"""Least-squares slope in units per 30 days.
Ordinal dates rather than array indices: gaps in the record (a watch left
on the charger for a week) would otherwise compress the x-axis and inflate
the slope.
"""
if len(points) < 3:
return None
xs = [datetime.date.fromisoformat(d).toordinal() for d, _ in points]
ys = [float(v) for _, v in points]
mx, my = statistics.fmean(xs), statistics.fmean(ys)
denom = sum((x - mx) ** 2 for x in xs)
if denom == 0:
return None
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom
return round(slope * 30, 3)
def trends(rows, window_days=TREND_DAYS, edge=30):
"""Long-arc movement per metric: endpoint means plus a fitted slope.
Endpoint means (first `edge` days vs last `edge` days) answer "where did
this end up"; the slope answers "was it a trend or two different plateaus".
Reporting only one of them has misled us before — a metric can finish
higher after months of decline if it spikes in the final week.
"""
if not rows:
return []
cutoff = (
datetime.date.fromisoformat(rows[-1]["date"])
- datetime.timedelta(days=window_days)
).isoformat()
window = [r for r in rows if r["date"] >= cutoff]
out = []
for metric, (label, unit, higher_better) in METRICS.items():
points = _series(window, metric)
if len(points) < 10:
continue
# With fewer than two edges' worth of points the two windows would
# overlap and both converge on the overall mean — reporting delta 0
# for a series that visibly moved. Split it in half instead.
span = min(edge, len(points) // 2)
head = [v for _, v in points[:span]]
tail = [v for _, v in points[-span:]]
first, last = statistics.fmean(head), statistics.fmean(tail)
delta = last - first
entry = {
"metric": metric, "label": label, "unit": unit,
"days": (
datetime.date.fromisoformat(points[-1][0])
- datetime.date.fromisoformat(points[0][0])
).days,
"samples": len(points),
"firstMean": round(first, 2),
"lastMean": round(last, 2),
"delta": round(delta, 2),
"slopePer30d": _slope_per_30d(points),
}
if higher_better is not None and abs(delta) > 0:
entry["direction"] = "改善" if (delta > 0) == bool(higher_better) else "退步"
out.append(entry)
return out
def activity_shift(rows, recent=7, prior=30):
"""Recent activity volume against the weeks before it.
Separate from `deviations` because the question is different: not "is today
unusual" but "has the last week as a whole dropped off" — the drop that a
single quiet day cannot show.
"""
out = {}
for metric in ("steps", "intensityMinutes", "sleepDuration", "bodyBatteryHigh"):
points = _series(rows, metric)
if len(points) < recent + 5:
continue
recent_values = [v for _, v in points[-recent:]]
prior_values = [v for _, v in points[-(recent + prior):-recent]]
if not prior_values:
continue
r_mean, p_mean = statistics.fmean(recent_values), statistics.fmean(prior_values)
out[metric] = {
"label": METRICS[metric][0],
"recentMean": round(r_mean, 2),
"priorMean": round(p_mean, 2),
"changePct": round((r_mean - p_mean) / p_mean * 100, 1) if p_mean else None,
}
return out
def _sleep_block(today):
duration = today.get("sleepDuration")
if duration is None:
return None
block = {
"durationHours": round(float(duration), 2),
"targetHours": SLEEP_TARGET_HOURS,
"score": today.get("sleepQuality"),
"deepPercent": today.get("sleepDeepPct"),
"remPercent": today.get("sleepRemPct"),
"lightPercent": today.get("sleepLightPct"),
"awakePercent": today.get("sleepAwakePct"),
"remReference": list(REM_REFERENCE_PCT),
"deepReference": list(DEEP_REFERENCE_PCT),
}
return block
def latest_of(rows, metric, within=180):
"""Most recent recorded value, for metrics that only refresh occasionally.
VO2max and endurance score update after a qualifying outdoor session, so
reading them off "today" yields None on any indoor or rest day even though
the last measured value is still the current one.
"""
for row in reversed(rows[-within:] if within else rows):
if row.get(metric) is not None:
return row[metric]
return None
def build_context(user_id, date=None, rows=None):
"""The structured payload every coach prompt is assembled from.
`date` selects the snapshot day; the default is the newest day on record
rather than the calendar date, because a sync may not have run yet today
and an empty snapshot produces a briefing about nothing.
"""
rows = rows if rows is not None else health.get_summary(user_id)
if not rows:
return None
flat = [_flatten(r) for r in rows]
if date:
matches = [r for r in flat if r["date"] == date]
if not matches:
return None
today = matches[0]
history = [r for r in flat if r["date"] <= date]
else:
today = flat[-1]
history = flat
profile = settings_svc.get_raw(user_id)
age = settings_svc.age_from(profile["birth_date"])
bmi = settings_svc.bmi_from(profile["height_cm"], profile["weight_kg"])
vo2max = latest_of(history, "vo2max")
body_age = fitness_age.estimate(
age=age, sex=profile["sex"], vo2max=vo2max,
resting_hr=latest_of(history, "heartRate", within=30), bmi=bmi,
)
start = (
datetime.date.fromisoformat(today["date"]) - datetime.timedelta(days=14)
).isoformat()
recent_activities = health.get_activities(user_id, start, today["date"])
sedentary = today.get("sedentaryHours")
return {
"snapshotDate": today["date"],
"userProfile": {
"age": age,
"sex": profile["sex"],
# `estimate` returns {"value": None, "missing": [...]} when the
# profile is incomplete, so this is None rather than a number
# until a birth date, sex and a VO2max reading all exist.
"fitnessAge": (body_age or {}).get("value"),
"vo2max": vo2max,
"enduranceScore": latest_of(history, "enduranceScore"),
"heightCm": profile["height_cm"],
"weightKg": profile["weight_kg"],
"bmi": bmi,
},
"todayMetrics": {
"sleep": _sleep_block(today),
"autonomicNervous": {
"restingHr": today.get("heartRate"),
"hrvMs": today.get("heartRateVariability"),
"stressAvg": today.get("stress"),
"stressMax": today.get("stressMax"),
"respirationAvg": today.get("respirationAvg"),
"spo2Avg": today.get("spo2Avg"),
},
"recovery": {
"bodyBatteryPeak": today.get("bodyBatteryHigh"),
"bodyBatteryLow": today.get("bodyBatteryLow"),
"bodyBatteryCharged": today.get("bodyBatteryCharged"),
"bodyBatteryDrained": today.get("bodyBatteryDrained"),
"trainingReadiness": today.get("trainingReadiness"),
},
"activityToday": {
"steps": today.get("steps"),
"stepGoal": today.get("stepGoal"),
"intensityMinutes": today.get("intensityMinutes"),
"sedentaryHours": sedentary,
"floorsAscended": today.get("floorsAscended"),
"caloriesBurned": today.get("caloriesBurned"),
"activeCalories": today.get("activeCalories"),
},
},
"deviations": deviations(history, today),
"trends": trends(history),
"activityShift": activity_shift(history),
"recentActivities": [
{
"date": a.get("start_time"),
"sport": a.get("activity_type"),
"durationMin": round((a.get("duration") or 0) / 60) or None,
"distanceKm": (
round(a["distance"] / 1000, 2) if a.get("distance") else None
),
"calories": a.get("calories"),
"avgHr": a.get("heart_rate_average"),
"maxHr": a.get("heart_rate_max"),
}
for a in recent_activities[-15:]
],
"dataQuality": {
"totalDays": len(history),
"firstDate": history[0]["date"],
"lastDate": history[-1]["date"],
"staleDays": (
datetime.date.today()
- datetime.date.fromisoformat(history[-1]["date"])
).days,
},
}
def window_context(user_id, metric, start, end, rows=None):
"""Context for one metric over a user-selected span (chart brush).
Narrower than `build_context` on purpose: the question being answered is
"what happened to this line here", so the payload carries the selected
series plus whatever else moved alongside it in the same window.
"""
# An unknown metric would otherwise produce a well-formed window with an
# empty series, and the model would dutifully write an attribution for a
# line that does not exist.
if metric not in METRICS:
return None
rows = rows if rows is not None else health.get_summary(user_id)
flat = [_flatten(r) for r in rows]
window = [r for r in flat if start <= r["date"] <= end]
if not window:
return None
label, unit, _ = METRICS[metric]
points = _series(window, metric)
before = [r for r in flat if r["date"] < start][-BASELINE_DAYS:]
baseline = _stats([v for _, v in _series(before, metric)])
companions = {}
for other in METRICS:
if other == metric:
continue
values = [v for _, v in _series(window, other)]
stats = _stats(values)
if stats and stats["n"] >= 3:
companions[other] = {
"label": METRICS[other][0],
"mean": round(stats["mean"], 2),
"n": stats["n"],
}
return {
"metric": metric,
"label": label,
"unit": unit,
"start": start,
"end": end,
"points": [{"date": d, "value": v} for d, v in points],
"mean": round(statistics.fmean([v for _, v in points]), 2) if points else None,
"min": min((v for _, v in points), default=None),
"max": max((v for _, v in points), default=None),
"slopePer30d": _slope_per_30d(points),
"baselineBefore": (
{"mean": round(baseline["mean"], 2), "days": baseline["n"]}
if baseline else None
),
"companions": companions,
"activities": [
{
"date": a.get("start_time"),
"sport": a.get("activity_type"),
"durationMin": round((a.get("duration") or 0) / 60) or None,
"calories": a.get("calories"),
"avgHr": a.get("heart_rate_average"),
}
for a in health.get_activities(user_id, start, end)[:40]
],
}