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

@@ -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 = []