Compare commits
1
Commits
2c8bbb6469
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b263171f2 |
@@ -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(红-绿-重构)
|
||||
|
||||
- 任何新功能/修复必须先写失败测试(红),再实现最小代码让其通过(绿),
|
||||
|
||||
@@ -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 调参方案 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` 从日志重算。
|
||||
@@ -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` |
|
||||
@@ -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`
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user