feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列

原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外
的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、
身体年龄、挑战赛、运动详情。

不是给每个页面写一套,而是一个通用管线:
- services/scopes.py:一个页面一个 context builder,返回同一个信封。
  context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不
  可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。
  没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。
- coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面
  的差异全在 context 里,加页面 = 加一个 builder。
- 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成
  lib/insight.ts 的 usePolledInsight,晨报卡也改用它。

## 队列

一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队,
worker 负责消费(services/jobs.py)。

优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按
背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务
立刻提到队首、下一个就跑。你在看什么,队列就在算什么。

队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder
声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由
user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一
个任务。

## 网关中断时踩到的两个坑(当场修了)

写完正好赶上 oracle 那台机器不通,于是看到:
- 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把
  那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置
  尝试次数再排一次。
- 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending
  现在跟着队列状态走,并把失败原因带给卡片。

顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py:
教练要拿它做参照,而 services 不该反向依赖 routes。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 15:17:17 +08:00
parent 984a06a098
commit 241ae0d6a3
26 changed files with 1948 additions and 182 deletions

View File

@@ -13,6 +13,7 @@ import db
from config import CORS_ORIGINS, PORT, STATIC_DIR
from routes import auth, garmin, health, analysis, settings
from services import scheduler
from services import jobs as ai_jobs
from services import garmin as garmin_svc
@@ -32,6 +33,14 @@ def create_app():
# one of them actually runs a given tick.
scheduler.start()
# The AI coach's consumer. Same story: the queue is in the database, so
# every worker can run one and a given job is still generated once.
# Claims held by the previous process are released first — otherwise they
# sit in `running` until they time out, which to the screen waiting on one
# is indistinguishable from a generation that never finishes.
ai_jobs.reset_stale_claims()
ai_jobs.start()
# In production the built React app is served by this same process, so the
# deployment is a single port with no reverse proxy to configure. In
# development STATIC_DIR does not exist and the CRA dev server serves the

View File

@@ -302,6 +302,31 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
-- 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.
-- The coach's work queue. Generating one insight costs minutes against the
-- gateway, so nothing is produced inside a request: screens enqueue, a worker
-- consumes. `priority` is what makes the screen the user is actually looking
-- at jump ahead of the backfill queued after a sync (lower runs first).
--
-- `id` is derived from user+kind+subject, so enqueueing the same work twice
-- updates one row rather than piling up duplicates — which is what keeps a
-- poll every few seconds from queueing a job every few seconds.
CREATE TABLE IF NOT EXISTS ai_jobs (
id VARCHAR(160) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
kind VARCHAR(32) NOT NULL,
subject VARCHAR(96) NOT NULL,
fingerprint VARCHAR(64),
priority INT NOT NULL DEFAULT 10,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
error TEXT,
holder VARCHAR(64),
claimed_at DATETIME,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS ai_insights (
id VARCHAR(160) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,

View File

@@ -7,6 +7,8 @@ from auth import require_auth
from services import analysis as analysis_svc
from services import ai as ai_svc
from services import insights
from services import jobs as ai_jobs
from services import scopes
bp = Blueprint("analysis", __name__)
@@ -135,3 +137,39 @@ def copilot():
# until it completes, which would undo the point of streaming it.
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@bp.route("/insight", methods=["GET"])
@require_auth
def insight():
"""One screen's AI reading. `?scope=` names the screen.
Answers immediately, like the briefing: the computed highlights come back
with `meta.pending` while the model's version is generated. Opening a
screen queues it at interactive priority, ahead of any backfill still
running, so what you are looking at is what the queue works on next.
`?subject=` identifies the item for per-item screens (`activity` needs an
activity id, `daily` takes a date).
"""
scope = request.args.get("scope")
if scope not in scopes.SCOPES:
return jsonify({
"error": f"不支持的页面: {scope}",
"supported": sorted(scopes.SCOPES),
}), 400
return jsonify(analysis_svc.get_scope_insight(
g.user_id, scope,
subject=request.args.get("subject") or None,
refresh=_flag("refresh"),
))
@bp.route("/insight/queue", methods=["GET"])
@require_auth
def insight_queue():
"""What the coach still has to generate — for a progress indicator."""
return jsonify({
"pending": ai_jobs.pending_count(g.user_id),
"enabled": ai_jobs.ENABLED,
})

View File

@@ -3,6 +3,7 @@ from flask import Blueprint, request, g, jsonify
from auth import require_auth
from services import settings as settings_svc
from services.insights import BAND_SOURCES
from services import fitness_age
bp = Blueprint("settings", __name__)
@@ -54,32 +55,3 @@ def rating_basis():
"AI 只负责解读这些结果,不参与设定任何阈值。"
),
})
# Kept beside the UI's RANGES table (client/src/lib/ranges.ts). Each entry says
# where that metric's band edges came from — the honest answer for several of
# them is "general-population orientation figures", and it says so.
BAND_SOURCES = [
{"metric": "步数", "bands": "<5k 偏低 · 58k 一般 · 812k 达标 · >12k 优秀",
"source": "步数与死亡率的队列研究(约 8000 步起获益明显12000 步后趋平)"},
{"metric": "静息心率", "bands": "<50 很低 · 5065 正常 · 6575 偏高 · >75 较高",
"source": "健康成人静息心率 60100 bpm 为正常范围,规律运动者常低于 60"},
{"metric": "心率变异性", "bands": "<25 偏低 · 2540 一般 · 4070 良好 · >70 很好",
"source": "夜间 RMSSD 的一般人群分布;个体差异极大,趋势比绝对值更有意义"},
{"metric": "睡眠时长", "bands": "<6h 不足 · 67h 偏少 · 79h 充足 · >9h 偏多",
"source": "美国睡眠医学会 / 睡眠研究会成人 79 小时建议"},
{"metric": "压力", "bands": "025 休息 · 2650 偏低 · 5175 中等 · >75 偏高",
"source": "Garmin 官方压力分级,与手表显示一致"},
{"metric": "身体电量", "bands": "025 很低 · 2650 偏低 · 5175 良好 · >75 充足",
"source": "Garmin 官方 Body Battery 分级"},
{"metric": "血氧", "bands": "<90 偏低 · 9094 略低 · ≥95 正常",
"source": "静息血氧饱和度常用临床参考;腕表光学测量误差较大,仅供趋势参考"},
{"metric": "呼吸频率", "bands": "<12 偏低 · 1220 正常 · >20 偏高",
"source": "成人静息呼吸频率 1220 次/分"},
{"metric": "强度分钟", "bands": "<10 偏少 · 1021 一般 · ≥21 达标",
"source": "WHO 每周 150 分钟中等强度活动,折合每天约 21 分钟"},
{"metric": "训练准备度", "bands": "025 很低 · 2650 偏低 · 5175 就绪 · >75 很好",
"source": "Garmin 官方 Training Readiness 分级"},
{"metric": "爬楼", "bands": "<5 偏少 · 510 达标 · >10 优秀",
"source": "一般性活动量参考,无权威标准"},
]

View File

