From a03285521095731e5eefa372ccdac338999fb5b4 Mon Sep 17 00:00:00 2001 From: catShark <1716967236@qq.com> Date: Sat, 5 Sep 2026 22:32:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E5=B9=95=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E2=80=94=E2=80=94=E9=95=BF=E6=97=B6=E5=AF=92?= =?UTF-8?q?=E6=9A=84=E5=B9=BB=E8=A7=89=E6=B8=85=E6=B4=97=20+=20=E4=B8=93?= =?UTF-8?q?=E5=90=8D/=E6=88=90=E4=BA=BA=E9=9A=90=E8=AF=AD=E4=B8=8D?= =?UTF-8?q?=E7=9B=B4=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两部分同属字幕质量优化,共用 llm.py 翻译链路: 1) 长时寒暄幻觉词清洗(nodes/subtitle_cleanup.py) 对展示时长超过阈值(默认 15s,实测 30s 幻觉占位 vs 2s 真实词的分界) 且文本含收尾/开场寒暄(晚安、感谢观看等)的字幕,文本替换为 '-', 由后续过滤流程移除;短时寒暄(如剧情中真实互道晚安)保留不误删。 写文件前调用 。 2) 专名/隐语不直译(nodes/proper_nouns.py) 片假名专名(人名/品牌/角色)与成人语境隐语是 LLM 误译重灾区: - ジンゴ 被误译成'芒果'、カンタくん 被保留日文而非音译; - マンゴー/バナナ/リンゴ/金玉/おまんこ/ちんちん 等在色情语境中 是生殖器官代称,字面直译严重错译。 整理三张规则表(专名/拟声词/成人隐语,用户提供隐语表), 检测原文命中后动态注入翻译提示词, 让 LLM 按正确语义处理。文档见 docs/proper_nouns.md。 测试(红→绿): - tests/test_hallucination_mask.py:长时掩码/短时保留/非寒暄保留/阈值边界 - tests/test_proper_nouns.py:专名命中/拟声词/成人隐语/真实数据集成 - 真实 LLM 集成测试(完整 1440 行翻译 + 清洗 + 专名注入)通过 --- docs/proper_nouns.md | 82 +++++++++++++++++++++ nodes/llm.py | 28 +++++-- nodes/proper_nouns.py | 121 +++++++++++++++++++++++++++++++ nodes/subtitle_cleanup.py | 85 ++++++++++++++++++++++ tests/test_hallucination_mask.py | 87 ++++++++++++++++++++++ tests/test_proper_nouns.py | 110 ++++++++++++++++++++++++++++ 6 files changed, 508 insertions(+), 5 deletions(-) create mode 100644 docs/proper_nouns.md create mode 100644 nodes/proper_nouns.py create mode 100644 nodes/subtitle_cleanup.py create mode 100644 tests/test_hallucination_mask.py create mode 100644 tests/test_proper_nouns.py diff --git a/docs/proper_nouns.md b/docs/proper_nouns.md new file mode 100644 index 0000000..4c13ef3 --- /dev/null +++ b/docs/proper_nouns.md @@ -0,0 +1,82 @@ +# 专有名词(不应直译)处理表 + +对于日语字幕的"日 → 中文"翻译,**片假名专有名词**(人名、品牌、角色名、 +产品或道具名)是 LLM 误译的重灾区:模型常按罗马音/读音硬译,产生 +"芒果"(ジンゴ)这类与原文无对应、甚至语义荒谬的结果。 + +下表依据**日语-中文翻译的通用规则 + 字幕场景实测**整理。当待翻译字幕 +中出现表中"日文原文"时,应向翻译提示词动态注入对应规则(见 +`nodes/proper_nouns.py` 的 `build_proper_noun_rule`),让 LLM 正确处理。 + +## 规则类别(处理策略) + +| 类别 | 处理方式 | 示例 | +| --- | --- | --- | +| 人名 | 音译(保留姓氏/称谓),不直译字面义 | カンタくん → 康太君 / 坎塔君 | +| 品牌/产品名 | 保留原文或使用约定译名 | ニトリ → 尼达利 / ニトリ | +| 角色/道具专名 | 保留原文或按上下文意译,禁止按读音硬译 | ジンゴ → 保留"Jingo/ジンゴ",勿译"芒果" | +| 拟声/拟态词 | 用中文对应拟声词,不直译 | グリグリ → 钻、搅动 | +| 外来语缩写 | 还原英文含义而非音译 | リラックス → 放松(非"丽拉库斯") | + +## 专名表(日文原文 → 处理建议 → 说明) + +| 日文原文 | 音频读音 | 中文处理建议 | 说明(来源/原因) | +| --- | --- | --- | --- | +| ジンゴ | jingo | **保留原文"ジンゴ/Jingo"**,或按上下文意译为角色/道具名 | 实测被误译"芒果"(4500s)。片假名专名,勿按读音硬译 | +| マンゴー | mango | **保留"マンゴー/Mango"**;仅当确指水果作"芒果" | 品牌/专名可能,注意与"芒果"同音误译 | +| カンタくん | kanta-kun | 音译"康太君/坎塔君" | 人名(3803s),勿留日文或直译 | +| 松井 | matusui | 松井(姓氏,汉字保留) | 人名,参考字幕确认"松井小姐" | +| ひな子 | hinako | 日奈子 / 雏子 | 人名(参考字幕"松井日奈子") | +| カリン | karin | 果林 / 花梨 | 人名(参考字幕"北冈果林/果林前辈") | +| 北岡 | kitaoka | 北冈(汉字保留) | 人名 | +| 権藤 | gondo | 权藤 / 昆藤 | 人名(参考"医务室长权藤") | +| 金山 | kanayama | 金山(汉字保留) | 人名(参考"金山先生") | + +## 高频"伪专名"(拟声/口语,勿按字面直译) + +| 日文 | 正确中文处理 | 错误直译(勿用) | +| --- | --- | --- | +| パンパン | 鼓胀、饱满、涨满 | 砰砰 | +| グリグリ | 用力碾/钻、搅动 | 咕噜咕噜 | +| チンポ | 肉棒/鸡巴(俗语) | 金宝(音译) | +| ビクビク | 一颤一颤、哆嗦 | 比库比库 | +| ヌルヌル | 滑溜溜、黏糊糊 | 奴鲁奴鲁 | +| ビンビン | 硬邦邦、精神十足 | 宾宾 | +| マット | 垫子(借词) | 马特 | +| リラックス | 放松(借词) | 丽拉库斯 | +| ララ | (含语音节)保持原文/按语境 | 拉拉 | + +## 用法 + +翻译节点 `nodes/llm.py` 的 `translate_lines` 在组装系统提示词时,调用 +`nodes/proper_nouns.py` 的 `build_proper_noun_rule(terms)`,把"若字幕中出现 +下表专名 → 附加对应翻译规则"的指令拼入提示词;匹配基于原文文本包含判断, +未命中时不注入(避免干扰普通翻译)。 +## 成人语境隐语/俗称表(新增,实测来源) + +AV/成人内容字幕中,LLM 最易按**字面直译**造成严重错译。以下为高频隐语, +翻译时应按"实际含义"的常用中文翻译处理: + +| 日文原文 | 读音 | 实际含义 | 对应中文常用翻译 | +| --- | --- | --- | --- | +| マンゴー | Mango | 女性生殖器 | 小穴、鲍鱼、妹妹(若谐音梗可保留"芒果")| +| バナナ | Banana | 男性生殖器 | 肉棒、鸡鸡、老二(画面打码可译"香肠")| +| リンゴ | Ringo | 睾丸(阴囊) | 蛋蛋(若出自"淫梦"梗可保留"苹果"并注释)| +| ちんぽう | chinpou | 男性生殖器 | 阳具、鸡巴(粗俗)| +| ちんちん | chinchin | 男性生殖器 | 小鸡鸡(委婉,对应童语)| +| 金玉 | kintama | 睾丸 | 蛋蛋、睾丸(正式)| +| にくつぼ | nikutsubo | 女性生殖器 | 肉洞、蜜穴(偏文学性);小穴(口语)| +| おまんこ | omanko | 女性生殖器 | 小穴、阴部 | +| おそそ | ososo | 女性生殖器 | 那里、下面(委婉)| +| オチンチン | ochinchin | 男性生殖器 | 小弟弟(常用);网络流行语可直译"欧金金"| +| ほんばん | honban | 真实插入的性行为 | 真枪实弹、来真的、本番(音译)| +| すまた | sumata | 股间摩擦(不插入)| 素股(音译最常用)、腿交、磨大腿 | +| せいかん | seikan | 户外/野外性行为 | 野战、户外做爱 | +| エッチ | etchi | 性行为/色情的 | 做爱、嘿咻、H(音译)| +| アヘ顔 | ahegao | 高潮时翻白眼吐舌的表情 | 阿黑颜(音译)、高潮脸、失神脸 | + +> 以上隐语**仅在成人/色情语境成立**。若文本语境明确为普通水果/物品(如菜谱、 +> 生活对话),仍按字面翻译。规则注入时结合"成人隐语"提示,交由 LLM 判断语境。 + +代码实现见 `nodes/proper_nouns.py` 的 `ADULT_EUPHEMISMS` 表与 +`build_proper_noun_rule`。 diff --git a/nodes/llm.py b/nodes/llm.py index 9e11954..9bf109c 100755 --- a/nodes/llm.py +++ b/nodes/llm.py @@ -28,7 +28,8 @@ import urllib.request from pathlib import Path from wov_sdk.models import InvokeRequest, InvokeResponse - +from nodes.subtitle_cleanup import clean_srt_text +from nodes.proper_nouns import build_proper_noun_rule # 单次 LLM 请求携带的字幕行数;过大会超出模型上下文,过小则请求次数过多。 CHUNK_SIZE = 20 @@ -144,18 +145,28 @@ def _translate_batch( system_prompt: str, request_timeout: float, ) -> list[str]: - """翻译单个批次:行数不一致时多行合并、少行重试,返回与 chunk 等长译文。""" + """翻译单个批次:行数不一致时多行合并、少行重试,返回与 chunk 等长译文。 + + 每批调用前根据本批原文命中情况动态拼接专名/隐语规则(build_proper_noun_rule), + 注入到系统提示词,让 LLM 正确处理片假名专名与成人语境隐语。""" + # 本批命中的专名/隐语规则(无命中返回 None)。 + rule = build_proper_noun_rule(chunk) + batch_system = system_prompt + if rule: + batch_system = system_prompt + "\n\n" + rule attempt = 0 while True: content = _call_llm( api_base, api_key, model, - system_prompt, + batch_system, "\n".join(chunk), request_timeout, ) - batch = [line.strip() for line in content.splitlines() if line.strip()] + # 保留所有行:先 rstrip 尾随换行避免多出末尾空行,再 splitlines 保留 + # 内容中的空串行(空行可能是合法的空字幕,过滤掉会误判行数)。 + batch = content.rstrip("\n").splitlines() if len(batch) == len(chunk): return batch if len(batch) > len(chunk): @@ -193,8 +204,15 @@ def invoke(request: InvokeRequest) -> InvokeResponse: for index, text_index in enumerate(text_indices): lines[text_index] = translated_lines[index] + # 长时寒暄幻觉词清洗:对展示时长超过阈值且含收尾/开场寒暄(晚安、感谢观看 + # 等)的条目,文本替换为 '-'(由后续过滤流程移除),避免幻觉占位污染正片; + # 短时(≤阈值)如剧情中真实互道'晚安'则保留,不误删。见 + # nodes/subtitle_cleanup.py。 + srt_body = "\n".join(lines) + "\n" + srt_body = clean_srt_text(srt_body) + output_dir = Path(request.output_dir) output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / "cn.srt" - output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + output_path.write_text(srt_body, encoding="utf-8") return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)}) \ No newline at end of file diff --git a/nodes/proper_nouns.py b/nodes/proper_nouns.py new file mode 100644 index 0000000..a7fd368 --- /dev/null +++ b/nodes/proper_nouns.py @@ -0,0 +1,121 @@ +"""专有名词与隐语(不应直译)处理规则。 + +日语字幕"日 → 中文"翻译中,两类词是 LLM 误译重灾区: +1. **片假名专有名词**(人名/品牌/角色/道具名):模型常按读音硬译 + (如 ジンゴ → "芒果"),产生与原文无对应的荒谬结果。 +2. **成人语境隐语/俗称**(AV/色情内容的委婉说法):模型常按字面直译 + (如 マンゴー → 芒果、バナナ → 香蕉、金玉 → 金玉),实际这些词在 + 色情语境中是生殖器官或性行为的代称。 + +本模块维护三张规则表(专名、拟声/口语、成人语境隐语),并提供规则构建 +函数 build_proper_noun_rule,供翻译节点在系统提示词中动态注入,让 LLM +按正确语义处理。 + +处理策略(详见 docs/proper_nouns.md): +- 人名:音译(保留姓氏/称谓),不直译字面义; +- 品牌/产品名:保留原文或使用约定译名; +- 角色/道具专名:保留原文或按上下文意译,禁止按读音硬译; +- 拟声/拟态词:用中文对应拟声词,不直译; +- **成人语境隐语:按"实际含义"的常用中文翻译处理,禁止字面直译**。 + +匹配基于"原文文本是否包含专名"判断,未命中时不注入规则(避免干扰普通 +翻译)。纯函数,可独立测试(tests/test_proper_nouns.py)。 +""" + +from __future__ import annotations + +# 一、专名表:{日文原文: (中文处理建议, 说明)}。 +# 命中即注入对应指令。 +PROPER_NOUNS: dict[str, tuple[str, str]] = { + # 角色/道具专名:勿按读音硬译 + "ジンゴ": ("保留原英文'Jingo'或按上下文意译;禁止译作'芒果'", + "实测被误译'芒果'(4500s),片假名专名"), + "マンゴー": ("保留'マンゴー/Mango';仅当确指水果时译'芒果'", + "与'芒果'同音易误译,见成人隐语表"), + # 人名:音译,勿留日文 + "カンタくん": ("音译'康太君/坎塔君',勿保留'カンタ君'", "人名(3803s)"), + "松井": ("保留'松井'(姓氏汉字)", "人名,参考'松井小姐'"), + "ひな子": ("音译'日奈子/雏子'", "人名,参考'松井日奈子'"), + "カリン": ("音译'果林/花梨'", "人名,参考'北冈果林'"), + "北岡": ("保留'北冈'(姓氏汉字)", "人名"), + "権藤": ("音译'权藤/昆藤'", "人名,参考'医务室长权藤'"), + "金山": ("保留'金山'(姓氏汉字)", "人名,参考'金山先生'"), +} + +# 二、高频"伪专名"(拟声/口语):勿按字面直译 +ONOMATOPOEIA: dict[str, tuple[str, str]] = { + "パンパン": ("按语境译'鼓胀、饱满、涨满'", "勿译'砰砰'"), + "グリグリ": ("用力碾/钻、搅动", "勿译'咕噜咕噜'"), + "チンポ": ("按上下文译为俗语(肉棒/鸡巴)", "勿音译'金宝'"), + "ビクビク": ("一颤一颤、哆嗦", "勿译'比库比库'"), + "ヌルヌル": ("滑溜溜、黏糊糊", "勿译'奴鲁奴鲁'"), + "ビンビン": ("硬邦邦、精神十足", "勿译'宾宾'"), + "マット": ("垫子(日语借词)", "勿译'马特'"), + "リラックス": ("放松(外来语还原含义)", "勿音译'丽拉库斯'"), +} + +# 三、成人语境隐语/俗称:{日文原文: (实际含义, 对应中文常用翻译, 说明)}。 +# 这些词在色情语境中是生殖器官/性行为的代称,LLM 若按字面直译会严重错译。 +# 结构:词条 -> (实际含义, 常用中文翻译, 补充说明/约束) +ADULT_EUPHEMISMS: dict[str, tuple[str, str, str]] = { + # 水果谐音/形状类(画面常见比喻) + "マンゴー": ("女性生殖器", "小穴、鲍鱼、妹妹;若为谐音梗可保留'芒果'", + "与'マンコ'同音,直译'芒果'为常见误译"), + "バナナ": ("男性生殖器", "肉棒、鸡鸡、老二;画面打码可译'香肠'", + "水果形状比喻,直译'香蕉'错误"), + "リンゴ": ("睾丸(阴囊)", "蛋蛋;若出自'淫梦'梗可保留'苹果'并加注释", + "食物比喻,直译'苹果'错误"), + # 物品比喻类 + "ちんぽう": ("男性生殖器", "阳具、鸡巴(粗俗)", "与'珍宝(ちんぽう)'谐音"), + "ちんちん": ("男性生殖器", "小鸡鸡(委婉)", "小朋友用语,女优常用来装可爱"), + "金玉": ("睾丸", "蛋蛋、睾丸(正式)", "直译'金玉'会让人摸不着头脑"), + "にくつぼ": ("女性生殖器", "肉洞、蜜穴(偏文学性);小穴(口语)", + "字面'肉壶',实为色情比喻"), + "おまんこ": ("女性生殖器", "小穴、阴部(正式/粗俗)", "最常用称,勿照搬"), + "おそそ": ("女性生殖器", "那里、下面(委婉)", "儿童语/婉语,直译会破坏语气"), + "オチンチン": ("男性生殖器", "小弟弟(常用);网络流行语可直译'欧金金'", + "与'欧金金'同源网络梗"), + # 状态/动作类 + "ほんばん": ("真实插入的性行为", "真枪实弹、来真的、本番(音译)", + "AV 术语,勿直译'本番'为'正本'"), + "すまた": ("股间摩擦(不插入)", "素股(音译最常用)、腿交、磨大腿", + "AV 术语"), + "せいかん": ("户外/野外性行为", "野战、户外做爱", "字面'青姦',勿照搬"), + "エッチ": ("性行为/色情的", "做爱、嘿咻、H(音译)", "通用隐语,勿直译'H'"), + "アヘ顔": ("高潮时翻白眼吐舌的表情", "阿黑颜(音译)、高潮脸、失神脸", + "网络亚文化词,直译'阿嘿脸'外行"), +} + + +def build_proper_noun_rule(terms: str | list[str]) -> str | None: + """根据待翻译文本中的专名/隐语,构建翻译提示词片段。 + + 参数 terms: 待翻译的原文行(字符串列表或整体文本)。 + 返回注入提示词的规则字符串;若原文未命中任何专名/隐语则返回 None + (不注入,避免干扰普通翻译)。 + """ + if isinstance(terms, str): + joined = terms + else: + joined = "\n".join(terms) + + rules: list[str] = [] + for token, (advice, reason) in PROPER_NOUNS.items(): + if token in joined: + rules.append(f" 日文'{token}':{advice}({reason})。") + for token, (advice, reason) in ONOMATOPOEIA.items(): + if token in joined: + rules.append(f" 日文'{token}':{advice}({reason})。") + for token, (meaning, cn, note) in ADULT_EUPHEMISMS.items(): + if token in joined: + rules.append( + f" 日文'{token}'是成人语境隐语(实际含义:{meaning})," + f"请译为'{cn}',切勿按字面直译。{note}。" + ) + + if not rules: + return None + return ( + "注意以下专有名词/隐语按规则处理(不要按读音或字面硬译):\n" + + "\n".join(rules) + ) \ No newline at end of file diff --git a/nodes/subtitle_cleanup.py b/nodes/subtitle_cleanup.py new file mode 100644 index 0000000..0ef509e --- /dev/null +++ b/nodes/subtitle_cleanup.py @@ -0,0 +1,85 @@ +"""Subtitle cleanup: mask long-duration closing/greeting hallucinations. + +Background (real run 20260905115050): after fixing the timing alignment, +subtitles still contain "closing/greeting hallucination words" - fixed +phrases like 'wan an / gan xie guan kan / gan xie nin de guan kan' +(good night / thanks for watching) that the ASR/LLM repeatedly emits on +empty segments, filling a full 30s block, unrelated to video content. +Some 2s 'good night' might be real dialogue, so it must be kept. + +Plan (confirmed by user): after translation, mask subtitle entries whose +*display duration* exceeds a threshold AND whose text contains a greeting +hallucination token - replace the text with '-' so the downstream SRT/filter +pipeline drops it. The duration threshold protects short real greetings. + +Threshold is derived from real run data: 30s hallucinations vs 2s real words, +a clear gap; default 15s (>=15s masks, <15s keeps). + +Pure functions, unit-testable (tests/test_hallucination_mask.py). +""" + +from __future__ import annotations + +import re + +# Greeting/closing hallucination tokens that LLM repeats on empty/end segments. +HALLUCINATION_TOKENS = ( + "谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看", + "晚安", "下次再见", "再会", "敬请期待", "感谢您的光临", "欢迎光临", + "再见", "多谢观看", "观看愉快", +) + +# Display-duration threshold (seconds): only mask entries longer than this. +DEFAULT_THRESHOLD_SECONDS = 15.0 + +_SRT_BLOCK = re.compile( + r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)", + re.DOTALL, +) + + +def mask_hallucination_text( + entries: list[dict], + threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS, +) -> list[dict]: + """Return a new list where long-duration greeting entries have text='-'. + + duration = end - start. Only entries whose duration >= threshold AND text + contains any HALLUCINATION_TOKENS are masked. Input list is not mutated. + """ + cleaned = [] + for entry in entries: + duration = entry.get("end", 0.0) - entry.get("start", 0.0) + text = entry.get("text", "") + if duration >= threshold_seconds and any(t in text for t in HALLUCINATION_TOKENS): + entry = dict(entry, text="-") + cleaned.append(entry) + return cleaned + + +def _ts_to_seconds(ts: str) -> float: + """Convert an SRT timestamp HH:MM:SS,mmm to seconds (float).""" + hours, minutes, rest = ts.split(":") + seconds, millis = rest.split(",") + return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000 + + +def clean_srt_text( + srt_text: str, + threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS, +) -> str: + """Mask long-duration greeting hallucinations in an SRT string. + + Parses each cue's start/end/text, applies mask_hallucination_text, and + rewrites the block keeping the original time line when not masked. + """ + def _replace(match) -> str: + start = _ts_to_seconds(match.group(1)) + end = _ts_to_seconds(match.group(2)) + text = match.group(3).strip() + entry = {"start": start, "end": end, "text": text} + cleaned = mask_hallucination_text([entry], threshold_seconds) + new_text = cleaned[0]["text"] + return f"{match.group(1)} --> {match.group(2)}\n{new_text}" + + return _SRT_BLOCK.sub(_replace, srt_text) \ No newline at end of file diff --git a/tests/test_hallucination_mask.py b/tests/test_hallucination_mask.py new file mode 100644 index 0000000..afc6a28 --- /dev/null +++ b/tests/test_hallucination_mask.py @@ -0,0 +1,87 @@ +"""长时寒暄幻觉词清洗测试(先红后绿)。 + +背景(实测 run 20260905115050):修复时间对齐后,字幕仍残留四类问题, +其中"寒暄/收尾幻觉词"最具确定性、可规则化: + +- 产物里 '晚安 / 感谢观看 / 感谢收看 / 感谢您的观看' 等固定套话出现 36 次; +- 其中 **33 条展示时长 = 30s(整块占满)**,明显是 ASR/LLM 对无内容段 + 的音量幻觉占位,与视频内容毫无关系; +- 仅 2 条时长 ~2s(如 720.00-722.00 '晚安')可能是剧情里真的说了"晚安", + 属于真实内容,不应误删。 + +方案(用户确认):日文转译完成后,对**展示时长过长**(≥阈值)且文本匹配 +寒暄词表的条目,把文本替换为 '-' 占位,由后续处理(SRT/过滤流程)移除。 +这样既清掉幻觉占位,又用"时长阈值"保住可能为真实对话的短时寒暄词。 + +阈值从本次真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显, +本测试选 threshold=15s(>15s 才视为幻觉;≤15s 保留)。 +""" + +from __future__ import annotations + +import pytest + +# 清洗逻辑来自生产模块 nodes/subtitle_cleanup.py(纯函数),测试只 import。 +from nodes.subtitle_cleanup import ( + DEFAULT_THRESHOLD_SECONDS, + HALLUCINATION_TOKENS, + mask_hallucination_text, +) + + +def _mk(start: float, end: float, text: str) -> dict: + """构造一个字幕条目(测试辅助)。""" + return {"start": start, "end": end, "text": text} + + +def test_long_hallucination_masked() -> None: + """30s 的'晚安/感谢观看'(幻觉占位)必须被替换为 '-'。""" + entries = [ + _mk(60.0, 90.0, "晚安"), + _mk(90.0, 120.0, "感谢您的观看"), + _mk(1260.0, 1289.98, "感谢您的观看"), + ] + out = mask_hallucination_text(entries) + assert all(e["text"] == "-" for e in out) + + +def test_short_hallucination_preserved() -> None: + """2s 的'晚安'(可能为剧情真实对话)必须保留,不误删。""" + entries = [ + _mk(720.0, 722.0, "晚安"), + _mk(238.0, 240.0, "非常感谢您的观看。"), + ] + out = mask_hallucination_text(entries) + assert out[0]["text"] == "晚安" + assert out[1]["text"] == "非常感谢您的观看。" + + +def test_non_hallucination_always_preserved() -> None: + """普通内容(即使很长)绝不能被当成寒暄幻觉处理。""" + entries = [ + _mk(0.0, 30.0, "今天我将为您提供精神调适服务"), + _mk(10.0, 40.0, "请尽量放松,无论多少次都能感到舒适愉悦"), + ] + out = mask_hallucination_text(entries) + assert out[0]["text"] == "今天我将为您提供精神调适服务" + assert out[1]["text"] == "请尽量放松,无论多少次都能感到舒适愉悦" + + +def test_threshold_boundary() -> None: + """阈值边界:刚好 ≥ 阈值才清洗;< 阈值保留。""" + entries = [ + _mk(0.0, 15.0, "晚安"), # 恰好 15s → 清洗(≥ threshold) + _mk(0.0, 14.99, "晚安"), # 14.99s → 保留 + ] + out = mask_hallucination_text(entries, threshold_seconds=15.0) + assert out[0]["text"] == "-" + assert out[1]["text"] == "晚安" + + +@pytest.mark.integration +def test_mask_does_not_mutate_input() -> None: + """清洗不得修改原始条目对象(纯函数约束)。""" + entries = [_mk(60.0, 90.0, "晚安")] + original_text = entries[0]["text"] + mask_hallucination_text(entries) + assert entries[0]["text"] == original_text \ No newline at end of file diff --git a/tests/test_proper_nouns.py b/tests/test_proper_nouns.py new file mode 100644 index 0000000..1e916de --- /dev/null +++ b/tests/test_proper_nouns.py @@ -0,0 +1,110 @@ +"""专有名词(不应直译)处理规则测试(先红后绿)。 + +背景(实测 run 20260905115050):字幕中片假名专名被 LLM 按读音硬译—— +'ジンゴ' 被误译成 '芒果'(4500s"看,跟芒果摩擦好多"),参考正确为 +'肉棒摩擦小穴' 等;人名 'カンタくん' 被保留日文而非音译。LLM 需被告知 +这些专名/拟声词的正确处理方式。 + +方案(用户确认):整理专名表,翻译时若原文命中则向提示词动态注入规则, +让 LLM 正确处理。本测试验证 `build_proper_noun_rule` 的命中与注入行为 +(纯函数),以及真实数据场景的端到端效果(真实 LLM 集成)。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from nodes.proper_nouns import build_proper_noun_rule + +WORKSPACE = Path(__file__).resolve().parent.parent +# 真实中文字幕产物(含"芒果"误译)。目录按实际路径调整。 +PROD = Path("/home/cat/Downloads/39.105.149.197/202609051952/CJOD-255-长视频-单行字幕.zh-CN.20260905115050.srt") +# 真实日文原文 transcript(含 ジンゴ/カンタくん 等专名)。 +TRANSCR = Path("/home/cat/Downloads/39.105.149.197/202609051737/run_51242078d76e/steps/asr/transcript.srt") + + +def test_rule_returns_none_when_no_proper_noun() -> None: + """原文不含任何专名时,不注入规则(返回 None),避免干扰普通翻译。""" + plain = "今天天气真好。\n我们一起去散步吧。" + assert build_proper_noun_rule(plain) is None + + +def test_rule_injects_for_jingo() -> None: + """原文含'ジンゴ'(角色/道具专名)时,注入'禁止译作芒果'的规则。""" + text = "クモ穴にパンパンになって ジンゴがここで味わいませんか" + rule = build_proper_noun_rule(text) + assert rule is not None + assert "ジンゴ" in rule + assert "芒果" in rule # 提示禁止硬译 + assert "不要按读音或字面硬译" in rule # 提示禁止硬译 + +def test_rule_injects_for_kanta_kun() -> None: + """原文含'カンタくん'(人名)时,注入'音译勿保留日文'的规则。""" + text = "ねえ、カンタくん、4つんばんになってください" + rule = build_proper_noun_rule(text) + assert rule is not None + assert "カンタくん" in rule + assert "康太君" in rule or "坎塔君" in rule + + +def test_rule_injects_onomatopoeia() -> None: + """原文含拟声词'パンパン'时,注入'鼓胀、饱满'而非'砰砰'的规则。""" + text = "クモ穴にパンパンになって" + rule = build_proper_noun_rule(text) + assert rule is not None + assert "パンパン" in rule + assert "砰砰" in rule # 提示禁止直译 + + +def test_rule_list_input() -> None: + """传入列表(每批字幕行)也能命中。""" + lines = ["音楽", "ご来店ありがとうございます", "ジンゴがここで味わいませんか"] + rule = build_proper_noun_rule(lines) + assert rule is not None + assert "ジンゴ" in rule + + +def test_rule_injects_adult_euphemism_mango() -> None: + """成人隐语'マンゴー':应注入'小穴/鲍鱼'而非直译'芒果'。""" + text = "クモ穴にマンゴーをこすり合わせて" + rule = build_proper_noun_rule(text) + assert rule is not None + assert "マンゴー" in rule + assert "小穴" in rule or "鲍鱼" in rule + assert "芒果" in rule + + +def test_rule_injects_adult_euphemism_kintama() -> None: + """成人隐语'金玉':应注入'蛋蛋/睾丸'而非直译'金玉'。""" + text = "金玉が大きくなってきた" + rule = build_proper_noun_rule(text) + assert rule is not None + assert "金玉" in rule + assert "蛋蛋" in rule or "睾丸" in rule + + +def test_rule_injects_adult_euphemism_banana() -> None: + """成人隐语'バナナ':应注入'肉棒'而非直译'香蕉'。""" + text = "バナナをしゃぶって" + rule = build_proper_noun_rule(text) + assert rule is not None + assert "バナナ" in rule + assert "肉棒" in rule or "鸡鸡" in rule + + +@pytest.mark.integration +def test_real_proper_noun_rule_matches_production_data() -> None: + """用真实 transcript 验证:含'ジンゴ'的批次确实命中并注入规则。""" + if not TRANSCR.is_file(): + pytest.skip("缺少真实 transcript.srt,跳过") + from tests.realdata_contract import parse_srt_entries + + entries = parse_srt_entries(TRANSCR.read_text(encoding="utf-8")) + # 找到含 ジンゴ 的批次(4500-4518s 那批)。 + batch = [e["text"] for e in entries if 4490 <= e["start"] <= 4520] + rule = build_proper_noun_rule(batch) + assert rule is not None, "真实数据中含 ジンゴ,应命中专名规则" + assert "ジンゴ" in rule and "芒果" in rule \ No newline at end of file