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:
@@ -499,6 +499,8 @@ def get_scope_insight(user_id, scope, subject=None, refresh=False):
|
|||||||
insight, meta = cached
|
insight, meta = cached
|
||||||
return {"insight": insight, "context": context, "meta": meta}
|
return {"insight": insight, "context": context, "meta": meta}
|
||||||
|
|
||||||
|
if not scopes.SCOPES[scope].per_item:
|
||||||
|
jobs.supersede(user_id, scope, resolved)
|
||||||
state = jobs.enqueue(user_id, scope, resolved, fingerprint,
|
state = jobs.enqueue(user_id, scope, resolved, fingerprint,
|
||||||
jobs.PRIORITY_INTERACTIVE)
|
jobs.PRIORITY_INTERACTIVE)
|
||||||
return {
|
return {
|
||||||
@@ -540,12 +542,15 @@ def prefetch_insights(user_id):
|
|||||||
if not built:
|
if not built:
|
||||||
continue
|
continue
|
||||||
resolved, context = built
|
resolved, context = built
|
||||||
|
if not scopes.SCOPES[scope].per_item:
|
||||||
|
jobs.supersede(user_id, scope, resolved)
|
||||||
jobs.enqueue(user_id, scope, resolved, _context_fingerprint(context),
|
jobs.enqueue(user_id, scope, resolved, _context_fingerprint(context),
|
||||||
jobs.PRIORITY_PREFETCH)
|
jobs.PRIORITY_PREFETCH)
|
||||||
queued.append(scope)
|
queued.append(scope)
|
||||||
|
|
||||||
context = insights.build_context(user_id)
|
context = insights.build_context(user_id)
|
||||||
if context:
|
if context:
|
||||||
|
jobs.supersede(user_id, "briefing", context["snapshotDate"])
|
||||||
jobs.enqueue(user_id, "briefing", context["snapshotDate"],
|
jobs.enqueue(user_id, "briefing", context["snapshotDate"],
|
||||||
_context_fingerprint(context), jobs.PRIORITY_PREFETCH)
|
_context_fingerprint(context), jobs.PRIORITY_PREFETCH)
|
||||||
queued.append("briefing")
|
queued.append("briefing")
|
||||||
|
|||||||
@@ -150,6 +150,25 @@ def enqueue(user_id, kind, subject, fingerprint=None,
|
|||||||
return "pending"
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def supersede(user_id, kind, subject):
|
||||||
|
"""Drop queued work of the same kind for a different subject.
|
||||||
|
|
||||||
|
A single-entry scope has one live subject; anything else queued under that
|
||||||
|
kind is about a snapshot that no longer exists, and running it would spend
|
||||||
|
a gateway call on an answer nothing will read.
|
||||||
|
|
||||||
|
This is a safety net, not the mechanism: subjects are supposed to be stable
|
||||||
|
(see scopes.py). It exists because they were not — a row count in the key
|
||||||
|
made every poll mint a new `trends` job, and production had 36 of them
|
||||||
|
queued before anyone noticed.
|
||||||
|
"""
|
||||||
|
execute(
|
||||||
|
"DELETE FROM ai_jobs WHERE user_id = ? AND kind = ? AND subject != ? "
|
||||||
|
"AND status = 'pending'",
|
||||||
|
[user_id, kind, subject],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def status_of(user_id, kind, subject):
|
def status_of(user_id, kind, subject):
|
||||||
row = query_one("SELECT * FROM ai_jobs WHERE id = ?",
|
row = query_one("SELECT * FROM ai_jobs WHERE id = ?",
|
||||||
[job_id(user_id, kind, subject)])
|
[job_id(user_id, kind, subject)])
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ def build_sleep(user_id, subject=None):
|
|||||||
+ (",偏低。" if deep < deep_low else ",达标。"),
|
+ (",偏低。" 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",
|
"scope": "sleep",
|
||||||
"label": "睡眠",
|
"label": "睡眠",
|
||||||
"windowNights": len(window),
|
"windowNights": len(window),
|
||||||
@@ -286,7 +288,7 @@ def build_trends(user_id, subject=None):
|
|||||||
if not highlights:
|
if not highlights:
|
||||||
highlights = [{"title": "整体", "detail": "各项指标长期走势平稳,无明显方向性变化。"}]
|
highlights = [{"title": "整体", "detail": "各项指标长期走势平稳,无明显方向性变化。"}]
|
||||||
|
|
||||||
return f"{rows[-1]['date']}:{len(rows)}", {
|
return rows[-1]["date"], {
|
||||||
"scope": "trends",
|
"scope": "trends",
|
||||||
"label": "长期趋势",
|
"label": "长期趋势",
|
||||||
"highlights": highlights,
|
"highlights": highlights,
|
||||||
@@ -492,7 +494,9 @@ def build_challenges(user_id, subject=None):
|
|||||||
"detail": f"{c.get('startDate')} ~ {c.get('endDate')},状态 {c.get('status')}。",
|
"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",
|
"scope": "challenges",
|
||||||
"label": "挑战赛",
|
"label": "挑战赛",
|
||||||
"highlights": highlights,
|
"highlights": highlights,
|
||||||
@@ -559,13 +563,17 @@ def build_activity(user_id, subject=None):
|
|||||||
|
|
||||||
# --- registry ----------------------------------------------------------------
|
# --- registry ----------------------------------------------------------------
|
||||||
class Scope:
|
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.name = name
|
||||||
self.label = label
|
self.label = label
|
||||||
self.build = build
|
self.build = build
|
||||||
self.needs_subject = needs_subject
|
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 = {
|
SCOPES = {
|
||||||
@@ -574,12 +582,13 @@ SCOPES = {
|
|||||||
Scope("sleep", "睡眠", build_sleep),
|
Scope("sleep", "睡眠", build_sleep),
|
||||||
Scope("exercise", "运动", build_exercise),
|
Scope("exercise", "运动", build_exercise),
|
||||||
Scope("trends", "长期趋势", build_trends),
|
Scope("trends", "长期趋势", build_trends),
|
||||||
Scope("daily", "每日数据", build_daily),
|
Scope("daily", "每日数据", build_daily, per_item=True),
|
||||||
Scope("body", "身体成分", build_body),
|
Scope("body", "身体成分", build_body),
|
||||||
Scope("race", "成绩预测", build_race),
|
Scope("race", "成绩预测", build_race),
|
||||||
Scope("bodyAge", "身体年龄", build_body_age),
|
Scope("bodyAge", "身体年龄", build_body_age),
|
||||||
Scope("challenges", "挑战赛", build_challenges),
|
Scope("challenges", "挑战赛", build_challenges),
|
||||||
Scope("activity", "运动详情", build_activity, needs_subject=True),
|
Scope("activity", "运动详情", build_activity, needs_subject=True,
|
||||||
|
per_item=True),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -981,3 +981,75 @@ class TestOutageRecovery:
|
|||||||
assert out["meta"]["pending"] is False
|
assert out["meta"]["pending"] is False
|
||||||
assert out["meta"]["reason"], "the card should be able to say why"
|
assert out["meta"]["reason"], "the card should be able to say why"
|
||||||
assert out["insight"]["headline"], "and still show the computed facts"
|
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"
|
||||||
|
|||||||
@@ -85,6 +85,36 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aip-detail {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
animation: aip-open 0.26s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aip-open {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width, at the foot of the card: the same affordance the 今日 briefing
|
||||||
|
uses, so the two read as one pattern rather than two. */
|
||||||
|
.aip-toggle {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 0 0.1rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aip-toggle:active { color: var(--accent); }
|
||||||
|
|
||||||
.aip-point { margin-bottom: 0.55rem; }
|
.aip-point { margin-bottom: 0.55rem; }
|
||||||
|
|
||||||
.aip-tag {
|
.aip-tag {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
apiClient, InsightScope, InsightMeta, ScopeInsight,
|
apiClient, InsightScope, InsightMeta, ScopeInsight,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -48,6 +48,13 @@ function Badge({ meta }: { meta: InsightMeta }) {
|
|||||||
* screen — which opening the screen moves to the front of.
|
* screen — which opening the screen moves to the front of.
|
||||||
*/
|
*/
|
||||||
function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
|
function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
|
||||||
|
/* Collapsed by default, the same as the 今日 briefing card. These panels sit
|
||||||
|
on top of screens that are already dense with charts; opening every one of
|
||||||
|
them by default would push the actual data below the fold on every screen
|
||||||
|
at once. The conclusion is always visible — it is the detail behind it
|
||||||
|
that waits to be asked for. */
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const resp = await apiClient.getInsight(scope, { subject });
|
const resp = await apiClient.getInsight(scope, { subject });
|
||||||
return { data: resp.insight, meta: resp.meta };
|
return { data: resp.insight, meta: resp.meta };
|
||||||
@@ -71,6 +78,8 @@ function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
|
|||||||
|
|
||||||
{data.headline && <p className="aip-headline">{data.headline}</p>}
|
{data.headline && <p className="aip-headline">{data.headline}</p>}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="aip-detail">
|
||||||
{data.points.map((p) => (
|
{data.points.map((p) => (
|
||||||
<div className="aip-point" key={p.title + p.detail}>
|
<div className="aip-point" key={p.title + p.detail}>
|
||||||
<span className="aip-tag">{p.title}</span>
|
<span className="aip-tag">{p.title}</span>
|
||||||
@@ -92,6 +101,19 @@ function AiPanel({ scope, subject, title = 'AI 解读' }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<span>{CONFIDENCE_LABEL[data.confidence]}</span>
|
<span>{CONFIDENCE_LABEL[data.confidence]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nothing to expand into when the reading is a headline on its own. */}
|
||||||
|
{(!!data.points.length || !!data.actions.length || !!data.caution) && (
|
||||||
|
<button
|
||||||
|
className="aip-toggle"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{open ? '收起' : `展开详细(${data.points.length} 项)`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user