@@ -8,12 +8,13 @@ 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 services import jobs
from services import scopes
from db import query_all, query_one, execute
from config import DB_TYPE
@@ -261,19 +262,11 @@ 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}"
# --- AI coach: briefing, per-screen insights, attribution, Copilot ----------
# Same caching rationale as the recommendations above, with one addition: no
# screen can wait on a generation, so every one of them answers immediately
# from the rule engine and queues the model's version, which replaces it on a
# later poll. The queue lives in services/jobs.py.
def _read_insight(user_id, kind, subject, fingerprint):
row = query_one(
"SELECT * FROM ai_insights WHERE user_id = ? AND kind = ? AND subject = ?",
@@ -322,52 +315,14 @@ def _write_insight(user_id, kind, subject, fingerprint, payload, meta):
def _context_fingerprint(context):
"""Digest of everything the prompt will contain.
The whole context rather than a chosen subset: a briefing is derived from
The whole context rather than a chosen subset: an insight 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.
synced activity — should expire the stored 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)
@@ -398,7 +353,6 @@ def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
fingerprint = _context_fingerprint(context)
subject = context["snapshotDate"]
key = _insight_key("briefing", subject)
if not refresh:
cached = _read_insight(user_id, "briefing", subject, fingerprint)
@@ -425,19 +379,14 @@ def get_briefing(user_id, date=None, model=None, refresh=False, wait=False):
"meta": {"source": "rules", "reason": str(e)},
}
started = _run_in_background(
key, lambda: generate_briefing(user_id, context, model)
)
# `pending` in the meta is what tells the client to poll again: the card it
# is showing is the placeholder, not the final answer.
state = jobs.enqueue(user_id, "briefing", subject, fingerprint,
jobs.PRIORITY_INTERACTIVE)
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),
},
"meta": _queued_meta(state, user_id, "briefing", subject),
}
@@ -510,6 +459,113 @@ def _delete_insight(user_id, kind, subject):
)
def generate_scope_insight(user_id, scope, subject=None):
"""Generate and store one screen's insight. Raises on failure."""
built = scopes.build(user_id, scope, subject)
if not built:
return None
resolved, context = built
completion, meta = ai_svc.complete(coach.scope_messages(context))
insight = coach.parse_scope_insight(completion.text)
_write_insight(user_id, scope, resolved, _context_fingerprint(context),
insight, meta)
return insight, meta
def get_scope_insight(user_id, scope, subject=None, refresh=False):
"""One screen's AI insight, answered immediately.
Returns the stored model answer when it matches the current data.
Otherwise the computed highlights are returned right away and the
generation is queued **at interactive priority** — so opening a screen
puts it ahead of whatever backfill is still working through.
"""
if scope not in scopes.SCOPES:
raise KeyError(scope)
built = scopes.build(user_id, scope, subject)
if not built:
return {"insight": None, "context": None,
"meta": {"source": "none", "reason": "这个页面还没有可分析的数据"}}
resolved, context = built
fingerprint = _context_fingerprint(context)
if refresh:
_delete_insight(user_id, scope, resolved)
else:
cached = _read_insight(user_id, scope, resolved, fingerprint)
if cached:
insight, meta = cached
return {"insight": insight, "context": context, "meta": meta}
state = jobs.enqueue(user_id, scope, resolved, fingerprint,
jobs.PRIORITY_INTERACTIVE)
return {
"insight": coach.rule_scope_insight(context),
"context": context,
"meta": _queued_meta(state, user_id, scope, resolved),
}
def _queued_meta(state, user_id, kind, subject, **extra):
"""Meta for an answer that is standing in for a queued generation.
`pending` drives the client's poll loop, so it must be False once the
queue has given up — otherwise the screen keeps polling for minutes for an
answer that is not coming, and shows a spinner the whole time.
"""
meta = {"source": "rules", "pending": state in ("pending", "running"),
"queue": state, "subject": subject, **extra}
if state == "failed":
info = jobs.status_of(user_id, kind, subject) or {}
meta["reason"] = info.get("error") or "生成失败,稍后会自动重试"
return meta
def prefetch_insights(user_id):
"""Queue every screen's insight at background priority.
Called after a sync: the data has changed, so every stored answer is stale.
These run for as long as they run — anything the user actually opens jumps
the queue ahead of them.
"""
queued = []
for scope in scopes.PREFETCH_SCOPES:
try:
built = scopes.build(user_id, scope)
except Exception as e: # noqa: BLE001 - one screen must not stop the rest
print(f"[analysis] prefetch {scope} failed to build: {e}")
continue
if not built:
continue
resolved, context = built
jobs.enqueue(user_id, scope, resolved, _context_fingerprint(context),
jobs.PRIORITY_PREFETCH)
queued.append(scope)
context = insights.build_context(user_id)
if context:
jobs.enqueue(user_id, "briefing", context["snapshotDate"],
_context_fingerprint(context), jobs.PRIORITY_PREFETCH)
queued.append("briefing")
return queued
def _run_job(user_id, kind, subject):
"""What the queue worker calls. Dispatches on the job's kind."""
if kind == "briefing":
context = insights.build_context(user_id, subject)
if not context:
return
generate_briefing(user_id, context)
return
generate_scope_insight(user_id, kind, subject)
jobs.set_runner(_run_job)
def clear_insight_cache(user_id, kind=None):
if kind:
execute(

View File

@@ -407,3 +407,97 @@ def rule_trend_insight(window):
"caution": "该结论由规则计算得出,未经模型归因,仅描述相关性而非因果。",
"confidence": "low",
}
# --- per-screen insights ----------------------------------------------------
# One prompt for every screen. The screen-specific part is entirely in the
# context `scopes.py` builds, so a new screen needs a builder and nothing here.
SCOPE_SCHEMA = """{
"headline": "针对这个页面的一句话结论,不超过 45 字",
"points": [
{"title": "维度名,不超过 8 字", "detail": "该维度的判断与依据,引用具体数值,不超过 70 字"}
],
"actions": ["可执行的建议1~3 条,每条不超过 30 字;没有值得建议的就给空数组"],
"caution": "需要留意的风险或容易误读之处;没有就填 null",
"confidence": "high|medium|low —— 取决于样本量与证据强度"
}"""
def scope_messages(context):
"""Prompt for one screen's insight.
`highlights` goes in ahead of the raw data on purpose: they are the facts
already computed from it, and leading with them is what stops the model
re-deriving (and mis-deriving) numbers that are sitting right there.
"""
highlights = "\n".join(
f"- {h['title']}{h['detail']}" for h in context.get("highlights") or []
)
return [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
f"我正在看「{context.get('label')}」这个页面。以下是已经算好的要点:\n\n"
f"{highlights}\n\n"
"完整数据如下deviations 的 z 值是相对我自身近 28 天基线的偏离,"
"trends 是长周期走势,两者都已算好,直接引用即可):\n\n"
f"```json\n{_payload(context)}\n```\n\n"
"请针对这个页面给出解读与建议,只谈这个页面涉及的内容,"
f"严格按以下 JSON 结构输出:\n\n{SCOPE_SCHEMA}"
),
},
]
def parse_scope_insight(reply):
data = ai_svc.extract_json(reply)
if not isinstance(data, dict):
raise ai_svc.AIError("模型未返回 JSON 对象")
points = []
for item in data.get("points") or []:
if isinstance(item, dict):
title = _text(item.get("title"), 20)
detail = _text(item.get("detail"), 220)
else:
title, detail = None, _text(item, 220)
if detail:
points.append({"title": title or "要点", "detail": detail})
actions = [_text(a, 60) for a in (data.get("actions") or []) if _text(a, 60)]
confidence = str(data.get("confidence", "medium")).lower()
if confidence not in ("high", "medium", "low"):
confidence = "medium"
headline = _text(data.get("headline"), 140)
# A card with no headline and no points is blank space; rejecting it lets
# the caller fall back to the rule engine rather than render nothing.
if not headline and not points:
raise ai_svc.AIError("模型返回的解读没有可用内容")
return {
"headline": headline,
"points": points[:5],
"actions": actions[:3],
"caution": _text(data.get("caution"), 200),
"confidence": confidence,
}
def rule_scope_insight(context):
"""The computed highlights, rendered as-is.
No interpretation, and it says so: the honest fallback is the facts without
the reading of them, not a guess at what a model would have said.
"""
highlights = context.get("highlights") or []
return {
# The first highlight leads and is then dropped from the list: showing
# it in both places printed the same sentence twice.
"headline": highlights[0]["detail"] if highlights else None,
"points": [dict(h) for h in highlights[1:6]],
"actions": [],
"caution": "以下为直接计算结果,尚未经过模型解读。",
"confidence": "low",
}

View File

@@ -69,6 +69,15 @@ def _flatten(day):
return flat
def flatten_days(rows):
"""`get_summary` output as flat metric maps, with derived shares filled in.
Public counterpart of `_flatten` for the scope builders, which all need the
same normalisation before they can compute anything.
"""
return [_flatten(r) for r in rows]
# 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).
@@ -459,3 +468,34 @@ def window_context(user_id, metric, start, end, rows=None):
for a in health.get_activities(user_id, start, end)[:40]
],
}
# Reference bands, kept beside the UI's RANGES table (client/src/lib/ranges.ts).
# Each entry says where that metric's band edges came from — the honest answer
# for several of them is "general-population orientation figures", and it says
# so. Lives here rather than in routes/: the 评分依据 screen serves it, but the
# coach also reasons against it, and services must not import from routes.
BAND_SOURCES = [
{"metric": "步数", "bands": "<5k 偏低 · 58k 一般 · 812k 达标 · >12k 优秀",
"source": "步数与死亡率的队列研究(约 8000 步起获益明显12000 步后趋平)"},
{"metric": "静息心率", "bands": "<50 很低 · 5065 正常 · 6575 偏高 · >75 较高",
"source": "健康成人静息心率 60100 bpm 为正常范围,规律运动者常低于 60"},
{"metric": "心率变异性", "bands": "<25 偏低 · 2540 一般 · 4070 良好 · >70 很好",
"source": "夜间 RMSSD 的一般人群分布;个体差异极大,趋势比绝对值更有意义"},
{"metric": "睡眠时长", "bands": "<6h 不足 · 67h 偏少 · 79h 充足 · >9h 偏多",
"source": "美国睡眠医学会 / 睡眠研究会成人 79 小时建议"},
{"metric": "压力", "bands": "025 休息 · 2650 偏低 · 5175 中等 · >75 偏高",
"source": "Garmin 官方压力分级,与手表显示一致"},
{"metric": "身体电量", "bands": "025 很低 · 2650 偏低 · 5175 良好 · >75 充足",
"source": "Garmin 官方 Body Battery 分级"},
{"metric": "血氧", "bands": "<90 偏低 · 9094 略低 · ≥95 正常",
"source": "静息血氧饱和度常用临床参考;腕表光学测量误差较大,仅供趋势参考"},
{"metric": "呼吸频率", "bands": "<12 偏低 · 1220 正常 · >20 偏高",
"source": "成人静息呼吸频率 1220 次/分"},
{"metric": "强度分钟", "bands": "<10 偏少 · 1021 一般 · ≥21 达标",
"source": "WHO 每周 150 分钟中等强度活动,折合每天约 21 分钟"},
{"metric": "训练准备度", "bands": "025 很低 · 2650 偏低 · 5175 就绪 · >75 很好",
"source": "Garmin 官方 Training Readiness 分级"},
{"metric": "爬楼", "bands": "<5 偏少 · 510 达标 · >10 优秀",
"source": "一般性活动量参考,无权威标准"},
]

271
backend/services/jobs.py Normal file
View File

