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
29 changes: 29 additions & 0 deletions docs/sessions/261017-issue-cleanup/design.md
Original file line number Diff line number Diff line change
@@ -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。
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!-- delegate-outcome: succeeded -->
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/报告目录属于本卡,不作为工作树占用。
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
> 更新:本文件中的初始 clean 结论已被后续发现的跨行 debounce 问题和 [E 结论](E-hover-row-verdict.md) 取代。当前审查范围为 `c29326f09df1f7f7b1d8dcfdd3df53f14b6d620f..5fd502e76ee171cb20753017b1e34e70e7dfd08c`;请以 E 结论为准。

<!-- delegate-outcome: succeeded -->

# 撤权后摘要请求审查结论

审查范围:`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 项结果均由实现代理实际运行。
23 changes: 23 additions & 0 deletions docs/sessions/261017-issue-cleanup/reviews/E-hover-row-verdict.md
Original file line number Diff line number Diff line change
@@ -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 的结果也已通过。
10 changes: 10 additions & 0 deletions e2e/tests/local-upload-full-chain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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`);
Expand Down
42 changes: 40 additions & 2 deletions e2e/tests/local-upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,52 @@ 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) => {
expect(dialog.message()).toContain('确认关闭公开分享?关闭后将停止后续阅读、导出与重新处理');
await dialog.accept();
});

// Click stop share button
await page.clock.install();
await page.clock.pauseAt(new Date());
const summaryRequests = new Map<string, number>();
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<void>((resolve) => { releaseDelete = resolve; });
const deleteStarted = new Promise<void>((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('已停止公开分享');
Expand All @@ -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 }) => {
Expand Down
7 changes: 7 additions & 0 deletions src/video_transcript_api/api/services/transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/video_transcript_api/transcriber/funasr_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions src/video_transcript_api/transcriber/transcriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"):
Expand Down
Loading
Loading