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

@@ -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(