@@ -0,0 +1,271 @@
"""
The coach's producer/consumer queue.
Producing one insight costs 40s to several minutes against the gateway, so it
can never happen inside a request. Screens *enqueue*; a worker thread consumes.
Priority is the whole point of the queue rather than a plain background thread:
after a sync the backfill enqueues every scope at low priority, and those jobs
may take half an hour to work through — but the moment the user opens a screen,
that screen's job is promoted to the front and runs next. What they are looking
at is always what the queue is working on.
The queue lives in the database, not in memory, because Gunicorn runs several
workers: a job is claimed with a holder id and re-read to confirm, the same way
`scheduler.py` claims its tick, so exactly one worker runs a given job.
"""
import datetime
import hashlib
import os
import threading
import time
from config import DB_TYPE
from db import execute, query_one, query_all
# Lower runs first.
PRIORITY_INTERACTIVE = 0 # a screen the user has open right now
PRIORITY_PREFETCH = 10 # backfill after a sync
# A claim older than this is treated as abandoned: the worker holding it died
# mid-generation, and without expiry that job would never run again.
CLAIM_TIMEOUT_SECONDS = int(os.environ.get("AI_JOB_CLAIM_TIMEOUT") or 1800)
# Generations are slow, not frequent; polling this often costs nothing and
# keeps an interactive job's wait to a couple of seconds.
POLL_SECONDS = float(os.environ.get("AI_JOB_POLL_SECONDS") or 2)
MAX_ATTEMPTS = int(os.environ.get("AI_JOB_MAX_ATTEMPTS") or 3)
# How long a job that exhausted its attempts stays given up on before it is
# tried again. Without this a transient upstream outage is permanent: three
# quick failures while the gateway is unreachable would retire that screen's
# insight until its underlying data happened to change, which for a screen the
# user is not syncing could be days.
FAILED_RETRY_SECONDS = int(os.environ.get("AI_JOB_RETRY_AFTER") or 1800)
ENABLED = (os.environ.get("AI_JOBS") or "true").lower() not in ("0", "false", "no")
_started = False
_start_lock = threading.Lock()
# Set by analysis.py at import time. Injected rather than imported so this
# module stays free of the feature logic it schedules — and so the two do not
# import each other in a cycle.
_runner = None
def set_runner(fn):
"""Register `fn(user_id, kind, subject) -> None`, called for each job."""
global _runner
_runner = fn
def _now():
return datetime.datetime.utcnow()
def _iso(dt):
return dt.isoformat(timespec="seconds")
def _parse(value):
if not value:
return None
try:
return datetime.datetime.fromisoformat(str(value).replace(" ", "T"))
except ValueError:
return None
def job_id(user_id, kind, subject):
return hashlib.sha256(
f"{user_id}|{kind}|{subject}".encode("utf-8")
).hexdigest()[:64]
def enqueue(user_id, kind, subject, fingerprint=None,
priority=PRIORITY_PREFETCH):
"""Queue one generation, or promote it if it is already queued.
Returns the job's current status. Idempotent by design: the screen polls
every few seconds while it waits, and every one of those polls calls this.
A finished job is re-queued only when the data it was derived from has
changed — that is what `fingerprint` is for, and it is why a poll on
unchanged data does not restart the work that just completed.
"""
jid = job_id(user_id, kind, subject)
now = _iso(_now())
row = query_one("SELECT * FROM ai_jobs WHERE id = ?", [jid])
if row:
if row["status"] == "running":
claimed = _parse(row.get("claimed_at"))
if claimed and (_now() - claimed).total_seconds() < CLAIM_TIMEOUT_SECONDS:
# Already being generated. Promoting it now would not make the
# in-flight call any faster.
return "running"
stale = fingerprint and row.get("fingerprint") != fingerprint
if row["status"] == "done" and not stale:
return "done"
if row["status"] == "failed" and row["attempts"] >= MAX_ATTEMPTS and not stale:
gave_up = _parse(row.get("updated_at"))
if gave_up and (_now() - gave_up).total_seconds() < FAILED_RETRY_SECONDS:
return "failed"
# Past the cooldown: reset the attempt count so the outage that
# exhausted it does not count against the retry.
execute(
"UPDATE ai_jobs SET status = 'pending', attempts = 0, error = NULL, "
"holder = NULL, claimed_at = NULL, priority = ?, updated_at = ? "
"WHERE id = ?",
[min(priority, row["priority"]), now, jid],
)
return "pending"
# Promote (never demote): a screen the user just opened must not be
# pushed back by the prefetch entry that was already sitting there.
execute(
"UPDATE ai_jobs SET status = 'pending', priority = ?, "
"fingerprint = ?, holder = NULL, claimed_at = NULL, "
"attempts = ?, updated_at = ? WHERE id = ?",
[
min(priority, row["priority"]),
fingerprint or row.get("fingerprint"),
0 if stale else row["attempts"],
now, jid,
],
)
return "pending"
cols = ["id", "user_id", "kind", "subject", "fingerprint", "priority",
"status", "attempts", "created_at", "updated_at"]
execute(
f"INSERT INTO ai_jobs ({', '.join(cols)}) "
f"VALUES ({', '.join(['?'] * len(cols))})",
[jid, user_id, kind, subject, fingerprint, priority, "pending", 0,
now, now],
)
return "pending"
def status_of(user_id, kind, subject):
row = query_one("SELECT * FROM ai_jobs WHERE id = ?",
[job_id(user_id, kind, subject)])
if not row:
return None
return {
"status": row["status"],
"priority": row["priority"],
"attempts": row["attempts"],
"error": row.get("error"),
"updatedAt": row.get("updated_at"),
}
def pending_count(user_id=None):
sql = "SELECT COUNT(*) AS n FROM ai_jobs WHERE status IN ('pending', 'running')"
params = []
if user_id:
sql += " AND user_id = ?"
params.append(user_id)
row = query_one(sql, params)
return (row or {}).get("n") or 0
def _claim_next():
"""Take the highest-priority runnable job, or None.
Ordered by priority then age so the interactive job wins and, among equals,
the one that has waited longest goes first.
"""
cutoff = _iso(_now() - datetime.timedelta(seconds=CLAIM_TIMEOUT_SECONDS))
rows = query_all(
"SELECT * FROM ai_jobs WHERE status = 'pending' "
"OR (status = 'running' AND (claimed_at IS NULL OR claimed_at < ?)) "
"ORDER BY priority ASC, created_at ASC",
[cutoff],
)
holder = f"{os.getpid()}-{threading.get_ident()}"
for row in rows:
if row["attempts"] >= MAX_ATTEMPTS:
continue
execute(
"UPDATE ai_jobs SET status = 'running', holder = ?, claimed_at = ?, "
"attempts = ?, updated_at = ? WHERE id = ? AND status = ?",
[holder, _iso(_now()), row["attempts"] + 1, _iso(_now()),
row["id"], row["status"]],
)
# Re-read: another worker may have claimed it between the SELECT and
# the UPDATE, in which case its holder is the one now recorded.
check = query_one("SELECT holder FROM ai_jobs WHERE id = ?", [row["id"]])
if check and check.get("holder") == holder:
return row
return None
def _finish(jid, error=None):
execute(
"UPDATE ai_jobs SET status = ?, error = ?, holder = NULL, "
"claimed_at = NULL, updated_at = ? WHERE id = ?",
["failed" if error else "done", (error or "")[:500] if error else None,
_iso(_now()), jid],
)
def run_once():
"""Claim and run one job. Returns True when something was run."""
if _runner is None:
return False
row = _claim_next()
if not row:
return False
try:
_runner(row["user_id"], row["kind"], row["subject"])
except Exception as e: # noqa: BLE001 - one bad job must not stop the queue
_finish(row["id"], f"{type(e).__name__}: {e}")
print(f"[ai-jobs] {row['kind']}:{row['subject']} failed: {e}")
return True
_finish(row["id"])
return True
def _loop():
while True:
try:
# Straight on to the next job when one was just run: after a
# sync there is a whole backfill waiting, and sleeping between
# each would add hours to it for no reason.
if not run_once():
time.sleep(POLL_SECONDS)
except Exception as e: # noqa: BLE001 - the loop must outlive any failure
print(f"[ai-jobs] worker error: {e}")
time.sleep(POLL_SECONDS)
def start():
"""Start one consumer per process."""
global _started
if not ENABLED:
print("[ai-jobs] disabled by AI_JOBS")
return
with _start_lock:
if _started:
return
_started = True
threading.Thread(target=_loop, daemon=True, name="ai-jobs").start()
print("[ai-jobs] worker started")
def reset_stale_claims():
"""Release jobs a previous process was running when it stopped.
Without this they sit in `running` until the claim expires, which for the
screen waiting on one looks exactly like a generation that never finishes.
"""
execute(
"UPDATE ai_jobs SET status = 'pending', holder = NULL, claimed_at = NULL "
"WHERE status = 'running'"
)

View File

@@ -22,6 +22,7 @@ import time
from config import DB_TYPE
from db import execute, query_one, query_all
from services import analysis as analysis_svc
from services import garmin as garmin_svc
from services import settings as settings_svc
@@ -163,6 +164,14 @@ def sync_all_accounts(days=None, respect_schedule=False):
out = garmin_svc.sync_data(uid, {}, days=d)
results.append({"user": uid, "status": out.get("status"),
"records": out.get("recordsSynced")})
# New data invalidates every stored insight, so queue them all at
# background priority. They may take a while; anything the user
# opens meanwhile jumps ahead of them.
if out.get("recordsSynced"):
try:
analysis_svc.prefetch_insights(uid)
except Exception as e: # noqa: BLE001 - never fail a sync over this
print(f"[scheduler] prefetch failed for {uid}: {e}")
except Exception as e: # noqa: BLE001 - one account must not stop the rest
results.append({"user": uid, "status": "error", "error": str(e)[:200]})
return results

601
backend/services/scopes.py Normal file
View File

