From 250e8e96d80111a0013246e50a9355675d0a2a14 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 12:36:39 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A9=BA=E8=BD=AC?= =?UTF-8?q?=E5=BD=95=E8=BF=9B=E5=85=A5=E6=88=90=E5=8A=9F=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1480f-4908-7eb2-8738-ca17d4f23775 --- docs/sessions/261017-issue-cleanup/design.md | 29 +++ .../api/services/transcription.py | 7 + .../transcriber/funasr_client.py | 3 + .../transcriber/transcriber.py | 4 + .../test_issue243_empty_transcript.py | 212 ++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 docs/sessions/261017-issue-cleanup/design.md create mode 100644 tests/integration/test_issue243_empty_transcript.py diff --git a/docs/sessions/261017-issue-cleanup/design.md b/docs/sessions/261017-issue-cleanup/design.md new file mode 100644 index 00000000..6930c55e --- /dev/null +++ b/docs/sessions/261017-issue-cleanup/design.md @@ -0,0 +1,29 @@ +# 2026-10-17 Issues 收尾设计 + +基线:origin/main 29f24581a8532605413c0aab22faec028deb5e58 +结论来源:Opus 主报告与补充报告、FunASR 空结果 probe、主脑核实的 gate-hub 消费规则。 +目标:今天分三批减少真实故障,并关闭没有本仓用户价值或已修复的 backlog。 + +## 判断原则 + +本项目由个人维护,面向自托管用户。检查真实 producer 与消费结果,在产生错误状态的最窄边界修复;不为维护者私有部署添产品框架。待办必须对应用户价值或明确触发条件。 + +## issue 裁决 + +| Issue / 子项 | 决定 | 依据 | +|---|---|---| +| #243 | 批次 A 修 | 真 FunASR + CacheManager/DB/export probe 证明空 segments 会写缓存、任务进入 calibrating、export 200;LLM 最终 success 由 mock 产生,不算完整链路证据。 | +| #244-1 | 不修,已独立刻画 | 重复排队/coordinator 开销存在,但 inferencer 命中、没有重复外部 LLM 或错名,不值产品改动。 | +| #244-2/#244-7 | 批次 B 修 | top2 固定配额违背停用/大配置;重复计算 top2 可移出说话人循环。 | +| #244-3/#244-4/#244-5/#244-8 | 关闭 not planned | 当前没有受影响结果或数据丢失证据;不宣称 JSON 序列化契约已完整。缓存规则升级后重算可接受。 | +| #244-6 | 关闭已修 | main 已按 evidence_type 对应分区核对 quote。 | +| #259/#199 | 批次 C 修并收尾 | 保留真实 Chromium 阻断门禁,补 GitHub 原生失败 artifact/trace/screenshot,明确长期不接 PWRS 私有服务。 | +| #211/#212 | 关闭不计划做 | 前者是私有 GlitchTip 接收核验且生产收据未知;后者仅外观问题,无收敛设计。 | + +## 三批边界 + +A:FunASR 的 transcribe_sync 格式化后拒绝空/纯空白结果,复用 EmptyTranscriptError;同批检查 YouTube API 平台字幕 parse 后 text 与 segments 都空的路径。FunASR adapter 拦截覆盖两个 ASR 调用点;不要改格式化函数(历史缓存仍读取它)。CapsWriter 契约保持不变;执行者核锁定 SDK 是否保证纯正文非空,若不保证,只在 Transcriber.transcribe 读 txt 后拒绝纯空白。保护跨变体已有缓存。预算目标 200、硬 250 行。 +B:普通说话人基准配额;top2 取 max(基准, 8 条/1200 字符),任一配置为 0 则全员停用,身份 fallback 不调用 LLM;不加负数校验。保留单条 120 字符与全局预算。同步修改示例配置的相关注释。#244-5 关闭不代表 JSON 契约齐全。预算目标/硬上限 200 行。 +C:#259/#199 同一 PR。失败时保留 Playwright trace 与 screenshot,并上传 Actions artifact 7 天;同时更新 AGENTS.md 和浏览器测试文档为无期限本仓政策:真实 Chromium 阻断回归、不接 PWRS。只有本地与 CI artifact 证据通过后,文案才承诺 artifact。不开替代待办,不改跨仓 SOP/registry。预算硬上限 100 行。 + +各批单独 PR,执行者验证并提交 PR;主脑独立 review、ready、完整 gate 后合并并关闭 issue。 diff --git a/src/video_transcript_api/api/services/transcription.py b/src/video_transcript_api/api/services/transcription.py index 8d3425d3..ac3b5d67 100644 --- a/src/video_transcript_api/api/services/transcription.py +++ b/src/video_transcript_api/api/services/transcription.py @@ -1977,6 +1977,13 @@ def _finalize_presentation_fields( # /送 LLM,否则任务成功了正文却是空串。 if not (transcript or "").strip() and yt_api_segments: transcript = _rebuild_text_from_segments(yt_api_segments) + if not (transcript or "").strip(): + return _fail_task_and_notify( + "[youtube-api] subtitles are empty", + notify_status="转录失败", + title=video_title, + author_name=author, + ) yt_api_extra_json = ( {"segments": yt_api_segments} if yt_api_segments diff --git a/src/video_transcript_api/transcriber/funasr_client.py b/src/video_transcript_api/transcriber/funasr_client.py index df56850d..56382f01 100644 --- a/src/video_transcript_api/transcriber/funasr_client.py +++ b/src/video_transcript_api/transcriber/funasr_client.py @@ -32,6 +32,7 @@ from urllib.parse import urlsplit, urlunsplit from loguru import logger +from ..errors import EmptyTranscriptError from ..utils.logging import load_config from ..terminology.terminology_db import get_terminology_db @@ -794,6 +795,8 @@ def transcribe_sync(self, audio_path, output_format="json", force_refresh=False) ) ) formatted_text = self.format_transcript_with_speakers(result) + if not formatted_text.strip(): + raise EmptyTranscriptError() return {"transcription_result": result, "formatted_text": formatted_text} finally: loop.close() diff --git a/src/video_transcript_api/transcriber/transcriber.py b/src/video_transcript_api/transcriber/transcriber.py index 0b996d7a..91d8ed43 100644 --- a/src/video_transcript_api/transcriber/transcriber.py +++ b/src/video_transcript_api/transcriber/transcriber.py @@ -4,6 +4,7 @@ import shutil import time from pathlib import Path +from ..errors import EmptyTranscriptError from ..utils.logging import setup_logger, load_config, ensure_dir from .capswriter_client import CapsWriterClient, Config as ClientConfig @@ -158,6 +159,9 @@ def transcribe(self, audio_path, output_base=None, media_duration=None): logger.info(f"已从文本文件提取转录文本") except Exception as e: logger.warning(f"读取转录文本失败: {str(e)}") + else: + if not result["transcript"].strip(): + raise EmptyTranscriptError() # 处理 FunASR 兼容格式的 JSON 文件 elif file_path_str.endswith("_funasr.json"): diff --git a/tests/integration/test_issue243_empty_transcript.py b/tests/integration/test_issue243_empty_transcript.py new file mode 100644 index 00000000..561265eb --- /dev/null +++ b/tests/integration/test_issue243_empty_transcript.py @@ -0,0 +1,212 @@ +"""Issue #243: empty producer output must not become a successful cache entry.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from capswriter_asr import Transcript +from video_transcript_api.api.services import transcription +from video_transcript_api.cache.cache_manager import CacheManager +from video_transcript_api.downloaders import generic +from video_transcript_api.downloaders.youtube_api_client import YouTubeApiClient +from video_transcript_api.errors import EmptyTranscriptError +from video_transcript_api.transcriber import capswriter_client, transcriber as transcriber_module +from video_transcript_api.transcriber.funasr_client import FunASRSpeakerClient +from video_transcript_api.utils import url_validator + + +def _process_fixture( + tmp_path, monkeypatch, *, route, segments=None, api_result=None, + use_speaker_recognition=True, seed_cache=False, +): + cache = CacheManager(str(tmp_path / "cache")) + media_id = f"issue243-{tmp_path.name}" + url = ( + "https://youtube.com/watch?v=fixture" + if route == "youtube-api" + else f"recorder://fixture/{media_id}" + ) + media = tmp_path / "fixture.mp4" + media.write_bytes(b"local fixture; external audio work is isolated") + if seed_cache: + cache.save_cache( + platform="youtube", url=url, media_id=media_id, + use_speaker_recognition=False, transcript_data="previous valid transcript", + transcript_type="capswriter", title="Existing", author="Fixture", + ) + + task_id = cache.create_task(url=url)["task_id"] + queue = MagicMock() + monkeypatch.setattr(transcription, "cache_manager", cache) + monkeypatch.setattr(transcription, "get_notification_router", lambda: MagicMock()) + monkeypatch.setattr(transcription, "get_inflight_registry", lambda: MagicMock()) + monkeypatch.setattr(transcription, "get_temp_manager", lambda: MagicMock()) + monkeypatch.setattr(transcription, "llm_task_queue", queue) + monkeypatch.setattr(transcription, "_ensure_audio_track", lambda _path: None) + + if segments is not None: + client = FunASRSpeakerClient() + producer_result = client._finalize({"segments": segments, "speakers": []}, 0) + monkeypatch.setattr(transcription, "FunASRSpeakerClient", lambda: client) + monkeypatch.setattr( + client, "transcribe_with_speaker_recognition", + AsyncMock(return_value=producer_result), + ) + + if route == "youtube-api": + class YoutubeDownloader: + use_api_server = True + + def get_metadata(self, _url): + return SimpleNamespace( + video_id=media_id, title="Fixture", author="Fixture", + description="", platform="youtube", + ) + + def fetch_for_transcription(self, _url, _use_speaker_recognition): + return api_result + + monkeypatch.setattr(transcription, "create_downloader", lambda _url: YoutubeDownloader()) + else: + monkeypatch.setattr(url_validator, "validate_url_safe", lambda value: value) + monkeypatch.setattr( + generic.GenericDownloader, + "download_file", + lambda *_args, **_kwargs: str(media), + ) + + result = transcription.process_transcription( + task_id, + url, + download_url=None if route == "youtube-api" else "https://example.invalid/audio", + metadata_override={"title": "Fixture", "author": "Fixture"}, + preparsed_url=SimpleNamespace( + platform="youtube", video_id=media_id, normalized_url=url, is_short_url=False, + ), + processing_options={"calibrate": False, "summarize": False}, + use_speaker_recognition=use_speaker_recognition, + ) + return cache, task_id, media_id, result, queue + + +@pytest.mark.parametrize( + ("route", "segments"), + [ + ("main", []), + ("main", [{"text": " \t "}]), + ("youtube-api", []), + ("youtube-api", [{"text": " \t "}]), + ], + ids=["main-empty", "main-whitespace", "youtube-api-empty", "youtube-api-whitespace"], +) +def test_empty_funasr_fails_before_cache_and_handoff(tmp_path, monkeypatch, route, segments): + api_result = None + if route == "youtube-api": + api_result = { + "video_id": f"issue243-{tmp_path.name}", "video_title": "Fixture", + "author": "Fixture", "description": "", "platform": "youtube", + "need_transcription": True, "audio_path": str(tmp_path / "fixture.mp4"), + } + cache, task_id, media_id, result, queue = _process_fixture( + tmp_path, monkeypatch, route=route, segments=segments, api_result=api_result, + ) + + assert result["status"] == "failed" + task = cache.get_task_by_id(task_id) + assert task["status"] == "failed" + assert "Transcript is empty" in task["error_message"] + assert cache.get_cache("youtube", media_id, use_speaker_recognition=True) is None + queue.put.assert_not_called() + + +@pytest.mark.parametrize("route", ["main", "youtube-api"]) +def test_numeric_funasr_transcript_is_cached_and_handed_off(tmp_path, monkeypatch, route): + api_result = None + if route == "youtube-api": + api_result = { + "video_id": f"issue243-{tmp_path.name}", "video_title": "Fixture", + "author": "Fixture", "description": "", "platform": "youtube", + "need_transcription": True, "audio_path": str(tmp_path / "fixture.mp4"), + } + cache, task_id, media_id, result, queue = _process_fixture( + tmp_path, monkeypatch, route=route, segments=[{"text": "123"}], api_result=api_result, + ) + + assert result["status"] == "success" + assert cache.get_task_by_id(task_id)["status"] == "calibrating" + cached = cache.get_cache("youtube", media_id, use_speaker_recognition=True) + assert cached["transcript_data"]["segments"] == [{"text": "123"}] + assert queue.put.call_args.args[0]["transcript"] == "Unknown:123" + + +def test_empty_funasr_does_not_mask_existing_valid_cache(tmp_path, monkeypatch): + cache, _task_id, media_id, result, queue = _process_fixture( + tmp_path, monkeypatch, route="main", segments=[], seed_cache=True, + ) + + assert result["status"] == "failed" + existing = cache.get_cache("youtube", media_id, use_speaker_recognition=False) + assert existing["transcript_data"] == "previous valid transcript" + assert cache.get_cache("youtube", media_id, use_speaker_recognition=True) is None + assert not list(Path(tmp_path / "cache").rglob("*_funasr.json")) + queue.put.assert_not_called() + + +@pytest.mark.parametrize("srt, expected_text", [("", None), ( + "1\n00:00:01,000 --> 00:00:04,000\n42\n\n2\n00:00:05,000 --> 00:00:08,000\n100\n", + "42 100", +)], ids=["empty-subtitle", "numeric-structured-subtitle"]) +def test_youtube_api_subtitles_require_text_or_preserved_segments( + tmp_path, monkeypatch, srt, expected_text, +): + parsed = YouTubeApiClient.parse_srt_to_subtitle_result(srt) + api_result = { + "video_id": f"issue243-{tmp_path.name}", "video_title": "Fixture", + "author": "Fixture", "description": "", "platform": "youtube", + "need_transcription": False, "transcript": parsed.text, + "transcript_segments": parsed.segments, + } + cache, task_id, media_id, result, queue = _process_fixture( + tmp_path, monkeypatch, route="youtube-api", api_result=api_result, + use_speaker_recognition=False, + ) + + if expected_text is None: + assert result["status"] == "failed" + assert cache.get_task_by_id(task_id)["status"] == "failed" + assert cache.get_cache("youtube", media_id, use_speaker_recognition=False) is None + queue.put.assert_not_called() + else: + assert result["status"] == "success" + assert cache.get_task_by_id(task_id)["status"] == "calibrating" + cached = cache.get_cache( + "youtube", media_id, use_speaker_recognition=False, + ) + assert cached["transcript_data"] == expected_text + assert queue.put.call_args.args[0]["transcript"] == expected_text + + +def test_capswriter_whitespace_token_is_rejected_after_real_file_read(tmp_path, monkeypatch): + output_dir = tmp_path / "capswriter-output" + audio = tmp_path / "fixture.wav" + audio.write_bytes(b"local fixture") + monkeypatch.setattr(transcriber_module, "get_workspace_dir", lambda: str(output_dir)) + monkeypatch.setattr(capswriter_client, "load_config", lambda: {"storage": {}, "capswriter": {}}) + monkeypatch.setattr(capswriter_client.Config, "generate_funasr_compat", False) + monkeypatch.setattr( + capswriter_client, "transcribe_file_sync", + lambda *_args, **_kwargs: Transcript( + text=" ", tokens=[" "], timestamps=[0.0], duration=1.0, + raw={"text_accu": " ", "task_id": "issue243-whitespace"}, + ), + ) + transcriber = transcriber_module.Transcriber( + config={"capswriter": {"server_url": "ws://localhost:6006"}, "storage": {}}, + ) + + with pytest.raises(EmptyTranscriptError, match="Transcript is empty"): + transcriber.transcribe(str(audio), "whitespace") + + assert (output_dir / "fixture.txt").read_text(encoding="utf-8") == " " From 0d03ddc0195e283574cef34c7c85f1498b41c06b Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 13:06:07 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E8=AE=B0=E5=BD=95=20#243=20=E7=A9=BA?= =?UTF-8?q?=E8=BD=AC=E5=BD=95=E7=8B=AC=E5=AE=A1=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1480f-4908-7eb2-8738-ca17d4f23775 --- .../reviews/A-empty-transcript-verdict.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/sessions/261017-issue-cleanup/reviews/A-empty-transcript-verdict.md diff --git a/docs/sessions/261017-issue-cleanup/reviews/A-empty-transcript-verdict.md b/docs/sessions/261017-issue-cleanup/reviews/A-empty-transcript-verdict.md new file mode 100644 index 00000000..7a8f091c --- /dev/null +++ b/docs/sessions/261017-issue-cleanup/reviews/A-empty-transcript-verdict.md @@ -0,0 +1,49 @@ + +failure-visibility: p2-only + +[codex] 接受该 P2 不在本次范围内修复:本次仅覆盖真实 producer 正常产出或成功读回的空/空白正文,并阻止其进入缓存或 LLM handoff;CapsWriter `.txt` 读取 I/O 异常保留为独立存量 P2。 + +## Verdict + +- 判定:发现 1 条 P2;不阻断交付。 +- 严重度:P2。触发需要 CapsWriter 已成功写出正文文件后,Transcriber 读取该文件发生 I/O 错误;个人单机部署中触发面窄,但一旦发生会把空正文作为成功结果继续处理。 +- 交付阻断:否。按 personal 风险等级,本轮空 producer 结果、空白正文和字幕边界均已在最窄写入点拒绝;遗留项只覆盖文件读取故障。 + +## 审查范围 + +固定审查 `29f24581a8532605413c0aab22faec028deb5e58..250e8e96d80111a0013246e50a9355675d0a2a14`,风险等级 personal。核对被审设计文档、三个生产文件、缓存写入与 LLM handoff 的直接调用方,以及新增集成用例。没有读取实现方报告,也没有修改产品、测试或配置。 + +OCR 前置扫描状态为 `reviewed`,主腿返回 1 条候选;下列 P2 经源码核对确认。OCR 仅作初筛,结论按本仓不变式裁决。 + +## 不变式核查 + +1. **FunASR 空 segments/空白文本:通过。** `transcribe_sync` 在真实 formatter 返回后检查格式化正文;空串或纯空白会抛 `EmptyTranscriptError`(`funasr_client.py:797-800`)。主路径和 YouTube API 音频路径分别在 `transcription.py:2518-2527`、`2101-2107` 调用它,异常发生在对应缓存写入和后续 handoff 之前。新增用例覆盖空 segments、空白 segment、两个入口以及数字正文(`test_issue243_empty_transcript.py:94-141`)。 +2. **CapsWriter 空白 token:部分通过,见 P2。** 正常读取成功时,Transcriber 对 `.txt` 正文 `.strip()` 后拒绝空白(`transcriber.py:157-164`);新增用例实际经过 CapsWriter `_save_results` 写文件,再由 Transcriber 读到空格并失败(`test_issue243_empty_transcript.py:191-212`)。未改 token/timestamp 校验契约。读取本身异常时检查未执行,详见 P2。 +3. **YouTube API 平台字幕:通过。** 空正文有可用 segments 时先经 `_rebuild_text_from_segments` 恢复;恢复后仍空则调用既有失败收口,并在缓存写入前返回(`transcription.py:1973-1986`)。新增用例调用真实 SRT parser,覆盖空字幕失败和纯数字 cue 恢复(`test_issue243_empty_transcript.py:157-188`)。 +4. **合法数字正文与其他缓存变体:通过。** 数字 FunASR 正文经 formatter、缓存并交给 LLM handoff;空新结果在 `save_cache` 之前失败,不覆盖另一变体的有效缓存(新增用例:`test_issue243_empty_transcript.py:124-154`)。 +5. **历史读取契约和范围:通过。** 本次 diff 未改 formatter、CacheManager reader 或 export 路径,也未增加新失败状态、重试或兜底框架;只在 producer/Transcriber/字幕消费边界拒绝空正文。 + +## Findings + +### P2 — CapsWriter 正文读取失败会降级为空正文成功 + +- 违反:目标总述“空转写不会被写入成功缓存或交给下游大模型”,以及不变式 2 的 Transcriber 正文失败边界。此触发源是读取异常,不是正常读取到空格 token。 +- 位置:`src/video_transcript_api/transcriber/transcriber.py:160-164`。读取失败分支只记 warning;`result["transcript"]` 初始值仍为空串,而 `txt_path` 已在读取前设定。新增的空白检查只在 `else`(读取成功)分支执行;函数后续只确认 `txt_path` 存在便返回(`:175-181`)。 +- 触发方式:CapsWriter 已返回 `.txt` 路径且写入成功后,令 `open()` 或 `read()` 因文件 I/O 错误而抛异常;Transcriber 捕获异常后返回 `transcript == ""`。调用方随后以该值调用 `save_cache`(`api/services/transcription.py:2575-2596`),再把它放入 LLM handoff payload(`:2636-2651`)。 +- 风险:读取故障会留下 warning 日志,但任务仍可能成功缓存空正文并向下游交付空文本。 +- 处置:P2,不阻断交付。正常 producer 空白结果已被本次改动拒绝;读取异常属于低频 I/O 路径。若后续修复,应让读取失败上抛或在读取循环后统一校验最终正文,避免读取错误被降级为空成功。 + +## 验证与现场 + +- `git diff --check 29f24581a8532605413c0aab22faec028deb5e58 250e8e96d80111a0013246e50a9355675d0a2a14`:通过。 +- 检查约束力:临时在本 verdict 标题行注入尾随空格后,`git diff --check` 以退出码 2 报出该行;复原后未暂存与暂存 diff 检查均退出码 0。 +- 本卡未运行 pytest;指定验证命令仅为 `git diff --check`。 +- CI 未运行。派发基线 API 不可用,因此继承红未能判定;本次没有新增 CI 红的结论。 +- 接手锚点:无交接单。pickup 巡检原文: + + ## 巡检 + open issue 总数:4 + 存活探针不可用 + memory 候审 1 条(死指针 0 / 超龄 0 / 探针失败 1 / 探针出错 0),详单在本机状态目录,时间 2026-10-12T01:04:06Z + +- 全局待处理清单中,VideoTranscriptAPI #263 等待评审且已有负责人;同 dispatch id 的 unit/pane/报告目录属于本卡,不作为工作树占用。 From 061896da5e413494635ff6c7dfea68aa8abeef52 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 13:24:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=A4=E9=94=80?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=90=8E=E7=9A=84=E6=91=98=E8=A6=81=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1480f-4908-7eb2-8738-ca17d4f23775 --- e2e/tests/local-upload-full-chain.spec.ts | 10 ++++++++++ src/web/static/history.html | 2 ++ 2 files changed, 12 insertions(+) diff --git a/e2e/tests/local-upload-full-chain.spec.ts b/e2e/tests/local-upload-full-chain.spec.ts index 4df6367d..1a5dc8d0 100644 --- a/e2e/tests/local-upload-full-chain.spec.ts +++ b/e2e/tests/local-upload-full-chain.spec.ts @@ -374,6 +374,14 @@ test('Chromium uploads raw media through production API, worker, shared ASR/LLM, await expect(row.locator('.task-title')).toContainText('全链路浏览器上传样本'); const stopShare = row.locator('.btn-stop-share'); await expect(stopShare).toBeVisible(); + await page.clock.install(); + await page.clock.pauseAt(new Date()); + let revokedSummaryRequests = 0; + page.on('request', (item) => { + if (item.method() === 'GET' && new URL(item.url()).pathname === '/api/audit/summary') { + revokedSummaryRequests += 1; + } + }); page.once('dialog', async (dialog) => dialog.accept()); const deleteRequest = page.waitForRequest((item) => item.method() === 'DELETE' && new URL(item.url()).pathname === `/api/uploads/${receipt.upload_id}/share`); @@ -384,6 +392,8 @@ test('Chromium uploads raw media through production API, worker, shared ASR/LLM, expect(ownerDelete.headers().authorization).toBe(`Bearer ${OWNER_TOKEN}`); expect(deleteResult.status()).toBe(200); expect((await deleteResult.json()).share_active).toBe(false); + await page.clock.fastForward(301); + expect(revokedSummaryRequests).toBe(0); await expect(page.locator('#authStatus')).toContainText('已停止公开分享'); const deniedFreshRead = await request.get(`${realServer.api_url}/view/${receipt.view_token}?raw=transcript`); diff --git a/src/web/static/history.html b/src/web/static/history.html index 047ed449..e8226e19 100644 --- a/src/web/static/history.html +++ b/src/web/static/history.html @@ -1189,6 +1189,8 @@

