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:
@@ -13,6 +13,8 @@ from services import ai as ai_svc
|
||||
from services import analysis as analysis_svc
|
||||
from services import coach
|
||||
from services import insights
|
||||
from services import jobs
|
||||
from services import scopes
|
||||
|
||||
|
||||
def day(date, **metrics):
|
||||
@@ -487,27 +489,20 @@ class TestGetBriefing:
|
||||
def test_the_non_blocking_path_answers_without_calling_a_model(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
"""It enqueues instead. No worker runs in the suite, so a model call
|
||||
here would mean the request generated inline."""
|
||||
calls = answer(monkeypatch, json.dumps(GOOD_BRIEFING, ensure_ascii=False))
|
||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||
out = analysis_svc.get_briefing(month["id"])
|
||||
assert out["meta"]["pending"] is True
|
||||
assert out["briefing"]["status"]
|
||||
assert calls == []
|
||||
assert jobs.pending_count(month["id"]) == 1
|
||||
|
||||
def test_one_generation_per_key_no_matter_how_often_it_is_polled(self):
|
||||
"""The poll runs every few seconds; a generation takes minutes."""
|
||||
started = []
|
||||
# A job that never finishes, so the key stays claimed across polls.
|
||||
blocked = analysis_svc.threading.Event()
|
||||
analysis_svc._run_in_background("test-key", lambda: (
|
||||
started.append(1), blocked.wait(5)
|
||||
))
|
||||
try:
|
||||
for _ in range(5):
|
||||
analysis_svc._run_in_background("test-key", lambda: started.append(1))
|
||||
assert len(started) == 1
|
||||
finally:
|
||||
blocked.set()
|
||||
def test_polling_does_not_queue_a_job_per_poll(self, month, gateway):
|
||||
"""The screen polls every few seconds; a generation takes minutes."""
|
||||
for _ in range(5):
|
||||
analysis_svc.get_briefing(month["id"])
|
||||
assert jobs.pending_count(month["id"]) == 1
|
||||
|
||||
|
||||
class TestGetTrendInsight:
|
||||
@@ -654,10 +649,8 @@ class TestEndpoints:
|
||||
assert "可以,注意强度。" in body
|
||||
|
||||
def test_briefing_never_leaks_the_gateway_token(
|
||||
self, client, auth, month, gateway, monkeypatch
|
||||
self, client, auth, month, gateway
|
||||
):
|
||||
# No real background generation: this suite must not reach the network.
|
||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||
body = client.get("/api/analysis/briefing", headers=auth).get_data(as_text=True)
|
||||
assert "test-token" not in body
|
||||
|
||||
@@ -698,9 +691,293 @@ class TestRegenerate:
|
||||
analysis_svc.get_briefing(month["id"], wait=True)
|
||||
assert analysis_svc.get_briefing(month["id"])["meta"]["cached"] is True
|
||||
|
||||
monkeypatch.setattr(analysis_svc, "_run_in_background", lambda key, fn: True)
|
||||
analysis_svc.get_briefing(month["id"], refresh=True)
|
||||
|
||||
after = analysis_svc.get_briefing(month["id"])
|
||||
assert after["meta"].get("cached") is not True
|
||||
assert after["meta"]["pending"] is True
|
||||
|
||||
|
||||
# --- the producer/consumer queue --------------------------------------------
|
||||
class TestJobQueue:
|
||||
def test_enqueue_then_claim(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "2026-08-30:14")
|
||||
claimed = jobs._claim_next()
|
||||
assert claimed["kind"] == "sleep"
|
||||
assert claimed["status"] == "pending", "the row read is the pre-claim one"
|
||||
assert jobs.status_of(user["id"], "sleep", "2026-08-30:14")["status"] == "running"
|
||||
|
||||
def test_the_same_work_queued_twice_is_one_row(self, db, user):
|
||||
for _ in range(6):
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
assert jobs.pending_count(user["id"]) == 1
|
||||
|
||||
def test_opening_a_screen_promotes_it_ahead_of_the_backfill(self, db, user):
|
||||
for scope in ("health", "exercise", "trends"):
|
||||
jobs.enqueue(user["id"], scope, "s", priority=jobs.PRIORITY_PREFETCH)
|
||||
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_PREFETCH)
|
||||
# The user opens 睡眠 while the backfill is still queued.
|
||||
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_INTERACTIVE)
|
||||
assert jobs._claim_next()["kind"] == "sleep"
|
||||
|
||||
def test_priority_is_never_demoted(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_INTERACTIVE)
|
||||
jobs.enqueue(user["id"], "sleep", "s", priority=jobs.PRIORITY_PREFETCH)
|
||||
assert jobs.status_of(user["id"], "sleep", "s")["priority"] == \
|
||||
jobs.PRIORITY_INTERACTIVE
|
||||
|
||||
def test_equal_priority_runs_oldest_first(self, db, user):
|
||||
jobs.enqueue(user["id"], "health", "s")
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
assert jobs._claim_next()["kind"] == "health"
|
||||
|
||||
def test_a_finished_job_is_not_run_again(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||
jobs._finish(jobs.job_id(user["id"], "sleep", "s"))
|
||||
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "done"
|
||||
assert jobs._claim_next() is None
|
||||
|
||||
def test_changed_data_requeues_a_finished_job(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||
jobs._finish(jobs.job_id(user["id"], "sleep", "s"))
|
||||
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="xyz") == "pending"
|
||||
|
||||
def test_a_running_job_is_not_restarted_by_a_poll(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
jobs._claim_next()
|
||||
assert jobs.enqueue(user["id"], "sleep", "s") == "running"
|
||||
|
||||
def test_a_claim_left_by_a_dead_worker_is_retried(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
jobs._claim_next()
|
||||
assert jobs._claim_next() is None, "still within the claim window"
|
||||
# Backdate the claim past its timeout, as a killed worker would leave it.
|
||||
stale = (jobs._now() - jobs.datetime.timedelta(
|
||||
seconds=jobs.CLAIM_TIMEOUT_SECONDS + 60))
|
||||
db.execute("UPDATE ai_jobs SET claimed_at = ?", [jobs._iso(stale)])
|
||||
assert jobs._claim_next() is not None
|
||||
|
||||
def test_restart_releases_whatever_was_running(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
jobs._claim_next()
|
||||
jobs.reset_stale_claims()
|
||||
assert jobs.status_of(user["id"], "sleep", "s")["status"] == "pending"
|
||||
|
||||
def test_a_job_that_keeps_failing_is_eventually_dropped(self, db, user):
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
for _ in range(jobs.MAX_ATTEMPTS):
|
||||
row = jobs._claim_next()
|
||||
assert row is not None
|
||||
jobs._finish(row["id"], "boom")
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
assert jobs._claim_next() is None
|
||||
|
||||
def test_the_runner_failing_does_not_stop_the_queue(self, db, user, monkeypatch):
|
||||
monkeypatch.setattr(jobs, "_runner", lambda *a: (_ for _ in ()).throw(RuntimeError("x")))
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
assert jobs.run_once() is True
|
||||
assert jobs.status_of(user["id"], "sleep", "s")["status"] == "failed"
|
||||
|
||||
def test_run_once_dispatches_to_the_registered_runner(self, db, user, monkeypatch):
|
||||
seen = []
|
||||
monkeypatch.setattr(jobs, "_runner", lambda u, k, s: seen.append((u, k, s)))
|
||||
jobs.enqueue(user["id"], "sleep", "2026-08-30:14")
|
||||
assert jobs.run_once() is True
|
||||
assert seen == [(user["id"], "sleep", "2026-08-30:14")]
|
||||
assert jobs.status_of(user["id"], "sleep", "2026-08-30:14")["status"] == "done"
|
||||
|
||||
def test_run_once_is_a_no_op_on_an_empty_queue(self, db, user, monkeypatch):
|
||||
monkeypatch.setattr(jobs, "_runner", lambda *a: None)
|
||||
assert jobs.run_once() is False
|
||||
|
||||
def test_jobs_are_per_account(self, db, user, make_user):
|
||||
other = make_user("other@example.com")
|
||||
jobs.enqueue(user["id"], "sleep", "s")
|
||||
assert jobs.pending_count(other["id"]) == 0
|
||||
|
||||
|
||||
# --- per-screen scopes ------------------------------------------------------
|
||||
class TestScopes:
|
||||
def test_every_registered_scope_builds_or_declines(self, month):
|
||||
"""No builder may raise on a real account — an empty screen returns
|
||||
None so the caller can stay quiet."""
|
||||
for name in scopes.SCOPES:
|
||||
out = scopes.build(month["id"], name, subject="1" if name == "activity" else None)
|
||||
assert out is None or (isinstance(out, tuple) and len(out) == 2)
|
||||
|
||||
def test_a_scope_with_data_carries_highlights_and_a_subject(self, month):
|
||||
subject, context = scopes.build(month["id"], "sleep")
|
||||
assert subject
|
||||
assert context["scope"] == "sleep"
|
||||
assert context["highlights"]
|
||||
|
||||
def test_contexts_stay_small_enough_to_prompt_with(self, month):
|
||||
for name in ("health", "sleep", "exercise", "trends", "daily"):
|
||||
built = scopes.build(month["id"], name)
|
||||
if not built:
|
||||
continue
|
||||
blob = json.dumps(built[1], ensure_ascii=False)
|
||||
assert len(blob) < 30_000, f"{name} context is {len(blob)} chars"
|
||||
|
||||
def test_an_empty_screen_declines_rather_than_inventing_one(self, db, user):
|
||||
assert scopes.build(user["id"], "body") is None
|
||||
assert scopes.build(user["id"], "race") is None
|
||||
|
||||
def test_per_item_scopes_need_a_subject(self, month):
|
||||
assert scopes.build(month["id"], "activity") is None
|
||||
|
||||
def test_unknown_scope_raises(self, month):
|
||||
with pytest.raises(KeyError):
|
||||
scopes.build(month["id"], "nope")
|
||||
|
||||
def test_prefetch_list_excludes_per_item_screens(self):
|
||||
"""Pre-warming `activity` would queue one job per session, not one."""
|
||||
assert "activity" not in scopes.PREFETCH_SCOPES
|
||||
assert "daily" not in scopes.PREFETCH_SCOPES
|
||||
assert set(scopes.PREFETCH_SCOPES) <= set(scopes.SCOPES)
|
||||
|
||||
def test_health_scope_carries_the_bands_the_ui_shows(self, month):
|
||||
_, context = scopes.build(month["id"], "health")
|
||||
assert context["referenceBands"], "the model must not contradict the card"
|
||||
|
||||
|
||||
class TestScopeInsight:
|
||||
def test_unknown_scope_raises(self, month):
|
||||
with pytest.raises(KeyError):
|
||||
analysis_svc.get_scope_insight(month["id"], "nope")
|
||||
|
||||
def test_an_empty_screen_reports_it(self, db, user, gateway):
|
||||
out = analysis_svc.get_scope_insight(user["id"], "race")
|
||||
assert out["meta"]["source"] == "none"
|
||||
|
||||
def test_first_visit_answers_from_rules_and_queues_the_model(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
calls = answer(monkeypatch, "{}")
|
||||
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||
assert out["meta"]["pending"] is True
|
||||
# The computed facts, with the first one leading; a screen with a
|
||||
# single fact has a headline and no remaining points.
|
||||
assert out["insight"]["headline"]
|
||||
assert calls == [], "nothing may be generated inside the request"
|
||||
assert jobs.pending_count(month["id"]) == 1
|
||||
|
||||
def test_the_stored_answer_is_served_once_generated(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
reply = json.dumps({
|
||||
"headline": "睡眠偏短。", "points": [{"title": "时长", "detail": "6.5 小时。"}],
|
||||
"actions": ["提前入睡"], "caution": None, "confidence": "medium",
|
||||
}, ensure_ascii=False)
|
||||
answer(monkeypatch, reply)
|
||||
analysis_svc.generate_scope_insight(month["id"], "sleep")
|
||||
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||
assert out["meta"]["source"] == "ai"
|
||||
assert out["meta"]["cached"] is True
|
||||
assert out["insight"]["headline"] == "睡眠偏短。"
|
||||
|
||||
def test_rules_fallback_says_it_has_not_been_interpreted(self, month, gateway):
|
||||
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||
assert "尚未经过模型解读" in out["insight"]["caution"]
|
||||
|
||||
def test_prefetch_queues_every_screen_that_has_data(self, month, gateway):
|
||||
queued = analysis_svc.prefetch_insights(month["id"])
|
||||
assert "briefing" in queued
|
||||
assert "sleep" in queued
|
||||
assert jobs.pending_count(month["id"]) == len(queued)
|
||||
|
||||
def test_the_queue_runner_reaches_a_scope(self, month, gateway, monkeypatch):
|
||||
reply = json.dumps({"headline": "ok", "points": [], "actions": [],
|
||||
"confidence": "low"}, ensure_ascii=False)
|
||||
answer(monkeypatch, reply)
|
||||
subject, _ = scopes.build(month["id"], "sleep")
|
||||
analysis_svc._run_job(month["id"], "sleep", subject)
|
||||
assert analysis_svc.get_scope_insight(month["id"], "sleep")["meta"]["source"] == "ai"
|
||||
|
||||
|
||||
class TestParseScopeInsight:
|
||||
def test_valid_reply(self):
|
||||
out = coach.parse_scope_insight(json.dumps({
|
||||
"headline": "睡眠不足。",
|
||||
"points": [{"title": "时长", "detail": "平均 6 小时。"}],
|
||||
"actions": ["提前入睡"], "caution": "样本偏少", "confidence": "high",
|
||||
}, ensure_ascii=False))
|
||||
assert out["confidence"] == "high"
|
||||
assert out["actions"] == ["提前入睡"]
|
||||
|
||||
def test_points_written_as_plain_strings(self):
|
||||
out = coach.parse_scope_insight(json.dumps(
|
||||
{"headline": "h", "points": ["纯文本要点"]}, ensure_ascii=False))
|
||||
assert out["points"][0]["detail"] == "纯文本要点"
|
||||
|
||||
def test_blank_reply_is_rejected(self):
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
coach.parse_scope_insight(json.dumps({"confidence": "high"}))
|
||||
|
||||
def test_reasoning_preamble_is_skipped(self):
|
||||
reply = ("Let me think. Maybe {\"headline\": \"draft\"} ... final:\n"
|
||||
+ json.dumps({"headline": "定稿", "points": []}, ensure_ascii=False))
|
||||
assert coach.parse_scope_insight(reply)["headline"] == "定稿"
|
||||
|
||||
|
||||
class TestScopeEndpoint:
|
||||
def test_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/insight?scope=sleep").status_code == 401
|
||||
|
||||
def test_unknown_scope_lists_the_supported_ones(self, client, auth, month):
|
||||
resp = client.get("/api/analysis/insight?scope=nope", headers=auth)
|
||||
assert resp.status_code == 400
|
||||
assert "sleep" in resp.get_json()["supported"]
|
||||
|
||||
def test_answers_immediately(self, client, auth, month):
|
||||
resp = client.get("/api/analysis/insight?scope=sleep", headers=auth)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["insight"]["headline"]
|
||||
|
||||
def test_queue_endpoint_reports_outstanding_work(self, client, auth, month):
|
||||
client.get("/api/analysis/insight?scope=sleep", headers=auth)
|
||||
assert client.get("/api/analysis/insight/queue", headers=auth).get_json()["pending"] >= 1
|
||||
|
||||
|
||||
class TestOutageRecovery:
|
||||
"""A gateway outage must delay an insight, not retire it."""
|
||||
|
||||
def test_a_given_up_job_is_retried_after_the_cooldown(self, db, user):
|
||||
jid = jobs.job_id(user["id"], "sleep", "s")
|
||||
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||
for _ in range(jobs.MAX_ATTEMPTS):
|
||||
row = jobs._claim_next()
|
||||
jobs._finish(row["id"], "connection refused")
|
||||
jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc")
|
||||
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "failed"
|
||||
|
||||
# Past the cooldown, as it would be once the upstream is back.
|
||||
old = jobs._now() - jobs.datetime.timedelta(
|
||||
seconds=jobs.FAILED_RETRY_SECONDS + 60)
|
||||
db.execute("UPDATE ai_jobs SET updated_at = ? WHERE id = ?",
|
||||
[jobs._iso(old), jid])
|
||||
assert jobs.enqueue(user["id"], "sleep", "s", fingerprint="abc") == "pending"
|
||||
assert jobs.status_of(user["id"], "sleep", "s")["attempts"] == 0
|
||||
assert jobs._claim_next() is not None
|
||||
|
||||
def test_the_screen_stops_polling_once_the_queue_gives_up(
|
||||
self, month, gateway, monkeypatch
|
||||
):
|
||||
"""`pending` must go false, or the card spins for minutes waiting on an
|
||||
answer that is not coming."""
|
||||
def boom(self, messages, timeout=None, max_tokens=None):
|
||||
raise ai_svc.AIError("upstream unreachable")
|
||||
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "chat", boom)
|
||||
monkeypatch.setattr(ai_svc.OpenAICompatProvider, "stream", boom)
|
||||
|
||||
subject, _ = scopes.build(month["id"], "sleep")
|
||||
for _ in range(jobs.MAX_ATTEMPTS):
|
||||
analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||
jobs.run_once()
|
||||
|
||||
out = analysis_svc.get_scope_insight(month["id"], "sleep")
|
||||
assert out["meta"]["queue"] == "failed"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user