@@ -0,0 +1,601 @@
"""
Per-screen contexts for the AI coach.
One builder per screen, all returning the same envelope, so the queue, the
prompt, the cache and the UI component stay generic — adding a screen means
adding an entry here, not another endpoint and another card.
Every builder returns `(subject, context)`:
* `subject` identifies *what* the insight is about (a date, an activity id, a
window). Together with the scope name it keys the cache and the job queue,
so 睡眠 for last week and 睡眠 for today are separate entries rather than one
overwriting the other.
* `context["highlights"]` is the plain-language facts, already computed. The
model interprets them; the rule engine renders them verbatim when no model
answers. Both versions therefore quote the same numbers, which is what keeps
a fallback from reading like a different app.
Builders return None when the screen has nothing to say about — no nights
recorded, no scale readings — so the caller can stay quiet instead of asking a
model to comment on an empty table.
"""
import datetime
import statistics
from services import garmin as garmin_svc
from services import garmin_extras as extras
from services import fitness_age
from services import health
from services import insights
from services import settings as settings_svc
# The same reference bands the 评分依据 screen shows. Passed to the model so it
# cannot call a number 偏高 that the card beside it labels 正常.
from services.insights import BAND_SOURCES
def _round(value, digits=2):
return None if value is None else round(float(value), digits)
def _mean(values):
values = [v for v in values if v is not None]
return round(statistics.fmean(values), 2) if values else None
def _recent(rows, days):
"""The last `days` calendar days of flattened rows, relative to the newest."""
if not rows:
return []
end = datetime.date.fromisoformat(rows[-1]["date"])
cutoff = (end - datetime.timedelta(days=days - 1)).isoformat()
return [r for r in rows if r["date"] >= cutoff]
def _summary(user_id):
return insights.flatten_days(health.get_summary(user_id))
def _deviations_for(rows, metrics):
"""Today's departures, narrowed to the metrics this screen is about."""
if not rows:
return []
picked = set(metrics)
return [
d for d in insights.deviations(rows, rows[-1])
if d["metric"] in picked
]
def _trends_for(rows, metrics):
picked = set(metrics)
return [t for t in insights.trends(rows) if t["metric"] in picked]
# --- 健康 --------------------------------------------------------------------
def build_health(user_id, subject=None):
rows = _summary(user_id)
if not rows:
return None
today = rows[-1]
deviations = insights.deviations(rows, today)
notable = [d for d in deviations
if d["z"] is not None and abs(d["z"]) >= insights.Z_NOTABLE]
highlights = [{
"title": d["label"],
# The label is repeated inside the sentence because the first
# highlight is also used as a standalone headline, where the title
# beside it is not shown.
"detail": (
f"{d['label']} {d['value']}{d['unit']},近 {d['baselineDays']} 天基线 "
f"{d['baselineMean']}{d['unit']},偏离 {abs(d['z']):.1f} 个标准差。"
),
} for d in notable[:5]]
if not highlights:
highlights = [{"title": "整体", "detail": "今日各项指标均在个人基线的正常波动范围内。"}]
return today["date"], {
"scope": "health",
"label": "健康总览",
"snapshotDate": today["date"],
"highlights": highlights,
"deviations": deviations,
"trends": insights.trends(rows)[:8],
"referenceBands": BAND_SOURCES,
"profile": _profile(user_id, rows),
}
def _profile(user_id, rows):
raw = settings_svc.get_raw(user_id)
age = settings_svc.age_from(raw["birth_date"])
return {
"age": age,
"sex": raw["sex"],
"bmi": settings_svc.bmi_from(raw["height_cm"], raw["weight_kg"]),
"vo2max": insights.latest_of(rows, "vo2max"),
"enduranceScore": insights.latest_of(rows, "enduranceScore"),
}
# --- 睡眠 --------------------------------------------------------------------
SLEEP_METRICS = ("sleepDuration", "sleepQuality", "sleepDeepPct", "sleepRemPct")
NIGHTS = 14
def build_sleep(user_id, subject=None):
rows = _summary(user_id)
nights = [r for r in rows if r.get("sleepDuration") is not None]
if not nights:
return None
window = _recent(nights, NIGHTS)
durations = [n["sleepDuration"] for n in window]
avg = _mean(durations)
debt = round(sum(insights.SLEEP_TARGET_HOURS - d for d in durations
if d < insights.SLEEP_TARGET_HOURS), 1)
deep = _mean([n.get("sleepDeepPct") for n in window])
rem = _mean([n.get("sleepRemPct") for n in window])
rem_low, rem_high = insights.REM_REFERENCE_PCT
deep_low, _ = insights.DEEP_REFERENCE_PCT
highlights = [{
"title": f"{len(window)}",
"detail": (
f"平均 {avg} 小时(目标 {insights.SLEEP_TARGET_HOURS:g}"
f"累计缺口 {debt} 小时,平均评分 "
f"{_mean([n.get('sleepQuality') for n in window])}"
),
}]
if rem is not None:
highlights.append({
"title": "REM 占比",
"detail": f"平均 {rem}%(参考 {rem_low:g}~{rem_high:g}%"
+ (",偏低。" if rem < rem_low else ""),
})
if deep is not None:
highlights.append({
"title": "深睡占比",
"detail": f"平均 {deep}%(参考 ≥{deep_low:g}%"
+ (",偏低。" if deep < deep_low else ",达标。"),
})
return f"{window[-1]['date']}:{len(window)}", {
"scope": "sleep",
"label": "睡眠",
"windowNights": len(window),
"highlights": highlights,
"nights": [{
"date": n["date"],
"hours": _round(n.get("sleepDuration")),
"score": n.get("sleepQuality"),
"deepPct": n.get("sleepDeepPct"),
"remPct": n.get("sleepRemPct"),
"awakePct": n.get("sleepAwakePct"),
"sleepHr": n.get("sleepRespirationAvg"),
"sleepStress": n.get("sleepStressAvg"),
"spo2": n.get("sleepSpo2Avg"),
} for n in window],
"averages": {
"hours": avg, "debtHours": debt, "deepPct": deep, "remPct": rem,
"targetHours": insights.SLEEP_TARGET_HOURS,
},
"deviations": _deviations_for(rows, SLEEP_METRICS),
"trends": _trends_for(rows, SLEEP_METRICS),
# The night's autonomic readings are what tell recovery apart from
# merely lying down for eight hours.
"autonomic": {
"restingHr": rows[-1].get("heartRate"),
"hrv": rows[-1].get("heartRateVariability"),
},
}
# --- 运动 --------------------------------------------------------------------
EXERCISE_METRICS = ("intensityMinutes", "steps", "trainingReadiness",
"enduranceScore", "vo2max")
EXERCISE_DAYS = 30
def build_exercise(user_id, subject=None):
rows = _summary(user_id)
if not rows:
return None
end = rows[-1]["date"]
start = (datetime.date.fromisoformat(end)
- datetime.timedelta(days=EXERCISE_DAYS - 1)).isoformat()
activities = health.get_activities(user_id, start, end)
by_sport = {}
for a in activities:
sport = a.get("activity_type") or "其他"
entry = by_sport.setdefault(sport, {"sessions": 0, "minutes": 0, "calories": 0})
entry["sessions"] += 1
entry["minutes"] += round((a.get("duration") or 0) / 60)
entry["calories"] += a.get("calories") or 0
window = _recent(rows, EXERCISE_DAYS)
intensity = [r.get("intensityMinutes") for r in window]
weekly = round((sum(v for v in intensity if v) / max(len(window), 1)) * 7)
highlights = [{
"title": f"{EXERCISE_DAYS}",
"detail": (
f"{len(activities)} 次运动,合计 "
f"{sum(e['minutes'] for e in by_sport.values())} 分钟;"
f"强度分钟周均约 {weekly}WHO 建议 150"
),
}]
if by_sport:
top = max(by_sport.items(), key=lambda kv: kv[1]["minutes"])
highlights.append({
"title": "主要项目",
"detail": f"{top[0]}{top[1]['sessions']} 次 / {top[1]['minutes']} 分钟。",
})
readiness = insights.latest_of(rows, "trainingReadiness", within=7)
if readiness is not None:
highlights.append({"title": "训练准备度", "detail": f"最近一次 {readiness:g}/100。"})
return f"{end}:{EXERCISE_DAYS}", {
"scope": "exercise",
"label": "运动",
"windowDays": EXERCISE_DAYS,
"highlights": highlights,
"bySport": by_sport,
"weeklyIntensityMinutes": weekly,
"sessions": [{
"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) 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 activities[-30:]],
"deviations": _deviations_for(rows, EXERCISE_METRICS),
"trends": _trends_for(rows, EXERCISE_METRICS),
"activityShift": insights.activity_shift(rows),
"personalRecords": health.get_personal_records(user_id)[:10],
"profile": _profile(user_id, rows),
}
# --- 趋势 --------------------------------------------------------------------
def build_trends(user_id, subject=None):
rows = _summary(user_id)
if len(rows) < 20:
return None
trends = insights.trends(rows)
moved = sorted(
[t for t in trends if t.get("direction")],
key=lambda t: abs(t["slopePer30d"] or 0), reverse=True,
)
highlights = [{
"title": t["label"],
"detail": (
f"{t['days']} 天内由 {t['firstMean']}{t['unit']}"
f"{t['lastMean']}{t['unit']}{t['direction']}"
f"斜率约 {t['slopePer30d']}{t['unit']}/30 天。"
),
} for t in moved[:5]]
if not highlights:
highlights = [{"title": "整体", "detail": "各项指标长期走势平稳,无明显方向性变化。"}]
return f"{rows[-1]['date']}:{len(rows)}", {
"scope": "trends",
"label": "长期趋势",
"highlights": highlights,
"trends": trends,
"activityShift": insights.activity_shift(rows),
"coverage": {
"days": len(rows), "first": rows[0]["date"], "last": rows[-1]["date"],
},
"profile": _profile(user_id, rows),
}
# --- 每日 --------------------------------------------------------------------
def build_daily(user_id, subject=None):
"""One calendar day, including the within-day curves the screen plots."""
rows = _summary(user_id)
if not rows:
return None
date = subject or rows[-1]["date"]
match = [r for r in rows if r["date"] == date]
if not match:
return None
day = match[0]
history = [r for r in rows if r["date"] <= date]
series = extras.get_daily_series(user_id, date) or {}
curves = {}
for kind, points in series.items():
values = [p[1] for p in points if isinstance(p, (list, tuple)) and p[1] is not None]
if values:
# The curve itself is thousands of points; its shape is what the
# reader can see on the chart, so only the summary is sent.
curves[kind] = {"min": min(values), "max": max(values),
"mean": _mean(values), "samples": len(values)}
deviations = insights.deviations(history, day)
notable = [d for d in deviations
if d["z"] is not None and abs(d["z"]) >= insights.Z_NOTABLE]
highlights = [{
"title": d["label"],
"detail": f"{d['label']} {d['value']}{d['unit']},偏离基线 "
f"{abs(d['z']):.1f} 个标准差。",
} for d in notable[:4]] or [
{"title": date, "detail": "这一天各项指标都在常态范围内。"}
]
return date, {
"scope": "daily",
"label": f"{date} 当日",
"date": date,
"highlights": highlights,
"metrics": {k: v for k, v in day.items() if k != "date"},
"curves": curves,
"activities": [{
"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, date, date)],
"deviations": deviations,
}
# --- 身体成分 ----------------------------------------------------------------
def build_body(user_id, subject=None):
series = extras.get_body_composition(user_id)
pressure = extras.get_blood_pressure(user_id)
if not series and not pressure:
return None
highlights = []
if len(series) >= 2:
first, last = series[0], series[-1]
for key, label, unit in (("weightKg", "体重", "kg"),
("bodyFatPct", "体脂率", "%"),
("muscleMassKg", "肌肉量", "kg")):
a, b = first.get(key), last.get(key)
if a is None or b is None:
continue
highlights.append({
"title": label,
"detail": f"{first['date']} {a}{unit}{last['date']} {b}{unit}"
f"{'+' if b >= a else ''}{round(b - a, 1)}{unit})。",
})
elif series:
last = series[-1]
highlights.append({
"title": "最近一次测量",
"detail": f"{last['date']}:体重 {last.get('weightKg')} kg"
f"体脂 {last.get('bodyFatPct')}%。",
})
if pressure:
p = pressure[0]
highlights.append({
"title": "血压",
"detail": f"最近一次 {p['systolic']}/{p['diastolic']} mmHg"
f"{str(p['measuredAt'])[:16]})。",
})
subject_key = (series[-1]["date"] if series
else str(pressure[0]["measuredAt"])[:10])
return subject_key, {
"scope": "body",
"label": "身体成分",
"highlights": highlights,
"composition": series[-60:],
"bloodPressure": pressure[:20],
"profile": _profile(user_id, _summary(user_id)),
}
# --- 成绩预测 ----------------------------------------------------------------
def build_race(user_id, subject=None):
predictions = extras.get_race_predictions(user_id)
if not predictions:
return None
rows = _summary(user_id)
latest = predictions[-1]
def mmss(seconds):
if not seconds:
return None
return f"{int(seconds) // 60}:{int(seconds) % 60:02d}"
highlights = [{
"title": "当前预测",
"detail": " · ".join(
f"{label} {mmss(latest.get(key))}"
for key, label in (("time5k", "5K"), ("time10k", "10K"),
("timeHalf", "半马"), ("timeMarathon", "全马"))
if latest.get(key)
) + f"{latest['date']})。",
}]
if len(predictions) >= 2:
first = predictions[0]
a, b = first.get("time5k"), latest.get("time5k")
if a and b:
delta = int(a - b)
highlights.append({
"title": "5K 变化",
"detail": f"{first['date']}{'快了' if delta > 0 else '慢了'} "
f"{abs(delta)} 秒。",
})
return latest["date"], {
"scope": "race",
"label": "成绩预测",
"highlights": highlights,
"predictions": predictions[-40:],
"trends": _trends_for(rows, ("vo2max", "enduranceScore", "heartRate")),
"profile": _profile(user_id, rows),
}
# --- 身体年龄 ----------------------------------------------------------------
def build_body_age(user_id, subject=None):
rows = _summary(user_id)
if not rows:
return None
raw = settings_svc.get_raw(user_id)
age = settings_svc.age_from(raw["birth_date"])
estimate = fitness_age.estimate(
age=age, sex=raw["sex"], vo2max=insights.latest_of(rows, "vo2max"),
resting_hr=insights.latest_of(rows, "heartRate", within=30),
bmi=settings_svc.bmi_from(raw["height_cm"], raw["weight_kg"]),
)
if not estimate or estimate.get("value") is None:
return None
highlights = [{
"title": "身体年龄",
"detail": f"{estimate['value']} 岁,实际 {estimate['chronologicalAge']}"
f"{'+' if estimate['delta'] >= 0 else ''}{estimate['delta']} 年)。",
}] + [{
"title": step["label"],
"detail": f"输入 {step['input']},贡献 {step['years']} 年。",
} for step in estimate.get("steps", [])]
return rows[-1]["date"], {
"scope": "bodyAge",
"label": "身体年龄",
"highlights": highlights,
"estimate": {k: v for k, v in estimate.items() if k != "basis"},
"trends": _trends_for(rows, ("vo2max", "heartRate", "enduranceScore")),
"profile": _profile(user_id, rows),
}
# --- 挑战赛 ------------------------------------------------------------------
def build_challenges(user_id, subject=None):
rows = extras.get_challenges(user_id)
if not rows:
return None
active = [c for c in rows if (c.get("status") or "").lower() in
("active", "in_progress", "inprogress")]
highlights = [{
"title": "进行中",
"detail": f"{len(active)} 项进行中,历史累计 {len(rows)} 项。",
}]
for c in active[:3]:
highlights.append({
"title": c.get("name") or "挑战",
"detail": f"{c.get('startDate')} ~ {c.get('endDate')},状态 {c.get('status')}",
})
return f"{rows[0].get('startDate')}:{len(rows)}", {
"scope": "challenges",
"label": "挑战赛",
"highlights": highlights,
"challenges": [{k: v for k, v in c.items() if k != "payload"}
for c in rows[:40]],
"activityShift": insights.activity_shift(_summary(user_id)),
}
# --- 运动详情 ----------------------------------------------------------------
def build_activity(user_id, subject=None):
"""One session. `subject` is the activity id."""
if not subject:
return None
detail = garmin_svc.read_activity_detail(user_id, subject)
listed = [a for a in health.get_activities(user_id) if str(a.get("id")) == str(subject)]
if not detail and not listed:
return None
summary = (detail or {}).get("summary") or {}
base = listed[0] if listed else {}
zones = (detail or {}).get("hrZones") or []
total_zone = sum(z.get("seconds") or 0 for z in zones)
minutes = round((base.get("duration") or summary.get("duration") or 0) / 60)
highlights = [{
"title": base.get("activity_type") or (detail or {}).get("activityType") or "运动",
"detail": f"{minutes} 分钟"
+ (f"{round((base.get('distance') or 0) / 1000, 2)} km"
if base.get("distance") else "")
+ (f",平均心率 {base.get('heart_rate_average')}"
if base.get("heart_rate_average") else "")
+ "",
}]
if total_zone:
share = " · ".join(
f"Z{z['zone']} {round((z.get('seconds') or 0) / total_zone * 100)}%"
for z in zones if z.get("seconds")
)
highlights.append({"title": "心率区间分布", "detail": share + ""})
return str(subject), {
"scope": "activity",
"label": "运动详情",
"activityId": str(subject),
"highlights": highlights,
"session": {
"sport": base.get("activity_type"),
"startTime": base.get("start_time"),
"durationMin": minutes or None,
"distanceKm": _round((base["distance"] / 1000) if base.get("distance") else None),
"calories": base.get("calories"),
"avgHr": base.get("heart_rate_average"),
"maxHr": base.get("heart_rate_max"),
},
"hrZones": zones,
# The full summary carries dozens of sport-specific fields; only the
# numeric ones are useful to reason over and they are already small.
"detailSummary": {k: v for k, v in summary.items()
if isinstance(v, (int, float))},
"laps": ((detail or {}).get("laps") or [])[:20],
}
# --- registry ----------------------------------------------------------------
class Scope:
__slots__ = ("name", "label", "build", "needs_subject")
def __init__(self, name, label, build, needs_subject=False):
self.name = name
self.label = label
self.build = build
self.needs_subject = needs_subject
SCOPES = {
s.name: s for s in (
Scope("health", "健康总览", build_health),
Scope("sleep", "睡眠", build_sleep),
Scope("exercise", "运动", build_exercise),
Scope("trends", "长期趋势", build_trends),
Scope("daily", "每日数据", build_daily),
Scope("body", "身体成分", build_body),
Scope("race", "成绩预测", build_race),
Scope("bodyAge", "身体年龄", build_body_age),
Scope("challenges", "挑战赛", build_challenges),
Scope("activity", "运动详情", build_activity, needs_subject=True),
)
}
# Scopes worth generating ahead of the user asking, after a sync brings new
# data in. `activity` and `daily` are excluded on purpose: they are per-item,
# so pre-warming them would queue one job per session or per day rather than
# one job.
PREFETCH_SCOPES = ("health", "sleep", "exercise", "trends", "body", "race",
"bodyAge", "challenges")
def build(user_id, scope, subject=None):
"""`(subject, context)` for one screen, or None when there is nothing to say."""
entry = SCOPES.get(scope)
if not entry:
raise KeyError(scope)
if entry.needs_subject and not subject:
return None
return entry.build(user_id, subject)

View File

@@ -13,6 +13,8 @@ from services import ai as ai_svc
from services import analysis as analysis_svc
from services import coach
from services import insights
from services import jobs
from services import scopes
def day(date, **metrics):
@@ -487,27 +489,20 @@ class TestGetBriefing:
def test_the_non_blocking_path_answers_without_calling_a_model(
self, month, gateway, monkeypatch
):
"""It enqueues instead. No worker runs in the suite, so a model call
here would mean the request generated inline."""
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 == []
assert jobs.pending_count(month["id"]) == 1
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()
def test_polling_does_not_queue_a_job_per_poll(self, month, gateway):
"""The screen polls every few seconds; a generation takes minutes."""
for _ in range(5):
analysis_svc.get_briefing(month["id"])
assert jobs.pending_count(month["id"]) == 1
class TestGetTrendInsight:
@@ -654,10 +649,8 @@ class TestEndpoints:
assert "可以,注意强度。" in body
def test_briefing_never_leaks_the_gateway_token(
self, client, auth, month, gateway, monkeypatch
self, client, auth, month, gateway
):
# 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
@@ -698,9 +691,293 @@ class TestRegenerate:
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
# --- the producer/consumer queue --------------------------------------------
class TestJobQueue:
def test_enqueue_then_claim(self, db, user):
jobs.enqueue(user["id"], "sleep", "2026-08-30:14")
claimed = jobs._claim_next()
assert claimed["kind"] == "sleep"
assert claimed["status"] == "pending", "the row read is the pre-claim one"
assert jobs.status_of(user["id"], "sleep", "2026-08-30:14")["status"] == "running"
def test_the_same_work_queued_twice_is_one_row(self, db, user):
for _ in range(6):
jobs.enqueue(user["id"], "sleep", "s")
assert jobs.pending_count(user["id"]) == 1
def test_opening_a_screen_promotes_it_ahead_of_the_backfill(self, db, user):
for scope in ("health", "exercise", "trends"):
jobs.enqueue(user["id"], scope, "s", priority=jobs.PRIORITY_PREFETCH)
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_PREFETCH)
# The user opens 睡眠 while the backfill is still queued.
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_INTERACTIVE)
assert jobs._claim_next()["kind"] == "sleep"
def test_priority_is_never_demoted(self, db, user):
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_INTERACTIVE)
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_PREFETCH)
assert jobs.status_of(user["id"], "sleep", "s")["priority"] == \
jobs.PRIORITY_INTERACTIVE
def test_equal_priority_runs_oldest_first(self, db, user):
jobs.enqueue(user["id"], "health", "s")
jobs.enqueue(user["id"], "sleep", "s")
assert jobs._claim_next()["kind"] == "health"
def test_a_finished_job_is_not_run_again(self, db, user):
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
jobs._finish(jobs.job_id(user["id"], "sleep", "s"))
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "done"
assert jobs._claim_next() is None
def test_changed_data_requeues_a_finished_job(self, db, user):
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
jobs._finish(jobs.job_id(user["id"], "sleep", "s"))
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="xyz") == "pending"
def test_a_running_job_is_not_restarted_by_a_poll(self, db, user):
jobs.enqueue(user["id"], "sleep", "s")
jobs._claim_next()
assert jobs.enqueue(user["id"], "sleep", "s") == "running"
def test_a_claim_left_by_a_dead_worker_is_retried(self, db, user):
jobs.enqueue(user["id"], "sleep", "s")
jobs._claim_next()
assert jobs._claim_next() is None, "still within the claim window"
# Backdate the claim past its timeout, as a killed worker would leave it.
stale = (jobs._now() - jobs.datetime.timedelta(
seconds=jobs.CLAIM_TIMEOUT_SECONDS + 60))
db.execute("UPDATE ai_jobs SET claimed_at = ?", [jobs._iso(stale)])
assert jobs._claim_next() is not None
def test_restart_releases_whatever_was_running(self, db, user):
jobs.enqueue(user["id"], "sleep", "s")
jobs._claim_next()
jobs.reset_stale_claims()
assert jobs.status_of(user["id"], "sleep", "s")["status"] == "pending"
def test_a_job_that_keeps_failing_is_eventually_dropped(self, db, user):
jobs.enqueue(user["id"], "sleep", "s")
for _ in range(jobs.MAX_ATTEMPTS):
row = jobs._claim_next()
assert row is not None
jobs._finish(row["id"], "boom")
jobs.enqueue(user["id"], "sleep", "s")
assert jobs._claim_next() is None
def test_the_runner_failing_does_not_stop_the_queue(self, db, user, monkeypatch):
monkeypatch.setattr(jobs, "_runner", lambda *a: (_ for _ in ()).throw(RuntimeError("x")))
jobs.enqueue(user["id"], "sleep", "s")
assert jobs.run_once() is True
assert jobs.status_of(user["id"], "sleep", "s")["status"] == "failed"
def test_run_once_dispatches_to_the_registered_runner(self, db, user, monkeypatch):
seen = []
monkeypatch.setattr(jobs, "_runner", lambda u, k, s: seen.append((u, k, s)))
jobs.enqueue(user["id"], "sleep", "2026-08-30:14")
assert jobs.run_once() is True
assert seen == [(user["id"], "sleep", "2026-08-30:14")]
assert jobs.status_of(user["id"], "sleep", "2026-08-30:14")["status"] == "done"
def test_run_once_is_a_no_op_on_an_empty_queue(self, db, user, monkeypatch):
monkeypatch.setattr(jobs, "_runner", lambda *a: None)
assert jobs.run_once() is False
def test_jobs_are_per_account(self, db, user, make_user):
other = make_user("other@example.com")
jobs.enqueue(user["id"], "sleep", "s")
assert jobs.pending_count(other["id"]) == 0
# --- per-screen scopes ------------------------------------------------------
class TestScopes:
def test_every_registered_scope_builds_or_declines(self, month):
"""No builder may raise on a real account — an empty screen returns
None so the caller can stay quiet."""
for name in scopes.SCOPES:
out = scopes.build(month["id"], name, subject="1" if name == "activity" else None)
assert out is None or (isinstance(out, tuple) and len(out) == 2)
def test_a_scope_with_data_carries_highlights_and_a_subject(self, month):
subject, context = scopes.build(month["id"], "sleep")
assert subject
assert context["scope"] == "sleep"
assert context["highlights"]
def test_contexts_stay_small_enough_to_prompt_with(self, month):
for name in ("health", "sleep", "exercise", "trends", "daily"):
built = scopes.build(month["id"], name)
if not built:
continue
blob = json.dumps(built[1], ensure_ascii=False)
assert len(blob) < 30_000, f"{name} context is {len(blob)} chars"
def test_an_empty_screen_declines_rather_than_inventing_one(self, db, user):
assert scopes.build(user["id"], "body") is None
assert scopes.build(user["id"], "race") is None
def test_per_item_scopes_need_a_subject(self, month):
assert scopes.build(month["id"], "activity") is None
def test_unknown_scope_raises(self, month):
with pytest.raises(KeyError):
scopes.build(month["id"], "nope")
def test_prefetch_list_excludes_per_item_screens(self):
"""Pre-warming `activity` would queue one job per session, not one."""
assert "activity" not in scopes.PREFETCH_SCOPES
assert "daily" not in scopes.PREFETCH_SCOPES
assert set(scopes.PREFETCH_SCOPES) <= set(scopes.SCOPES)
def test_health_scope_carries_the_bands_the_ui_shows(self, month):
_, context = scopes.build(month["id"], "health")
assert context["referenceBands"], "the model must not contradict the card"
class TestScopeInsight:
def test_unknown_scope_raises(self, month):
with pytest.raises(KeyError):
analysis_svc.get_scope_insight(month["id"], "nope")
def test_an_empty_screen_reports_it(self, db, user, gateway):
out = analysis_svc.get_scope_insight(user["id"], "race")
assert out["meta"]["source"] == "none"
def test_first_visit_answers_from_rules_and_queues_the_model(
self, month, gateway, monkeypatch
):
calls = answer(monkeypatch, "{}")
out = analysis_svc.get_scope_insight(month["id"], "sleep")
assert out["meta"]["pending"] is True
# The computed facts, with the first one leading; a screen with a
# single fact has a headline and no remaining points.
assert out["insight"]["headline"]
assert calls == [], "nothing may be generated inside the request"
assert jobs.pending_count(month["id"]) == 1
def test_the_stored_answer_is_served_once_generated(
self, month, gateway, monkeypatch
):
reply = json.dumps({
"headline": "睡眠偏短。", "points": [{"title": "时长", "detail": "6.5 小时。"}],
"actions": ["提前入睡"], "caution": None, "confidence": "medium",
}, ensure_ascii=False)
answer(monkeypatch, reply)
analysis_svc.generate_scope_insight(month["id"], "sleep")
out = analysis_svc.get_scope_insight(month["id"], "sleep")
assert out["meta"]["source"] == "ai"
assert out["meta"]["cached"] is True
assert out["insight"]["headline"] == "睡眠偏短。"
def test_rules_fallback_says_it_has_not_been_interpreted(self, month, gateway):
out = analysis_svc.get_scope_insight(month["id"], "sleep")
assert "尚未经过模型解读" in out["insight"]["caution"]
def test_prefetch_queues_every_screen_that_has_data(self, month, gateway):
queued = analysis_svc.prefetch_insights(month["id"])
assert "briefing" in queued
assert "sleep" in queued
assert jobs.pending_count(month["id"]) == len(queued)
def test_the_queue_runner_reaches_a_scope(self, month, gateway, monkeypatch):
reply = json.dumps({"headline": "ok", "points": [], "actions": [],
"confidence": "low"}, ensure_ascii=False)
answer(monkeypatch, reply)
subject, _ = scopes.build(month["id"], "sleep")
analysis_svc._run_job(month["id"], "sleep", subject)
assert analysis_svc.get_scope_insight(month["id"], "sleep")["meta"]["source"] == "ai"
class TestParseScopeInsight:
def test_valid_reply(self):
out = coach.parse_scope_insight(json.dumps({
"headline": "睡眠不足。",
"points": [{"title": "时长", "detail": "平均 6 小时。"}],
"actions": ["提前入睡"], "caution": "样本偏少", "confidence": "high",
}, ensure_ascii=False))
assert out["confidence"] == "high"
assert out["actions"] == ["提前入睡"]
def test_points_written_as_plain_strings(self):
out = coach.parse_scope_insight(json.dumps(
{"headline": "h", "points": ["纯文本要点"]}, ensure_ascii=False))
assert out["points"][0]["detail"] == "纯文本要点"
def test_blank_reply_is_rejected(self):
with pytest.raises(ai_svc.AIError):
coach.parse_scope_insight(json.dumps({"confidence": "high"}))
def test_reasoning_preamble_is_skipped(self):
reply = ("Let me think. Maybe {\"headline\": \"draft\"} ... final:\n"
+ json.dumps({"headline": "定稿", "points": []}, ensure_ascii=False))
assert coach.parse_scope_insight(reply)["headline"] == "定稿"
class TestScopeEndpoint:
def test_requires_auth(self, client):
assert client.get("/api/analysis/insight?scope=sleep").status_code == 401
def test_unknown_scope_lists_the_supported_ones(self, client, auth, month):
resp = client.get("/api/analysis/insight?scope=nope", headers=auth)
assert resp.status_code == 400
assert "sleep" in resp.get_json()["supported"]
def test_answers_immediately(self, client, auth, month):
resp = client.get("/api/analysis/insight?scope=sleep", headers=auth)
assert resp.status_code == 200
assert resp.get_json()["insight"]["headline"]
def test_queue_endpoint_reports_outstanding_work(self, client, auth, month):
client.get("/api/analysis/insight?scope=sleep", headers=auth)
assert client.get("/api/analysis/insight/queue", headers=auth).get_json()["pending"] >= 1
class TestOutageRecovery:
"""A gateway outage must delay an insight, not retire it."""
def test_a_given_up_job_is_retried_after_the_cooldown(self, db, user):
jid = jobs.job_id(user["id"], "sleep", "s")
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
for _ in range(jobs.MAX_ATTEMPTS):
row = jobs._claim_next()
jobs._finish(row["id"], "connection refused")
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "failed"
# Past the cooldown, as it would be once the upstream is back.
old = jobs._now() - jobs.datetime.timedelta(
seconds=jobs.FAILED_RETRY_SECONDS + 60)
db.execute("UPDATE ai_jobs SET updated_at = ? WHERE id = ?",
[jobs._iso(old), jid])
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "pending"
assert jobs.status_of(user["id"], "sleep", "s")["attempts"] == 0
assert jobs._claim_next() is not None
def test_the_screen_stops_polling_once_the_queue_gives_up(
self, month, gateway, monkeypatch
):
"""`pending` must go false, or the card spins for minutes waiting on an
answer that is not coming."""
def boom(self, messages, timeout=None, max_tokens=None):
raise ai_svc.AIError("upstream unreachable")
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", boom)
subject, _ = scopes.build(month["id"], "sleep")
for _ in range(jobs.MAX_ATTEMPTS):
analysis_svc.get_scope_insight(month["id"], "sleep")
jobs.run_once()
out = analysis_svc.get_scope_insight(month["id"], "sleep")
assert out["meta"]["queue"] == "failed"
assert out["meta"]["pending"] is False
assert out["meta"]["reason"], "the card should be able to say why"
assert out["insight"]["headline"], "and still show the computed facts"

