feat(ai): 设置里加「AI 生成队列」,看得见后台在算什么

队列本来是完全不可见的:页面上一句「排队生成中」说不出自己是下一个、第二十
个,还是已经放弃了——网关挂掉的时候,「还在生成」和「永远不会好」长得一模
一样。今天排查就是这么排的。

- GET /analysis/insight/queue 返回队列(running 在前,其次按优先级和年龄,
  和 worker 实际取任务的顺序一致)、已生成的解读、scope 名到中文标签的映射
  (前端不必再抄一份),以及消费者的限流配置
- POST /analysis/insight/queue/retry:手动把「已放弃」的重新排队,不等冷却。
  自动重试要等冷却是为了不去捶一个正在抽风的上游;人按下重试是他自己判断值得
  再试一次
- 页面在 设置 → AI 生成队列。插队的任务标「插队」——这是整个界面最想让人看见
  的一件事:为什么是它排在最前面
- 「已生成」单独列:队列空了意味着「没有待办」,不是「什么都没生成过」,
  没有这一节这两件事在界面上没法区分

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 16:02:05 +08:00
parent a2d6a5c57f
commit 05f55e695b
9 changed files with 551 additions and 6 deletions

View File

@@ -168,8 +168,23 @@ def insight():
@bp.route("/insight/queue", methods=["GET"])
@require_auth
def insight_queue():
"""What the coach still has to generate — for a progress indicator."""
"""The coach's queue: what is running, what is waiting, what gave up.
Also lists what has already been generated, because "nothing queued" and
"nothing generated" look the same from the queue alone and mean opposite
things.
"""
return jsonify({
"pending": ai_jobs.pending_count(g.user_id),
"enabled": ai_jobs.ENABLED,
"jobs": ai_jobs.list_jobs(g.user_id),
"insights": analysis_svc.list_insights(g.user_id),
"scopes": {name: scope.label for name, scope in scopes.SCOPES.items()},
"settings": ai_jobs.settings(),
})
@bp.route("/insight/queue/retry", methods=["POST"])
@require_auth
def insight_queue_retry():
"""Re-queue everything that gave up, without waiting out the cooldown."""
return jsonify({"pending": ai_jobs.retry_failed(g.user_id)})

View File

@@ -571,6 +571,22 @@ def _run_job(user_id, kind, subject):
jobs.set_runner(_run_job)
def list_insights(user_id, limit=60):
"""Stored answers, newest first — what the coach has actually produced."""
rows = query_all(
"SELECT kind, subject, model, upstream, created_at FROM ai_insights "
"WHERE user_id = ? ORDER BY created_at DESC",
[user_id],
)
return [{
"kind": r["kind"],
"subject": r["subject"],
"model": r["model"],
"upstream": r["upstream"],
"generatedAt": r["created_at"],
} for r in rows[:limit]]
def clear_insight_cache(user_id, kind=None):
if kind:
execute(

View File

@@ -197,6 +197,60 @@ def status_of(user_id, kind, subject):
}
def list_jobs(user_id, limit=60):
"""The queue as it stands, for the 设置 screen.
Ordered the way the worker will actually take them — priority, then age —
so the list reads as "what happens next" rather than as a table of rows.
Finished jobs come last: they are history, not queue.
"""
rows = query_all(
"SELECT * FROM ai_jobs WHERE user_id = ? "
"ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'pending' THEN 1 "
"WHEN 'failed' THEN 2 ELSE 3 END, priority ASC, created_at ASC",
[user_id],
)
return [{
"kind": r["kind"],
"subject": r["subject"],
"status": r["status"],
"priority": r["priority"],
# The queue only distinguishes "the user is looking at this" from
# "backfill"; showing the raw number would mean explaining the scale.
"interactive": r["priority"] <= PRIORITY_INTERACTIVE,
"attempts": r["attempts"],
"maxAttempts": MAX_ATTEMPTS,
"error": r.get("error"),
"updatedAt": r.get("updated_at"),
} for r in rows[:limit]]
def settings():
"""What the consumer is configured to do, for the same screen."""
return {
"enabled": ENABLED,
"concurrency": MAX_CONCURRENT,
"gapSeconds": GAP_SECONDS,
"maxAttempts": MAX_ATTEMPTS,
"retryAfterSeconds": FAILED_RETRY_SECONDS,
}
def retry_failed(user_id):
"""Put every given-up job back in the queue, now, at the user's request.
The automatic retry waits out a cooldown so a flapping upstream is not
hammered; a person pressing 重试 has decided it is worth trying again.
"""
execute(
"UPDATE ai_jobs SET status = 'pending', attempts = 0, error = NULL, "
"holder = NULL, claimed_at = NULL, updated_at = ? "
"WHERE user_id = ? AND status = 'failed'",
[_iso(_now()), user_id],
)
return pending_count(user_id)
def pending_count(user_id=None):
sql = "SELECT COUNT(*) AS n FROM ai_jobs WHERE status IN ('pending', 'running')"
params = []