Webhook 别名(保存在本地浏览器)

try { await apiFetch(`/api/uploads/${encodeURIComponent(uploadId)}/share`, {}, privateRequestGeneration, 'DELETE'); + clearTimeout(summaryDebounce); + summaryDebounce = null; showStatus('ok', '已停止公开分享'); const btn = document.querySelector(`.btn-stop-share[data-upload-id="${uploadId}"]`); if (btn) { From c29326f09df1f7f7b1d8dcfdd3df53f14b6d620f Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 13:28:21 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=92=A4=E6=9D=83?= =?UTF-8?q?=E6=91=98=E8=A6=81=E8=AF=B7=E6=B1=82=E5=AE=A1=E6=9F=A5=E7=BB=93?= =?UTF-8?q?=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1480f-4908-7eb2-8738-ca17d4f23775 --- .../reviews/D-hover-revoke-verdict.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md diff --git a/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md b/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md new file mode 100644 index 00000000..e1677bb4 --- /dev/null +++ b/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md @@ -0,0 +1,23 @@ + + +# 撤权后摘要请求审查结论 + +审查范围:`5eab9f14b6b644045846c3f53ba277097ba3cbb0..061896da5e413494635ff6c7dfea68aa8abeef52`。 + +## Verdict + +failure-visibility: clean + +本轮已穷举这些不变式轴:成功撤销后取消待执行的摘要 debounce;失败撤销不走成功清理分支;测试冻结时钟并触发 hover,再推进超过 300 ms 检查请求数;撤权后的读取仍为 404 且不泄露正文;现有 console/network 错误断言未变;in-flight 请求不在改动范围;无新增抽象或双路径。 + +未发现问题。stopShare.click() 将指针移入带 data-vt 的行时会排入 debounce;时钟已暂停,撤销成功后推进 301 ms,零摘要请求断言能捕获漏掉取消逻辑的情况。服务端 404 和错误断言仍保留。审查者仅静态反向推演,未执行 base 或 Chromium。 + +结论:可合并(代码审查);实际 Chromium 回归仍是合并前置验证。 + +审查路由记录:native incremental reviewer clean;OCR `reviewed_fallback/deepseek` 完成且 0 finding;主腿结果为 `content_error`。 + +## 新增测试判据 + +- `Chromium uploads raw media through production API, worker, shared ASR/LLM, history, read, notification, and owner revoke`:停住页面时钟,触发 stop-share 按钮的 hover debounce,成功 DELETE 后推进 301 ms,断言撤权后没有摘要 GET。移除 `stopShare` 成功分支的 timer 清理时,断言由预期 0 变成实际 1;对应不变式为成功撤权后不得由 pending hover timer 发起新的摘要请求。 +- 修复后针对该用例 1 passed,完整浏览器套件 `npm test --prefix e2e` 为 19 passed。既有 `test_public_read_summary_exports_and_owner_history_close_without_content_bypass` 针对撤权后 summary 404 的断言 1 passed。 +- 审查者未在 base 或 Chromium 上执行测试;本文件记录的红绿和 19 项结果均由实现代理实际运行。 From 5fd502e76ee171cb20753017b1e34e70e7dfd08c Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 13:41:51 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=A4=E6=9D=83?= =?UTF-8?q?=E4=B8=8D=E5=BD=B1=E5=93=8D=E5=85=B6=E4=BB=96=E8=A1=8C=E6=91=98?= =?UTF-8?q?=E8=A6=81=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1480f-4908-7eb2-8738-ca17d4f23775 --- e2e/tests/local-upload.spec.ts | 42 ++++++++++++++++++++++++++++++++-- src/web/static/history.html | 6 ++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/e2e/tests/local-upload.spec.ts b/e2e/tests/local-upload.spec.ts index 10facef5..d57ec6d5 100644 --- a/e2e/tests/local-upload.spec.ts +++ b/e2e/tests/local-upload.spec.ts @@ -204,7 +204,12 @@ test('history page filters upload tasks, displays retention and share state, and const stopShareBtn = row.locator('.btn-stop-share'); await expect(stopShareBtn).toBeVisible(); const token = await row.getAttribute('data-vt'); - expect(token).toBeTruthy(); + const otherRow = page.locator('.task-row').nth(1); + await expect(otherRow.locator('.task-title')).toContainText('浏览器本地上传样本'); + const otherToken = await otherRow.getAttribute('data-vt'); + const uploadId = await stopShareBtn.getAttribute('data-upload-id'); + const uploadKey = (await (await request.get(`${baseURL}/__e2e__/last-upload`)).json()).idempotency_key; + expect(token && otherToken && uploadId).toBeTruthy(); // Set up dialog confirmation page.once('dialog', async (dialog) => { @@ -212,8 +217,39 @@ test('history page filters upload tasks, displays retention and share state, and await dialog.accept(); }); - // Click stop share button + await page.clock.install(); + await page.clock.pauseAt(new Date()); + const summaryRequests = new Map(); + page.on('request', (item) => { + const url = new URL(item.url()); + if (item.method() === 'GET' && url.pathname === '/api/audit/summary') { + const viewToken = url.searchParams.get('view_token') ?? ''; + summaryRequests.set(viewToken, (summaryRequests.get(viewToken) ?? 0) + 1); + } + }); + let releaseDelete!: () => void, markDeleteStarted!: () => void; + const deleteGate = new Promise((resolve) => { releaseDelete = resolve; }); + const deleteStarted = new Promise((resolve) => { markDeleteStarted = resolve; }); + await page.route(`**/api/uploads/${uploadId}/share`, async (route) => { + markDeleteStarted(); + await deleteGate; + await route.continue(); + }); + const otherSummary = page.waitForResponse((response) => response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/audit/summary', { timeout: 2_000 }).catch(() => null); + const revokeResponse = page.waitForResponse((response) => + response.request().method() === 'DELETE' && new URL(response.url()).pathname === `/api/uploads/${uploadId}/share`); await stopShareBtn.click(); + await deleteStarted; + await otherRow.hover(); + releaseDelete(); + expect((await revokeResponse).status()).toBe(200); + await page.clock.fastForward(301); + expect((await otherSummary)?.status()).toBe(200); + expect(summaryRequests.get(token ?? '') ?? 0).toBe(0); + expect(summaryRequests.get(otherToken ?? '') ?? 0).toBe(1); + await expect(page.locator('#summaryTooltip')).toBeVisible(); + await expect(page.locator('#tooltipContent')).toHaveText('夹具摘要'); // Assert status and updated UI await expect(page.locator('#authStatus')).toContainText('已停止公开分享'); @@ -223,6 +259,8 @@ test('history page filters upload tasks, displays retention and share state, and // Assert backend revoked state directly via API const viewRes = await request.get(`${baseURL}/view/${token}`); expect(viewRes.status()).toBe(404); + const stored = await request.get(`${baseURL}/api/uploads/by-idempotency-key/${uploadKey}`, { headers: { Authorization: 'Bearer browser-fixture-token' } }); + expect((await stored.json()).share_active).toBe(false); }); test('mobile viewport, keyboard selection, and DOM drop submit raw bytes and all-false options', async ({ page, request }) => { diff --git a/src/web/static/history.html b/src/web/static/history.html index e8226e19..6d69b6fd 100644 --- a/src/web/static/history.html +++ b/src/web/static/history.html @@ -1189,8 +1189,6 @@

Webhook 别名(保存在本地浏览器)

try { await apiFetch(`/api/uploads/${encodeURIComponent(uploadId)}/share`, {}, privateRequestGeneration, 'DELETE'); - clearTimeout(summaryDebounce); - summaryDebounce = null; showStatus('ok', '已停止公开分享'); const btn = document.querySelector(`.btn-stop-share[data-upload-id="${uploadId}"]`); if (btn) { @@ -1493,7 +1491,9 @@

Webhook 别名(保存在本地浏览器)

const rowEl = e.currentTarget; // 必须在 handler 内先存引用,setTimeout 后 currentTarget 会被置 null const vt = rowEl.dataset.vt; if (!vt) return; - summaryDebounce = setTimeout(() => fetchSummary(vt, rowEl), 300); + summaryDebounce = setTimeout(() => { + if (rowEl.dataset.vt === vt) fetchSummary(vt, rowEl); + }, 300); } function onRowMove(e) { const tooltip = document.getElementById('summaryTooltip'); From 2b99891eaa8576689d0a97d1a3bce5092322e678 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 13:48:39 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=92=A4=E6=9D=83?= =?UTF-8?q?=E6=82=AC=E5=81=9C=E5=AE=A1=E6=9F=A5=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1480f-4908-7eb2-8738-ca17d4f23775 --- .../reviews/D-hover-revoke-verdict.md | 2 ++ .../reviews/E-hover-row-verdict.md | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 docs/sessions/261017-issue-cleanup/reviews/E-hover-row-verdict.md diff --git a/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md b/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md index e1677bb4..60b622fd 100644 --- a/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md +++ b/docs/sessions/261017-issue-cleanup/reviews/D-hover-revoke-verdict.md @@ -1,3 +1,5 @@ +> 更新:本文件中的初始 clean 结论已被后续发现的跨行 debounce 问题和 [E 结论](E-hover-row-verdict.md) 取代。当前审查范围为 `c29326f09df1f7f7b1d8dcfdd3df53f14b6d620f..5fd502e76ee171cb20753017b1e34e70e7dfd08c`;请以 E 结论为准。 + # 撤权后摘要请求审查结论 diff --git a/docs/sessions/261017-issue-cleanup/reviews/E-hover-row-verdict.md b/docs/sessions/261017-issue-cleanup/reviews/E-hover-row-verdict.md new file mode 100644 index 00000000..90438615 --- /dev/null +++ b/docs/sessions/261017-issue-cleanup/reviews/E-hover-row-verdict.md @@ -0,0 +1,23 @@ +# 撤权 hover debounce 跨行行为审查结论 + +审查范围:`c29326f09df1f7f7b1d8dcfdd3df53f14b6d620f..5fd502e76ee171cb20753017b1e34e70e7dfd08c`。 + +## Verdict + +failure-visibility: clean + +修复在成功撤权后保留其他行已排队的 hover 摘要请求,并在回调执行时核对该行仍持有原分享 token。没有发现生产逻辑回归、额外状态、请求 abort 或 fallback。最初提出的 P2 测试缺口已撤回:该意见漏看已有 full-chain 撤权用例,新增回归检查现已覆盖 A 行撤权后的延迟摘要请求。 + +OCR 结论为 `reviewed_fallback`。存量 detached row 若仍保留 `data-vt` 的情况是已接受的低级 P2,本次不扩展处理范围;in-flight 请求也不 abort。 + +## 不变式轴与检测点 + +| 状态 / 行为 | 检测点 | +|---|---| +| A 行撤权成功后,其待执行 hover timer 不得再发起摘要请求 | [full-chain E2E](../../../../e2e/tests/local-upload-full-chain.spec.ts):暂停时钟、点击 A 的撤权按钮、确认 DELETE 200、推进 301 ms,断言 A 的摘要 GET 数为 0。移除回调 token guard 的红验得到 Expected 0 / Received 1。 | +| A 行撤权期间,B 行仍持有 token 时,B 的 timer 必须保留并成功读取摘要 | [local-upload E2E](../../../../e2e/tests/local-upload.spec.ts):阻塞 A 的 DELETE,hover B,再放行 A;推进 301 ms,断言 A 请求数为 0、B 为 1、B 响应 200 且 tooltip 可见。旧实现红验为 B Expected 1 / Received 0。 | +| 撤权后的读取仍拒绝访问,且撤权确实写入数据库 | [生命周期集成测试](../../../../tests/integration/test_local_upload_lifecycle.py) 保留 summary 404 断言;跨行 E2E 从数据库回读 `share_active=false`。 | +| 浏览器错误检查继续生效 | full-chain 用例保留原有 console/network 错误断言。 | +| 已发出的请求与 detached row 边界 | 本次不增加 abort 或 detached-row 行为;detached row 的接受说明见上。 | + +独立审查者未执行 Chromium。上表中的红绿浏览器证据来自实现代理本地实跑:full-chain 和跨行用例修复后均通过;对应两次红验分别得到 A 的摘要 GET 为 1,以及 B 的摘要 GET 从预期 1 变为实际 0。既有生命周期测试针对撤权 404 的结果也已通过。