View File

@@ -1,17 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import {
apiClient, errorMessage, Briefing, BriefingContext, InsightMeta,
apiClient, Briefing, BriefingContext, InsightMeta,
} from '../services/api';
import { usePolledInsight } from '../lib/insight';
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;
@@ -79,65 +72,17 @@ function Deviations({ context }: { context: BriefingContext }) {
}
function AiBriefing({ date }: Props) {
const [briefing, setBriefing] = useState<Briefing | null>(null);
const [context, setContext] = useState<BriefingContext | null>(null);
const [meta, setMeta] = useState<InsightMeta | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
/* The poll is cleared on unmount and whenever the day changes, so stepping
back through dates cannot leave a timer writing into a stale card. */
const timer = useRef<number>();
const startedAt = useRef(0);
const load = useCallback(async (refresh?: boolean) => {
try {
const data = await apiClient.getBriefing({ date, refresh });
setBriefing(data.briefing);
setContext(data.context);
setMeta(data.meta);
setError('');
return data.meta;
} catch (err: any) {
setError(errorMessage(err, '获取简报失败'));
return null;
} finally {
setLoading(false);
}
const resp = await apiClient.getBriefing({ date, refresh });
return { data: resp.briefing, meta: resp.meta, extra: resp.context };
}, [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]);
const {
data: briefing, meta, extra, loading, error, refresh,
} = usePolledInsight<Briefing>(load, '获取简报失败');
const context = extra as BriefingContext | null;
if (loading && !briefing) {
return <div className="brief-skeleton" aria-label="正在生成简报" />;
@@ -197,11 +142,7 @@ function AiBriefing({ date }: Props) {
{context && <Deviations context={context} />}
<div className="brief-foot">
<button
className="brief-link"
onClick={() => poll(true)}
disabled={loading}
>
<button className="brief-link" onClick={refresh} disabled={loading}>
{loading ? '重新生成中…' : '重新生成'}
</button>
{meta.generatedAt && (

View File

@@ -0,0 +1,150 @@
/* The AI reading shown on every data screen. Deliberately quieter than the
今日 briefing card: that one is the hero of its screen, these sit among the
charts they comment on. */
.aip {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.85rem 0.9rem 0.6rem;
margin-bottom: 1.25rem;
animation: aip-in 0.32s var(--ease) both;
}
@keyframes aip-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.aip-skeleton {
height: 104px;
border-radius: 14px;
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: aip-shimmer 1.4s linear infinite;
}
@keyframes aip-shimmer {
from { background-position: 180% 0; }
to { background-position: -80% 0; }
}
.aip-error { color: var(--status-critical); font-size: 0.82rem; }
.aip-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.15rem;
}
.aip-head .sec-title { margin: 0; }
/* Always visible: a model reading and a plain calculation look alike on the
page, and which one it is changes how much weight it deserves. */
.aip-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.64rem;
font-weight: 600;
padding: 0.14rem 0.42rem;
border-radius: 999px;
color: var(--text-muted);
background: var(--surface-0);
border: 1px solid var(--border);
white-space: nowrap;
}
.aip-badge-ai {
color: var(--accent);
background: var(--accent-soft);
border-color: transparent;
}
.aip-badge-pending { color: var(--text-secondary); }
.aip-spinner {
width: 0.52rem;
height: 0.52rem;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
border-top-color: var(--accent);
animation: aip-spin 0.8s linear infinite;
}
@keyframes aip-spin { to { transform: rotate(360deg); } }
.aip-headline {
margin: 0.4rem 0 0.7rem;
font-size: 0.88rem;
line-height: 1.55;
color: var(--text-primary);
}
.aip-point { margin-bottom: 0.55rem; }
.aip-tag {
display: inline-block;
margin-bottom: 0.14rem;
font-size: 0.68rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 6px;
color: var(--accent);
background: var(--accent-soft);
}
.aip-point p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--text-secondary);
}
.aip-actions {
margin: 0.5rem 0 0;
padding-left: 1.05rem;
}
.aip-actions li {
font-size: 0.83rem;
line-height: 1.55;
color: var(--text-secondary);
}
.aip-caution {
margin: 0.55rem 0 0;
padding-left: 0.55rem;
border-left: 2px solid var(--status-warning);
font-size: 0.76rem;
line-height: 1.5;
color: var(--text-muted);
}
.aip-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.65rem;
padding-top: 0.5rem;
border-top: 1px solid var(--grid);
font-size: 0.68rem;
color: var(--text-muted);
}
.aip-link {
background: none;
border: none;
padding: 0;
font-size: 0.74rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.aip-link:disabled { color: var(--text-muted); cursor: default; }

View File

@@ -0,0 +1,99 @@
import { useCallback } from 'react';
import {
apiClient, InsightScope, InsightMeta, ScopeInsight,
} from '../services/api';
import { usePolledInsight } from '../lib/insight';
import './AiPanel.css';
const CONFIDENCE_LABEL: Record<string, string> = {
high: '证据充分', medium: '证据一般', low: '证据薄弱',
};
interface Props {
scope: InsightScope;
/** Identifies the item for per-item screens: an activity id, a date. */
subject?: string;
/** Heading. Defaults to the generic one. */
title?: string;
}
function Badge({ meta }: { meta: InsightMeta }) {
if (meta.source === 'ai') {
return (
<span className="aip-badge aip-badge-ai" title={meta.model ?? undefined}>
AI {meta.upstream ? ` · ${meta.upstream}` : ''}
</span>
);
}
if (meta.pending) {
return (
<span className="aip-badge aip-badge-pending">
<span className="aip-spinner" aria-hidden="true" />
</span>
);
}
return <span className="aip-badge" title={meta.reason}></span>;
}
/**
* One screen's AI reading.
*
* The same component on every screen: what differs between 睡眠 and 运动 is
* entirely in the context the backend builds, so a new screen is one more
* `<AiPanel scope="…" />` and a builder — not another card.
*
* It always renders something. The computed facts appear immediately, and the
* model's interpretation replaces them when the coach's queue reaches this
* screen — which opening the screen moves to the front of.
*/
function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
const load = useCallback(async () => {
const resp = await apiClient.getInsight(scope, { subject });
return { data: resp.insight, meta: resp.meta };
}, [scope, subject]);
const { data, meta, loading, error, refresh } =
usePolledInsight<ScopeInsight>(load);
if (loading && !data) return <div className="aip-skeleton" aria-label="正在读取解读" />;
if (error) return <section className="aip aip-error">{error}</section>;
if (!data || !meta) return null;
// Nothing to read yet on this screen — a card saying so is worse than none.
if (meta.source === 'none') return null;
return (
<section className="aip">
<div className="aip-head">
<h3 className="sec-title">{title}</h3>
<Badge meta={meta} />
</div>
{data.headline && <p className="aip-headline">{data.headline}</p>}
{data.points.map((p) => (
<div className="aip-point" key={p.title + p.detail}>
<span className="aip-tag">{p.title}</span>
<p>{p.detail}</p>
</div>
))}
{!!data.actions.length && (
<ul className="aip-actions">
{data.actions.map((a) => <li key={a}>{a}</li>)}
</ul>
)}
{data.caution && <p className="aip-caution">{data.caution}</p>}
<div className="aip-foot">
<button className="aip-link" onClick={refresh} disabled={loading}>
{loading ? '重新生成中…' : '重新生成'}
</button>
<span>{CONFIDENCE_LABEL[data.confidence]}</span>
</div>
</section>
);
}
export default AiPanel;

