Files
vrsub/tests/test_translation_line_alignment.py
T
cat-shark 5c12bbcb74 fix: 翻译批内行数错位(内容-时间错位)+ system_prompt 元组 bug
根因(真实任务 run_51242078d76e):
1. LLM 按 CHUNK_SIZE=20 分批翻译时,对语义碎片句(如单独的助词/名词/
   语气词)偶发多拆/少拆一行,translate_lines 无条件 extend 导致:
   - 多行 -> 后续所有字幕文本整体错位,时间戳从原文复制、文本却错贴时间;
   - 少行 -> invoke 末尾补空导致该条内容缺失。
   程序按时戳看不出问题,实际"内容对错时间"(如第756条"好像喜欢害羞
   的样子"错贴 3805s,实为"恥ずかしいのが好きみたいなので"的译文)。
2. system_prompt 圆括号内出现 f-string 赋值导致隐式字符串拼接失效,
   整体变成 tuple,json 序列化后 content 是数组 -> LLM API 400。

修复:
- 提示词强化:逐行独立翻译 + 碎片句按语境独立成行 + 禁止合并/拆分;
- _repair_batch:多行末尾合并到前一行、少行重试该批(最多3次)仍不足
  补空串占位(宁缺勿错位),保证译文与原文逐条时间对齐;
- system_prompt 显式 + 拼接为单个字符串。

测试(先红后绿):
- test_translate_lines_aligns_extra_line:多行合并对齐
- test_translate_lines_aligns_missing_line:少行重试补齐
- test_translate_lines_pads_after_retries_exhausted:重试耗尽补空
- test_pipeline_zh_cn_timetext_alignment:真实 LLM 完整 1440 行逐条对齐
pyproject.toml: 注册 integration marker
2026-09-05 19:36:47 +08:00

231 lines
9.3 KiB
Python
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.
"""翻译批处理行数对齐测试(先红后绿)。
背景:真实任务 run_51242078d76eCJOD-255-长视频)产出的中文字幕存在
"内容-时间错位"——例如第 756 条「好像喜欢害羞的样子」被贴到 3805.34s
(该时间实际是日文「4つんばんですか(趴着吗)」的位置),而这条译文本应是
第 758 条「恥ずかしいのが好きみたいなので(喜欢害羞姿势)」的译文。
根因:nodes/llm.py 的 translate_lines 按 CHUNK_SIZE=20 分批把日文行发给
LLM,返回的译文行用 translated.extend() **无条件顺序拼接**,全批结束后只在
invoke 末尾做"多截断、少补空"。只要某批 LLM 返回行数 != 输入行数(实测大量
批次出现译文 21 行/原文 20 行),该批之后**所有字幕文本整体错位**,而时间戳
(从原文复制)保持不变 —— 造成"文本对错时间,程序从时间戳上看不出问题"。
修复(见 nodes/llm.py):
1. 系统提示词新增"逐行独立翻译 + 碎片句按语境给含义 + 禁止合并/拆分",
从源头减少 LLM 重组断句导致的行数不一致;
2. 程序侧兜底 _repair_batch:返回行数 != 输入行数时,
- 多行:末尾多余行合并到前一行(碎片本质同一句,时间轴保留);
- 少行:末尾补空串占位(宁缺勿错位,不挤占相邻字幕时间轴)。
本测试分两层:
1. _repair_batch / translate_lines 确定性单元测试(红 -> 绿);
2. 真实数据 + 真实 LLM 集成测试(非 mock),验证产物与原文逐条对齐。
数据/Key 缺失时 skip。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from tests.realdata_contract import parse_srt_entries
WORKSPACE = Path(__file__).resolve().parent.parent
TRANSCRIPT = Path(
"/home/cat/Downloads/39.105.149.197/202609051737"
"/run_51242078d76e/steps/asr/transcript.srt"
)
CHUNK_SIZE = 20
# ---------------------------------------------------------------------------
# 层一 helper:可注入的假 HTTP 客户端(与 nodes/llm.py 的 urllib 契约一致)
# ---------------------------------------------------------------------------
class _FakeUrlOpen:
"""模拟 urllib.request.urlopen:按调用次数依次返回预置的 LLM 输出。"""
def __init__(self, contents: list[str]):
self._contents = contents
self._calls = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self) -> bytes:
content = self._contents[self._calls]
self._calls += 1
payload = {"choices": [{"message": {"content": content}}]}
return json.dumps(payload).encode("utf-8")
def _patch_translate_llm(monkeypatch, batch_outputs: list[str]) -> None:
"""统一打桩:把 translate_lines 内 urlopen 换成 _FakeUrlOpen。"""
import urllib.request
# 直接替换 urllib.request.urlopennodes/llm.py 也是经它调用)。
# 直接替换 urllib.request.urlopennodes/llm.py 也是经它调用)。
fake = _FakeUrlOpen(batch_outputs)
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: fake)
monkeypatch.setenv("LLM_API_KEY", "test-key")
monkeypatch.setenv("LLM_API_BASE", "http://fake/v1/chat/completions")
# ---------------------------------------------------------------------------
# 层一:_repair_batch 确定性单元测试
# ---------------------------------------------------------------------------
def test_repair_batch_extra_lines_merged() -> None:
"""多行:LLM 返回 21 行但输入 20 行,末尾多余行应合并到前一行。"""
from nodes.llm import _repair_batch
out = _repair_batch([f"译{i}" for i in range(21)], 20)
assert len(out) == 20
# 最后一行 = 原第 19(索引19)+第 20(索引20)行的合并。
assert out[19] == "译19 译20"
def test_repair_batch_fewer_lines_padded() -> None:
"""少行:LLM 返回 19 行但输入 20 行,末尾补空串占位不挤占时间轴。"""
from nodes.llm import _repair_batch
out = _repair_batch([f"译{i}" for i in range(19)], 20)
assert len(out) == 20
assert out[19] == ""
def test_repair_batch_exact_unchanged() -> None:
"""正好对齐:原样返回。"""
from nodes.llm import _repair_batch
out = _repair_batch([f"译{i}" for i in range(20)], 20)
assert len(out) == 20
assert out == [f"译{i}" for i in range(20)]
# ---------------------------------------------------------------------------
# 层一:translate_lines 整批校验(多行/少行场景经修复后必须对齐)
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_translate_lines_aligns_extra_line(monkeypatch) -> None:
"""输入 40 行(两批 20),首批 LLM 返回 21 行:修复后必须对齐为 40 行。"""
from nodes import llm as llm_node
src_lines = [f"原文{i}" for i in range(40)]
batch1_wrong = "\n".join([f"译{i}" for i in range(21)]) # 21 行错位源
batch2_ok = "\n".join([f"译{i}" for i in range(20, 40)])
_patch_translate_llm(monkeypatch, [batch1_wrong, batch2_ok])
result = llm_node.translate_lines(src_lines, {})
assert len(result) == len(src_lines), (
f"translate_lines 未把多行合并对齐:输入 {len(src_lines)} 行,返回 {len(result)} 行"
)
@pytest.mark.integration
def test_translate_lines_aligns_missing_line(monkeypatch) -> None:
"""第二批 LLM 少行时触发重试:重试返回正确 20 行后必须仍为 40 行。"""
from nodes import llm as llm_node
src_lines = [f"原文{i}" for i in range(40)]
batch1_ok = "\n".join([f"译{i}" for i in range(20)])
# 第二批第一次返回 19 行(少行)-> 触发重试;第二次返回正确 20 行。
batch2_short = "\n".join([f"译{i}" for i in range(20, 39)]) # 19 行
batch2_retry = "\n".join([f"译{i}" for i in range(20, 40)]) # 20 行
_patch_translate_llm(monkeypatch, [batch1_ok, batch2_short, batch2_retry])
result = llm_node.translate_lines(src_lines, {})
assert len(result) == len(src_lines), (
f"translate_lines 未把少行补齐:输入 {len(src_lines)} 行,返回 {len(result)} 行"
)
@pytest.mark.integration
def test_translate_lines_pads_after_retries_exhausted(monkeypatch) -> None:
"""少行且重试耗尽:必须补空串占位,仍保持与输入等长(宁缺勿错位)。"""
from nodes import llm as llm_node
src_lines = [f"原文{i}" for i in range(40)]
batch1_ok = "\n".join([f"译{i}" for i in range(20)])
batch2_short = "\n".join([f"译{i}" for i in range(20, 39)])
from nodes.llm import MAX_BATCH_RETRIES
# 首次调用 + 重试重发,共 MAX_BATCH_RETRIES 次对 batch2 的调用都返回 19 行。
responses = [batch1_ok] + [batch2_short] * MAX_BATCH_RETRIES
_patch_translate_llm(monkeypatch, responses)
result = llm_node.translate_lines(src_lines, {})
assert len(result) == len(src_lines), (
f"重试耗尽后未能补空串:输入 {len(src_lines)} 行,返回 {len(result)} 行"
)
# ---------------------------------------------------------------------------
# 层二:真实数据 + 真实 LLM 集成测试(非 mock)
# ---------------------------------------------------------------------------
def _llm_credentials_ok() -> bool:
"""是否具备真实 LLM 调用条件(加载 .env 后 Key 非空)。"""
try:
from dotenv import load_dotenv
load_dotenv(WORKSPACE / ".env")
except Exception:
pass
return bool(os.getenv("LLM_API_KEY"))
@pytest.mark.integration
def test_pipeline_zh_cn_timetext_alignment(tmp_path) -> None:
"""真实数据 + 真实 LLM:完整翻译流水线后,译文必须与原文时间逐条对齐。
方法:把真实日文 transcript.srt 喂给 llm.invoke(真实 LLM API),产出
cn.srt;逐条比较 cn.srt 与原文的 (start, 行序) 严格一致。
"""
if not _llm_credentials_ok():
pytest.skip("未配置 LLM_API_KEY,跳过真实 LLM 集成测试")
if not TRANSCRIPT.is_file():
pytest.skip("缺少真实 transcript.srt,跳过集成测试")
from wov_sdk.models import InvokeRequest
from nodes import llm as llm_node
out_dir = tmp_path / "out"
response = llm_node.invoke(
InvokeRequest(
run_id="align_llm_test",
node_instance_id="",
inputs={"srt_uri": str(TRANSCRIPT)},
params={"target_language": "zh-CN"},
output_dir=str(out_dir),
)
)
assert response.status == "completed", response.error
zh_path = Path(response.outputs["cn_srt_uri"])
zh_entries = parse_srt_entries(zh_path.read_text(encoding="utf-8"))
src_entries = parse_srt_entries(TRANSCRIPT.read_text(encoding="utf-8"))
assert len(zh_entries) == len(src_entries), (
f"译文条数 {len(zh_entries)} != 原文 {len(src_entries)}:批内行数不一致导致错位。"
)
for i, (ze, se) in enumerate(zip(zh_entries, src_entries)):
if abs(ze["start"] - se["start"]) > 0.01:
raise AssertionError(
f"第 {i} 条译文时间 {ze['start']:.2f} != 原文 {se['start']:.2f}"
f"译文文本已整体错位(原文 '{se['text'][:15]}'"
)