Files
vrsub/nodes/subtitle_correction.py
T
cat-shark 8f6083f8cf feat: 全系统统一 Whisper V2 权重并移除 demo 工作流
确认所有运行时引用均使用 V2(V3 已停用),并修复一处真实不一致:

- 工作流数据文件:learn-translate 用 faster-whisper-large-v2、
  zh-direct 用 whisper-large-v2-translate-zh-v0.2-st-ct2(原本即 V2);
- 本地库的 demo 最新版本仍指向 large-v3:workflows/demo.json 早已改为 V2,
  但 seed 对已存在工作流刻意跳过,导致旧库停留在历史上用 V3 保存的定义,
  即本机跑 demo 实际加载 V3 权重。按用户决定移除 demo 工作流及其关联的
  6 个 run、1 个批量任务与 431 条明细(媒体库中已放置的 6 个字幕成品保留);
- nodes/whisper.py 候选与远端兜底本就是 large-v2;
- V3 权重目录保留在盘上仅作对照实验,文档标注为废弃;
  scripts/compare_whisper_v2_vs_v3.py 保留用于对照。

顺带修复与清理:
- src/wov_app/scheduler.py:_file_size 补捕 ValueError(见上一条提交说明
  的真实缺陷,此处为同一批改动);
- .gitignore:data/ 改为 /data/,避免连带忽略 tests/**/data/;
- scripts/*:评测集路径改到 scripts/data/translate_eval/;
- 代码注释与文档同步移除 demo 引用(历史调研文档保留说明性引用)。

验证:全量 477 passed;新库 seed 只创建 3 个 V2 工作流。
2026-09-13 15:41:58 +08:00

240 lines
9.9 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,返回 [{start, end, text}](时间轴为秒,供上下文与评分使用)。
解析复用生产模块 `nodes/srt.py` 的严格解析器(支持 BOM/CRLF、多行与
空正文,非法条目明确报错),再按秒换算,不用测试包实现。
"""
from nodes.srt import parse_srt as parse_cues
def to_seconds(timestamp: str) -> float:
"""SRT 时间戳 HH:MM:SS,mmm → 秒。"""
hours, minutes, rest = timestamp.split(":")
seconds, millis = rest.split(",")
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
return [
{"start": to_seconds(cue.start), "end": to_seconds(cue.end), "text": cue.text}
for cue in parse_cues(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)})