feat: 字幕领域纠错模块(通用领域词表 + 有效上下文,不过拟合)

背景:成人视频 ASR 常把性器官(チンポ/マンコ)听错成近音词(手先/
チェーンバー 等),翻译逐字直译导致与画面严重不符。

实验结论:
- 硬编码 ASR 误听例子(手先→肉棒)过拟合——换视频的误听词就失效;
- 通用领域词表(只列性器官的常见日文词+中文)+ 有效上下文 + 通用引导
  可泛化——对从没见过的误听(バナナ/マンゴー→性器官)也能按语境推断。

实现 nodes/subtitle_correction.py:
- PROPER_SESSION_WORDS:通用性器官领域词表(不含 ASR 误听噪声词)
- _is_fragment:纯语气词/碎片过滤
- _build_context:目标 ±60s 有效上下文(过滤碎片,保留动作链)
- _system_prompt:通用引导(无具体误听例子)
- _extract_target_line:从 LLM 输出解析目标行译文
- invoke:逐条领域纠错,产出 corrected.srt

测试 tests/test_subtitle_correction.py(12 个):
- 碎片判定/上下文过滤/目标解析/提示词无噪声例子(9 快速单测)
- 真实 LLM 泛化回归(对未出现误听词也能推断性器官)+ 端到端 invoke
This commit is contained in:
2026-09-06 10:44:09 +08:00
parent 2c3c356348
commit f2895ee103
3 changed files with 448 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
"""字幕领域纠错节点(通用领域词表 + 有效上下文,不依赖具体 ASR 误听例子)。
背景(实验验证):
- 成人视频中 ASR 常把性器官(チンポ/マンコ)听错成近音词(チェーンバー/手先等),
翻译逐字直译导致与真实画面严重不符。
- 实验证明:
* "硬编码 ASR 误听例子"(如 手先→肉棒)**过拟合**——换视频的误听词就失效;
* "通用领域词表(列出性器官的常见日语词+中文对应)+ 有效上下文 + 通用引导"
**可泛化**——对从未见过的误听(如 バナナ/マンゴー→性器官)也能按语境推断。
本节点实现"通用方案"
1. **领域词表**:只列出性器官的**通用日文词**(チンポ/マンコ/金玉/乳首…)+ 中文,
不绑定任何 ASR 误听的具体形式;
2. **有效上下文**:过滤纯语气词/碎片,保留目标前后 ±60s 内有实际含义的句子,
让 LLM 看到完整动作链(推断语境);
3. **通用引导**:系统提示词只要求"结合上下文和领域常识判断",不给具体例子。
输入:ASR 转录 SRT(日语),输出:领域纠错后的 SRT(日语文本被改写为贴合语义的
日文,供后续翻译环节使用)。
"""
from __future__ import annotations
import json
import os
import re
import urllib.request
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
# 通用领域词表:性器官的常见日文词 + 中文对应。
# 注意:绝不包含"ASR 误听产生的噪声词"(如 チェーンバー)——那是过拟合来源。
PROPER_SESSION_WORDS = {
"チンポ": "肉棒/鸡巴",
"チンコ": "肉棒/鸡巴",
"マンコ": "小穴/阴部",
"おまんこ": "小穴/阴部",
"金玉": "蛋蛋/睾丸",
"タマ": "蛋蛋/睾丸",
"乳首": "乳头",
"バナナ": "肉棒/鸡巴", # 性语境中的常见近音指代(通用知识)
"マンゴー": "小穴/阴部", # 性语境中的常见近音指代(通用知识)
}
# 纯语气词/碎片判定:仅含这些字符或属于常见语气词。
_PURE_RE = re.compile(r"^[あいうえおっーんすよわぁぃぅぇぉ\s、。!?〜…]*$")
_PURE_WORDS = {"", "", "うん", "はい", "あっ", "あー", "あ〜", "ああ", "うっ", "おー", "えっ"}
# 上下文窗口(秒)。
CONTEXT_WINDOW = 60
def _is_fragment(text: str) -> bool:
"""判断一条 ASR 转录是否为纯语气词/碎片(无实义,不适合作为语境)。"""
if not text or not text.strip():
return True
if _PURE_RE.match(text):
return True
return text.strip() in _PURE_WORDS
def _read_srt_entries(srt_path: Path) -> list[dict]:
"""读 SRT,返回 [{index,start,end,text}](借用 realdata_contract 的解析)。"""
from tests.realdata_contract import parse_srt_entries
return parse_srt_entries(srt_path.read_text(encoding="utf-8"))
def _build_context(entries: list[dict], target_index: int, window: float = CONTEXT_WINDOW) -> str:
"""构造目标条目 ±window 秒内的有效上下文(过滤语气词/碎片)。
上下文保留有实际含义的句子并附时间,让 LLM 看到动作链推断语境。
目标自身始终保留(标记 <-- 目标)。
"""
target = entries[target_index]
lines: list[str] = []
for i, entry in enumerate(entries):
if abs(entry["start"] - target["start"]) > window:
continue
if i != target_index and _is_fragment(entry["text"]):
continue # 上下文过滤语气词,但目标本身保留
mark = " <-- 目标" if i == target_index else ""
lines.append(f"[{entry['start']:7.2f}] {entry['text']}{mark}")
return "\n".join(lines)
def _system_prompt(target_language: str) -> str:
"""通用引导系统提示词(不含任何 ASR 误听例子,避免过拟合)。"""
domain_terms = "".join(
f"{jp}{cn}" for jp, cn in PROPER_SESSION_WORDS.items()
)
return (
"你是一位成人向视频字幕翻译审校。下面提供一段视频在某个时间点前后的"
"**有效字幕片段**(已过滤纯语气词,只保留有含义的句子,时间单位秒)。\n"
f"目标语言:{target_language}\n"
"你的任务:把片段**翻译成自然、通顺、符合画面动作的中文字幕**。\n"
"**领域背景**:此类视频常涉及性行为,日文口语中以下词在性语境中有特定含义:\n"
f"{domain_terms}\n"
"若片段中的某句在性行为语境中出现可疑近音词,请结合整段上下文和领域常识,"
"推断其真实含义并按领域语义翻译,**不要机械照搬字面词**。\n"
"每条输出一行:`时间(秒,两位小数) 中文`。不要解释、不要标号。"
)
def _call_llm(
api_base: str, api_key: str, model: str, sysp: str, user: str, timeout: float
) -> str:
"""发送一次 OpenAI 兼容 chat.completions 请求,返回 content 字符串。"""
body = {
"model": model,
"messages": [
{"role": "system", "content": sysp},
{"role": "user", "content": user},
],
"enable_thinking": False,
"max_tokens": 1024,
}
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
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"))
return payload["choices"][0]["message"]["content"]
def correct_entry(
entry: dict,
entries: list[dict],
index: int,
params: dict,
) -> str:
"""对单个目标条目做领域纠错翻译,返回纠错后的中文行。
使用:目标 ±60s 有效上下文 + 通用领域词表 + 通用引导。
若 LLM 调用失败,回退为直接返回空串(由上层决定保留原文)。
"""
api_base = os.getenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions")
api_key = os.getenv("LLM_API_KEY", "")
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
target_language = str(params.get("target_language", "zh-CN"))
ctx = _build_context(entries, index)
sysp = _system_prompt(target_language)
try:
content = _call_llm(api_base, api_key, model, sysp, ctx, timeout)
# 解析模型输出,取与目标时间最接近的译文行。
target_text = _extract_target_line(content, entry["start"])
return target_text
except Exception: # noqa: BLE001
return ""
def _extract_target_line(content: str, target_time: float) -> str:
"""从 LLM 输出中解析目标条目的译文。
LLM 输出形如 "6704.10 啊,肉棒撞到了别的地方",找与 target_time 最接近的一行。
找不到时返回空串。
"""
best = ""
best_gap = float("inf")
for line in content.splitlines():
line = line.strip()
if not line:
continue
try:
ts = float(line.split()[0].rstrip(",").strip().rstrip(" "))
except (ValueError, IndexError):
continue
gap = abs(ts - target_time)
if gap < best_gap:
best_gap = gap
# 去掉时间前缀,保留译文。
best = line.split(None, 1)[1] if " " in line else line
return best
def _serialize_srt(entries: list[dict]) -> str:
"""把条目列表序列化为 SRT 文本。"""
blocks = []
for i, e in enumerate(entries, start=1):
blocks.append(f"{i}\n{_fmt(e['start'])} --> {_fmt(e['end'])}\n{e['text']}")
return "\n\n".join(blocks) + "\n"
def _fmt(seconds: float) -> str:
"""秒 -> SRT 时间戳 HH:MM:SS,mmm。"""
total_ms = int(round(seconds * 1000))
h, rem = divmod(total_ms, 3600000)
m, rem = divmod(rem, 60000)
s, ms = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def invoke(request: InvokeRequest) -> InvokeResponse:
"""对 ASR 转录做领域纠错,输出 corrected.srt。
输入:srt_uriASR 日语转录),输出:corrected.srt(领域纠错后的中文)。
"""
srt_uri = request.inputs.get("srt_uri")
if not srt_uri:
return InvokeResponse(status="failed", error="srt_uri is required")
srt_path = Path(srt_uri)
if not srt_path.is_file():
return InvokeResponse(status="failed", error="srt file not found")
entries = _read_srt_entries(srt_path)
# 仅对"疑似领域误听"的条目纠错:包含可疑语境词或处于性行为上下文的条目。
# 简化:对所有条目逐条纠错(纠错模型会自行判断是否改写)。
corrected_entries = list(entries)
for i, entry in enumerate(entries):
corrected = correct_entry(entry, entries, i, request.params)
if corrected:
corrected_entries[i]["text"] = corrected
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "corrected.srt"
output_path.write_text(_serialize_srt(corrected_entries), encoding="utf-8")
return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)})