diff --git a/backend/services/analysis.py b/backend/services/analysis.py index 50963c8..32a87f3 100644 --- a/backend/services/analysis.py +++ b/backend/services/analysis.py @@ -499,6 +499,8 @@ def get_scope_insight(user_id, scope, subject=None, refresh=False): insight, meta = cached 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, jobs.PRIORITY_INTERACTIVE) return { @@ -540,12 +542,15 @@ def prefetch_insights(user_id): if not built: continue 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.PRIORITY_PREFETCH) queued.append(scope) context = insights.build_context(user_id) if context: + jobs.supersede(user_id, "briefing", context["snapshotDate"]) jobs.enqueue(user_id, "briefing", context["snapshotDate"], _context_fingerprint(context), jobs.PRIORITY_PREFETCH) queued.append("briefing") diff --git a/backend/services/jobs.py b/backend/services/jobs.py index 4e088ca..2c12c55 100644 --- a/backend/services/jobs.py +++ b/backend/services/jobs.py @@ -150,6 +150,25 @@ def enqueue(user_id, kind, subject, fingerprint=None, 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): row = query_one("SELECT * FROM ai_jobs WHERE id = ?", [job_id(user_id, kind, subject)]) diff --git a/backend/services/scopes.py b/backend/services/scopes.py index 9751fc6..e06cc63 100644 --- a/backend/services/scopes.py +++ b/backend/services/scopes.py @@ -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), ) } diff --git a/backend/tests/test_coach.py b/backend/tests/test_coach.py index 1f7336f..5fdb6c2 100644 --- a/backend/tests/test_coach.py +++ b/backend/tests/test_coach.py @@ -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" diff --git a/client/src/components/AiPanel.css b/client/src/components/AiPanel.css index 5b317bb..95146bd 100644 --- a/client/src/components/AiPanel.css +++ b/client/src/components/AiPanel.css @@ -85,6 +85,36 @@ 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-tag { diff --git a/client/src/components/AiPanel.tsx b/client/src/components/AiPanel.tsx index ac70fe4..035335b 100644 --- a/client/src/components/AiPanel.tsx +++ b/client/src/components/AiPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { apiClient, InsightScope, InsightMeta, ScopeInsight, } from '../services/api'; @@ -48,6 +48,13 @@ function Badge({ meta }: { meta: InsightMeta }) { * screen — which opening the screen moves to the front of. */ 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 resp = await apiClient.getInsight(scope, { subject }); return { data: resp.insight, meta: resp.meta }; @@ -71,27 +78,42 @@ function AiPanel({ scope, subject, title = 'AI 解读' }: Props) { {data.headline &&
{data.headline}
} - {data.points.map((p) => ( -{p.detail}
-{p.detail}
+{data.caution}
} + +{data.caution}
} - -