diff --git a/AGENTS.md b/AGENTS.md index ca43ac6..d1ba3a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,9 @@ | 测试资产清单、真实模型集成测试 | [docs/testing.md](./docs/testing.md) | | 设计决策与踩坑记录(含实验结论) | [docs/decisions.md](./docs/decisions.md) | | 代码缺陷跟踪与修复验收标准 | [docs/代码审查问题跟踪.md](./docs/代码审查问题跟踪.md) | +| 待办:翻译上下文复用与 ASR 语义纠错 | [docs/待办-翻译上下文复用与语义纠错.md](./docs/待办-翻译上下文复用与语义纠错.md) | +| 字幕重生成的成本/耗时/功率实测 | [docs/实验数据-字幕重生成成本与耗时.md](./docs/实验数据-字幕重生成成本与耗时.md) | +| 字幕审校(定位+定向修复)实验与暂缓结论 | [docs/实验数据-字幕审校定位与修复实验.md](./docs/实验数据-字幕审校定位与修复实验.md) | **开工前先做两件事**:读 [README.md](./README.md) 建立项目认知, 再读 [docs/architecture.md](./docs/architecture.md#关键设计约束北极星不变式) @@ -41,6 +44,17 @@ - 测试与构建也遵循此规则:已知 `uv run pytest` 全量约 70 秒,用后台+轮询, 不要写 `timeout 1800`。 +## 实验与改动许可(强制,2026-09) + +- **未经用户明确许可,禁止编写或修改任何代码**(含 `scripts/`、`nodes/`、`src/`、 + `tests/` 以及 `/tmp` 等目录下的临时脚本)——**单次性实验脚本同样属于此列**。 +- **未经用户明确许可,禁止编写/运行实验**:包括跑数据集、调用模型或 LLM API 做 + 效果验证、调节参数试探、批量重跑等。先说明「要验证什么、打算怎么做、预计代价」, + 等用户同意后再执行。 +- 只读排查不受限:读文件、`rg`/`ls`/`stat`、`git status|log|diff`、查看服务与日志、 + 查询数据库。需要写文件、改代码、跑实验时,一律先请求许可。 +- **一次许可只对当次明确范围生效**,不自动延伸到后续实验、相邻改动或“顺手修一下”。 + ## 开发流程:TDD(红-绿-重构) - 任何新功能/修复必须先写失败测试(红),再实现最小代码让其通过(绿), diff --git a/README.md b/README.md index 35c237b..18d643e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ http://127.0.0.1:8000/docs API 文档 | [docs/testing.md](./docs/testing.md) | 测试结构(按模块目录组织)、测试资产归属、真实模型集成测试 | | [docs/decisions.md](./docs/decisions.md) | 设计决策与踩坑记录(模型选型、glm-ocr 循环、帧号排序等) | | [docs/代码审查问题跟踪.md](./docs/代码审查问题跟踪.md) | 缺陷清单 R01–R08、修复记录与验收标准 | +| [docs/待办-翻译上下文复用与语义纠错.md](./docs/待办-翻译上下文复用与语义纠错.md) | 待办:翻译上下文复用需求、ASR 听岔成句、已知坑与数据现状 | +| [docs/实验数据-字幕重生成成本与耗时.md](./docs/实验数据-字幕重生成成本与耗时.md) | 字幕重生成的成本/耗时/功率实测口径与全量推算 | +| [docs/实验数据-字幕审校定位与修复实验.md](./docs/实验数据-字幕审校定位与修复实验.md) | 字幕审校(定位+定向修复)的实测结果、成本与暂缓结论 | | [docs/VR双目字幕景深与遮挡调查报告.md](./docs/VR双目字幕景深与遮挡调查报告.md) | VR 字幕景深/遮挡方案的调研全文 | | [docs/调研-whisper漏句与decode_full验证.md](./docs/调研-whisper漏句与decode_full验证.md) | whisper 漏句与 decode_full 的实验记录 | | [docs/adaptive_vad.md](./docs/adaptive_vad.md) | 每视频自适应 VAD 调参方案 | diff --git a/docs/decisions.md b/docs/decisions.md index 1f589fd..ab49f29 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -102,8 +102,63 @@ - **并发写**:流水线多线程写产物/进度,SQLite 开 WAL + busy timeout (`WOV_DB_BUSY_TIMEOUT_SECONDS`)。 +## 翻译"会话式长上下文"方案否决 + +- **决定(2026-09 技术评审)**:不实现"翻译阶段复用会话上下文 + 文件夹级共享上下文 + + 150k 触发压缩"方案,见 + [待办-翻译上下文复用与语义纠错.md](./待办-翻译上下文复用与语义纠错.md)。 +- **前提不成立**:该方案预期"长上下文能让模型发现语义不顺从而修掉 ASR 误听",但 + 同一份待办文档记录的两次实验(`subtitle-correction` 节点 ±60s 上下文、加强提示词 + A/B)全部失败。误听句在字面上语法通顺、局部自洽,语境里没有声学证据,模型没有 + 改写依据——加长上下文只增加同类证据。 +- **成本**:会话式每批重发全部历史,输入随批数二次增长;该档 Qwen 在 SiliconFlow + 价格表"缓存价格"列为 `-`(无 cached-input 折扣),前缀缓存无法抵消。按实测口径 + (批均约 880 tokens、其中输入 529 / 输出 350;434 行/素材小时 ≈ 22.5 批/小时) + 推算:1.2 小时视频约 3.3×、3 小时视频约 6.5×、15 小时文件夹约 70×(约 ¥30/文件夹), + 剩余 187 小时素材由 ¥5.5–9.4 升到约 ¥300–400。 +- **阈值踩计费档**:`>150k 触发` 越过 `[128k,+∞)` 档(输入 ¥0.4→¥1.6、输出 + ¥3.2→¥12.8,整请求按该档计价),压缩阈值必须低于 128k,长上下文方案的收益结构 + 与计费结构天然冲突。 +- **与既有设计冲突**:文件夹会话是跨 run 的可变共享状态,与"节点只通过产物 URI + 交换数据、每 run 独立产物目录"的不变式、与批量"非 GPU 阶段并行"的调度成果、与 + "暂停后整节点重跑、不留半成品"的暂停契约都要冲突,需要新增会话存储子系统并扩展 + 暂停/幂等语义。 +- **未决的替代方向**:问题 A 更可能在 ASR 侧修(`word_timestamps` 已产出的词级 + `probability` / `avg_logprob` 目前未被任何节点使用;低置信度片段 + 文件夹级热词 + `initial_prompt` 二次解码等),但**尚未评审决定**,需要时另行提方案与代价。 +- **实测来源**:`data/experiments/regen_plan/app_cloud_run.log`(每批 tokens)、 + [实验数据-字幕重生成成本与耗时.md](./实验数据-字幕重生成成本与耗时.md)、 + SiliconFlow 价格页 `[0,128k)` / `[128k,+∞)` 分档。 +- **字幕审校(定位+定向修复)的实测结论**:见 + [实验数据-字幕审校定位与修复实验.md](./实验数据-字幕审校定位与修复实验.md) + +## 字幕审校(定位+定向修复)方案暂缓 + +- **决定(2026-09)**:不实施"LLM 定位问题 cue + 定向修复该 cue"的翻译审校节点, + 也不改造现有 `subtitle-correction` 节点。实测数据见 + [实验数据-字幕审校定位与修复实验.md](./实验数据-字幕审校定位与修复实验.md)。 +- **有效的部分**:术语/忠实性类问题(词表违背、译文与日文不一致、称呼跳变)定位与 + 修复都成立(例:`阴道`→`小穴`;"生来的里面插着生来的玩具"→"小鸡鸡插进了小穴里"), + 合成正样本定位召回 11/14 = 79%,增量成本约 +¥0.04/素材小时(全量约 +¥7)。 +- **无效的部分**:`字面通顺但语义错`(问题 A,ASR 听岔成正常句子)。实测把中文、 + 日文原文、邻句、系列词表全部给 LLM: + - 定位:只给中文时三条已知问题 cue 召回 0/3;加上日文也只到 1/3 + (三条出问题的共性是**中文读起来完全通顺**,模型没有怀疑动机); + - 修复:三条目标依次改成"声音传送到身体里""继续吃""已经完全进去了", + **全部保留错译**;且出现无依据脑补(`お腹鸣っちゃう`"肚子饿了"→"小穴要湿了")。 +- **原因判定**:不是上下文给得不够,而是**当前 LLM 能力不足以从错误的日文原文 + 反推真实含义**——能依据的日文本身是错的,模型只能沿错译换词。这与待办文档里 + 记录的前两次失败实验(纠错节点、加强提示词 A/B)结论一致。 +- **副产品结论**:① 硬规则(近音匹配)泛化不了——严格口径漏报(目标 0/3), + 宽松口径 48.6% 全是假阳性;② 系列自身的译法并不一致(同一个 `おなほ` 被译成 + "自慰套/手办/小鼻鼻/嘴"),所以"文件夹级上下文"的价值真实存在,但**惯用译名 + 靠挖掘挖不出来**,需要人工或强约束固定。 +- **若将来重开**:方向应从翻译侧挪回 ASR 侧(只审日文、给候选读法;先建场景再对照), + 那里才有声学证据;具体三个可测方向见实验文档第六节。 + ## 相关文档 - 当前生效的参数与协议:[node-protocol.md](./node-protocol.md) +- 实验数据(成本/耗时、审校定位实验):[实验数据-字幕重生成成本与耗时.md](./实验数据-字幕重生成成本与耗时.md)、[实验数据-字幕审校定位与修复实验.md](./实验数据-字幕审校定位与修复实验.md) - 运维语义与批量流程:[operations.md](./operations.md) - 缺陷 ID 与验收标准:[代码审查问题跟踪.md](./代码审查问题跟踪.md) diff --git a/docs/实验数据-字幕审校定位与修复实验.md b/docs/实验数据-字幕审校定位与修复实验.md new file mode 100644 index 0000000..c23bcba --- /dev/null +++ b/docs/实验数据-字幕审校定位与修复实验.md @@ -0,0 +1,173 @@ +# 实验数据:字幕审校(问题定位 + 定向修复) + +本文件记录"用 LLM 定位有问题的字幕 cue,再定向修复该 cue"这一方案的**实测数据与结论**。 +方案背景与需求见 [待办-翻译上下文复用与语义纠错.md](./待办-翻译上下文复用与语义纠错.md), +结论的取舍理由见 [decisions.md](./decisions.md#字幕审校定位定向修复方案暂缓)。 + +**一句话结论**:这套结构对"术语/忠实性"类问题有效且便宜,但对"字面通顺但语义错" +(问题 A:ASR 听岔成正常句子)**无效**——把中文、日文原文、邻句、系列词表全给 +LLM,它既定位不出、也改不动,甚至会把本来正常的句子改坏。判定为**当前模型能力不足**, +暂缓实施。 + +--- + +## 一、口径与素材 + +| 项 | 值 | +| --- | --- | +| 素材 | `/mnt/fnOS/123/kiwvr-886/masex.tv@kiwvr00886_{1,2}_8k.{JA,CN}.srt`(生产产物) | +| 规模 | part1 337 条 cue/part2 504 条 cue;JA 与 CN 条数与时间轴逐条一致 | +| 已知问题样本(正样本) | part2 的 **183 / 184 / 185**(见待办文档问题 A:`ママの声をおなか`、`あくまで`×2) | +| 模型 | SiliconFlow `Qwen/Qwen3.5-35B-A3B`(`enable_thinking=false`,与生产翻译节点一致) | +| 批大小 | 定位 30 条/请求;修复窗口 = 目标 ±2 条;合成正样本窗口 30 条 | +| 词表 | 由**该文件夹自己的产物**挖掘(不调 LLM,见下),严格映射阈值 50% | + +> 词表为什么不用 LLM 生成:直接问"这些成人用语的中文译名"会被模型拒答 +> (实测返回"我无法提供涉及色情内容的信息")。改为从库内已有 `.JA.srt`/`.CN.srt` +> 反推:候选日文词来自静态表 + 通用领域词表,中文译名取"含该词的 cue 里过半数 +> 出现"的中文候选词,否则留空。 + +--- + +## 二、方法(4 步) + +1. **规则层**(对照用):`① 词表违背`(日文用了词表词但译文没体现该译名)、 + `② 近音可疑`(日文里出现与词表词同长度、编辑距离 ≤1 的片段,且译文没体现该译名)。 +2. **定位层 V1**:只给**中文**(`id|时间|译文`)+ 系列词表,让 LLM 找出"与上下文不符、 + 逻辑不通、或与词表不一致"的条目。**这就是"纯 LLM 智力定位"的原设计**。 +3. **定位层 V2**:给 **中文 + 日文原文** + 系列词表,让 LLM 找"译文与日文原意不符 + (含近音听错)、上下文矛盾、与词表不一致"的条目。 +4. **修复层**:只对目标 cue,给 `日文原文 + 现有译文 + ±2 邻句(日文+中文)+ 系列词表`, + 要求返回窗口内**全部 id** 的译文(允许顺带改邻句,禁止增删条目)。 +5. **合成正样本**:把正常译文里的域词替换成中性词(小穴→肚子/声音/苹果…), + 制造"字面通顺但语义错"的样本,测定位召回。 + +--- + +## 三、实测结果 + +### 3.1 定位层(全片 part2,504 条) + +| 变体 | 输入 | 命中(触发率) | 三条目标召回 | 判为"误听/近音"的原因数 | +| --- | --- | --- | --- | --- | +| V1 | 中文 + 词表 | 37/504 = **7.3%** | **0/3** | 0 | +| V2 | 中文 + **日文** + 词表 | 38/504 = **7.5%** | **1/3**(命中 184) | **15** | + +- V1 在 165–195 区间标的是 165/175/176/177/192,**恰好漏掉 183**("那我让妈妈的声音 + 进入你肚子里了哦")——中文通顺时,纯中文审校不会怀疑它。 +- V2 加上日文后模型明显更会往"听错"方向想:把 187 `口紋`、192 `生鼻`、32/46 的 + `生おなか`(判为 `生おなほ` 的误听)都标了出来,**但 183 仍漏**。 +- 差异原因:32/46 的**中文不通**("因为是新鲜的肚子")才触发怀疑;183 的中文是正常人话。 + +### 3.2 规则层(对照,证明硬规则泛化不了) + +| 口径 | 命中率 | 目标召回 | +| --- | --- | --- | +| 等长窗口 + 编辑距离 ≤1 + 映射阈值 50% | 33/841 = 3.9%(part2 3.2%) | **0/3** | +| 放宽到 ±1 长度 + 距离 ≤2 | 409/841 = 48.6% | 3/3(但全是噪声:`まま`→`マット`/`マンゴー`) | + +即:严格则漏,宽松则全是假阳性;**近音匹配只能当候选生成器,不能当判定依据**。 + +### 3.3 合成正样本(V2 定位,14 条) + +| 项 | 值 | +| --- | --- | +| 召回 | **11/14 = 78.6%** | +| 漏掉的 3 条 | `看看肚子`、`时候插在里面`、`就是这里,这个时候`——全是"读起来通顺"的 | + +### 3.4 修复层(输入:日文 + 译文 + ±2 邻句 + 词表) + +| id | 日文 | 改前 | 改后 | 判定 | +| --- | --- | --- | --- | --- | +| 183 | じゃあ、ママの声を**おなか**に入れますね | 那我让妈妈的声音进入你肚子里了哦 | 那我把妈妈的**声音传送**到你**身体**里了哦 | ✗ 仍是"声音" | +| 184 | あくまで、あくまでください | 再吃一个,再吃一个吧 | 继续,继续吃 | ✗ 仍是"吃" | +| 185 | あくまで入ってしまいました | 它已经全都进来了 | 它已经完全进去了 | ✗ 未到"最里面" | +| 51 | 今のマンコの締め付け… | 刚才**阴道**的紧致感不错吗? | 刚才**小穴**的紧致感不错吗? | ✓ 术语统一 | +| 55 | 生の方に生チュートが入っていた | 生来的里面插着生来的玩具 | **小鸡鸡插进了小穴里** | ✓ 大幅改善 | +| 48 | あ、**お腹**鳴っちゃう | 啊,肚子饿了 | **啊,小穴要湿了** | ✗ **改坏了**(脑补,日文无依据) | + +--- + +## 四、成本与耗时 + +| 项 | 值 | +| --- | --- | +| 本次实测 | 71 次调用,输入 67,213 / 输出 12,630 tokens → **约 ¥0.067**(¥0.4/M 入、¥3.2/M 出) | +| 挂钟 | 全流程约 4 分钟(含 17+17 批定位、8 次修复、14 次合成样本) | +| 外推(每素材小时) | 定位约 15 批 + 修复约 25 个窗口 → **约 +¥0.04/素材小时** | +| 全量外推(187 小时待重生成) | **约 +¥7**(相对现状 ¥0.03–0.05/素材小时,约 2 倍) | + +成本不是瓶颈;**触发率**(7.5%)决定成本,也决定"改坏正常句"的风险面。 + +--- + +## 五、结论与原因分析 + +1. **有效面**:术语/忠实性类(`词表违背`、译文与日文不一致、上下文称呼跳变)—— + 修复正确率高(51/55 明显变好),合成正样本召回 79%。这部分即使单独做也有价值。 +2. **无效面(问题 A)**:`字面通顺但语义错`。原因不是"上下文给得不够": + - 定位阶段:中文通顺 ⇒ 模型不怀疑日文中的错词(183 在 V1/V2 都没被标出); + - 修复阶段:唯一可依据的日文原文本身是错的 ⇒ 模型沿错译走,只能换词(肚子→身体), + 不能凭空生成正确含义。这与待办文档里记录的两次失败实验(纠错节点、 + 提示词 A/B)结论一致。 +3. **副作用**:允许改邻句会脑补出新意思(48 从"肚子饿了"改成"小穴要湿了"), + 需要"必须给出日文依据/候选词才允许改动"的硬约束。 +4. **判定**:**当前 LLM 能力不足以完成"从错误的日文原文反推真实含义"这一步**, + 方案暂缓;不再在翻译侧继续做语义纠错。 + +--- + +## 六、未验证的下一步(若将来重开) + +按"换提问对象、换判据"而非"加规则"的思路,三条可测方向: + +1. **不审译文,审日文**:只给日文 + 系列词表,逐条问"这句像不像把某个词听成了另一个 + 常见词?给出候选词"。这样中文的通顺度再也掩盖不了问题(183 的 `おなか` 在 + "本系列 `おなほ` 出现 29 次"的语境下有机会被标出并给出候选 `おなほ`)。 +2. **先建场景再对照**:让 LLM 通读一个场景的日文,总结"在做什么、用了哪些道具/称呼", + 再用这份场景模型审中文(针对"进入肚子里"这类与动作链矛盾的句子)。 +3. **召回优先的提问**:把"找出有问题的条目"改为"逐条给可疑度 0–3 打分 + 理由", + 避免"只有读不通才报"的偏置,用阈值调触发率。 +4. 配套约束:修复必须能指出**日文依据或候选词**才允许改动正文,否则拒绝改写。 + +第 1、2 条本质上是把纠错从**翻译侧**挪回**ASR 侧**(那里才有声学证据)。 + +--- + +## 七、复现方式 + +```bash +# 全流程(规则层 + 定位 V1/V2 + 修复 + 合成正样本;约 71 次调用、¥0.07) +uv run python scripts/probe_subtitle_review_stage0.py --steps rules,locate,repair,synthetic + +# 只跑规则层(不调 LLM,验证脚本行为) +uv run python scripts/probe_subtitle_review_stage0.py --steps rules --out /tmp/stage0 + +# 从原始调用日志重算统计(不重跑、不烧 token) +uv run python scripts/probe_subtitle_review_stage0.py --steps rebuild +``` + +| 产物 | 路径(`data/` 已 gitignore,仅本机存档) | +| --- | --- | +| 每次请求与响应原文(唯一证据源) | `data/experiments/review_stage0/out/calls.jsonl` | +| 本次运行统计 | `data/experiments/review_stage0/out/summary.json` | +| 从日志重建的统计(定位/修复) | `data/experiments/review_stage0/out/rebuilt_summary.json` | +| 挖掘出的词表 / 规则层明细 | `.../out/glossary.json`、`.../out/rules.json` | +| 运行日志 | `data/experiments/review_stage0/run_all.log` | + +素材依赖:媒体库挂载 `/mnt/fnOS/123`;模型与端点来自 `.env`(`LLM_API_BASE`/`LLM_API_KEY`/ +`LLM_MODEL`)。素材缺失时脚本会直接报错退出(一次性实验,不做跳过)。 + +--- + +## 八、实验过程中发现的坑(供后续复用) + +- **两个 SRT 的 id 都从 1 开始**:跨文件统计必须按 `(part, id)` 组键,否则 part1 的 + 同号条目会冒充 part2 的条目,**直接把召回率算错**。 +- **词表映射不能靠共现**:低词频词的共现映射全是噪声(`ビクビク` 被映射成"小穴"); + 必须要求"含该词的 cue 里过半数出现"才采纳,否则词表不可信。 +- **系列自身译法就不一致**:同一个 `おなほ`(本系列出现 29 次)在已有译文里被译成 + "自慰套/手办/小鼻鼻/嘴"等多种说法——这既是文件夹级上下文的价值,也说明 + "惯用译名"本身需要人工或强约束来固定,挖不出来。 +- **统计产物会被后续步骤覆盖**:区分统计(`summary.json`)与原始证据(`calls.jsonl`), + 改口径时用 `--steps rebuild` 从日志重算。 diff --git a/docs/实验数据-字幕重生成成本与耗时.md b/docs/实验数据-字幕重生成成本与耗时.md new file mode 100644 index 0000000..42d5b7e --- /dev/null +++ b/docs/实验数据-字幕重生成成本与耗时.md @@ -0,0 +1,109 @@ +# 实验数据:字幕重生成的成本与耗时 + +本文件汇总 2026-09-18 ~ 09-19 媒体库字幕重生成(`/mnt/fnOS/123`)的**实测**资源与 +费用口径,供后续推算与对比复用。原始数据见文末"数据来源"。 + +--- + +## 一、口径与单价 + +| 项 | 值 | 来源 | +| --- | --- | --- | +| 翻译模型 | SiliconFlow `Qwen/Qwen3.5-35B-A3B`(`enable_thinking=false`) | `.env` 线上配置 | +| 输入单价 | ¥0.400 / M tokens(<128k 档) | 用户提供的价目表 | +| 输出单价 | ¥3.200 / M tokens(<128k 档) | 同上 | +| 电价 | ¥0.5 / 度(kWh) | 用户口径 | +| 本地卡 | RTX 3090(350W 上限,24GB) | `nvidia-smi` | +| 输入/输出拆分 | 按批输入 ≈529 tokens 实测回推,其余计输出 | 单批预检实测 | + +## 二、功率画像(实测) + +| 状态 | GPU 功率 | 说明 | +| --- | --- | --- | +| 空闲(提音/线上翻译阶段) | 19–20 W | GPU 显存 272 MiB | +| 转写(faster-whisper large-v2) | 270–290 W | 显存约 3.6–4.6 GB | +| 本机 LLM(Ollama `qwen3:30b-a3b`) | 348 W | 显存约 21 GB | +| 整机(电表校验) | **413 W 平均** | 第二批样本 58.1 分钟窗口,电表 1.6→2.0 度 = 0.40 kWh | +| 整机非 GPU 部分 | ≈137 W(本地 LLM 轮) / ≈100 W(云端轮估) | 由上面两项相减估算 | + +## 三、三轮样本实测对照 + +| 轮次 | LLM | 素材 | 结果 | 挂钟 | 速度 | GPU 耗电 | API 费用 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| ① `batch_204b2e4f0cf4` | 本地 | 6.54 h / 10 个 | 10/10 | 77.7 min | 11.9 min/素材小时 | 0.375 kWh(均 289.6W,峰 349.2W) | — | +| ② `batch_9ef5d063612b` | 本地 | 6.73 h / 10 个 | 9/10(1 个 API 失败后重试成功) | 58.1 min | 8.6 min/素材小时 | 0.267 kWh(均 275.6W);**电表整机 0.40 kWh** | — | +| ③ `batch_2dff6b79283f` | 云端 | 6.36 h / 10 个 | 10/10 | 41.5 min | 6.5 min/素材小时 | 0.077 kWh(均 111.7W,GPU 空闲占比 64%) | **¥0.186**(124,423 tokens) | + +第三轮明细:翻译 2,761 行 / 143 批 / 纯 LLM 17.4 分钟;输入≈75.6k、输出≈48.8k tokens。 + +## 四、单素材小时指标(可直接乘算) + +| 口径 | 值 | +| --- | --- | +| API(云端轮 ③) | 19,563 tokens/素材小时 → **¥0.0293/素材小时**(每视频 ¥0.0186) | +| API(28 视频修复轮,见五) | 33,136 tokens/素材小时 → **¥0.0504/素材小时** | +| API(NKKVR-175 A 单视频) | 17,400 tokens/素材小时 → **¥0.0258/素材小时** | +| 电耗(云端 + 新流水线) | ≈0.030 kWh/素材小时(GPU 0.021 + 系统 0.009) | +| 电耗(本地 LLM,电表实测) | **0.059 kWh/素材小时** | +| 成本合计(云端方案) | ¥0.015 电费 + ¥0.03–0.05 API = **¥0.045–0.065/素材小时** | + +## 五、28 视频损坏修复轮(2026-09-19) + +| 项 | 值 | +| --- | --- | +| 规模 | 28 个视频 / **16.87 小时**素材(27 个新字幕损坏 + 1 个命名特例) | +| 结果 | 26/28 一次成功;2 个云端 API 600s 读超时失败,重跑后 2/2 成功 | +| 挂钟 | 00:43 → 03:19 = **2.6 小时**(9.2 分钟/素材小时) | +| API | 558,940 tokens → **¥0.850**(输入≈335k / 输出≈224k) | +| 电耗 | GPU 0.598 kWh(均 230W、忙碌占比 80%)→ 整机估 0.858 kWh = ¥0.43 | + +## 六、流水线与 HST 的耗时影响 + +- **新流水线(非 GPU 阶段并行)** 实测重叠:`kiwvr-886/887` 那轮里 B 视频的云端翻译 + (22:51:08→22:52:37)与 A 视频的转写(22:51:08→22:54:42)完全重叠;串行实现下这段 + GPU 会空转。 +- **extract 可与转写并行**:8 个视频提音约 7.8 分钟(GPU 空闲),流水线里被转写掩盖。 +- **HST(`hallucination_silence_threshold=2.0` + `word_timestamps`)** 让转写变慢: + 同一段 120 秒音频 **15.4s → 25.4s(1.8×)**;换来片头 120 秒无对话段的字幕条数 + 15 → 4(且无套话幻觉残留)。 + +| 阶段(云端 + HST,8 个视频一组 / 5.1 小时素材) | 实测 | +| --- | --- | +| extract | ≈7.8 分钟(GPU 空闲,可并行) | +| ASR | ≈12.9 分钟 × 1.8(HST) ≈ 23 分钟(GPU 满载) | +| translate | ≈15–17 分钟(GPU 空闲,与 ASR 重叠) | +| ASS | 秒级 | + +## 七、全量推算(2026-09-19 状态) + +待重生成:**349 个 / 187.86 小时素材**(媒体库共 524 个 / 287.27 小时)。 + +| 方案 | API | 电费 | 合计 | 挂钟 | +| --- | --- | --- | --- | --- | +| 云端 + HST(当前配置) | ¥5.5–9.4(按 ¥0.03–0.05/素材小时) | ≈¥2.8 | **¥10–12** | 17–20 小时 | +| 本地 LLM(对照) | — | ¥5.7(11.5 kWh) | ¥5.7 | 28–39 小时 | + +敏感性:HST 关闭 → 电费约 ¥1.6、挂钟约 11 小时;HST 实测倍数升到 2.2× → 电费 ¥3.4、 +挂钟约 21 小时。若翻译改成"会话式长上下文"(见 +[待办-翻译上下文复用与语义纠错.md](./待办-翻译上下文复用与语义纠错.md)), +**输入 token 会大幅上升,上表 API 一列必须重算**。 + +## 八、测量陷阱 + +- **任务行的 `updated_at` 不是完成时间**:`GET /api/batch/jobs*` 会触发 + `sync_batch_job_progress` 重写该字段;请用日志的"批量任务 … 完成"行、明细的 + `updated_at`,或 `data/experiments/regen_plan/app_cloud_run.log` 的时间戳。 +- GPU 采样用 `nvidia-smi --query-gpu=... -l 15`,能量按"采样均值 × 窗口时长"积分; + 窗口边界要与起止时间对齐(脚本按时间过滤 CSV 行)。 + +## 九、数据来源(本机) + +| 内容 | 路径 | +| --- | --- | +| 全量实测报告(9 节,含根因与 A/B) | `data/experiments/regen_plan/REPORT.md` | +| 数据量扫描与逐条明细 | `data/experiments/regen_plan/plan_2026-09-01.json`、`sample10*.txt` | +| 应用的运行日志(含每批 tokens 与阶段时序) | `data/experiments/regen_plan/app_cloud_run.log` | +| GPU 功耗采样 | `data/experiments/regen_plan/gpu_power_*.csv` | +| 质量对照(新旧字幕指标) | `data/experiments/regen_plan/quality_review.md` | +| 提示词快照(做实验用) | `data/experiments/regen_plan/prompt_dump/` | +| 资源调度设计笔记 | `data/experiments/regen_plan/design_gpu_resource_scheduling.md` | diff --git a/docs/待办-翻译上下文复用与语义纠错.md b/docs/待办-翻译上下文复用与语义纠错.md new file mode 100644 index 0000000..74de2ed --- /dev/null +++ b/docs/待办-翻译上下文复用与语义纠错.md @@ -0,0 +1,179 @@ +# 待办:翻译上下文复用与语义纠错 + +本文件记录**尚未解决**的问题与用户给出的需求规格,供后续对话接着做。 +已完成的改动见 git 历史(分支 `master` 上的 `fix/review-improvements` 合并结果)。 + +--- + +## ⛔ 方案状态:已技术评审,**不实施** + +「一、需求规格」与「五、落地这个需求时需要先想清楚的点」所描述的**会话式长上下文 +方案经技术评审否决,后续不要再按本方案修改代码**。结论与依据见 +[decisions.md](./decisions.md#翻译会话式长上下文方案否决): + +1. **前提不成立**:需求 5 假设"长上下文能让模型发现并改掉语义错",但本文件自己 + 记录的两次实验(纠错节点 ±60s 上下文、加强提示词 A/B)全部失败。错因是 ASR + 听岔、语境里没有声学证据——加长上下文只是增加同类证据,不能修掉问题 A。 +2. **成本放大 3×~70×**:会话式每批原样重发全部历史(随批数二次增长),该档 Qwen + 在 SiliconFlow 无 cached-input 折扣(价格表缓存列为 `-`);按实测口径推算, + 剩余 187 小时素材的 API 费用从 ¥5.5–9.4 涨到约 ¥300–400。 +3. **压缩阈值踩计费档**:`>150k 触发` 跨过 `[128k,+∞)` 档(输入 ¥0.4→¥1.6、 + 输出 ¥3.2→¥12.8,整请求按 4× 计价),阈值应低于 128k。 +4. **与现有不变式/机制冲突**:文件夹级可变共享状态(节点只经产物 URI 交换、每 + run 独立产物目录)、批量非 GPU 阶段并行(共享会话要求按文件夹串行)、"暂停后 + 整节点重跑、不留半成品"的暂停语义,都要新增子系统并改动语义。 + +本文件**保留为需求与问题记录**(问题 A/B/C、需要知道的坑、数据现状仍然有效)。 +问题 A 的可行方向(ASR 侧低置信度 + 文件夹级热词二次解码等)**尚未评审、未决定**, +需要修时另行提出方案与代价。 + +--- + +## 一、需求规格(用户 2026-09-19 明确) + +1. **翻译阶段要复用对话**:不再每批(20 条)都是无状态独立请求,而是把同一视频的 + 多批放进同一个会话上下文里,让模型能看到前面译过的内容。 +2. **文件夹级共享上下文**:同一文件夹下的多个视频(同番号多集)共用一个上下文, + 让各集之间复用信息(人名、称呼、场景设定等)。 +3. **术语稳定**:因为共用上下文,整片/整文件夹的人名、专名、称呼能固定下来, + 不因批次不同而漂移。 +4. **上下文压缩规则**(小模型上下文有限): + - 上下文占用 **超过 150k** 时触发压缩; + - 压缩对象主要是**前 100k**:调用 LLM 对前 100k 做总结,**重点保留名字、场景**等信息; + - **后 50k 原样保留**,避免丢失"与当前高度相关"的近期信息。 +5. 预期收益:模型在长上下文里更容易发现"语义不顺/与前后矛盾"的句子,从而修掉 + 下面的问题 3(ASR 听岔成"成句但语义错")。 + +--- + +## 二、现状(代码事实,2026-09-19 核对) + +| 事实 | 位置 | +| --- | --- | +| 翻译按 `CHUNK_SIZE=20` 分块,**每块一次独立请求**:请求体只有 `system` + `user` 两条消息,无历史、无跨批信息 | `nodes/llm.py::translate_lines` / `_translate_batch` / `_call_llm` | +| 动态提示词**按批计算**:本批正文命中词表才追加规则;同一视频其它批次命中的词不会带过来 | `nodes/llm.py::_translate_batch` + `nodes/proper_nouns.py::build_proper_noun_rule` | +| 一批的"上下文"只有本批 20 条(约 1–2 分钟对话) | 同上 | +| 译文**整批返回并入库**(不是只取目标句):`_parse_translations` 要求返回的 id 集合与请求完全一致,缺项/多项/重复/空文本都判失败并重试 3 次 | `nodes/llm.py` | +| 纠错节点是**逐条调用**、输出中文译文(不是改日语),未接入任何工作流 | `nodes/subtitle_correction.py`(`workflows/*.json` 中无引用) | + +--- + +## 三、问题清单 + +### 已解决(供参考,不必再动) + +| # | 问题 | 处理 | 证据 | +| --- | --- | --- | --- | +| 1 | whisper 重复伪影(30 秒被同一单元填满;短时 3.7 秒填 111 个假名) | 清洗规则删除(长条 ≥15s 且重复 ≥6 次;或重复 ≥20 次) | `nodes/subtitle_cleanup.py::remove_repetition_entries` | +| 2 | 静音段幻觉(`こんにちは`/`おはようございます`/`東京都交通局8800形電車`…) | 开启 `hallucination_silence_threshold=2.0` + `word_timestamps`(工作流 v3);片头 120 秒由 15 条降到 4 条 | 见 `data/experiments/regen_plan/REPORT.md` 第九节 | +| 3 | 批量任务被误标 COMPLETED(明细写入竞态) | 任务先以 `CREATING` 入库,明细写完才置 QUEUED;启动清理遗留 | `src/wov_app/batch.py::create_job` | +| 4 | 历史产物损坏:CN 里出现"无正文的 cue",下一条的序号+时间轴被吸进正文(ASS 同源损坏,播放器加载失败) | 全库 112 个:85 个旧字幕随重跑修好;27 个新字幕 + 1 个命名特例已单独重做 | 见本文件"四、数据现状" | + +### 未解决(本次对话的核心遗留) + +**问题 A:ASR 把词听岔成"语法通顺但语义错"的句子,中文照字面直译** + +实例(`kiwvr-886/masex.tv@kiwvr00886_2_8k.mp4`,00:15:45–00:16:20): + +| 产品里的 JA(错) | 复核后的 JA(两个模型一致) | 现译(错) | 实际含义 | +| --- | --- | --- | --- | +| `じゃあ、ママの声をおなかに入れますね` | `じゃあ、まままんこにおなほに入れますね` | 那我让妈妈的声音进入你肚子里了哦 | 那妈妈用小穴(把它)套进去哦 | +| `写真してもこんなにカチカチなんですね` | `射精してもこんなにカチカチなんですね` | 也会变得这么僵硬呢 | 就算射了也还是这么硬呢 | +| `あくまで、あくまでください` | `奥まで、奥までください` | 再吃一个,再吃一个吧 | 再深一点,再深一点 | +| `あくまで入ってしまいました` | `奥まで入ってしまいました` | 它已经全都进来了 | 已经顶到最里面了 | +| `生鼻を使わさせていただきますね` / `口紋かでもご奉仕` / `生おなほ` / `おなほまんこで` | 均为 オナホ/マンコ 一类词的走音 | — | — | + +已实测**失败**的修法(不要再重复试): + +| 尝试 | 结果 | +| --- | --- | +| 纠错节点(`Qwen/Qwen3.6-35B-A3B` 与 `Qwen/Qwen3.5-35B-A3B`,±60s 上下文) | 均输出"那我把妈妈的声音放进肚子里哦"——**没改对** | +| 加强提示词 A:"语义不合常理/与动作链矛盾即判为误听" | 仍保留"声音" | +| 加强提示词 B:"上下文反复出现 オナホ/マンコ/チンポ;同位置的怪词应改写" | 仍保留"声音"(只把"身体/肚子"换了个说法) | +| 审校节点:LLM 定位问题 cue + 定向修复(输入日文原文 + 现有译文 + ±2 邻句 + 系列词表) | 定位召回 0/3(仅中文)~ 1/3(加日文);修复依次输出"声音传送到身体里""继续吃""已经完全进去了",**仍全部保留错译**,另有脑补改坏 | + +上述失败实验的完整数据(定位触发率/合成正样本召回/逐条修复前后对照)与 +原因分析见 [实验数据-字幕审校定位与修复实验.md](./实验数据-字幕审校定位与修复实验.md)。 + +**问题 B:翻译无跨批/跨视频上下文**(对应上面"需求规格"要解决的对象)。 +直接后果:一批里同时出现多处坏转写时(`写真しても`、`あくまで`×2),模型既无整片 +术语线索、也看不到前后动作链,只能按字面翻译。 + +**问题 C:`_call_llm` 不重试网络错误**。实测云端 API 偶发"挂住 600 秒读超时" +(2/634 批,两批集中在同一分钟),直接把整段翻译判失败、视频白跑一遍。 +用户明确选择**暂不修**,留待后续决策;全量 349 个约 5000 次调用,按此失败率会有 +10–20 个视频需要重跑(重跑方式:建库级任务,缺字幕的视频会自动判 PENDING)。 + +### 需要知道的坑 + +- **任务行的 `updated_at` 会被读操作刷新**:`GET /api/batch/jobs*` 会触发 + `sync_batch_job_progress` 重算并写回时间戳,统计耗时请用日志的"批量任务 … 完成"行 + 或明细的 `updated_at`,不要用任务行。 +- **产物损坏的根因**:某一步"只删正文行、留下空 cue",后续解析器把下一条的 + 序号+时间轴当成正文吸走。产出端(当前 pipeline)已验证不会再产生:`serialize_srt` + 对空正文会写空行,`clean_srt_text` 也能正确处理空文本 cue。 +- **`savr-1054/…_1_8k` 的产物名曾带 `666` 后缀**(`…_1_8k666.CN.srt`),批量引擎按 + "文件名含视频主名"仍能匹配,但严格按 `<视频名>.CN.srt` 匹配的脚本/播放器会漏掉。 + +--- + +## 四、数据现状(2026-09-19) + +- 媒体库:`/mnt/fnOS/123`(NAS `192.168.123.199:/vol1/1000/123`),524 个视频 / 287.27 小时。 +- 待重生成(CN 字幕早于 2026-09-01):**349 个 / 187.86 小时 / 1417.8 GiB**。 +- 已按新管线重做:20 个样本 + NKKVR-175 两个 + 28 个损坏修复 = 50 个视频。 +- 每个视频现有产物:`<视频名>.JA.srt`(ASR 后的日语转写)、`<视频名>.CN.srt`、 + `<视频名>.CN_dual_eye.ass`;旧字幕备份为 `*.old-20260918` / `*.corrupt-20260918`。 + +--- + +## 五、落地这个需求时需要先想清楚的点(新对话用;**本节方案已否决,不再作为实施依据**) + +1. **会话形态**:把 `messages` 从"system+user"扩成多轮,是把**上一批的 JSON 原文与译文** + 作为 assistant/user 消息追加,还是只追加"术语表 + 摘要"?前者上下文增长快(每批 ~800 + tokens),后者省但信息有损。 +2. **对齐与重试**:现有实现靠"每批独立、id 对齐、失败重试 3 次"保证不错位;改成会话后 + 某批失败/重试会污染会话历史,需要明确回滚策略(例如失败批不写入历史)。 +3. **压缩触发与实现**:`>150k` 触发、总结前 100k、保留后 50k。要确定 token 口径 + (用接口返回的 usage 还是本地估算)、总结用哪个模型(同模型/更便宜的模型)、 + 总结产物如何缓存复用(同一文件夹复用意味着总结要能跨视频复用)。 +4. **文件夹级共享**:按文件夹串行处理是前提(当前批量引擎是分组的,同组视频可能并行), + 否则两个视频同时读写同一会话会互相污染;还要定义文件夹内视频的处理顺序。 +5. **暂停/断点续跑**:run 恢复时上下文是否重建?重建口径(从已有 CN.srt 复原历史?)。 +6. **成本与限额**:长上下文会显著抬高输入 token(每批都带上历史),要重算 + `docs/实验数据-字幕重生成成本与耗时.md` 里的单价;注意不少接口对超长输入有倍率价。 +7. **与现有清洗/词表的关系**:`subtitle_cleanup`(重复伪影/寒暄幻觉/短呻吟)与 + `proper_nouns`(词表规则)仍然保留,会话上下文是补充而非替代。 +8. **验收用例**:至少覆盖本次的 `kiwvr-886_2` 00:15:45–00:16:20 那段——目标句应译成 + "那妈妈用小穴(把它)套进去哦"这类含义,而不是"声音进入肚子";同时不能把正常句 + 改坏(例如呻吟/短句不应被"脑补"成新意思)。 + +## 六、问题 A 的阶段性结论:暂缓(模型能力不足) + +已实测过两条路子,均不能解决问题 A: + +1. **翻译侧审校**(LLM 定位问题 cue + 定期修复:输入日文原文 + 译文 + ±2 邻句 + + 系列词表)。实测:定位召回 0/3(只给中文)~ 1/3(加上日文);修复后仍保留错译, + 且出现无依据脑补改坏正常句。**有效的只有术语/忠实性类**(如 `阴道`→`小穴`)。 +2. **硬规则定位**(词表违背 + 近音匹配):严格口径目标召回 0/3,宽松口径命中率 + 48.6% 全是假阳性——硬规则泛化不了,不作为定位手段。 + +原因:`字面通顺但语义错`的句子中文本身读得通,模型没有怀疑动机;即使怀疑, +可依据的日文原文也是错的,模型只能沿错译换词。**判定为当前 LLM 能力不足,暂缓。** + +实测数据、成本与复现方式: +[实验数据-字幕审校定位与修复实验.md](./实验数据-字幕审校定位与修复实验.md)。 +若将来重开,应把方向挪回 **ASR 侧**(只审日文/给候选读法/先建场景再对照), +那里才有声学证据;三个可测方向见该文档第六节。 + +## 七、相关代码与文档 + +- 翻译节点:`nodes/llm.py`(`translate_lines` / `_translate_batch` / `_call_llm` / `_system_prompt`) +- 审校实验脚本(一次性,已归档):`scripts/probe_subtitle_review_stage0.py` +- 词表规则:`nodes/proper_nouns.py`(`PROPER_NOUNS` / `ONOMANOPOEIA` / `ADULT_EUPHEMISMS`) +- 纠错节点(原型,未接入):`nodes/subtitle_correction.py` +- 清洗规则:`nodes/subtitle_cleanup.py` +- 批量引擎:`src/wov_app/batch.py`、资源门控 `src/wov_app/resources.py` +- 决定性样本与提示词快照:`data/experiments/regen_plan/prompt_dump/` +- 全量实测记录:`data/experiments/regen_plan/REPORT.md` +- 成本与耗时:`docs/实验数据-字幕重生成成本与耗时.md` diff --git a/scripts/probe_subtitle_review_stage0.py b/scripts/probe_subtitle_review_stage0.py new file mode 100644 index 0000000..0f6a17f --- /dev/null +++ b/scripts/probe_subtitle_review_stage0.py @@ -0,0 +1,654 @@ +"""字幕审校(问题定位 + 定向修复)可行性探测脚本(一次性实验,已归档)。 + +用途:在真实产物(库内已有的 `.JA.srt` / `.CN.srt`)上验证三件事—— +① LLM 能否定位"译文有问题"的 cue(只给中文 / 中文+日文原文两档对照); +② 给足日文原文 + 邻句 + 系列词表后,定向修复能否把目标 cue 改对; +③ 合成正样本(把域词译文替换成中性词)的定位召回率。 +另含 ④ 规则层(词表违背 / 近音匹配)对照,用于说明"硬规则泛化不了"。 + +结论与实测数据见 docs/实验数据-字幕审校定位与修复实验.md;本脚本保留以便复现, +不参与生产流程,也没有对应测试(不是功能模块)。运行方式: + + uv run python scripts/probe_subtitle_review_stage0.py --steps rules,locate,repair,synthetic + uv run python scripts/probe_subtitle_review_stage0.py --steps rebuild # 从 calls.jsonl 重算统计 + +产物默认写到 `data/experiments/review_stage0/out/`(data/ 已 gitignore): +`calls.jsonl`(每次请求与响应原文,唯一的证据源)、`summary.json`、 +`rebuilt_summary.json`、`glossary.json`、`rules.json`。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import re +import sys +import time +import unicodedata +import urllib.error +import urllib.request +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) + +from dotenv import load_dotenv # noqa: E402 + +from nodes.proper_nouns import ( # noqa: E402 + ADULT_EUPHEMISMS, + ONOMATOPOEIA, + PROPER_NOUNS, +) +from nodes.srt import parse_srt # noqa: E402 + +load_dotenv(ROOT / ".env") + +# 决定性样本所在文件夹(日语 ASR + 中文译文均已由生产流程产出)。 +DEFAULT_FOLDER = Path("/mnt/fnOS/123/kiwvr-886") +PARTS = (1, 2) +# 已记录的"语义错但字面通顺"样本(待办文档问题 A):part2 的 183/184/185。 +TARGET_IDS = {2: [183, 184, 185], 1: []} +# 定位层每批条数(与生产 CHUNK_SIZE=20 同量级,便于成本外推)。 +LOCATE_CHUNK = 30 +# 修复窗口:目标 ±N 条。 +REPAIR_WINDOW = 2 +# 合成正样本条数。 +SYNTHETIC_COUNT = 15 + +DEFAULT_OUT = ROOT / "data/experiments/review_stage0/out" + +API_BASE = os.getenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions") +API_KEY = os.getenv("LLM_API_KEY", "") +MODEL = os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B") +TIMEOUT = float(os.getenv("LLM_TIMEOUT_SECONDS", "180")) + +# 运行期全局:输出目录、素材文件夹、token 累计。 +OUT = DEFAULT_OUT +FOLDER = DEFAULT_FOLDER +TOTAL_TOKENS = {"in": 0, "out": 0, "calls": 0, "failed": 0} +CALL_LOG = DEFAULT_OUT / "calls.jsonl" + + +def log(msg: str) -> None: + """带时间戳打印进度(脚本长期后台运行,便于 tail 观察)。""" + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +# --------------------------------------------------------------------------- +# LLM 调用(与生产 nodes/llm.py 同构:enable_thinking=False、记录 usage) +# --------------------------------------------------------------------------- + +def call_llm(system: str, user: str, tag: str, max_tokens: int = 2048) -> str: + """调用一次 OpenAI 兼容接口,返回 content;失败重试 3 次后抛异常。""" + body = { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "enable_thinking": False, + "max_tokens": max_tokens, + } + headers = {"Content-Type": "application/json"} + if API_KEY: + headers["Authorization"] = f"Bearer {API_KEY}" + last_error = None + for attempt in range(3): + started = time.monotonic() + try: + request = urllib.request.Request( + API_BASE, data=json.dumps(body).encode("utf-8"), + headers=headers, method="POST", + ) + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + payload = json.loads(response.read().decode("utf-8")) + content = payload["choices"][0]["message"]["content"] + usage = payload.get("usage") or {} + TOTAL_TOKENS["in"] += int(usage.get("prompt_tokens", 0) or 0) + TOTAL_TOKENS["out"] += int(usage.get("completion_tokens", 0) or 0) + TOTAL_TOKENS["calls"] += 1 + with CALL_LOG.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({ + "tag": tag, "elapsed": round(time.monotonic() - started, 2), + "usage": usage, "system": system, "user": user, + "content": content, + }, ensure_ascii=False) + "\n") + return content + except (urllib.error.URLError, OSError, ValueError, KeyError) as exc: + last_error = exc + log(f" ! {tag} 第 {attempt + 1} 次失败: {exc}") + time.sleep(2 + 2 * attempt) + TOTAL_TOKENS["failed"] += 1 + raise RuntimeError(f"{tag} 调用失败: {last_error}") + + +def parse_json_loose(content: str): + """从模型输出里抠出 JSON(容忍 ```json 围栏与前后解释文字)。""" + text = content.strip() + fenced = re.search(r"```(?:json)?\s*(.+?)```", text, re.S) + if fenced: + text = fenced.group(1).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + pass + for opener, closer in (("[", "]"), ("{", "}")): + start, end = text.find(opener), text.rfind(closer) + if start != -1 and end > start: + try: + return json.loads(text[start:end + 1]) + except json.JSONDecodeError: + continue + raise ValueError(f"无法解析 JSON: {text[:200]}") + + +# --------------------------------------------------------------------------- +# 数据与词表 +# --------------------------------------------------------------------------- + +def secs(timestamp: str) -> float: + """SRT 时间戳 -> 秒。""" + hours, minutes, rest = timestamp.split(":") + seconds, millis = rest.split(",") + return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000 + + +def load_pairs(part: int) -> list[dict]: + """读取同一视频的 JA/CN 字幕,按位置配成 [{'id','start','ja','cn'}]。""" + ja = parse_srt((FOLDER / f"masex.tv@kiwvr00886_{part}_8k.JA.srt").read_text(encoding="utf-8")) + cn = parse_srt((FOLDER / f"masex.tv@kiwvr00886_{part}_8k.CN.srt").read_text(encoding="utf-8")) + assert len(ja) == len(cn), f"part{part} 条数不一致: {len(ja)} vs {len(cn)}" + return [ + {"id": i, "start": a.start, "t": secs(a.start), "ja": a.text, "cn": b.text} + for i, (a, b) in enumerate(zip(ja, cn), 1) + ] + + +def kana_norm(text: str) -> str: + """片假名折成平假名并去掉标点,用于近音比较。""" + out = [] + for ch in unicodedata.normalize("NFKC", text): + code = ord(ch) + # 片假名区(ァ-ヶ)平移到平假名区(ぁ-ゖ)。 + if 0x30A1 <= code <= 0x30F6: + out.append(chr(code - 0x60)) + elif ch in "、。!?…「」『』()()・,.:;!?  \n\t-—ー": + continue + else: + out.append(ch) + return "".join(out) + + +def levenshtein(a: str, b: str) -> int: + """字符级编辑距离。""" + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + cur = [i] + for j, cb in enumerate(b, 1): + cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))) + prev = cur + return prev[-1] + + +# 通用领域词(不依赖具体素材;用来从文件夹自己的字幕里挖出"本系列惯用译名")。 +GENERIC_TERMS = [ + "チンポ", "チンコ", "マンコ", "まんこ", "おまんこ", "おなほ", "オナホ", + "ちんちん", "バナナ", "マンゴー", "乳首", "金玉", "クリトリス", "おっぱい", +] +# 用于从中文译文里反推"本系列惯用译名"的中文候选词池。 +CN_POOL = [ + "小穴", "阴部", "肉棒", "鸡巴", "老二", "蛋蛋", "睾丸", "乳头", + "小鸡鸡", "鲍鱼", "下面", "妹妹", "蜜穴", "胸部", "阴蒂", +] + + +def near_miss(ja_text: str, term: str) -> list[dict]: + """在 JA 里找与词条【同长度、编辑距离 ≤1】的窗口(近音听错候选)。 + + 只用等长窗口 + 距离 ≤1:允许 ±1 长度与距离 ≤2 会让「まま」匹配上「マット」 + 这类毫无关系的片段,实测命中率高达 49%,无法作为判定依据。 + """ + target = kana_norm(term) + ja = kana_norm(ja_text) + hits = [] + size = len(target) + if size < 3 or size > len(ja): + return hits + for start in range(0, len(ja) - size + 1): + window = ja[start:start + size] + if window == target: + continue + distance = levenshtein(window, target) + if 0 < distance <= 1: + hits.append({"window": window, "distance": distance}) + return hits + + +def build_glossary(parts: dict[int, list[dict]], min_ratio: float = 0.5) -> list[dict]: + """从文件夹自己的字幕挖词表:候选日文词 + 由中文译文反推的惯用译名。 + + 不调 LLM(成人语境词表容易被模型拒答),完全由库里已有产物推导;"惯用译名" + 是这一系列实际用过的说法,正是文件夹级上下文要固定的东西。min_ratio 控制 + 映射可信度:只在含该词的 cue 里过半数出现的译名才被接受,否则留空 + (低词频词的共现映射噪声很大,如 ビクビク 会被映射成"小穴")。 + """ + all_cues = [cue for cues in parts.values() for cue in cues] + blob = "\n".join(cue["ja"] for cue in all_cues) + candidates = list(GENERIC_TERMS) + for table in (PROPER_NOUNS, ONOMATOPOEIA, ADULT_EUPHEMISMS): + candidates += list(table) + glossary = [] + for term in dict.fromkeys(candidates): + count = blob.count(term) + if count == 0: + continue + votes: Counter = Counter() + for cue in all_cues: + if term in cue["ja"]: + for word in CN_POOL: + votes[word] += cue["cn"].count(word) + cn = "" + if votes: + word, votes_count = votes.most_common(1)[0] + if votes_count >= max(2 if min_ratio > 0 else 1, count * min_ratio): + cn = word + glossary.append({"term": term, "cn": cn, "count": count}) + return glossary + + +# --------------------------------------------------------------------------- +# 定位层 +# --------------------------------------------------------------------------- + +LOCATE_SYSTEM = "你是成人向视频字幕的审校助手。只输出 JSON,不要解释。" + +LOCATE_RULES_V1 = ( + "下面是某系列成人视频某一片段的中文字幕(格式 `id|时间|译文`)。\n" + "请找出【译文有问题】的条目:\n" + "1. 译文与上下文明显不符、逻辑不通或自相矛盾;\n" + "2. 与前后条目的动作链/称呼不连贯;\n" + "3. 与本系列常用词表不一致(词表见下)。\n" + "注意:呻吟、短语气词、碎片句本身【不算】问题,不要把读得通的正常句列进来。\n" + "词表(日文→本系列惯用中文):{glossary}\n" + '只输出 JSON:{{"flags":[{{"id":数字,"why":"一句话原因"}}]}},无问题输出 {{"flags":[]}}。\n\n' + "字幕:\n{lines}" +) + +LOCATE_RULES_V2 = ( + "下面是某系列成人视频某一片段的字幕(格式 `id|时间|日文原文|现有中文译文`)。\n" + "请对照日文原文找出【译文有问题】的条目:\n" + "1. 译文与日文原意不符(含近音听错导致的语义错);\n" + "2. 译文与上下文矛盾、称呼/术语前后不一致;\n" + "3. 与本系列常用词表不一致(词表见下)。\n" + "注意:呻吟、短语气词、碎片句本身【不算】问题;日文通顺但语义可疑的句子可以列入。\n" + "词表(日文→本系列惯用中文):{glossary}\n" + '只输出 JSON:{{"flags":[{{"id":数字,"why":"一句话原因"}}]}},无问题输出 {{"flags":[]}}。\n\n' + "字幕:\n{lines}" +) + + +def glossary_table(glossary: list[dict], limit: int = 40) -> str: + """词表渲染成提示词片段:带惯用译名的写映射,未固定的只给高频日文词。""" + items = [] + for entry in glossary[:limit]: + if entry["cn"]: + items.append(f'{entry["term"]}→{entry["cn"]}(本系列 {entry["count"]} 次)') + else: + items.append(f'{entry["term"]}(本系列高频 {entry["count"]} 次,译名未固定)') + return ";".join(items) + + +def classify_rules(cue: dict, glossary: list[dict]) -> list[dict]: + """规则层判定一条 cue:① 词表违背(字面命中但译文没体现)② 近音可疑。 + + 两个规则都要求中文译文没体现该词的惯用译名,否则"本该出现却未出现"就 + 失去约束力(实测只看近音匹配时命中率高达 49%)。 + """ + findings = [] + for entry in glossary: + term, cn = entry["term"], entry["cn"] + if not cn: + continue + if term in cue["ja"]: + if cn not in cue["cn"]: + findings.append({"kind": "词表违背", "term": term, "expected": cn}) + continue + for hit in near_miss(cue["ja"], term): + if cn not in cue["cn"]: + findings.append({ + "kind": "近音可疑", "term": term, "expected": cn, + "window": hit["window"], "distance": hit["distance"], + }) + return findings + + +def locate(cues: list[dict], glossary: list[dict], variant: str, tag: str) -> dict: + """按 LOCATE_CHUNK 分批做问题定位,返回 {id: why}。""" + table = glossary_table(glossary) + template = LOCATE_RULES_V1 if variant == "v1" else LOCATE_RULES_V2 + flags: dict[int, str] = {} + chunks = [cues[i:i + LOCATE_CHUNK] for i in range(0, len(cues), LOCATE_CHUNK)] + for index, chunk in enumerate(chunks, 1): + lines = [] + for cue in chunk: + if variant == "v1": + lines.append(f'{cue["id"]}|{cue["start"]}|{cue["cn"]}') + else: + lines.append(f'{cue["id"]}|{cue["start"]}|{cue["ja"]}|{cue["cn"]}') + prompt = template.format(glossary=table, lines="\n".join(lines)) + try: + content = call_llm(LOCATE_SYSTEM, prompt, f"{tag}-chunk{index}") + payload = parse_json_loose(content) + except (ValueError, TypeError, RuntimeError) as exc: + log(f" ! {tag} 第 {index} 批失败: {exc}") + continue + items = payload.get("flags") if isinstance(payload, dict) else payload + if not isinstance(items, list): + continue + for item in items: + if isinstance(item, dict) and isinstance(item.get("id"), int): + flags[item["id"]] = str(item.get("why", ""))[:120] + log(f" {tag} 第 {index}/{len(chunks)} 批完成,累计命中 {len(flags)} 条") + time.sleep(0.3) + return flags + + +# --------------------------------------------------------------------------- +# 修复层 +# --------------------------------------------------------------------------- + +REPAIR_SYSTEM = "你是成人向视频字幕的译者与审校。只输出 JSON,不要解释。" + +REPAIR_TEMPLATE = ( + "这是某系列成人视频的一段字幕窗口(含日文原文与你此前的译文)。\n" + "审校意见:id={target} 的译文与语境不符,需要给出更合适的中文译文。\n" + "要求:\n" + "- 必须结合日文原文与上下文推断语义,不要照字面直译;\n" + "- 只改有问题的条目;为了让上下文连贯,可以顺带修正窗口内相邻条目," + "但不得把本来正常的条目改坏;\n" + "- 条目数量、id 集合必须与输入完全一致,不得新增、删除或合并条目;\n" + "- 本系列常用词与惯用译名:{glossary}\n" + '只输出 JSON 数组:[{{"id":整数,"text":"译文"}}, ...],包含窗口内全部 id。\n\n' + "窗口:\n{lines}" +) + + +def repair(cues: list[dict], target_id: int, glossary: list[dict]) -> dict[int, str]: + """对目标 cue 所在的 ±REPAIR_WINDOW 窗口做定向重译,返回 {id: 新译文}。""" + index = next(i for i, cue in enumerate(cues) if cue["id"] == target_id) + window = cues[max(0, index - REPAIR_WINDOW):index + REPAIR_WINDOW + 1] + table = glossary_table(glossary) + lines = "\n".join(f'{c["id"]}|{c["ja"]}|{c["cn"]}' for c in window) + prompt = REPAIR_TEMPLATE.format(target=target_id, glossary=table, lines=lines) + content = call_llm(REPAIR_SYSTEM, prompt, f"repair-{target_id}") + payload = parse_json_loose(content) + if not isinstance(payload, list): + raise ValueError(f"repair {target_id} 输出不是数组: {content[:120]}") + result = {} + for item in payload: + if not isinstance(item, dict): + continue + key, text = item.get("id"), item.get("text") + if type(key) is int and isinstance(text, str) and text.strip(): + result[key] = text.strip() + expected = {c["id"] for c in window} + if set(result) != expected: + raise ValueError(f"repair {target_id} id 集合不符: 期望 {sorted(expected)} 实得 {sorted(result)}") + return result + + +# --------------------------------------------------------------------------- +# 合成正样本 +# --------------------------------------------------------------------------- + +NEUTRAL_WORDS = ["声音", "肚子", "照片", "芒果", "香蕉", "苹果", "时候", "地方"] + + +def make_synthetic(cues: list[dict], glossary: list[dict]) -> list[dict]: + """把含域词的正常译文的域词替换成中性词,制造"字面通顺但语义错"的正样本。""" + pairs = [(g["term"], g["cn"]) for g in glossary if g["cn"] and len(g["cn"]) >= 2] + samples = [] + for cue in cues: + if len(samples) >= SYNTHETIC_COUNT: + break + if len(cue["ja"]) < 4 or len(cue["cn"]) < 4: + continue + for jp, cn in pairs: + key = cn.split("、")[0].split(";")[0].strip() + if key and jp in cue["ja"] and key in cue["cn"]: + corrupted = cue["cn"].replace(key, random.choice(NEUTRAL_WORDS), 1) + if corrupted != cue["cn"]: + samples.append({ + "id": cue["id"], "ja": cue["ja"], + "original": cue["cn"], "corrupted": corrupted, + "term": jp, "replaced": key, + }) + break + return samples + + +# --------------------------------------------------------------------------- +# 从原始调用日志重算统计(步骤:rebuild) +# --------------------------------------------------------------------------- + +def rebuild() -> dict: + """从 calls.jsonl 重建定位/修复统计,避免为改口径而重跑烧 token。""" + records = [ + json.loads(line) + for line in (OUT / "calls.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + locate_flags: dict[str, dict[int, str]] = {"v1": {}, "v2": {}} + for rec in records: + matched = re.match(r"locate-(v1|v2)-chunk\d+", rec["tag"]) + if not matched: + continue + payload = parse_json_loose(rec["content"]) + items = payload.get("flags") if isinstance(payload, dict) else payload + for item in items or []: + if isinstance(item, dict) and isinstance(item.get("id"), int): + locate_flags[matched.group(1)][item["id"]] = str(item.get("why", ""))[:200] + + summary: dict = {} + for variant in ("v1", "v2"): + flags = locate_flags[variant] + summary[f"locate_{variant}"] = { + "flagged": len(flags), + "trigger_rate": round(len(flags) / 504, 4), + "target_hits": {str(t): flags.get(t) for t in TARGET_IDS[2]}, + "recall_on_targets": round( + sum(1 for t in TARGET_IDS[2] if t in flags) / len(TARGET_IDS[2]), 3), + "misheard_reason_count": sum( + 1 for why in flags.values() + if any(word in why for word in ("误听", "听错", "听岔", "近音", "错听"))), + "flags": {str(k): v for k, v in sorted(flags.items())}, + } + repairs = {} + for rec in records: + matched = re.match(r"repair-(\d+)", rec["tag"]) + if not matched: + continue + payload = parse_json_loose(rec["content"]) + if not isinstance(payload, list): + continue + repairs[matched.group(1)] = { + str(item["id"]): item.get("text") for item in payload + if isinstance(item, dict) and isinstance(item.get("id"), int) + } + summary["repair"] = repairs + summary["tokens"] = { + "calls": len(records), + "in": sum(int((r.get("usage") or {}).get("prompt_tokens", 0) or 0) for r in records), + "out": sum(int((r.get("usage") or {}).get("completion_tokens", 0) or 0) for r in records), + } + (OUT / "rebuilt_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + return summary + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + +def main() -> None: + global OUT, FOLDER, CALL_LOG + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--steps", default="rules,locate,repair,synthetic", + help="rules,locate,repair,synthetic,rebuild 的逗号分隔子集") + parser.add_argument("--folder", default=str(DEFAULT_FOLDER), help="含 JA/CN 字幕的视频文件夹") + parser.add_argument("--out", default=str(DEFAULT_OUT), help="产物目录(默认 data/experiments/review_stage0/out)") + parser.add_argument("--map-ratio", type=float, default=0.5, + help="词表映射可信度阈值(0=宽松,0.5=严格)") + args = parser.parse_args() + steps = set(args.steps.split(",")) + OUT = Path(args.out) + FOLDER = Path(args.folder) + OUT.mkdir(parents=True, exist_ok=True) + CALL_LOG = OUT / "calls.jsonl" + random.seed(20260919) + + if steps == {"rebuild"}: + summary = rebuild() + log(f"重建完成: {json.dumps({k: summary[k] for k in ('tokens',)}, ensure_ascii=False)}") + return + + log(f"模型={MODEL} 端点={API_BASE} 素材={FOLDER} 输出={OUT}") + parts = {part: load_pairs(part) for part in PARTS} + all_ja = [c["ja"] for cues in parts.values() for c in cues] + summary: dict = {"model": MODEL, "folder": str(FOLDER), "target_ids": TARGET_IDS} + + glossary = build_glossary(parts, min_ratio=args.map_ratio) + log(f"词表 {len(glossary)} 条: " + ", ".join( + '{t}→{c}({n})'.format(t=g['term'], c=g['cn'], n=g['count']) for g in glossary)) + summary["glossary"] = glossary + (OUT / "glossary.json").write_text( + json.dumps(glossary, ensure_ascii=False, indent=2), encoding="utf-8", + ) + + if "rules" in steps: + log("== 规则层:词表违背 / 近音可疑 ==") + # 键必须带 part:两个文件的 id 都从 1 开始,只用 id 会让 part1 的条目 + # 冒充 part2 的同号条目(会直接算错召回率)。 + rules = {} + for part, cues in parts.items(): + for cue in cues: + findings = classify_rules(cue, glossary) + if findings: + rules[f"part{part}:{cue['id']}"] = { + "part": part, "id": cue["id"], "ja": cue["ja"], + "cn": cue["cn"], "findings": findings, + } + part2_rules = {k: v for k, v in rules.items() if v["part"] == 2} + kinds = Counter(f["kind"] for v in rules.values() for f in v["findings"]) + summary["rules"] = { + "hit_count": len(rules), + "hit_rate": round(len(rules) / len(all_ja), 4), + "part2_hit_rate": round(len(part2_rules) / len(parts[2]), 4), + "kinds": dict(kinds), + "target_hits": { + str(t): rules.get(f"part2:{t}", {}).get("findings") for t in TARGET_IDS[2]}, + "target_recall": round( + sum(1 for t in TARGET_IDS[2] if f"part2:{t}" in rules) / len(TARGET_IDS[2]), 3), + "sample": {k: rules[k] for k in sorted(rules)[:12]}, + } + (OUT / "rules.json").write_text( + json.dumps(rules, ensure_ascii=False, indent=2), encoding="utf-8", + ) + log(f"规则层命中 {len(rules)}/{len(all_ja)} 条(全局 {len(rules) / len(all_ja):.1%}," + f"part2 {len(part2_rules) / len(parts[2]):.1%}),类型 {dict(kinds)};目标命中 " + f"{ {t: f'part2:{t}' in rules for t in TARGET_IDS[2]} }") + + if "locate" in steps: + for variant in ("v1", "v2"): + log(f"== 定位层 {variant}(全片 part2)==") + cues = parts[2] + flags = locate(cues, glossary, variant, f"locate-{variant}") + target_hits = {t: flags.get(t) for t in TARGET_IDS[2]} + summary[f"locate_{variant}"] = { + "flagged": len(flags), + "trigger_rate": round(len(flags) / len(cues), 4), + "target_hits": target_hits, + "recall_on_targets": round( + sum(1 for t in TARGET_IDS[2] if t in flags) / len(TARGET_IDS[2]), 3), + "flags": {str(k): v for k, v in sorted(flags.items())}, + } + log(f"{variant}: 命中 {len(flags)}/{len(cues)} 条(触发率 " + f"{len(flags) / len(cues):.1%}),目标命中 {target_hits}") + + if "repair" in steps: + log("== 修复层:对目标 cue 定向重译 ==") + cues = parts[2] + repairs = {} + # 目标 cue + 定位层 v2 额外标记的条目(观察是否会把正常句改坏)。 + repair_ids = list(TARGET_IDS[2]) + locate_v2 = summary.get("locate_v2", {}).get("flags", {}) + repair_ids += [int(k) for k in locate_v2 if int(k) not in repair_ids][:5] + for target_id in repair_ids: + try: + result = repair(cues, target_id, glossary) + except (ValueError, TypeError, RuntimeError) as exc: + log(f" ! 修复 {target_id} 失败: {exc}") + continue + before = {c["id"]: c["cn"] for c in cues + if abs(c["id"] - target_id) <= REPAIR_WINDOW} + changed_neighbors = [i for i in result if i != target_id and result[i] != before.get(i)] + repairs[str(target_id)] = { + "ja": next(c["ja"] for c in cues if c["id"] == target_id), + "before": before.get(target_id), + "after": result.get(target_id), + "neighbors_input": before, + "neighbors_output": result, + "changed_neighbors": changed_neighbors, + } + log(f" 修复 {target_id}: {before.get(target_id)} -> {result.get(target_id)}" + f"(邻句改动 {changed_neighbors})") + summary["repair"] = repairs + + if "synthetic" in steps: + log("== 合成正样本召回 ==") + # 只用 part2 构造样本:两个文件的 id 都从 1 开始,混用会错配窗口。 + cues = parts[2] + samples = make_synthetic(cues, glossary) + log(f"合成 {len(samples)} 条损坏译文,逐条放入 30 条真实窗口中定位") + hits = 0 + detail = [] + index_by_id = {c["id"]: i for i, c in enumerate(cues)} + for sample in samples: + index = index_by_id.get(sample["id"]) + if index is None: + continue + window = [dict(c) for c in cues[max(0, index - LOCATE_CHUNK // 2): + index + LOCATE_CHUNK // 2 + 1]] + for c in window: + if c["id"] == sample["id"]: + c["cn"] = sample["corrupted"] + flags = locate(window, glossary, "v2", f"synth-{sample['id']}") + hit = sample["id"] in flags + hits += int(hit) + detail.append({**sample, "flagged": hit, "why": flags.get(sample["id"], "")}) + log(f" 合成样本 id={sample['id']} 替换 {sample['term']}→{sample['corrupted'][:18]} " + f"... 定位={'命中' if hit else '漏'}") + summary["synthetic"] = { + "count": len(detail), "hits": hits, + "recall": round(hits / len(detail), 3) if detail else None, + "detail": detail, + } + + summary["tokens"] = TOTAL_TOKENS + (OUT / "summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8", + ) + log(f"完成。token 用量: {TOTAL_TOKENS}") + log(f"结果: {OUT / 'summary.json'}") + + +if __name__ == "__main__": + main()