Files
vrsub/nodes/llm.py
T
cat-shark a032855210 feat: 字幕质量提升——长时寒暄幻觉清洗 + 专名/成人隐语不直译
两部分同属字幕质量优化,共用 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 行翻译 + 清洗 + 专名注入)通过
2026-09-05 22:32:40 +08:00

218 lines
9.3 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""LLM 翻译节点。
单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行
分批调用 LLM,再把译文回填到原 SRT 结构并输出 cn.srt。
关键修复(见 tests/test_translation_line_alignment.py):
1. **提示词强化**:要求"逐行独立翻译 + 碎片句按语境独立成行 + 禁止合并/拆分",
从源头减少 LLM 因语义碎片而重排断句、导致行数不一致。
2. **行数对齐(_repair_batch**:LLM 偶发多拆/少拆一行会让后续所有字幕文本
相对时间戳整体错位(时间戳从原文复制、文本却错贴到其他时间——程序按时戳
看不出问题,实测 run_51242078d76e 大量批次出现 21/19 行 vs 输入 20 行)。
处理:多行 -> 末尾多余行合并到前一行;少行 -> 重试该批(内容缺失无法靠
占位恢复),仍不足则补空串占位(宁缺勿错位)。
3. **system_prompt 拼接 bug**:圆括号内一旦出现 f-string 赋值(表达式),
隐式字符串拼接失效,整体变成 tuple;json 序列化后发出去的 content 是数组,
API 返回 400 invalid parameter。必须用 + 显式拼接为单个字符串。
"""
from __future__ import annotations
import json
import os
import urllib.error
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
# 批次翻译重试次数(LLM 偶发少行时重发本批,内容缺失无法靠占位恢复)。
MAX_BATCH_RETRIES = 3
def _system_prompt(target_language: str) -> str:
"""构造翻译系统提示词(返回单个字符串,不用隐式拼接避免 tuple bug)。
内容:明确要求逐行独立翻译;碎片句(不成句的助词/名词/语气词)也要结合
上下文给出自然中文并独立成行——这直接削弱 LLM 为求通顺而合并/拆分的倾向,
是行数错位的主要诱发源。
"""
return (
"你是专业字幕翻译。将用户提供的日文字幕翻译为"
+ target_language
+ "。每行是一条独立字幕,必须逐行独立翻译。"
+ "有些行可能是不完整的日语碎片(单独的助词/名词/语气词),"
+ "请结合前后文语境给出它最自然的中文含义并独立成行。"
+ "输入有 N 行,输出就必须恰好 N 行中文、顺序保持一致。"
+ "绝对禁止把两行合并成一行,也禁止把一行拆成两行。"
+ "只返回译文,不要解释。"
)
def _call_llm(
api_base: str,
api_key: str,
model: str,
system_prompt: str,
user_content: str,
request_timeout: float,
**_: object,
) -> str:
"""发送一次 OpenAI 兼容的 chat.completions 请求,返回 content 字符串。
支持响应 choices[0].message.content 字段;enable_thinking=False 避免
Qwen3 等模型的 reasoning_content 占满输出导致 content 为空/截断。
"""
body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"enable_thinking": False,
"max_tokens": 8192,
}
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=request_timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
content = payload["choices"][0]["message"]["content"]
return content
def _repair_batch(batch: list[str], expected: int) -> list[str]:
"""把 LLM 返回的一个批次修整到与输入一致的行数(多合并、少补齐)。
多行:末尾多出的行并入前一行(碎片本质同一句,时间轴落在该行窗口内);
少行:末尾补空串占位(宁缺勿错位,不挤占相邻字幕的时间轴)。
"""
if len(batch) == expected:
return batch
if len(batch) > expected:
merged = list(batch[:expected])
merged[-1] = " ".join(batch[expected - 1 :])
return merged
# 少行补空串。
return list(batch) + [""] * (expected - len(batch))
def translate_lines(lines: list[str], params: dict) -> list[str]:
"""分批调用 LLM 翻译纯文本行,返回顺序一致的译文列表。
每批输入行数保持一致;若 LLM 返回行数不一致:多行合并、少行重试该批
(最多 MAX_BATCH_RETRIES 次),仍不足则补空串占位。保证每条字幕都有
译文且时间轴与原文逐条对齐,杜绝"内容对错时间"的错位。
"""
api_base = os.getenv(
"LLM_API_BASE",
"https://api.siliconflow.cn/v1/chat/completions",
)
api_key = os.getenv("LLM_API_KEY", "")
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
target_language = str(params.get("target_language", "zh-CN"))
system_prompt = _system_prompt(target_language)
translated: list[str] = []
for start in range(0, len(lines), CHUNK_SIZE):
chunk = lines[start : start + CHUNK_SIZE]
batch_translated = _translate_batch(
chunk, api_base, api_key, model, system_prompt, request_timeout
)
translated.extend(batch_translated)
return translated
def _translate_batch(
chunk: list[str],
api_base: str,
api_key: str,
model: str,
system_prompt: str,
request_timeout: float,
) -> list[str]:
"""翻译单个批次:行数不一致时多行合并、少行重试,返回与 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,
batch_system,
"\n".join(chunk),
request_timeout,
)
# 保留所有行:先 rstrip 尾随换行避免多出末尾空行,再 splitlines 保留
# 内容中的空串行(空行可能是合法的空字幕,过滤掉会误判行数)。
batch = content.rstrip("\n").splitlines()
if len(batch) == len(chunk):
return batch
if len(batch) > len(chunk):
# 多行:末尾多出的行合并到前一行,直接返回。
return _repair_batch(batch, len(chunk))
# 少行:内容缺失,占位补空会丢语义,重试本批。
attempt += 1
if attempt >= MAX_BATCH_RETRIES:
# 重试耗尽:补空串占位(宁缺勿错位),避免整条任务失败。
return _repair_batch(batch, len(chunk))
def invoke(request: InvokeRequest) -> InvokeResponse:
"""翻译 SRT 文件中的字幕文本,输出 cn.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")
# 标准 SRT 每 4 行一组:序号、时间轴、文本、空行;文本位于第 3 行。
lines = srt_path.read_text(encoding="utf-8").splitlines()
text_indices = list(range(2, len(lines), 4))
source_lines = [lines[index] for index in text_indices]
# 翻译:返回与 source_lines 严格等长的译文(多/少行已在批内修复)。
translated_lines = translate_lines(source_lines, request.params)
# 防御性兜底:确保长度一致(translate_lines 已保证,此处双保险)。
translated_lines = translated_lines[: len(source_lines)]
translated_lines += [""] * max(0, len(source_lines) - len(translated_lines))
# 只替换文本行,序号、时间轴和空行保持不变。
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(srt_body, encoding="utf-8")
return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)})