"""字幕领域纠错节点(通用领域词表 + 有效上下文,不依赖具体 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_uri(ASR 日语转录),输出: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)})