Files
vrsub/nodes/subtitle_correction.py
T
cat-shark f2895ee103 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
2026-09-06 10:44:09 +08:00

227 lines
9.4 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""字幕领域纠错节点(通用领域词表 + 有效上下文,不依赖具体 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)})