Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
发版步骤见 [docs/release-process.md](docs/release-process.md)。

## [Unreleased]

### Fixed

- 章节目录:模型按主题弧线而非时间序返回章节时(每章时间锚点正确),章节层不再整体失败,改为按时间排序归一化(排序改变顺序时记入日志,含错位数与 LIS 统计)。此前此类输出会导致整个视频无章节目录(生产事故:BV1fcHD6cEw3)。
- 章节生成失败原因现在落盘到任务状态的 llm_status.json(chapters_error 字段),不再只出现在服务日志里;补跑成功后旧失败原因自动清除。

## [1.0.0] - 2026-10-10

首个版本基线,不追认历史。本条概括基线时点的对外能力与近期变更。
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 进度存档:章节生成 start_seg 乱序排序归一化 + chapters_error 落盘

Task-Id: VideoTranscriptAPI-20261015-01(dispatch dlg-20261010-101342-e71d15)

## 里程碑 1:A 排序归一化

- 当前阶段:repairing(A 完成并测试通过,B 未开工)
- 本段结论:`_validate_and_normalize_start_segs` 去重后非严格递增不再判 FAILED,改为按
start_seg 排序归一化;排序确实改变顺序时打 warning(含被移动章节数 moved、LIS 长度,
生产样本实测 moved 6/17、LIS 14/17)。排序发生在 `_derive_end_segs` 之前,区间推导
自洽;已升序输入无排序、无新 warning,行为与 HEAD 一致。生产 attempt 2 样本序列
(17 章)固化为测试输入 `tests/unit/test_chapters_processor.py::TestStartSegSortNormalization`。
- 关键决策与已否决方案:按卡面锁定决策执行(无条件排序、无 LIS 阈值、无碎章否决、
prompt 不改、不新增端点);统计口径选择「moved=逐位与排序结果比较的位移数」+
LIS 长度双口径,与卡面「错位 3/17」(= 17 - LIS 口径)并存不冲突。
- 下一步唯一动作:实现 B(save_llm_status 增加 chapters_error 参数 + GENERATED 显式
清空)并接线 llm_ops,配套 test_cache_manager / test_chapters_pipeline_wiring 测试。

## 里程碑 2:B chapters_error 落盘 + llm_ops 接线

- 当前阶段:repairing(A/B 实现与测试均完成,剩 CHANGELOG 与全量 make test)
- 本段结论:`save_llm_status` 新增 `chapters_error` 参数(None 不更新,非 None 写入);
合并逻辑新增「chapters_status=generated ⇒ 强制 pop chapters_error」不变式,集中在
cache_manager 一处,caller 误传也无法制造「已生成却显示旧错误」。llm_ops 提取
chapters_error(顶层优先、stats 兜底)传入 save_llm_status;未触碰层(None)保留旧值
语义不变。契约测试 4 个(FAILED 落盘 / 补跑清除 / 误传仍清除 / 未触碰保留)+ 接线
测试 3 个(FAILED 传递 / stats 兜底 / GENERATED 传 None)。
- 关键决策与已否决方案:GENERATED 清空放 save_llm_status 内部而非 caller 侧——不变式
单点锁定,任何 caller 都无法绕过(锁定决策 4);skipped_*/disabled 状态不清旧
error(轴表只约束 GENERATED 清除,扩大清空范围属超卡面行为变更,报告里备注)。
- 下一步唯一动作:写 CHANGELOG Fixed 条目,跑 make test 全量门禁,写报告。

## 里程碑 3:收尾(CHANGELOG + 全量门禁)

- 当前阶段:验收中(A/B 已提交并各自红验通过,CHANGELOG 已写,全量门禁已过)
- 本段结论:CHANGELOG 新增 [Unreleased] Fixed 两条(排序归一化、chapters_error 落盘)。
make test 全量门禁 exit=0(TMPDIR=/tmp;默认 TMP 下 test_unix_domain_socket_is_not_blocked
因本 worktree 根路径 88 字符致 mkdtemp socket 路径超 AF_UNIX 108 字节限制而失败,
该测试文件自 2026-10-02 d9f2e2c6 未变,属环境性继承红,与本次改动无关)。
- 关键决策与已否决方案:无
- 下一步唯一动作:写 report.md 交验收。
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 独立 Review Verdict:章节排序归一化与 chapters_error 落盘