View File

@@ -1105,3 +1105,64 @@ class TestGatewayCourtesy:
def test_there_is_a_gap_between_jobs(self):
"""Back-to-back is what saturates a four-thread box."""
assert jobs.GAP_SECONDS > 0
class TestQueueScreen:
def test_queue_endpoint_requires_auth(self, client):
assert client.get("/api/analysis/insight/queue").status_code == 401
assert client.post("/api/analysis/insight/queue/retry").status_code == 401
def test_lists_jobs_labels_and_limits(self, client, auth, month):
client.get("/api/analysis/insight?scope=sleep", headers=auth)
body = client.get("/api/analysis/insight/queue", headers=auth).get_json()
assert body["pending"] >= 1
assert any(j["kind"] == "sleep" for j in body["jobs"])
assert body["scopes"]["sleep"] == "睡眠", "the UI must not restate the list"
assert body["settings"]["concurrency"] == jobs.MAX_CONCURRENT
def test_an_open_screen_is_marked_as_having_jumped_the_queue(
self, client, auth, month
):
jobs.enqueue(month["id"], "trends", "2026-08-30", priority=jobs.PRIORITY_PREFETCH)
client.get("/api/analysis/insight?scope=sleep", headers=auth)
by_kind = {j["kind"]: j for j in
client.get("/api/analysis/insight/queue", headers=auth).get_json()["jobs"]}
assert by_kind["sleep"]["interactive"] is True
assert by_kind["trends"]["interactive"] is False
def test_running_jobs_are_listed_before_waiting_ones(self, client, auth, month):
jobs.enqueue(month["id"], "health", "a")
jobs.enqueue(month["id"], "sleep", "b")
jobs._claim_next()
listed = client.get("/api/analysis/insight/queue", headers=auth).get_json()["jobs"]
assert listed[0]["status"] == "running"
def test_generated_answers_are_listed_separately(
self, client, auth, month, gateway, monkeypatch
):
"""An empty queue means "nothing left to do", not "nothing was done"
the two look the same without this."""
answer(monkeypatch, json.dumps({
"headline": "h", "points": [], "actions": [], "confidence": "low",
}, ensure_ascii=False))
analysis_svc.generate_scope_insight(month["id"], "sleep")
body = client.get("/api/analysis/insight/queue", headers=auth).get_json()
assert any(i["kind"] == "sleep" for i in body["insights"])
def test_retry_puts_given_up_jobs_back(self, client, auth, month):
jobs.enqueue(month["id"], "sleep", "s")
row = jobs._claim_next()
for _ in range(jobs.MAX_ATTEMPTS):
jobs._finish(row["id"], "boom")
assert jobs.status_of(month["id"], "sleep", "s")["status"] == "failed"
resp = client.post("/api/analysis/insight/queue/retry", headers=auth)
assert resp.status_code == 200
assert jobs.status_of(month["id"], "sleep", "s")["status"] == "pending"
assert jobs.status_of(month["id"], "sleep", "s")["attempts"] == 0
def test_the_queue_is_per_account(self, client, auth, month, make_user):
other = make_user("other@example.com")
jobs.enqueue(other["id"], "sleep", "s")
body = client.get("/api/analysis/insight/queue", headers=auth).get_json()
assert body["jobs"] == []