From 7bcc0b6805d82a51d80fdb5eb4e1e1f4ecc2b254 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 23 Aug 2026 10:48:39 +0800 Subject: [PATCH 1/6] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=85=83=E6=95=B0=E6=8D=AE=E5=86=99=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=AE=9A=E7=A8=BF=E4=B8=8E=E6=B5=81=E7=A8=8B=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先锁定 finalize_presentation_metadata 契约及第一次元数据失败后 save_cache 必须写入真标题的 process_transcription 轴测试。 Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260823-024119-f208f4 Task-Id: VideoTranscriptAPI-20260823-01 --- .../test_transcription_flow_regression.py | 65 +++++++++ .../test_finalize_presentation_metadata.py | 129 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests/unit/test_finalize_presentation_metadata.py diff --git a/tests/features/test_transcription_flow_regression.py b/tests/features/test_transcription_flow_regression.py index b2394acc..189f2226 100644 --- a/tests/features/test_transcription_flow_regression.py +++ b/tests/features/test_transcription_flow_regression.py @@ -485,6 +485,71 @@ def test_flow_download_funasr(monkeypatch, patch_runtime): assert saved["use_speaker_recognition"] is True +class FlakyMetadataXiaoyuzhouDownloader: + """First get_metadata fails; later calls return cached real metadata.""" + + def __init__(self): + self._metadata_calls = 0 + self._cached_metadata = None + + def get_metadata(self, url): + self._metadata_calls += 1 + if self._metadata_calls == 1: + import requests + + raise requests.exceptions.ConnectTimeout("Connection timed out") + if self._cached_metadata is None: + self._cached_metadata = VideoMetadata( + video_id="6a89b9b7008ed7314d3acdbe", + platform="xiaoyuzhou", + title="Real Episode Title", + author="Real Author", + description="Real description", + ) + return self._cached_metadata + + def get_download_info(self, url): + self.get_metadata(url) + return DownloadInfo( + download_url="http://example.com/audio.mp3", + file_ext="mp3", + filename="audio.mp3", + ) + + def download_file(self, url, filename): + return "C:/tmp/test.mp3" + + +def test_flow_metadata_retry_before_save_cache(monkeypatch, patch_runtime): + """First metadata probe times out; download stage warms cache; persist sees real title.""" + cache_manager = DummyCacheManager(cache_data=None) + monkeypatch.setattr(transcription, "cache_manager", cache_manager) + + url = "https://www.xiaoyuzhoufm.com/episode/6a89b9b7008ed7314d3acdbe" + downloader = FlakyMetadataXiaoyuzhouDownloader() + monkeypatch.setattr(transcription, "create_downloader", lambda _: downloader) + + result = transcription.process_transcription( + task_id="task_metadata_retry", + url=url, + use_speaker_recognition=True, + wechat_webhook=None, + download_url=None, + metadata_override=None, + ) + + assert result["status"] == "success" + assert result["data"]["video_title"] == "Real Episode Title" + assert result["data"]["author"] == "Real Author" + assert cache_manager.saved + saved = cache_manager.saved[0] + assert saved["title"] == "Real Episode Title" + assert saved["author"] == "Real Author" + assert saved["title"] != "6a89b9b7008ed7314d3acdbe" + assert saved["transcript_type"] == "funasr" + assert saved["use_speaker_recognition"] is True + + def test_flow_separate_download_url(monkeypatch, patch_runtime): cache_manager = DummyCacheManager(cache_data=None) monkeypatch.setattr(transcription, "cache_manager", cache_manager) diff --git a/tests/unit/test_finalize_presentation_metadata.py b/tests/unit/test_finalize_presentation_metadata.py new file mode 100644 index 00000000..b6ad8050 --- /dev/null +++ b/tests/unit/test_finalize_presentation_metadata.py @@ -0,0 +1,129 @@ +"""Unit tests for finalize_presentation_metadata.""" + +from unittest.mock import MagicMock + +import pytest +import requests + +from video_transcript_api.api.services.transcription import ( + finalize_presentation_metadata, +) +from video_transcript_api.downloaders.models import VideoMetadata + + +class _StubDownloader: + def __init__(self, metadata=None, exc=None): + self._metadata = metadata + self._exc = exc + self.calls = [] + + def get_metadata(self, url): + self.calls.append(url) + if self._exc is not None: + raise self._exc + return self._metadata + + +def test_finalize_applies_metadata_override_over_existing(): + downloader = _StubDownloader( + metadata=VideoMetadata( + video_id="id1", + platform="test", + title="Fetched Title", + author="Fetched Author", + description="Fetched Desc", + ) + ) + title, author, description = finalize_presentation_metadata( + downloader=downloader, + url="https://example.com/episode/abc", + metadata_override={"title": "Override Title", "author": "Override Author"}, + title="Existing Title", + author="Existing Author", + description="", + ) + assert title == "Override Title" + assert author == "Override Author" + assert downloader.calls == [] + + +def test_finalize_retries_get_metadata_for_blank_title_and_author(): + downloader = _StubDownloader( + metadata=VideoMetadata( + video_id="id1", + platform="xiaoyuzhou", + title="Real Episode Title", + author="Real Author", + description="Real description", + ) + ) + url = "https://www.xiaoyuzhoufm.com/episode/6a89b9b7008ed7314d3acdbe" + title, author, description = finalize_presentation_metadata( + downloader=downloader, + url=url, + metadata_override=None, + title="", + author="", + description="", + ) + assert title == "Real Episode Title" + assert author == "Real Author" + assert description == "Real description" + assert downloader.calls == [url] + + +def test_finalize_does_not_overwrite_existing_non_empty_title(): + downloader = _StubDownloader( + metadata=VideoMetadata( + video_id="id1", + platform="test", + title="Retry Title", + author="Retry Author", + description="", + ) + ) + title, author, description = finalize_presentation_metadata( + downloader=downloader, + url="https://example.com/watch?v=abc", + metadata_override=None, + title="Already Known Title", + author="", + description="", + ) + assert title == "Already Known Title" + assert author == "Retry Author" + assert downloader.calls == ["https://example.com/watch?v=abc"] + + +def test_finalize_falls_back_to_basename_and_unknown_when_retry_fails(): + downloader = _StubDownloader(exc=requests.exceptions.ConnectTimeout("timed out")) + url = "https://www.xiaoyuzhoufm.com/episode/6a89b9b7008ed7314d3acdbe" + title, author, description = finalize_presentation_metadata( + downloader=downloader, + url=url, + metadata_override=None, + title="", + author="", + description="", + ) + assert title == "6a89b9b7008ed7314d3acdbe" + assert author == "Unknown" + assert description == "" + assert downloader.calls == [url] + + +def test_finalize_swallows_get_metadata_exception(): + downloader = MagicMock() + downloader.get_metadata.side_effect = RuntimeError("boom") + url = "https://example.com/podcast/1" + title, author, description = finalize_presentation_metadata( + downloader=downloader, + url=url, + metadata_override=None, + title="", + author="", + description="", + ) + assert title == "1" + assert author == "Unknown" + downloader.get_metadata.assert_called_once_with(url) From f1ee994b8db722bc5862e775b7a0ac9fc8ca80ce Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 23 Aug 2026 10:48:47 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E5=86=99=E8=BE=B9=E7=95=8C=E5=AE=9A?= =?UTF-8?q?=E7=A8=BF=E5=B1=95=E7=A4=BA=E5=85=83=E6=95=B0=E6=8D=AE=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=BF=87=E6=97=A9=E5=8D=A0=E4=BD=8D=E7=AC=A6?= =?UTF-8?q?=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一次 get_metadata 失败时不再填 URL basename/Unknown;在 save_cache 前通过 finalize_presentation_metadata 复用同实例补拉并仅填空字段。 Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260823-024119-f208f4 Task-Id: VideoTranscriptAPI-20260823-01 --- .../api/services/transcription.py | 123 +++++++++++++++++- 1 file changed, 118 insertions(+), 5 deletions(-) diff --git a/src/video_transcript_api/api/services/transcription.py b/src/video_transcript_api/api/services/transcription.py index 2929af32..9d104925 100644 --- a/src/video_transcript_api/api/services/transcription.py +++ b/src/video_transcript_api/api/services/transcription.py @@ -318,6 +318,79 @@ def merge_metadata(parsed_metadata: Optional[dict], metadata_override: Optional[ return final_metadata +def finalize_presentation_metadata( + *, + downloader: Optional[Any], + url: str, + metadata_override: Optional[dict], + title: str, + author: str, + description: str, +) -> tuple[str, str, str]: + """Persist-boundary resolver for presentation fields. + + 1. Apply non-empty metadata_override fields. + 2. If title or author still blank and downloader is not None, try + downloader.get_metadata(url) and fill blanks only. + 3. If title still blank: extract_filename_from_url(url) or "Untitled". + 4. If author still blank: "Unknown". + get_metadata exceptions are logged and swallowed. + + Args: + downloader: Downloader instance for metadata retry, or None to skip. + url: Platform URL passed to get_metadata on retry. + metadata_override: User-provided metadata overrides. + title: Current presentation title candidate. + author: Current presentation author candidate. + description: Current presentation description candidate. + + Returns: + Tuple of finalized (title, author, description). + """ + resolved_title = title or "" + resolved_author = author or "" + resolved_description = description or "" + + if metadata_override: + filtered_override = { + k: v + for k, v in metadata_override.items() + if v is not None and (not isinstance(v, str) or v.strip()) + } + override_title = filtered_override.get("title") or filtered_override.get( + "video_title" + ) + if override_title: + resolved_title = override_title + if filtered_override.get("author"): + resolved_author = filtered_override["author"] + if "description" in filtered_override: + resolved_description = filtered_override.get("description", "") + + needs_retry = ( + not (resolved_title or "").strip() or not (resolved_author or "").strip() + ) + if needs_retry and downloader is not None: + try: + metadata_obj = downloader.get_metadata(url) + if not (resolved_title or "").strip() and (metadata_obj.title or "").strip(): + resolved_title = metadata_obj.title + if not (resolved_author or "").strip() and (metadata_obj.author or "").strip(): + resolved_author = metadata_obj.author + if not (resolved_description or "").strip() and ( + metadata_obj.description or "" + ).strip(): + resolved_description = metadata_obj.description + except Exception as exc: + logger.warning(f"[presentation metadata] get_metadata retry failed: {exc}") + + if not (resolved_title or "").strip(): + resolved_title = extract_filename_from_url(url) or "Untitled" + if not (resolved_author or "").strip(): + resolved_author = "Unknown" + + return resolved_title, resolved_author, resolved_description + async def verify_token(authorization: str = Header(None), request: Request = None): """ 验证API令牌(支持多用户) @@ -1525,6 +1598,15 @@ def _fail_task_and_notify( if timeline_segments_seed is not None: handoff_payload["timeline_segments"] = timeline_segments_seed + video_title, author, description = finalize_presentation_metadata( + downloader=None, + url=parse_url, + metadata_override=metadata_override, + title=video_title, + author=author, + description=description, + ) + handoff_failure = _handoff_to_llm_stage( task_id, handoff_payload, @@ -1628,12 +1710,19 @@ def _fail_task_and_notify( video_id = final_metadata.get('video_id', video_id) logger.info(f"[元数据合并] 元数据解析成功,metadata_override 作为补充") else: - # 元数据获取失败,使用 metadata_override 或默认值 + # Metadata probe failed: keep only non-empty metadata_override values. final_metadata = metadata_override or {} - video_title = final_metadata.get('title') or extract_filename_from_url(url) or "Untitled" - author = final_metadata.get('author', 'Unknown') - description = final_metadata.get('description', '') - logger.info(f"[元数据合并] 元数据解析失败,使用 metadata_override 或默认值") + video_title = ( + final_metadata.get("title") + or final_metadata.get("video_title") + or "" + ) + author = final_metadata.get("author") or "" + description = final_metadata.get("description") or "" + logger.info( + "[元数据合并] 元数据解析失败,保留 metadata_override 非空字段," + "占位符延后至写边界定稿" + ) media_id = video_id is_from_generic = (platform == 'generic') @@ -1659,6 +1748,24 @@ def _fail_task_and_notify( else: download_downloader = create_downloader(url) + def _finalize_presentation_fields( + downloader_for_retry: Optional[Any] = None, + ) -> None: + nonlocal video_title, author, description + retry_downloader = ( + downloader_for_retry + if downloader_for_retry is not None + else download_downloader or metadata_downloader + ) + video_title, author, description = finalize_presentation_metadata( + downloader=retry_downloader, + url=parse_url, + metadata_override=metadata_override, + title=video_title, + author=author, + description=description, + ) + # ========== YouTube API Server 快速路径 ========== # 如果提供了 download_url,则跳过 API Server,强制使用 download_url 下载 if has_separate_download_url: @@ -1726,6 +1833,7 @@ def _fail_task_and_notify( author=author, ) + _finalize_presentation_fields() # 保存到缓存 cache_result = cache_manager.save_cache( platform=platform, @@ -1821,6 +1929,7 @@ def _fail_task_and_notify( transcript = funasr_result["formatted_text"] transcription_data = funasr_result["transcription_result"] + _finalize_presentation_fields() cache_result = cache_manager.save_cache( platform=platform, url=url, @@ -1853,6 +1962,7 @@ def _fail_task_and_notify( # CapsWriter timeline:Transcriber 已从 # *_funasr.json 读入 funasr_json_data,接通 # extra_json_data 落盘为 transcript_capswriter.json + _finalize_presentation_fields() cache_result = cache_manager.save_cache( platform=platform, url=url, @@ -2026,6 +2136,7 @@ def _fail_task_and_notify( author=author, ) + _finalize_presentation_fields() # 使用新的缓存系统保存平台字幕(有 segments 则写侧车 JSON) cache_result = cache_manager.save_cache( platform=platform, @@ -2205,6 +2316,7 @@ def _fail_task_and_notify( transcript = funasr_result["formatted_text"] transcription_data = funasr_result["transcription_result"] + _finalize_presentation_fields() # 使用新缓存系统保存 cache_result = cache_manager.save_cache( platform=platform, @@ -2253,6 +2365,7 @@ def _fail_task_and_notify( # CapsWriter timeline:接通 funasr_json_data 落盘 # 为 transcript_capswriter.json(缺省时 None 诚实降级) + _finalize_presentation_fields() cache_result = cache_manager.save_cache( platform=platform, url=url, From 39bdfc57e6229e03f3e07f6d5f2ae85d6d10cfbf Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 23 Aug 2026 11:06:44 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20title-finalize=20r1=20=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=AE=A1=E6=9F=A5=20verdict=EF=BC=88pass=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260823-030113-22340c Task-Id: VideoTranscriptAPI-20260823-02 --- .../reviews/r1-verdict.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/sessions/260823-title-finalize/reviews/r1-verdict.md diff --git a/docs/sessions/260823-title-finalize/reviews/r1-verdict.md b/docs/sessions/260823-title-finalize/reviews/r1-verdict.md new file mode 100644 index 00000000..ed57fd9a --- /dev/null +++ b/docs/sessions/260823-title-finalize/reviews/r1-verdict.md @@ -0,0 +1,126 @@ +# r1-verdict:title-finalize 写边界元数据定稿 + +- **审查范围**:`5ce323aad3fbaa3525df959f74a2d873d84d85b5..f1ee994b8db722bc5862e775b7a0ac9fc8ca80ce`(PR #62) +- **审查者**:Cursor 执行器(独立 review 卡 r1) +- **日期**:2026-08-23 +- **risk-tier**:personal(infra/状态机例外,降层三问必答) + +## Verdict + +**pass** — 无未接受的 P1。不变式 I1–I5 在审查范围内成立;`save_cache` 写边界前的定稿逻辑与测试一致。 + +## 本轮新证据 + +(以下均非复读 diff 文本,为审查过程中独立拉取的调用链与命令输出。) + +1. **save_cache 站点全覆盖**:在 head `f1ee994` 上对 `transcription.py` 执行 `grep save_cache|_finalize_presentation`,6 处 `save_cache` 前均有 `_finalize_presentation_fields()`;缓存未命中主路径无遗漏写边界。 +2. **LLM 队列消费字段**:阅读 `llm_ops.py` 中 `process_llm_task` — `video_title` 取自 `llm_task["video_title"]` 用于通知与 `_generate_title_if_needed`;对照 head 上缓存部分命中分支,发现 `handoff_payload` 在 `finalize_presentation_metadata` 之前组装,与 `calibrating_status_kwargs` 可能不一致(见 F2)。 +3. **补拉 downloader 选择**:阅读 `_finalize_presentation_fields` 中 `retry_downloader = download_downloader or metadata_downloader`;当 `has_separate_download_url` 时 `download_downloader` 恒为 `GenericDownloader()`,会优先于 `metadata_downloader`(平台 downloader)用于 `get_metadata` 补拉(见 F3)。 +4. **GenericDownloader 补拉能力**:阅读 `generic.py` `_fetch_metadata` — 依赖 `get_video_info(url)` 解析页面,对小宇宙 episode URL 无专用逻辑,补拉大概率仍得空标题后落 basename 占位。 +5. **红验锚点(卡面预取,本轮用于锁死测试约束力)**:base `5ce323aa` 上拷入 `test_flow_metadata_retry_before_save_cache` 失败,断言标题为 `6a89b9b7008ed7314d3acdbe` 而非 `Real Episode Title`;helper 单测在 base 上 ImportError — 测试确实绑定本次行为而非恒真。 + +## 降层三问(infra 例外) + +### ① 终态写入成功之前已发生哪些不可逆动作? + +| 阶段 | 不可逆动作 | 是否受 finalize 保护 | +|------|-----------|-------------------| +| 第一次 `get_metadata` 失败 | 仅日志;title/author 保持空(新代码) | 是(I1) | +| 下载 / 转录 | 音频落盘、转录文本生成 | 否(设计外) | +| 中途 `notify_task_status` | 通知可能带 interim 标题 | 否(行为层) | +| `save_cache` | video_cache 行 title/author 持久化 | **是** — `_finalize_presentation_fields()` 紧邻其前 | +| 缓存部分命中 → LLM handoff | `CALIBRATING` 状态写入 | `calibrating_status_kwargs` 用 finalize 后变量;`llm_payload` 可能仍用旧值(F2) | + +核心不变式针对的是 **video_cache 持久化标题**;下载/转录不可逆但不在本 PR 范围。 + +### ② 守卫用的值在实际部署形态下自身唯一吗? + +- **写边界守卫**:`finalize_presentation_metadata` 产出的 `(title, author)` _tuple,由当前 downloader 实例内存缓存(`BaseDownloader._metadata_cache` 按 `video_id`)+ 同进程第二次 `get_metadata` 复用。 +- **部署形态**:personal 单实例(n305);同 URL 同任务内单 downloader 实例 — 补拉与下载阶段 warming 共享缓存,唯一性成立。 +- **media_id / platform**:缓存键与任务状态键,单实例下无副本竞争问题。 + +### ③ 保护覆盖的是「写入」还是「行为」? + +- **写入**:`save_cache(..., title=..., author=...)` 前定稿 — **覆盖完整**(6 站点)。 +- **行为**:中途通知、`handoff_payload["video_title"]`(部分缓存命中)、成功响应 `data.video_title`(缓存全命中路径未调用 finalize)— **未全覆盖**;属展示/通知层,不导致本次设计的「错误标题永久写入 video_cache」静默错结果(F2/F4 为 P2)。 + +## 不变式核对 + +| ID | 判定 | 证据 | +|----|------|------| +| I1 | ✅ | 失败分支改为 `""` 而非 basename/Unknown(diff `1628–1636` 段);`test_flow_metadata_retry_before_save_cache` | +| I2 | ✅ | `_finalize_presentation_fields` + `finalize_presentation_metadata` 在全部 `save_cache` 前;单测 `test_finalize_retries_get_metadata_for_blank_title_and_author` | +| I3 | ✅ | override 优先且不触发 downloader;已有非空 title 不被 retry 覆盖 — 单测 `test_finalize_applies_metadata_override_over_existing`、`test_finalize_does_not_overwrite_existing_non_empty_title` | +| I4 | ✅ | `except Exception` 吞掉后仍落 fallback,任务继续 — 单测 `test_finalize_swallows_get_metadata_exception`、流程测试 success | +| I5 | ✅ | 无新 DownloadInfo 字段/质量枚举;`finalize_presentation_metadata` 为同文件函数,非独立模块 | + +## Findings + +### F1 — finalize 内宽 `except Exception`(OCR medium) + +| 维度 | 内容 | +|------|------| +| 工具标注 | OCR minimax `partial`,medium — 宽 except 吞掉所有异常 | +| 本仓判定 | **接受(设计内)** — 对应 I4;infra 卡锁定「补拉异常吞掉不得让任务失败」 | +| P1 两问 | ① 会触发:补拉失败时;② 后果:落 basename/Unknown 占位,任务仍 success — **可接受**(显式 fallback,非静默无结果) | +| 溯源 | I4 | +| 处置 | 不修(本 PR) | + +### F2 — 缓存部分命中:handoff_payload 在 finalize 之前组装(本仓新发现) + +| 维度 | 内容 | +|------|------| +| 工具标注 | — | +| 本仓判定 | **P2** | +| P1 两问 | ① 仅当 finalize 会改变 title(如 `metadata_override` 覆盖缓存标题、或缓存 title 为空走 fallback)且走部分命中 LLM 队列时触发;② LLM 通知/协调器读 `llm_task["video_title"]` 可能短时不一致,但 `video_cache` 已存正确标题 — **后果可接受,非静默持久化错误** | +| 溯源 | I2 行为层缺口 | +| 处置 | backlog:将 finalize 提前到 `handoff_payload` 组装前,或 finalize 后更新 `handoff_payload["video_title"]` | + +### F3 — separate `download_url` 时 GenericDownloader 优先补拉(卡面降层观察 + 本仓核实) + +| 维度 | 内容 | +|------|------| +| 工具标注 | — | +| 本仓判定 | **P2** | +| P1 两问 | ① `download_url` 与平台 URL 分离且首次 `get_metadata` 失败时触发;② GenericDownloader 对小宇宙 URL 难拿真标题,可能仍 basename — **与旧代码「立即冻 basename」同级或略好**(旧路径无任何补拉) | +| 溯源 | I2 补拉 downloader 选择 | +| 处置 | backlog:retry 优先 `metadata_downloader or download_downloader` | + +### F4 — 缓存全命中路径不调用 finalize(本仓新发现) + +| 维度 | 内容 | +|------|------| +| 工具标注 | — | +| 本仓判定 | **P2(接受不修)** | +| P1 两问 | ① 仅历史脏缓存(旧代码写入 basename)全命中时;② 展示仍错但 PR 范围是「防新发」— PR 描述已声明 | +| 溯源 | 非 I2 范围(无新 write) | +| 处置 | 接受;生产脏数据已单独回填 | + +### F5 — 成功路径 author 为空时 finalize 仍会 `get_metadata`(OCR medium) + +| 维度 | 内容 | +|------|------| +| 工具标注 | OCR medium — 成功路径可能再调 get_metadata | +| 本仓判定 | **P3** | +| P1 两问 | ① 仅 title 已填、author 仍空时;② 多一次网络调用,结果正确 — 可接受 | +| 溯源 | I2(填空白字段) | +| 处置 | backlog 可选优化:分字段判断 retry | + +### F6 — f-string 日志与 PEP8 空行(OCR low) + +| 维度 | 内容 | +|------|------| +| 工具标注 | OCR low | +| 本仓判定 | **P3** | +| P1 两问 | 不适用 | +| 处置 | 不修 | + +## 测试与红验 + +- 新增单测 `tests/unit/test_finalize_presentation_metadata.py`(5 条)覆盖 override、retry、不覆盖、fallback、吞异常。 +- 流程回归 `test_flow_metadata_retry_before_save_cache` 覆盖「首次 metadata 超时 → 下载 warming → save_cache 真标题」。 +- 红验:base 上流程测试失败、helper ImportError — 测试非恒真。 + +## 结论摘要 + +本次 diff 守住「展示元数据在写边界定稿」核心不变式:失败路径不再早填占位符,全部 `save_cache` 前统一 `finalize_presentation_metadata`。未发现会导致 **video_cache 错误标题静默持久化** 的 P1。遗留为通知/handoff 行为层与 separate `download_url` downloader 选择,记 P2 backlog,不阻塞合并。 From 00ccb80ac963225085f2582ad0ca210135832837 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 23 Aug 2026 11:14:57 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20title-finalize=20r2=20=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E5=AE=A1=E6=9F=A5=20verdict=EF=BC=88pass=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: kimi Agent-Model: kimi-code/k3-256k Agent-Effort: high Dispatch-Id: dlg-20260823-030951-e75090 Task-Id: VideoTranscriptAPI-20260823-03 --- .../reviews/r2-verdict.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/sessions/260823-title-finalize/reviews/r2-verdict.md diff --git a/docs/sessions/260823-title-finalize/reviews/r2-verdict.md b/docs/sessions/260823-title-finalize/reviews/r2-verdict.md new file mode 100644 index 00000000..52055edc --- /dev/null +++ b/docs/sessions/260823-title-finalize/reviews/r2-verdict.md @@ -0,0 +1,59 @@ +# r2-verdict:title-finalize 反向审查(persist 路径穷举) + +- **审查范围**:`5ce323aad3fbaa3525df959f74a2d873d84d85b5..f1ee994b8db722bc5862e775b7a0ac9fc8ca80ce`(PR #62,冻结范围,不含此后 verdict 提交) +- **审查者**:Kimi 执行器(独立 review 卡 r2,反向视角) +- **日期**:2026-08-23 +- **risk-tier**:personal;P1 仅当「真实使用方式下会把错误标题持久化进 video_cache 且不报错」 +- **本轮唯一问题**:还有没有路径能把 URL basename / `Unknown` **静默写进 video_cache**? + +## Verdict + +**pass** — 反向穷举未发现本 diff 引入或遗留未守的 video_cache 静默写占位符路径。唯一剩余路径(F1,merge_metadata 默认值预填使补拉失效)为存量行为、base/head 完全一致,记 P2 backlog。 + +## 本轮新证据(与 r1 不同的检查) + +r1 正向确认「6 处 save_cache 前有 finalize」;本轮反向穷举「谁能写 video_cache」: + +1. **video_cache 写入点全仓枚举(head f1ee994)**:`git grep INSERT/REPLACE INTO video_cache` 全仓仅 `cache/cache_manager.py:735`(`save_cache` 内)一处 SQL 写入;`save_cache` 全仓调用点仅 `transcription.py` 6 处。`llm/core/cache_manager.py` 的 `video_cache` 只是产物目录名,不写表。 +2. **save_cache 有/无 finalize 全量表**(head 行号,逐一核对上下文): + + | # | save_cache 行 | 场景 | finalize 行 | 紧邻 | + |---|---|---|---|---| + | 1 | 1838 | YouTube API 快路径·平台字幕直用 | 1836 | ✅ | + | 2 | 1933 | YouTube API 快路径·FunASR | 1932 | ✅ | + | 3 | 1966 | YouTube API 快路径·CapsWriter | 1965 | ✅ | + | 4 | 2141 | 平台字幕路径(get_subtitle_result) | 2139 | ✅ | + | 5 | 2321 | 常规下载·FunASR | 2319 | ✅ | + | 6 | 2369 | 常规下载·CapsWriter | 2368 | ✅ | + + YouTube API 快路径(卡面点名方向)3 处 save_cache **不漏点**;独立 `download_url` 路径汇入常规下载流(站点 5/6),同样过 finalize,且 finalize 的 `url=parse_url`(平台页 URL,非 CDN 下载直链),basename 兜底取值正确。 +3. **`_fail_task_and_notify` 是否写 cache**(head 行 857–944):只调 `cache_manager.update_task_status(task_id, FAILED, download_url=..., error_message=...)`,**不传 title/author**;且 `update_task_status` 写的是 `task_status` 表(cache_manager.py:2431 `UPDATE task_status`),不是 video_cache。失败收口无 video_cache 写入。✅ +4. **`merge_metadata` 反向核实(新发现 F1 的证据)**:head 行 303–306,该函数(本 diff 未改)在 `parsed_metadata` 为真但 title/author 为空时,就地填入 `extract_filename_from_url(url) or "Untitled"` 与 `"Unknown"`。再核对 downloader 实现:`xiaoyuzhou.py:224`、`generic.py:767` 均为 `title=info.get("video_title", "")`——**get_metadata 成功但返回空标题是现实可达的**(r1 F3 亦承认 GenericDownloader 对小宇宙 URL「大概率仍得空标题」)。 +5. **task_status 表的 title 写入**(旁证,非 video_cache):`llm_ops.py:468/818`、`transcription.py:1371` 等 `update_task_status(..., title=...)` 写 task_status 表,且 `cache_manager.py:2369` `if title:` 空串跳过;不在本卡 P1 定义(video_cache)范围内。 + +## 降层三问(短答) + +1. **终态写入前有哪些不可逆动作?** 音频落盘、转录文本生成、中途通知;video_cache 的唯一写边界是 `save_cache`(唯一 SQL 入口),6/6 站点 finalize 紧邻其前。 +2. **守卫用的值在部署形态下唯一吗?** finalize 产出 `(title, author)` 依赖同进程 downloader 实例内存缓存;personal 单实例部署,无副本竞争,成立。 +3. **保护覆盖写入还是行为?** 写入侧全覆盖(唯一 SQL 入口 + 6/6 调用点);行为层(中途通知、handoff 字段顺序)r1 已记 P2 并接受,本轮不重开。 + +## Findings + +### F1 — `merge_metadata` 默认值预填使 finalize 补拉失效(本轮新发现) + +| 维度 | 内容 | +|------|------| +| 工具标注 | —(本仓反向穷举发现,非外部工具) | +| 本仓判定 | **P2(backlog)** | +| 触发路径 | `get_metadata` **成功但返回空 title/author**(如 generic 页无 og:title、xiaoyuzhou 解析出空串不抛异常)→ `merge_metadata`(head:303–306,存量未改)就地填入 basename/`"Unknown"` → `finalize_presentation_metadata` 的 `needs_retry` 为 False(title/author 均非空)→ 补拉不发生 → basename/`Unknown` 静默写进 video_cache | +| P1 两问 | ① 真实使用下会触发吗?**可达**(上述 downloader 实现允许成功返空标题);② 后果能否接受?错误标题静默持久化——孤立看命中卡面 P1 定义,**但**该路径 base 与 head 行为逐字节一致,非本 diff 引入或加剧;按 review-discipline「只审本次 diff;存量代码问题直接记 backlog」降为 P2 | +| 溯源 | I1 的覆盖边界:I1 只管「get_metadata 异常」分支,不管「成功返空」分支 | +| 处置 | backlog:后续把 `merge_metadata` 步骤 2 的默认值填充删除或推迟到写边界 finalize(届时 finalize 的 needs_retry 才能对成功返空路径生效)。本 PR 不阻塞 | + +### r1 已登记项(不重开) + +r1 的 F1(宽 except)、F2(handoff_payload 顺序)、F3(GenericDownloader 优先补拉)、F4(全命中不 finalize)均已判 P2/P3 接受不修,本轮核实结论不变,不换措辞重提。 + +## 结论摘要 + +反向问题的答案:**除存量 F1 外,没有路径能把 URL basename / `Unknown` 静默写进 video_cache**。video_cache 有唯一 SQL 写入口,6 个调用点全部 finalize 紧邻;`_fail_task_and_notify`、YouTube API 快路径、平台字幕路径、独立 download_url 路径均无漏点。verdict:**pass**。 From 72bcd57a610fbba132700b209c7e99a59a6dd241 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 23 Aug 2026 11:33:33 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20gate=20=E4=BF=AE=E5=A4=8D=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=A1=A5=E6=8B=89=E4=BC=98=E5=85=88=E7=BA=A7?= =?UTF-8?q?=E4=B8=8E=E7=BC=93=E5=AD=98=20handoff=20=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立 download_url 时优先用平台 metadata_downloader 补拉;缓存部分命中 在组装 handoff_payload 前定稿展示字段。补 separate download_url 流程测试。 Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260823-033133-94f9ed Task-Id: VideoTranscriptAPI-20260823-01 --- .../api/services/transcription.py | 20 +++---- .../test_transcription_flow_regression.py | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/video_transcript_api/api/services/transcription.py b/src/video_transcript_api/api/services/transcription.py index 9d104925..1ff07d88 100644 --- a/src/video_transcript_api/api/services/transcription.py +++ b/src/video_transcript_api/api/services/transcription.py @@ -1575,6 +1575,15 @@ def _fail_task_and_notify( f"load_segments for chapters handoff failed: {segs_exc}" ) + video_title, author, description = finalize_presentation_metadata( + downloader=None, + url=parse_url, + metadata_override=metadata_override, + title=video_title, + author=author, + description=description, + ) + handoff_payload = { "task_id": task_id, "url": url, @@ -1598,15 +1607,6 @@ def _fail_task_and_notify( if timeline_segments_seed is not None: handoff_payload["timeline_segments"] = timeline_segments_seed - video_title, author, description = finalize_presentation_metadata( - downloader=None, - url=parse_url, - metadata_override=metadata_override, - title=video_title, - author=author, - description=description, - ) - handoff_failure = _handoff_to_llm_stage( task_id, handoff_payload, @@ -1755,7 +1755,7 @@ def _finalize_presentation_fields( retry_downloader = ( downloader_for_retry if downloader_for_retry is not None - else download_downloader or metadata_downloader + else metadata_downloader or download_downloader ) video_title, author, description = finalize_presentation_metadata( downloader=retry_downloader, diff --git a/tests/features/test_transcription_flow_regression.py b/tests/features/test_transcription_flow_regression.py index 189f2226..63a1bc21 100644 --- a/tests/features/test_transcription_flow_regression.py +++ b/tests/features/test_transcription_flow_regression.py @@ -550,6 +550,63 @@ def test_flow_metadata_retry_before_save_cache(monkeypatch, patch_runtime): assert saved["use_speaker_recognition"] is True +class SpyingGenericDownloader: + """Generic downloader stub that would poison title if used for metadata retry.""" + + def __init__(self): + self.calls = [] + self.metadata_calls = [] + + def get_metadata(self, url): + self.metadata_calls.append(url) + return VideoMetadata( + video_id="generic-id", + platform="generic", + title="", + author="", + description="", + ) + + def download_file(self, url, filename): + self.calls.append((url, filename)) + return "C:/tmp/direct.mp3" + + +def test_flow_metadata_retry_with_separate_download_url(monkeypatch, patch_runtime): + """Separate download_url must not let GenericDownloader win metadata retry.""" + cache_manager = DummyCacheManager(cache_data=None) + monkeypatch.setattr(transcription, "cache_manager", cache_manager) + + url = "https://www.xiaoyuzhoufm.com/episode/6a89b9b7008ed7314d3acdbe" + metadata_downloader = FlakyMetadataXiaoyuzhouDownloader() + monkeypatch.setattr(transcription, "create_downloader", lambda _: metadata_downloader) + + generic_downloader = SpyingGenericDownloader() + import video_transcript_api.downloaders.generic as generic_module + monkeypatch.setattr(generic_module, "GenericDownloader", lambda: generic_downloader) + + result = transcription.process_transcription( + task_id="task_metadata_retry_separate_url", + url=url, + use_speaker_recognition=True, + wechat_webhook=None, + download_url="http://example.com/audio.mp3", + metadata_override=None, + ) + + assert result["status"] == "success" + assert result["data"]["video_title"] == "Real Episode Title" + assert result["data"]["author"] == "Real Author" + assert generic_downloader.metadata_calls == [] + assert cache_manager.saved + saved = cache_manager.saved[0] + assert saved["title"] == "Real Episode Title" + assert saved["author"] == "Real Author" + assert saved["title"] != "6a89b9b7008ed7314d3acdbe" + assert saved["transcript_type"] == "funasr" + assert saved["use_speaker_recognition"] is True + + def test_flow_separate_download_url(monkeypatch, patch_runtime): cache_manager = DummyCacheManager(cache_data=None) monkeypatch.setattr(transcription, "cache_manager", cache_manager) From 3fae1512d6c2da4db5f037a2ae1dd3e884ad92e3 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sun, 23 Aug 2026 11:43:17 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20finalize=20=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E9=9D=9E=E5=AD=97=E7=AC=A6=E4=B8=B2=20override=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20strip=20AttributeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅接受 strip 后非空的 str 作为 title/author/description;其它类型当空走补拉或 fallback。 Co-authored-by: Cursor Agent-Executor: cursor Agent-Model: composer-2.5 Agent-Effort: unknown Dispatch-Id: dlg-20260823-034136-0bf944 Task-Id: VideoTranscriptAPI-20260823-01 --- .../api/services/transcription.py | 53 +++++++++---------- .../test_finalize_presentation_metadata.py | 29 ++++++++++ 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/video_transcript_api/api/services/transcription.py b/src/video_transcript_api/api/services/transcription.py index 1ff07d88..f8c1b3cd 100644 --- a/src/video_transcript_api/api/services/transcription.py +++ b/src/video_transcript_api/api/services/transcription.py @@ -347,46 +347,45 @@ def finalize_presentation_metadata( Returns: Tuple of finalized (title, author, description). """ - resolved_title = title or "" - resolved_author = author or "" - resolved_description = description or "" + def _accepted_str(value: Any) -> str: + if isinstance(value, str): + stripped = value.strip() + if stripped: + return stripped + return "" + + resolved_title = _accepted_str(title) + resolved_author = _accepted_str(author) + resolved_description = _accepted_str(description) if metadata_override: - filtered_override = { - k: v - for k, v in metadata_override.items() - if v is not None and (not isinstance(v, str) or v.strip()) - } - override_title = filtered_override.get("title") or filtered_override.get( - "video_title" + override_title = _accepted_str(metadata_override.get("title")) or _accepted_str( + metadata_override.get("video_title") ) if override_title: resolved_title = override_title - if filtered_override.get("author"): - resolved_author = filtered_override["author"] - if "description" in filtered_override: - resolved_description = filtered_override.get("description", "") + override_author = _accepted_str(metadata_override.get("author")) + if override_author: + resolved_author = override_author + if "description" in metadata_override: + resolved_description = _accepted_str(metadata_override.get("description")) - needs_retry = ( - not (resolved_title or "").strip() or not (resolved_author or "").strip() - ) + needs_retry = not resolved_title or not resolved_author if needs_retry and downloader is not None: try: metadata_obj = downloader.get_metadata(url) - if not (resolved_title or "").strip() and (metadata_obj.title or "").strip(): - resolved_title = metadata_obj.title - if not (resolved_author or "").strip() and (metadata_obj.author or "").strip(): - resolved_author = metadata_obj.author - if not (resolved_description or "").strip() and ( - metadata_obj.description or "" - ).strip(): - resolved_description = metadata_obj.description + if not resolved_title: + resolved_title = _accepted_str(metadata_obj.title) + if not resolved_author: + resolved_author = _accepted_str(metadata_obj.author) + if not resolved_description: + resolved_description = _accepted_str(metadata_obj.description) except Exception as exc: logger.warning(f"[presentation metadata] get_metadata retry failed: {exc}") - if not (resolved_title or "").strip(): + if not resolved_title: resolved_title = extract_filename_from_url(url) or "Untitled" - if not (resolved_author or "").strip(): + if not resolved_author: resolved_author = "Unknown" return resolved_title, resolved_author, resolved_description diff --git a/tests/unit/test_finalize_presentation_metadata.py b/tests/unit/test_finalize_presentation_metadata.py index b6ad8050..c635114b 100644 --- a/tests/unit/test_finalize_presentation_metadata.py +++ b/tests/unit/test_finalize_presentation_metadata.py @@ -127,3 +127,32 @@ def test_finalize_swallows_get_metadata_exception(): assert title == "1" assert author == "Unknown" downloader.get_metadata.assert_called_once_with(url) + + +def test_finalize_ignores_non_string_override_values(): + url = "https://www.xiaoyuzhoufm.com/episode/6a89b9b7008ed7314d3acdbe" + title, author, description = finalize_presentation_metadata( + downloader=None, + url=url, + metadata_override={"title": 123, "author": None}, + title="", + author="", + description="", + ) + assert title == "6a89b9b7008ed7314d3acdbe" + assert author == "Unknown" + assert description == "" + + +def test_finalize_preserves_str_title_against_non_string_override(): + title, author, description = finalize_presentation_metadata( + downloader=None, + url="https://example.com/episode/abc", + metadata_override={"title": 123, "author": 999}, + title="Already Known Title", + author="Existing Author", + description="", + ) + assert title == "Already Known Title" + assert author == "Existing Author" + assert description == ""