审查对象:`30466452..819b6f8435d191ba3fad9734f5d757d28295521e`(冻结 H0)
风险档位:personal
结论:**继续**(p2-only,不阻断交付)
failure-visibility: p2-only

## Findings

### P2 — 两个单调用统计 helper 没有第二消费者

- 位置:`src/video_transcript_api/llm/processors/chapters_processor.py:631`、`src/video_transcript_api/llm/processors/chapters_processor.py:642`、调用点 `src/video_transcript_api/llm/processors/chapters_processor.py:787`。
- 违反:卡面反熵条款要求对新增符号检查第二消费者;全局约束也要求新增 helper 有已发生的失败或第二调用方。`rg` 全仓结果显示 `_count_moved_entries` 与 `_longest_increasing_subsequence` 各只有该归一化分支一个生产调用点,未发现既有失败需要这层抽象。
- 影响:不影响归一化结果或 warning 数值;增加两个仅服务一个分支的符号与维护面。作为低风险清理项,不阻断交付;可接受不修,若修则可把统计内联到该分支。

## P1 判定

无 P1 finding,因此无需执行 P1 两问。唯一 finding 为 P2,personal 风险下不阻断交付。

## I1–I10 不变式核对

- **I1 通过**:归一化排序在 `src/video_transcript_api/llm/processors/chapters_processor.py:795`,区间推导在 `src/video_transcript_api/llm/processors/chapters_processor.py:1328`;生产样本测试还断言排序后区间连续且末章覆盖最后下标(`tests/unit/test_chapters_processor.py:1040`)。
- **I2 通过**:排序移动完整字典记录,仅以 `start_seg` 为键(`src/video_transcript_api/llm/processors/chapters_processor.py:779`、`src/video_transcript_api/llm/processors/chapters_processor.py:795`);生产样本测试比较完整 `(title, gist, start_seg)` 集合(`tests/unit/test_chapters_processor.py:1033`)。
- **I3 通过**:仅 `is_sorted` 为假时计算统计、告警并排序(`src/video_transcript_api/llm/processors/chapters_processor.py:782`);已排序输入测试断言顺序不变、只调用一次且没有排序 warning(`tests/unit/test_chapters_processor.py:1077`)。
- **I4 通过**:乱序 warning 同时记录 moved 数与 LIS 长度(`src/video_transcript_api/llm/processors/chapters_processor.py:789`);生产序列测试断言 `moved 6/17`、`LIS 14/17`(`tests/unit/test_chapters_processor.py:1056`)。
- **I5 通过**:幸存下标成员校验仍在排序前执行(`src/video_transcript_api/llm/processors/chapters_processor.py:742`);去重保留首次(`src/video_transcript_api/llm/processors/chapters_processor.py:771`);title/gist 非空校验(`src/video_transcript_api/llm/processors/chapters_processor.py:749`、`src/video_transcript_api/llm/processors/chapters_processor.py:756`)、首章钳位(`src/video_transcript_api/llm/processors/chapters_processor.py:799`)和章节数量边界(`src/video_transcript_api/llm/processors/chapters_processor.py:1314`)均保留。测试继续覆盖越界、重复、钳位与空 title/gist(`tests/unit/test_chapters_processor.py:890`、`tests/unit/test_chapters_processor.py:970`、`tests/unit/test_chapters_processor.py:1120`)。
- **I6 通过**:指纹仍基于输入 segments 而非章节数组顺序(`src/video_transcript_api/llm/processors/chapters_processor.py:283`、`src/video_transcript_api/llm/processors/chapters_processor.py:1133`);排序后仍先推导 end/time,再合并相邻同名章(`src/video_transcript_api/llm/processors/chapters_processor.py:1328`、`src/video_transcript_api/llm/processors/chapters_processor.py:1332`)。内嵌章节头按 `start_seg` 对齐原始 dialog 下标(`src/video_transcript_api/utils/rendering/dialog_renderer.py:439`、`src/video_transcript_api/utils/rendering/dialog_renderer.py:525`);笔记切片按闭区间下标取原始 segments(`src/video_transcript_api/llm/processors/notes_processor.py:188`、`src/video_transcript_api/llm/processors/notes_processor.py:272`)。生产样本的配对与区间测试通过。
- **I7 通过**:Coordinator 把 `ChaptersResult.error` 放入 stats(`src/video_transcript_api/llm/coordinator.py:332`、`src/video_transcript_api/llm/coordinator.py:388`);llm_ops 顶层优先、stats 兜底后传给状态写入(`src/video_transcript_api/api/services/llm_ops.py:1990`、`src/video_transcript_api/api/services/llm_ops.py:2489`);CacheManager 将非 None 值写入合并状态(`src/video_transcript_api/cache/cache_manager.py:2062`)。测试读取实际写出的 `llm_status.json` 并断言失败状态与错误字段(`tests/unit/test_cache_manager.py:1797`)。
- **I8 通过**:CacheManager 在写入 error 后按 `chapters_status == "generated"` 强制删除该字段(`src/video_transcript_api/cache/cache_manager.py:2064`、`src/video_transcript_api/cache/cache_manager.py:2070`);测试覆盖 FAILED 补跑清除及 caller 误传仍清除(`tests/unit/test_cache_manager.py:1810`、`tests/unit/test_cache_manager.py:1824`)。
- **I9 通过**:未触碰章节层时 `chapters_status` 与 `chapters_error` 都从撤销前快照回填(`src/video_transcript_api/api/services/llm_ops.py:2396`、`src/video_transcript_api/api/services/llm_ops.py:2410`);resummarize 接线测试断言两者均保留(`tests/unit/test_chapters_pipeline_wiring.py:282`)。generate_notes 只更新 `notes_status`(`src/video_transcript_api/api/services/llm_ops.py:473`),CacheManager 的 None 合并语义保留旧 error,且有实际文件断言(`tests/unit/test_cache_manager.py:1842`)。
- **I10 通过**:字段仍只在非 None 时更新,并且读改写全程处于 `media_lock` 内(`src/video_transcript_api/cache/cache_manager.py:2031`、`src/video_transcript_api/cache/cache_manager.py:2054`);未触碰测试验证旧状态与 error 均保留(`tests/unit/test_cache_manager.py:1842`)。

