fix(ai): subject 里塞了行数,每轮询一次就新建一个任务

生产上 trends 队列里积了 36 个任务,subject 是 2026-09-01:1033、:1039、
:1044……一路涨。这台账号当时正在补历史,get_summary 的行数每隔几分钟就变,
而我把 len(rows) 写进了 subject——subject 同时是缓存键和任务队列的键,一变
就是一条全新的任务,轮询几次就刷出十几条。

subject 该回答的是「这条解读是关于什么的」,不是「当时有多少行数据」。
数据变化本来就由 fingerprint 负责。

- trends 的 subject 改成快照日期;sleep 用配置的窗口常量而不是实际夜数
  (缺一晚也不该换键);challenges 用固定键
- 加了不变量测试:补一天历史数据后 subject 不许变;任何 subject 段都不许
  长得像行数

顺带加一层兜底 jobs.supersede():单实例 scope 只该有一个在跑的 subject,
队列里同 kind 的其它 pending 任务是关于已经不存在的快照的,跑完也没人看。
per_item 的 daily / activity 不受影响——它们本来就一天一条、一次运动一条。
兜底不是机制,机制是 subject 稳定;它存在只是因为这次 subject 不稳定,而
36 条任务堆在那里之前没人发现。

顺带按要求把 AiPanel 改成默认精简:只显示标题、来源和一句话结论,点「展开
详细」才出要点/建议/依据,可再收起——和今日晨报卡片一致。这些面板压在本来
就很密的图表页上面,全部默认展开会把真正的数据一次性挤到屏幕外。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 15:33:49 +08:00
parent 241ae0d6a3
commit 6dd070ec9b
6 changed files with 182 additions and 25 deletions

View File

@@ -164,7 +164,9 @@ def build_sleep(user_id, subject=None):
+ (",偏低。" if deep < deep_low else ",达标。"),
})
return f"{window[-1]['date']}:{len(window)}", {
# The configured window, not `len(window)`: a night missing from the record
# would otherwise change the key and queue a second job for the same screen.
return f"{window[-1]['date']}:{NIGHTS}", {
"scope": "sleep",
"label": "睡眠",
"windowNights": len(window),
@@ -286,7 +288,7 @@ def build_trends(user_id, subject=None):
if not highlights:
highlights = [{"title": "整体", "detail": "各项指标长期走势平稳,无明显方向性变化。"}]
return f"{rows[-1]['date']}:{len(rows)}", {
return rows[-1]["date"], {
"scope": "trends",
"label": "长期趋势",
"highlights": highlights,
@@ -492,7 +494,9 @@ def build_challenges(user_id, subject=None):
"detail": f"{c.get('startDate')} ~ {c.get('endDate')},状态 {c.get('status')}",
})
return f"{rows[0].get('startDate')}:{len(rows)}", {
# One entry per account: which challenges exist is what the fingerprint
# tracks, so the key does not need to encode how many there are.
return "all", {
"scope": "challenges",
"label": "挑战赛",
"highlights": highlights,
@@ -559,13 +563,17 @@ def build_activity(user_id, subject=None):
# --- registry ----------------------------------------------------------------
class Scope:
__slots__ = ("name", "label", "build", "needs_subject")
__slots__ = ("name", "label", "build", "needs_subject", "per_item")
def __init__(self, name, label, build, needs_subject=False):
def __init__(self, name, label, build, needs_subject=False, per_item=False):
self.name = name
self.label = label
self.build = build
self.needs_subject = needs_subject
# `per_item` scopes legitimately have one entry per date or per
# session. Every other scope has exactly one live entry, so an older
# subject sitting in the queue is stale work — see `supersede`.
self.per_item = per_item
SCOPES = {
@@ -574,12 +582,13 @@ SCOPES = {
Scope("sleep", "睡眠", build_sleep),
Scope("exercise", "运动", build_exercise),
Scope("trends", "长期趋势", build_trends),
Scope("daily", "每日数据", build_daily),
Scope("daily", "每日数据", build_daily, per_item=True),
Scope("body", "身体成分", build_body),
Scope("race", "成绩预测", build_race),
Scope("bodyAge", "身体年龄", build_body_age),
Scope("challenges", "挑战赛", build_challenges),
Scope("activity", "运动详情", build_activity, needs_subject=True),
Scope("activity", "运动详情", build_activity, needs_subject=True,
per_item=True),
)
}