Skip to content

[P1][LLM] 降级内容不再永久停留假分:补分析恢复路径 + P70 排除 + 前端标记 (#90) - #91

Open
fxbin wants to merge 10 commits into
mainfrom
issue-90-llm-fallback-requeue
Open

fxbin wants to merge 10 commits into
mainfrom
issue-90-llm-fallback-requeue

Conversation

@fxbin

@fxbin fxbin commented Oct 1, 2026

Copy link
Copy Markdown
Owner

What & why

Fixes #90

LLM 降级(熔断 / 内容过滤 / 预算超限)把内容写成 ANALYZED 终态、summary_source='local_fallback' 落库后,全代码库没有消费者会把它捡回来重跑——触发原因消失了也起不来,内容永久停在降级时算出的假分上,并污染 P70 百分位门槛、挤掉真实 65-70 分内容。

本 PR 分三块交付。

一、#90 主体(2a8e67c / ae59cc4)

  • requeue 恢复路径:新增 services/analysis_requeue.py + scheduler 每 15 分钟调度。预算余量 ≥30%(fail-closed)且降级满 60 分钟后,把 local_fallback 内容重置回 PENDING 让分析队列重新拿真实分析。四重防护:余量门控、60 分钟冷却、每轮 50 条最旧优先、已有 ≥2 条 fallback 的不再回收。
  • 预算拒绝不消耗重试次数:analysis.py 把 LlmBudgetExceededError 纳入降级触发源。预算拒绝刻意不计入 analysis_attempts——日窗熔断可能比重试预算活得久,否则整条 backlog 会被烧成永久 ERROR。
  • P70 门槛只由真实评分决定:scoring_engine.py 排除 local_fallback 的确定性假分(curation 收敛 61-64 窄带);全候选皆降级时回退全量分数。
  • summary_source 透传补齐:DuckDB 主路径、OLTP fallback、API 响应 payload、周报/月报 digest。
  • 前端标记:today-picks 卡片显示「本地速览 · 待 AI 复核」。
  • 预算豁免:daily_report / weekly_digest / monthly_digest 不做预算检查,防日窗耗尽致日报硬失败。

二、预算熔断闸前置(5e86605)

三窗(分钟/小时/日)保险丝。config.py 默认三窗全 0(opt-in,不改变任何现有部署行为)。.env.example 记录圆桌四席的数值分歧与收紧前提。

三、今日选题/报告窗口按原文发布时间归档(0fc73d5)

窗口条件从 crawled_at 改为 COALESCE(published_at, crawled_at),三处口径统一。与 #90 无逻辑依赖,但已被 #90 的查询改动牵连,一并交付以免拆分支。

四、独立复核后的修正(e9fd620 / 191de4b / 84c5c55)

  • test_all_fallback_batch_falls_back_to_full_scores 名实不符:原断言只检查 threshold_used 非空,而「本批分数」和「全局默认」两条路径都给数字,删掉被测的回退分支后测试仍全绿。改为断言门槛等于本批分数的 P70 且不等于全局默认。
  • 回归矩阵 [P1][LLM] LLM 降级内容不会再被重新分析:local_fallback 落 ANALYZED 终态后无恢复路径 #90 条目状态从「待修复」更新为「修复完成待合并」,补上测试条数与变异结论。
  • .gitignore 忽略 .worktrees/(agent 隔离 issue 切片用)。

Area

  • backend
  • frontend
  • docs
  • config / infra

Verification

  • pytest tests/test_scoring_engine.py tests/test_analysis_requeue.py tests/test_llm_budget_guard.py → 32 passed
  • python scripts/check_layering.py → 49 个 api/v1 文件通过
  • ruff check(5 个改动文件)→ All checks passed
  • npx tsc --noEmit → 0 错
  • npx vitest run → 195/195(13 个测试文件)
  • 后端全量套件 → 仅 2 条 FAILED,均为 tests/test_source_url_safety.py 依赖真实网络的存量红,已在 main 上跑同一文件确认同样 FAILED,非本 PR 引入
  • 手动 smoke test → 未做。需真实触发降级,本机全走本地模型(llm_call_logs.total_cost 全 0),预算闸不触发

变异验证

绿灯只证明测试存在,不证明测试有效。两个变异都在隔离 worktree 里施加(不污染开发工作区):

变异 结果
删掉 P70 排除逻辑整段 test_p70_threshold_excludes_local_fallback_fake_scores 转红
删掉全降级批次的 or [...] 回退分支 改前存活,test_all_fallback_batch_falls_back_to_full_scores 转红(修正后)

第二条的根因值得记一笔:这个回退分支不是防御性冗余。真实分为空时 _compute_percentile_threshold 返回全局默认 55,而降级内容 final_score 约 19.94 全部低于 55——缺了这个分支,整批一条都选不出来,today-picks 直接空白。原代码注释「避免收到空列表」的因果是错的(空列表在函数内已处理)。

Verifier verdict

pass(针对 #90 主体实现)

独立复核由非实现过程方执行,逐项核过:分层检查、ruff、前端类型、接线正确性(summary_source 在 analysis 对象内,getAnalysis() 取的正是 item.analysis,徽标能显示)、以及冷却基准(content_items.updated_at 有 onupdate,60 分钟冷却确实从降级时刻算起)。

首轮 verdict = hold,抓到 1 条存活变异与 1 条过期矩阵登记,均已在本 PR 内修正并复验转红。

未覆盖的复核边界:第四块的修正由复核者本人提交,未再经第三方独立复核;前端徽标仍无组件测试(JSX 渲染层无测试基础设施,是 #88 起已登记的已知缺口)。合并前以 CI 五项为准。

Checklist

  • Commits follow Conventional Commits — see AGENTS.md
  • Branch named issue-<number>-<short-slug>; PR 正文写 Fixes #90 让 issue 随合并关闭
  • No local-only files staged
  • Docs updated

Notes for reviewer

  • 已知遗留风险(不阻塞):≥2 条 fallback 不再回收 的判断数的是终身降级次数,没有时间边界——理论上历史数据里有多条 fallback 的内容会被永久排除,而这恰是 [P1][LLM] LLM 降级内容不会再被重新分析:local_fallback 落 ANALYZED 终态后无恢复路径 #90 最想救的陈年数据。实践中难触发(手动端点遇已有记录早退、自动候选谓词只认 PENDING/僵死 ANALYZING/ERROR,内容正常只被分析一次),属潜在风险不是现网 bug。收紧只需给个时间窗,成本很低。
  • 数值待观察:config.py 三窗默认全 0,需显式配置才启用。.env.example 里记了收紧前提——requeue 稳定运行后可收紧至 60/1200/10000 并加 10k 告警线。

today_picks / 日报 / 周报的候选时间窗从 crawled_at 改为
COALESCE(published_at, crawled_at):3 天前发布、今天才抓到的
旧文不再进「今天」,按原文发布时间归档;published_at 为空的
信源回退按抓取时间计。覆盖 DuckDB 主路径、OLTP fallback 与
repo 两侧共 6 处窗口,主备口径逐条对齐。
新增 budget_guard:分钟/小时/日三窗预算保险丝,按 llm_call_logs
实际 DONE 调用数计数,超限拒绝新付费调用(LlmBudgetExceededError)。
插入点在熔断检查与响应缓存之后——缓存命中(免费)照常放行。
分析链路遇预算拒绝走本地降级(与 CircuitOpenError 同分支),
不消耗分析重试次数;预算拒绝记 BUDGET_REJECTED 审计行与
budget_rejected 指标事件;计数查询失败 fail-open。

默认 0/0/0 全关(opt-in),.env.example 附开启建议。
PR #89 合并进 main(squash 08071e8),三条缺陷的回归测试已随返工提交补齐并
在 main 上复验通过,状态从「修复完成待合并」改为「已修复关闭」。
LLM 降级路径(熔断 / 内容过滤 / 预算超限)把内容写成 ANALYZED 终态、
summary_source='local_fallback' 落库后,全代码库没有消费者会把它捡回来重跑:
重分析资格谓词只认 PENDING、僵死 ANALYZING 和 ERROR,手动分析端点遇到已有
记录直接早退。触发原因消失后内容也起不来,永久停在降级时算出的假分上。
requeue job 每 15 分钟在预算余量 ≥30% 时(fail-closed),把最新分析为
local_fallback 的 ANALYZED 内容重置回 PENDING 让分析队列重新拿真实
LLM 分析——把 #90 的永久降级疤变一过性。四重防护:余量门控、60 分钟
冷却、每轮 50 条最旧优先、已有 ≥2 条 fallback 的不再回收(防确定性
降级在 attempts 归零后无限循环)。

预算闸豁免 daily_report/weekly_digest/monthly_digest 低频核心场景,
防日窗耗尽致日报硬失败;新增 budget_headroom_ok 供 requeue 门控。
.env.example 补圆桌四席数值对照表与收紧路线。
scoring_engine 的百分位门槛只由真实 LLM 评分决定:local_fallback 的
确定性假分(curation 收敛 61-64 窄带)不再拖拽 P70 门槛、挤掉真实
65-70 分内容;全降级批次回退全量计算。降级项仍参与 selected 判定
与展示,由 requeue job 换回真实分析。

summary_source 透传补齐全部构造点:DuckDB 主路径、OLTP fallback、
API 响应 payload(前端徽标的数据源)、周报/月报 digest 路径(P70
同类污染一并修复)。today-picks 卡片显示「本地速览 · 待 AI 复核」
琥珀标记,悬停说明自动恢复机制。P70 排除测试为三组自证明对照
(含守门断言,删掉排除逻辑必红)。
原断言只检查 threshold_used 非空,而「本批分数」和「全局默认」两条路径
都会给出数字,删掉被测的 `or [bd.final_score ...]` 回退分支后测试照样全绿。

改成断言门槛等于本批分数的 P70、且不等于 CONFIG["curation_threshold"],
并要求本批至少选出一条。这条回退分支不是防御性冗余:真实分为空时
_compute_percentile_threshold 返回全局默认 55,而降级内容的 final_score
约为 19.94,全部低于 55,缺了这个分支整批一条都选不出来。

变异验证:删掉该分支后本测试转红(55 != 19.94)。
#90 修复已随 wip-llm-budget-guard 分支落地,条目仍写「待修复」和「回归测试
待补」,与实际状态矛盾。补上状态、落地范围、测试条数与变异验证结论。

顺带把条目里的「结疤」换成具体说法,与 AGENTS.md「中文说明怎么写」一致。
agent 用来隔离 issue 切片的 git worktree 放在仓库内 .worktrees/,否则
主工作区的 git status 会被它污染。不入库。
CI backend-lint 阻塞:`ae59cc4` 里那个列表推导被折成 3 行,但并未超长,
ruff format 要求单行。本地此前只跑 ruff check、漏了 format --check,
是 CI 抓出来的。

顺带更正上方注释的因果。原文写「避免 _compute_percentile_threshold 收到
空列表」,但空列表在函数内已被处理;真实原因是空列表会被回退到全局默认
阈值 55,而降级内容的 final_score 普遍低于它,那样整批一条都选不出来。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1][LLM] LLM 降级内容不会再被重新分析:local_fallback 落 ANALYZED 终态后无恢复路径

1 participant