## 必查项与验证

- 测试断言抽查:新增排序测试用具体的序列、配对、区间、warning 数值断言;落盘测试读取实际 JSON。未发现恒真断言。
- 静默失败候选扫描:新增生产行未命中吞异常、异常后返回空值/成功等候选;扫描唯一命中 `if not is_sorted` 是正常排序分支。
- 新符号检查:两个统计 helper 只有一个生产调用方;已作为上方 P2 记录。
- `git diff --check 30466452..819b6f84`:通过,退出码 0。
- `uv run pytest tests/unit/test_chapters_pipeline_wiring.py tests/unit/test_cache_manager.py tests/unit/test_chapters_processor.py -p no:warnings`:`261 passed in 2.68s`。
- 主干 CI 基线:卡面记录 `gh api request failed`;继承红未能判定。本轮本地 Narrow-Verify 未观察到新红。
- OCR 前置扫描:`skipped`。OCR 工具可用,但卡面禁止有费用的网络调用,本轮未请求外部模型;该 skipped 不等同于扫过且干净。

## 下一轮输入

无。审查时远端实现分支仍指向冻结 H0 `819b6f8435d191ba3fad9734f5d757d28295521e`,未发现 H0 之后的新提交。

## Backlog

无其他与本轮 diff 相关的存量问题。
22 changes: 22 additions & 0 deletions src/video_transcript_api/api/services/llm_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,13 @@ def _save_llm_results(
or stats.get("chapters_source_kind")
or "none"
)
# chapters_error:章节层失败原因原文(协调器把它写进 stats["chapters_error"],
# GENERATED 时为 None、FAILED 时为 ChaptersResult.error)。顶层优先、stats 兼底,
# 两者都没有则视为本轮无失败信息(None → save_llm_status 合并语义不更新;
# generated 状态下的旧错误清除由 save_llm_status 内部强制完成)。
chapters_error = result_dict.get("chapters_error")
if chapters_error is None:
chapters_error = stats.get("chapters_error")

