1 Commits
Author SHA1 Message Date
cat-shark 5b263171f2 docs: 归档翻译上下文/字幕审校方案的评审结论与实测数据
- 待办:会话式长上下文方案标记为已技术评审且不实施;补记问题 A 的阶段性结论(暂缓)
- 新增 docs/实验数据-字幕审校定位与修复实验.md:LLM 定位触发率/召回、合成正样本、逐条修复前后对照、成本与复现方式
- 新增 docs/实验数据-字幕重生成成本与耗时.md:单价口径、功率画像、全量推算
- decisions:补充两条决策(会话式长上下文否决、审校定位+定向修复暂缓)
- scripts:归档一次性探测脚本 probe_subtitle_review_stage0.py(含 rebuild 子步骤)
- AGENTS/README:补充实验与改动许可规则、文档索引
2026-09-19 18:33:28 +08:00
7 changed files with 1187 additions and 0 deletions
+14
View File
@@ -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(红-绿-重构)
- 任何新功能/修复必须先写失败测试(红),再实现最小代码让其通过(绿),
+3
View File
@@ -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 调参方案 |
+55
View File
@@ -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 / 输出 350434 行/素材小时 ≈ 22.5 批/小时)
推算:1.2 小时视频约 3.3×、3 小时视频约 6.5×、15 小时文件夹约 70×(约 ¥30/文件夹),
剩余 187 小时素材由 ¥5.59.4 升到约 ¥300400。
- **阈值踩计费档**`>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)
@@ -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 条 cuepart2 504 条 cueJA 与 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 定位层(全片 part2504 条)
| 变体 | 输入 | 命中(触发率) | 三条目标召回 | 判为"误听/近音"的原因数 |
| --- | --- | --- | --- | --- |
| V1 | 中文 + 词表 | 37/504 = **7.3%** | **0/3** | 0 |
| V2 | 中文 + **日文** + 词表 | 38/504 = **7.5%** | **1/3**(命中 184 | **15** |
- V1 在 165195 区间标的是 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. **召回优先的提问**:把"找出有问题的条目"改为"逐条给可疑度 03 打分 + 理由",
避免"只有读不通才报"的偏置,用阈值调触发率。
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` 从日志重算。
@@ -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 3090350W 上限,24GB | `nvidia-smi` |
| 输入/输出拆分 | 按批输入 ≈529 tokens 实测回推,其余计输出 | 单批预检实测 |
## 二、功率画像(实测)
| 状态 | GPU 功率 | 说明 |
| --- | --- | --- |
| 空闲(提音/线上翻译阶段) | 1920 W | GPU 显存 272 MiB |
| 转写(faster-whisper large-v2 | 270290 W | 显存约 3.64.6 GB |
| 本机 LLMOllama `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/101 个 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.7WGPU 空闲占比 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 |
| API28 视频修复轮,见五) | 33,136 tokens/素材小时 → **¥0.0504/素材小时** |
| APINKKVR-175 A 单视频) | 17,400 tokens/素材小时 → **¥0.0258/素材小时** |
| 电耗(云端 + 新流水线) | ≈0.030 kWh/素材小时(GPU 0.021 + 系统 0.009 |
| 电耗(本地 LLM,电表实测) | **0.059 kWh/素材小时** |
| 成本合计(云端方案) | ¥0.015 电费 + ¥0.030.05 API = **¥0.0450.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.4s1.8×)**;换来片头 120 秒无对话段的字幕条数
15 → 4(且无套话幻觉残留)。
| 阶段(云端 + HST,8 个视频一组 / 5.1 小时素材) | 实测 |
| --- | --- |
| extract | ≈7.8 分钟(GPU 空闲,可并行) |
| ASR | ≈12.9 分钟 × 1.8HST ≈ 23 分钟(GPU 满载) |
| translate | ≈1517 分钟(GPU 空闲,与 ASR 重叠) |
| ASS | 秒级 |
## 七、全量推算(2026-09-19 状态)
待重生成:**349 个 / 187.86 小时素材**(媒体库共 524 个 / 287.27 小时)。
| 方案 | API | 电费 | 合计 | 挂钟 |
| --- | --- | --- | --- | --- |
| 云端 + HST(当前配置) | ¥5.59.4(按 ¥0.030.05/素材小时) | ≈¥2.8 | **¥1012** | 1720 小时 |
| 本地 LLM(对照) | — | ¥5.711.5 kWh | ¥5.7 | 2839 小时 |
敏感性: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` |
@@ -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.59.4 涨到约 ¥300400。
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:4500: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:4500: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`
+654
View File
@@ -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()