99
client/src/lib/insight.ts Normal file
View File

@@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { errorMessage, InsightMeta } from '../services/api';
/* A generation runs for minutes on the gateway, so every AI card shows the
rule-based version first and polls for the model's. The interval is a
compromise: often enough that the swap belongs to this visit, rare enough
that a five-minute wait costs a few dozen requests rather than a few
hundred. */
export const POLL_MS = 8000;
export const POLL_LIMIT_MS = 8 * 60 * 1000;
export interface Loaded<T> {
data: T | null;
meta: InsightMeta;
}
export interface Polled<T> {
data: T | null;
meta: InsightMeta | null;
/** Extra payload the loader returned alongside the insight (the context). */
extra: unknown;
loading: boolean;
error: string;
/** Discard the stored answer and generate again. */
refresh: () => void;
}
/**
* Load an AI insight, then keep polling while the model is still working.
*
* Shared by every AI card. `load` must be stable — wrap it in `useCallback`
* keyed on whatever identifies the thing being read — because a new identity
* restarts the poll, which is exactly right when the screen changes what it
* is about and exactly wrong on an unrelated re-render.
*/
export function usePolledInsight<T>(
load: (refresh?: boolean) => Promise<Loaded<T> & { extra?: unknown }>,
fallbackMessage = '获取 AI 解读失败'
): Polled<T> {
const [data, setData] = useState<T | null>(null);
const [meta, setMeta] = useState<InsightMeta | null>(null);
const [extra, setExtra] = useState<unknown>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const timer = useRef<number>();
const startedAt = useRef(0);
const cancelled = useRef(false);
const fetchOnce = useCallback(async (refresh?: boolean) => {
try {
const result = await load(refresh);
if (cancelled.current) return null;
setData(result.data);
setMeta(result.meta);
setExtra(result.extra ?? null);
setError('');
return result.meta;
} catch (err: any) {
if (!cancelled.current) setError(errorMessage(err, fallbackMessage));
return null;
} finally {
if (!cancelled.current) setLoading(false);
}
}, [load, fallbackMessage]);
const poll = useCallback(async (refresh?: boolean) => {
window.clearTimeout(timer.current);
startedAt.current = Date.now();
setLoading(true);
// Only the first request may bypass the cache; a `refresh` on every tick
// would restart the generation the poll is waiting for.
const tick = async (first: boolean) => {
const result = await fetchOnce(first && refresh);
if (cancelled.current) return;
// Give up after the window a generation realistically needs — an
// upstream that has gone quiet must not leave the tab polling forever.
if (result?.pending && Date.now() - startedAt.current < POLL_LIMIT_MS) {
timer.current = window.setTimeout(() => tick(false), POLL_MS);
}
};
await tick(true);
}, [fetchOnce]);
useEffect(() => {
cancelled.current = false;
poll();
return () => {
cancelled.current = true;
window.clearTimeout(timer.current);
};
}, [poll]);
return {
data, meta, extra, loading, error,
refresh: () => { poll(true); },
};
}