# 保存 LLM 模型配置到数据库
if models_used:
Expand Down Expand Up @@ -2391,6 +2398,20 @@ def _save_llm_results(
if effective_chapters_status is not None
else old_llm_status.get("chapters_status")
)
# chapters_error 回填(与上面 final_* 同构,三分支语义):
# 1) 本轮尝试章节层(effective 非 None)→ 用本轮值;GENERATED 时本轮值
# 为 None,旧错误清除由 cache_manager 内部 GENERATED⇒pop 强制完成,
# 此处不变动该不变式;
# 2) 本轮未触碰章节层(resummarize / generate_notes 等)→ 回填旧值:
# 上面 write-ahead 撤销已删除磁盘旧状态文件,此处若传 None,
# save_llm_status 的合并语义无旧值可保,历史失败诊断会被静默丢失,
# 违反「FAILED 时 llm_status.json 含 chapters_error」的落盘契约;
# 3) 旧值缺失(存量数据无 chapters_error 字段)→ None,与现状一致。
final_chapters_error = (
chapters_error
if effective_chapters_status is not None
else old_llm_status.get("chapters_error")
)

if calibrated_saved or summary_saved:
def record_saved_artifact(layer: str, filename: str) -> Optional[dict]:
Expand Down Expand Up @@ -2465,6 +2486,7 @@ def record_saved_artifact(layer: str, filename: str) -> Optional[dict]:
"calibration_stats": final_calibration_stats,
"summary_status": final_summary_status,
"chapters_status": final_chapters_status,
"chapters_error": final_chapters_error,
}
if final_artifact_provenance or old_artifact_provenance is not None:
status_updates["artifact_provenance"] = final_artifact_provenance
Expand Down
14 changes: 14 additions & 0 deletions src/video_transcript_api/cache/cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,7 @@ def save_llm_status(
chapters_status: Optional[str] = None,
notes_status: Optional[str] = None,
artifact_provenance: Optional[Dict[str, Any]] = None,
chapters_error: Optional[str] = None,
) -> Dict[str, Any]:
"""写入/合并 llm_status.json("诚实状态模型"统一落盘文件)。

Expand All @@ -2019,6 +2020,9 @@ def save_llm_status(
skipped_no_timeline/failed/pending/disabled),None 表示不更新
artifact_provenance: 校对/总结来源记录;None 表示不更新,空字典表示清除
整个可选记录,非空字典在同一次原子状态写入中替换旧记录
chapters_error: 章节层失败原因原文(ChaptersResult.error),None 表示
不更新;此外 chapters_status=generated 时无论是否传入都会强制
清空旧 chapters_error(见下方合并逻辑注释)

Returns:
dict: 锁内完成写入后的完整合并快照
Expand Down Expand Up @@ -2055,6 +2059,16 @@ def save_llm_status(
existing['summary_status'] = summary_status
if chapters_status is not None:
existing['chapters_status'] = chapters_status
if chapters_error is not None:
existing['chapters_error'] = chapters_error
# GENERATED 蕴含本轮无失败:显式清空历史失败原因。不能只靠
# 「非 None 才更新」的合并语义——从 FAILED 补跑 GENERATED 时
# 本轮 error 为 None,旧失败原因会残留,制造「已生成却显示
# 旧错误」的静默错。放在 error 写入之后:即使 caller 在
# generated 状态下误传了 error 也会被清掉,不变式
# 「generated ⇒ 无 chapters_error」集中锁在这一处。
if chapters_status == "generated":
existing.pop('chapters_error', None)
if notes_status is not None:
existing['notes_status'] = notes_status
if artifact_provenance is not None:
Expand Down
Loading
Loading