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

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