View File

@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './ActivityDetail.css';
@@ -289,6 +291,7 @@ function ActivityDetailPage({ id, f7route }: Props) {
return (
<Screen title={title} subtitle={s.startTimeLocal?.slice(0, 16).replace('T', ' ')} backLink>
{FEATURES.ai && <AiPanel scope="activity" subject={activityId} title="AI 本次运动解读" />}
<div className="metric-tabs">
{TABS.map(([tabId, text]) => (
<button

View File

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, FitnessAge } from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import Skeleton from '../components/Skeleton';
import './BodyAge.css';
@@ -42,6 +44,8 @@ function BodyAgePage() {
</div>
) : (
<>
{FEATURES.ai && <AiPanel scope="bodyAge" title="AI 身体年龄解读" />}
<section className="ba-hero">
<div className="ba-value">
{data.value}<span className="ba-unit"></span>

View File

@@ -4,6 +4,8 @@ import {
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
} from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import { daysAgo, today as todayIso } from '../lib/day';
@@ -71,6 +73,8 @@ function BodyPage() {
</div>
) : (
<>
{FEATURES.ai && <AiPanel scope="body" title="AI 身体成分解读" />}
<section className="md-hero">
<div className="md-value">
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}

View File

@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, Challenge, errorMessage } from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import Skeleton from '../components/Skeleton';
import './Challenges.css';
@@ -54,6 +56,8 @@ function ChallengesPage() {
</div>
) : (
<>
{FEATURES.ai && <AiPanel scope="challenges" title="AI 挑战赛解读" />}
{kinds.length > 2 && (
<div className="metric-tabs">
{kinds.map((k) => (

View File

@@ -6,6 +6,8 @@ import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './Daily.css';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { iso, today as todayIso } from '../lib/day';
/** Every stored metric, grouped the way the device groups them. */
@@ -189,6 +191,7 @@ function DailyPage() {
return (
<Screen title="每日数据" backLink>
{FEATURES.ai && <AiPanel scope="daily" subject={date} title="AI 当日解读" />}
<div className="day-nav">
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天"></button>

View File

@@ -4,6 +4,8 @@ import {
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
} from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { daysAgo, today as todayIso } from '../lib/day';
import MetricCard from '../components/charts/MetricCard';
import Chart from '../components/charts/Chart';
@@ -126,6 +128,8 @@ function ExercisePage() {
<Screen title="运动" subtitle={`${WINDOW_DAYS}`}>
{error && <div className="screen-error">{error}</div>}
{FEATURES.ai && <AiPanel scope="exercise" title="AI 训练解读" />}
<section className="sec">
<div className="mcard-grid">
<MetricCard label="运动时长" value={totalMinutes} unit="分钟"

View File

@@ -5,6 +5,8 @@ import { METRICS, metricHref } from '../lib/metrics';
import MetricCard from '../components/charts/MetricCard';
import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { daysAgo, today as todayIso } from '../lib/day';
import './Health.css';
import './Settings.css';
@@ -143,6 +145,8 @@ function HealthPage() {
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<BodyAge data={bodyAge} />
{FEATURES.ai && <AiPanel scope="health" title="AI 健康解读" />}
{SECTIONS.map((section) => (
<section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3>

View File

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, RacePrediction } from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './MetricDetail.css';
@@ -75,6 +77,8 @@ function RacePage() {
<Screen title="成绩预测" backLink subtitle={latest.date}>
{error && <div className="screen-error">{error}</div>}
{FEATURES.ai && <AiPanel scope="race" title="AI 成绩解读" />}
<div className="race-list">
{DISTANCES.map(([key, label]) => (
<div className="race-row" key={key}>

View File

@@ -5,6 +5,8 @@ import Chart from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile';
import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { daysAgo, today as todayIso } from '../lib/day';
const RANGES = [7, 14, 30, 90];
@@ -75,6 +77,7 @@ function SleepPage() {
return (
<Screen title="睡眠" backLink subtitle="分期、评分与夜间生理指标">
{FEATURES.ai && <AiPanel scope="sleep" title="AI 睡眠解读" />}
<div className="segmented-row">
<span className="segmented-label"></span>

View File

@@ -7,6 +7,8 @@ import {
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
} from '../lib/aggregate';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { daysAgo, today as todayIso } from '../lib/day';
const RANGES = [
@@ -221,6 +223,7 @@ function TrendsPage() {
return (
<Screen title="趋势">
{FEATURES.ai && <AiPanel scope="trends" title="AI 长期趋势归因" />}
<div className="segmented-row">
<span className="segmented-label"></span>

View File

@@ -427,6 +427,27 @@ export interface TrendInsightResponse {
meta: InsightMeta;
}
/** One screen's AI reading. The same shape for every screen, so one component
* renders all of them. */
export interface ScopeInsight {
headline: string | null;
points: Array<{ title: string; detail: string }>;
actions: string[];
caution: string | null;
confidence: 'high' | 'medium' | 'low';
}
export interface ScopeInsightResponse {
insight: ScopeInsight | null;
context: Record<string, any> | null;
meta: InsightMeta;
}
/** Screens the coach can read. Mirrors SCOPES in backend/services/scopes.py. */
export type InsightScope =
| 'health' | 'sleep' | 'exercise' | 'trends' | 'daily'
| 'body' | 'race' | 'bodyAge' | 'challenges' | 'activity';
export interface CopilotTurn {
role: 'user' | 'assistant';
content: string;
@@ -777,6 +798,34 @@ class ApiClient {
}
// --- AI coach ---
/**
* One screen's AI reading.
*
* Answers immediately: while the model's version is being generated the
* computed highlights come back with `meta.pending`, and opening the screen
* puts its job at the front of the coach's queue. Poll to pick up the
* finished version.
*/
async getInsight(
scope: InsightScope,
opts: { subject?: string; refresh?: boolean } = {}
) {
const { data } = await this.client.get<ScopeInsightResponse>('/analysis/insight', {
params: {
scope, subject: opts.subject, ...(opts.refresh ? { refresh: 1 } : {}),
},
});
return data;
}
/** How much the coach still has to generate — for a progress hint. */
async getInsightQueue() {
const { data } = await this.client.get<{ pending: number; enabled: boolean }>(
'/analysis/insight/queue'
);
return data;
}
/**
* 晨间简报 for one day, plus the computed context behind it.
*