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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user