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