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:
@@ -981,3 +981,75 @@ class TestOutageRecovery:
|
||||
assert out["meta"]["pending"] is False
|
||||
assert out["meta"]["reason"], "the card should be able to say why"
|
||||
assert out["insight"]["headline"], "and still show the computed facts"
|
||||
|
||||
|
||||
class TestSubjectStability:
|
||||
"""The subject keys the cache *and* the job queue, so it must identify what
|
||||
the insight is about — never how much data happened to be there.
|
||||
|
||||
A count in the key made every poll mint a fresh job: production
|
||||
accumulated a dozen `trends` jobs in minutes, all for the same screen."""
|
||||
|
||||
def test_the_subject_does_not_move_when_the_data_grows(
|
||||
self, db, user, seed_health
|
||||
):
|
||||
seed_health([
|
||||
{"date": f"2026-08-{d:02d}", "steps": 8000, "heart_rate": 60,
|
||||
"hrv": 45, "sleep_duration": 7, "sleep_quality": 80, "stress": 30}
|
||||
for d in range(1, 29)
|
||||
])
|
||||
before = {
|
||||
name: scopes.build(user["id"], name)[0]
|
||||
for name in ("health", "sleep", "exercise", "trends")
|
||||
if scopes.build(user["id"], name)
|
||||
}
|
||||
|
||||
# A backfilled day, as a history sync would add: older than the newest,
|
||||
# so what the screen is about has not changed.
|
||||
seed_health([{"date": "2026-07-31", "steps": 7000, "heart_rate": 61,
|
||||
"hrv": 44, "sleep_duration": 7, "sleep_quality": 79,
|
||||
"stress": 31}])
|
||||
|
||||
after = {name: scopes.build(user["id"], name)[0] for name in before}
|
||||
assert after == before
|
||||
|
||||
def test_no_subject_encodes_a_row_count(self, month):
|
||||
for name in ("health", "sleep", "exercise", "trends", "challenges"):
|
||||
built = scopes.build(month["id"], name)
|
||||
if not built:
|
||||
continue
|
||||
subject = built[0]
|
||||
# A count would grow without bound; a date or a window constant
|
||||
# will not. This catches the shape of the mistake, not one instance.
|
||||
for part in str(subject).split(":"):
|
||||
assert not (part.isdigit() and int(part) > 400), \
|
||||
f"{name} subject {subject!r} looks like a row count"
|
||||
|
||||
|
||||
class TestSupersede:
|
||||
def test_opening_a_screen_clears_queued_work_for_an_older_snapshot(
|
||||
self, month, gateway
|
||||
):
|
||||
jobs.enqueue(month["id"], "trends", "2026-08-01")
|
||||
jobs.enqueue(month["id"], "trends", "2026-08-15")
|
||||
analysis_svc.get_scope_insight(month["id"], "trends")
|
||||
rows = analysis_svc.query_all(
|
||||
"SELECT subject FROM ai_jobs WHERE kind = 'trends'")
|
||||
assert len(rows) == 1, "only the current snapshot should be queued"
|
||||
|
||||
def test_per_item_screens_keep_one_job_each(self, month, gateway):
|
||||
"""每日 and 运动详情 legitimately have one entry per date / session."""
|
||||
jobs.enqueue(month["id"], "daily", "2026-08-01")
|
||||
jobs.enqueue(month["id"], "daily", "2026-08-02")
|
||||
analysis_svc.get_scope_insight(month["id"], "daily", subject="2026-08-03")
|
||||
rows = analysis_svc.query_all(
|
||||
"SELECT subject FROM ai_jobs WHERE kind = 'daily'")
|
||||
assert len(rows) == 3
|
||||
|
||||
def test_running_work_is_not_dropped_from_under_the_worker(self, month, gateway):
|
||||
jobs.enqueue(month["id"], "trends", "2026-08-01")
|
||||
jobs._claim_next()
|
||||
analysis_svc.get_scope_insight(month["id"], "trends")
|
||||
statuses = {r["subject"]: r["status"] for r in analysis_svc.query_all(
|
||||
"SELECT subject, status FROM ai_jobs WHERE kind = 'trends'")}
|
||||
assert statuses.get("2026-08-01") == "running"
|
||||
|
||||
Reference in New Issue
Block a user