fix: SRT 按 cue 解析并按 ID 回填译文,OCR 空帧分段与失败重试

This commit is contained in:
2026-09-11 17:19:25 +08:00
parent 3a612919f7
commit 6eb65e4356
9 changed files with 391 additions and 326 deletions
+6 -6
View File
@@ -485,7 +485,7 @@ def test_llm_translate_lines_via_fake_api(monkeypatch) -> None:
"choices": [
{
"message": {
"content": "译文一\n译文二\n译文三\n译文四\n译文五"
"content": json.dumps([{"id": i, "text": text} for i, text in enumerate(["译文一", "译文二", "译文三", "译文四", "译文五"], 1)])
}
}
]
@@ -548,7 +548,7 @@ def test_llm_translate_lines_default_timeout(monkeypatch) -> None:
def fake_open(request, timeout):
captured["timeout"] = timeout
return FakeUrlOpenResponse("译文一")
return FakeUrlOpenResponse('[{"id": 1, "text": "译文一"}]')
monkeypatch.setattr("nodes.llm.urllib.request.urlopen", fake_open)
monkeypatch.setenv("LLM_API_BASE", "http://fake/v1/chat/completions")
@@ -565,7 +565,7 @@ def test_llm_translate_lines_env_timeout(monkeypatch) -> None:
def fake_open(request, timeout):
captured["timeout"] = timeout
return FakeUrlOpenResponse("译文一")
return FakeUrlOpenResponse('[{"id": 1, "text": "译文一"}]')
monkeypatch.setattr("nodes.llm.urllib.request.urlopen", fake_open)
monkeypatch.setenv("LLM_API_BASE", "http://fake/v1/chat/completions")
@@ -598,8 +598,8 @@ def test_llm_invoke_success(tmp_path, monkeypatch) -> None:
assert "译文1" in content
def test_llm_invoke_pads_short_translation(tmp_path, monkeypatch) -> None:
"""验证译文数不足时用空行补齐,保持 SRT 结构完整"""
def test_llm_invoke_rejects_short_translation(tmp_path, monkeypatch) -> None:
"""防御性校验:译文数不足时失败,不用空行掩盖不完整结果"""
source = _make_srt(tmp_path, count=3)
monkeypatch.setattr(
"nodes.llm.translate_lines",
@@ -613,7 +613,7 @@ def test_llm_invoke_pads_short_translation(tmp_path, monkeypatch) -> None:
output_dir=str(tmp_path / "out2"),
)
)
assert response.status == "completed"
assert response.status == "failed"
def test_llm_invoke_missing_input(tmp_path) -> None:
+7 -5
View File
@@ -397,8 +397,8 @@ def test_ocr_merges_consecutive_same_text(monkeypatch, tmp_path) -> None:
assert "00:00:06,000 --> 00:00:10,000" in srt
def test_ocr_skips_failed_frames(monkeypatch, tmp_path) -> None:
"""个别帧 OCR 失败时跳过,不影响其余帧汇总"""
def test_ocr_preserves_checkpoint_for_failed_frames(monkeypatch, tmp_path) -> None:
"""个别帧持续失败时返回 failed,其余成功帧存档供重试复用"""
frames = []
for index in range(3):
image = tmp_path / f"f{index}.png"
@@ -421,9 +421,11 @@ def test_ocr_skips_failed_frames(monkeypatch, tmp_path) -> None:
output_dir=str(tmp_path / "out"),
)
)
assert response.status == "completed", response.error
srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "SUB 001" in srt
assert response.status == "failed"
partial = [json.loads(line) for line in (tmp_path / "out/ocr_partial.jsonl").read_text().splitlines()]
assert {item["frame"] for item in partial} == {0, 2}
assert all(item["text"] == "SUB 001" for item in partial)
assert not (tmp_path / "out/subtitle.srt").exists()
def test_ocr_missing_manifest(tmp_path) -> None:
+66
View File
@@ -0,0 +1,66 @@
"""R06:真实图片清单上的临时网络故障、空帧与跨空白字幕段回归。"""
import json
from pathlib import Path
import pytest
from nodes import subtitle_ocr
from wov_sdk.models import InvokeRequest, InvokeResponse
def test_identical_text_separated_by_blank_is_two_cues():
"""A、空白、A 不能合并,否则字幕会覆盖原本无文字的时段。"""
manifest = [{"time": i * 0.5} for i in range(4)]
assert subtitle_ocr._merge_kept(manifest, ["你好", "你好", "", "你好"]) == [
(0.0, 0.5, "你好"), (1.5, 1.5, "你好")]
@pytest.mark.parametrize("raises", [False, True])
def test_failed_frame_retries_and_resume_preserves_success(monkeypatch, tmp_path, raises):
"""失败帧单独重试,持续故障不存为空;下次只补失败帧,成功空帧不重做。"""
assets = Path(__file__).resolve().parent.parent / "testdata"
image = assets / "ocr_text.png"
empty = assets / "ocr_notext.png"
if not image.is_file() or not empty.is_file():
pytest.skip("缺少真实 OCR 图片")
manifest = tmp_path / "frames.json"
manifest.write_text(json.dumps([{"time": 0, "image_uri": str(empty)},
{"time": 0.5, "image_uri": str(image)}]))
out = tmp_path / "out"
request = InvokeRequest(run_id="r", node_instance_id="", inputs={"frames_manifest": str(manifest)}, output_dir=str(out))
calls = []
broken = True
def invoke(node_id, req):
uri = req.inputs["image_uri"]
calls.append(uri)
if uri == str(image) and broken:
if raises:
raise TimeoutError("timeout")
return InvokeResponse(status="failed", error="timeout")
return InvokeResponse(status="completed", outputs={"text": "" if uri == str(empty) else "你好"})
monkeypatch.setattr("wov_app.registry.invoke", invoke)
response = subtitle_ocr.invoke(request)
assert response.status == "failed"
assert calls.count(str(image)) == 2
assert calls.count(str(empty)) == 1
partial = [json.loads(line) for line in (out / "ocr_partial.jsonl").read_text().splitlines()]
assert partial == [{"frame": 0, "text": "", "status": "completed"}]
assert not (out / "subtitle.srt").exists()
broken = False
calls.clear()
response = subtitle_ocr.invoke(request)
assert response.status == "completed"
assert calls == [str(image)]
assert "你好" in Path(response.outputs["srt_uri"]).read_text()
def test_legacy_empty_checkpoint_is_rechecked(tmp_path):
"""旧版空串可能来自超时,不能当成确认无文字;旧版非空成功结果可复用。"""
(tmp_path / "ocr_partial.jsonl").write_text(
json.dumps({"frame": 0, "text": ""}) + "\n" +
json.dumps({"frame": 1, "text": "你好"}, ensure_ascii=False) + "\n" +
json.dumps({"frame": 2, "text": "", "status": "completed"}) + "\n")
assert subtitle_ocr._load_partial(tmp_path) == {1: "你好", 2: ""}
+24 -5
View File
@@ -2,6 +2,10 @@
目标:验证 subtitle-ocr 在**多线程**执行时能否正确处理字幕顺序。
R06 更新:下述 1666 条文件保留作历史记录,含跨空白帧合并缺陷,已不作为
逐字节正确性标准。基线由真实单线程/完整成功存档组装产生(1942 条),
同时逐采样点检查正文和空白,要求多线程及断点结果与新基线一致。
- 不走真实 vlm-ocrOllama)网络调用:registry.invoke 被替换为
FakeVlmOcrApi,按 image_uri 文件名中的帧号,直接从测试数据(真实任务
run_ac7f480a3ccb 的**全量**逐帧 OCR 结果)取该帧文本返回,模拟真实
@@ -111,7 +115,9 @@ def _run_ocr(monkeypatch, manifest_path: Path, texts_by_frame: dict[int, str],
"""用给定线程配置运行 subtitle-ocr,返回 (产物路径, 假 API 实例)。"""
from nodes.subtitle_ocr import invoke as ocr_invoke
fake = FakeVlmOcrApi(texts_by_frame, seed=seed)
# 单线程基线无需模拟网络等待,多线程仍保留真实延迟分布验证乱序完成。
fake = (FakeVlmOcrApi(texts_by_frame, seed=seed, fast_ms=0, slow_ms=0)
if pool_max == 1 else FakeVlmOcrApi(texts_by_frame, seed=seed))
monkeypatch.setattr("wov_app.registry.invoke", fake)
response = ocr_invoke(
InvokeRequest(
@@ -157,6 +163,11 @@ def _assert_alignment(srt_text: str, manifest: list[dict], texts_by_frame: dict[
best = min(time_text, key=lambda t: abs(t - start_s))
assert abs(best - start_s) <= 0.002, f"字幕起始时刻 {start_s}s 无对应帧"
assert time_text[best] == text.strip(), f"时刻 {start_s}s 的文本与帧不一致"
# 每个采样点都须匹配正文,不能让同一句字幕跨过无文字帧。
end_s = _ts_to_seconds(_end)
covered = [value for timestamp, value in time_text.items()
if best <= timestamp < end_s - 0.002]
assert covered and all(value == text.strip() for value in covered)
times.append(start_s)
assert all(a < b for a, b in zip(times, times[1:])), "时间轴必须严格递增"
@@ -174,7 +185,11 @@ class TestSubtitleOcrOrderUnderThreading:
def test_full_real_data_variable_latency_keeps_order(self, monkeypatch, tmp_path) -> None:
"""全量真实数据 + 真实可变延迟:多线程产物与单线程确认结果逐字节一致。"""
manifest, texts_by_frame = _load_full_data()
confirmed = CONFIRMED_SRT.read_text(encoding="utf-8")
# R06:旧 1666 条结果跨空白合并,改用当前真实单线程路径构建基线。
confirmed_path, _ = _run_ocr(monkeypatch, FULL_MANIFEST, texts_by_frame,
1, 1, tmp_path / "single", 20260817)
confirmed = confirmed_path.read_text(encoding="utf-8")
assert confirmed.count("-->") == 1942
outputs: dict[tuple, str] = {}
fakes: dict[tuple, FakeVlmOcrApi] = {}
@@ -188,7 +203,7 @@ class TestSubtitleOcrOrderUnderThreading:
outputs[(pool_min, pool_max)] = srt_path.read_text(encoding="utf-8")
fakes[(pool_min, pool_max)] = fake
# ① 多线程产物与用户确认过的精确结果(真实单线程运行逐字节一致。
# ① 多线程产物与本次真实单线程运行基线逐字节一致R06 修正跨空白)
assert outputs[(4, 4)] == confirmed, "4 线程产物与确认结果不一致"
assert outputs[(16, 16)] == confirmed, "16 线程产物与确认结果不一致"
assert outputs[(4, 4)] == outputs[(16, 16)]
@@ -223,7 +238,11 @@ def test_ocr_resumes_from_partial_checkpoint(monkeypatch, tmp_path) -> None:
跑完逐字节一致——重启不浪费已处理的帧。
"""
manifest, texts_by_frame = _load_full_data()
confirmed = CONFIRMED_SRT.read_text(encoding="utf-8")
# 基线走完整 OCR 路径,包含超长输出跳过规则,不手写业务处理后的存档。
baseline_path, _ = _run_ocr(monkeypatch, FULL_MANIFEST, texts_by_frame,
1, 1, tmp_path / "baseline", 99)
confirmed = baseline_path.read_text(encoding="utf-8")
assert confirmed.count("-->") == 1942
out_dir = tmp_path / "resume"
partial_path = out_dir / "ocr_partial.jsonl"
partial_path.parent.mkdir(parents=True)
@@ -231,7 +250,7 @@ def test_ocr_resumes_from_partial_checkpoint(monkeypatch, tmp_path) -> None:
for i in range(100):
# 存档按 0-based 帧序号记录;manifest[i] 的帧号 = i+1。
lines.append(
json.dumps({"frame": i, "text": texts_by_frame[i + 1]}, ensure_ascii=False)
json.dumps({"frame": i, "text": texts_by_frame[i + 1], "status": "completed"}, ensure_ascii=False)
)
if i == 50:
lines.append("") # 空行:_load_partial 必须跳过,不视为一条记录。
+105 -216
View File
@@ -1,231 +1,120 @@
"""翻译批处理行数对齐测试(先红后绿)
"""R05 回归:合法 SRT 多行/空 cue、稳定 ID 翻译和非法模型输出重试
背景:真实任务 run_51242078d76e(CJOD-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。
历史 run_51242078d76e 出现文本贴错时间;仅检查行数或在末尾合并/补空无法
定位中间缺失。本测试在 HTTP 边界注入 JSON 响应,调用真实翻译实现。
"""
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
from nodes import llm
from wov_sdk.models import InvokeRequest
# ---------------------------------------------------------------------------
# 层一 helper:可注入的假 HTTP 客户端(与 nodes/llm.py 的 urllib 契约一致)
# ---------------------------------------------------------------------------
def _http(monkeypatch, answers):
"""按次序返回真实 chat.completions 结构,并记录发出的输入。"""
calls = []
iterator = iter(answers)
class Response:
def __init__(self, content):
self.content = content
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return json.dumps({"choices": [{"message": {"content": self.content}}],
"usage": {"total_tokens": 10}}).encode()
def open_request(request, **kwargs):
calls.append(json.loads(request.data))
answer = next(iterator)
return Response(answer if isinstance(answer, str) else json.dumps(answer))
monkeypatch.setattr("urllib.request.urlopen", open_request)
return calls
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),
)
)
def test_multiline_and_empty_cues_keep_timestamps(monkeypatch, tmp_path):
"""多行正文视为一条 cue,空 cue 不发送翻译,时间轴不会进入模型输入"""
source = tmp_path / "input.srt"
source.write_text("\ufeff7\n00:00:01,000 --> 00:00:02,000\nこんにちは\n元気ですか\n\n"
"8\n00:00:03,000 --> 00:00:04,000\n\n"
"9\n00:00:05,000 --> 00:00:06,000\nはい\n", encoding="utf-8")
calls = _http(monkeypatch, [[{"id": 3, "text": "是的"}, {"id": 1, "text": "你好\n还好吗"}]])
response = llm.invoke(InvokeRequest(run_id="r", node_instance_id="", inputs={"srt_uri": str(source)}, output_dir=str(tmp_path / "out")))
assert response.status == "completed", response.error
assert json.loads(calls[0]["messages"][1]["content"]) == [
{"id": 1, "text": "こんにちは\n元気ですか"}, {"id": 3, "text": "はい"}]
text = Path(response.outputs["cn_srt_uri"]).read_text()
assert text.count("-->") == 3
assert "00:00:01,000 --> 00:00:02,000\n你好\n还好吗" in text
assert "00:00:03,000 --> 00:00:04,000\n\n" in text
assert "00:00:05,000 --> 00:00:06,000\n是的" in text
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]}'"
)
@pytest.mark.parametrize("bad", [
[{"id": 1, "text": ""}],
[{"id": 1, "text": ""}, {"id": 1, "text": "重复"}],
[{"id": 1, "text": ""}, {"id": 99, "text": "未知"}],
[{"id": True, "text": ""}, {"id": 2, "text": ""}],
[{"id": 1, "text": ""}, {"id": 2, "text": ""}],
"\n", "{truncated", {"1": "", "2": ""},
])
def test_invalid_ids_retry_without_positional_repair(monkeypatch, bad):
"""缺失/重复/未知 ID 和无结构文本均重试整批,不猜测句子对应关系。"""
calls = _http(monkeypatch, [bad, [{"id": 2, "text": ""}, {"id": 1, "text": ""}]])
assert llm.translate_lines(["first", "second"], {}) == ["", ""]
assert len(calls) == 2
assert calls[0]["messages"][1] == calls[1]["messages"][1]
def test_exhausted_alignment_retries_fail_node(monkeypatch, tmp_path):
"""无法对齐时返回 failed,不生成带空占位或错位文本的成功成品。"""
source = tmp_path / "input.srt"
source.write_text("1\n00:00:01,000 --> 00:00:02,000\nhello\n", encoding="utf-8")
calls = _http(monkeypatch, ["无 ID 输出"] * llm.MAX_BATCH_RETRIES)
response = llm.invoke(InvokeRequest(run_id="r", node_instance_id="", inputs={"srt_uri": str(source)}, output_dir=str(tmp_path / "out")))
assert response.status == "failed"
assert len(calls) == llm.MAX_BATCH_RETRIES
assert not (tmp_path / "out/cn.srt").exists()
def test_batch_ids_are_global_and_order_independent(monkeypatch):
"""跨批 ID 保持全局位置,乱序输出也能按正确 cue 回填。"""
answers = [[{"id": i, "text": f"{i}"} for i in range(20, 0, -1)],
[{"id": 22, "text": "译22"}, {"id": 21, "text": "译21"}]]
calls = _http(monkeypatch, answers)
assert llm.translate_lines([f"{i}" for i in range(1, 23)], {}) == [f"{i}" for i in range(1, 23)]
assert json.loads(calls[1]["messages"][1]["content"])[0]["id"] == 21
def test_malformed_srt_fails_without_llm(monkeypatch, tmp_path):
"""非空坏字幕不能被静默解析为空并成功输出。"""
source = tmp_path / "input.srt"
source.write_text("1\ninvalid timestamp\nhello\n", encoding="utf-8")
calls = _http(monkeypatch, [])
response = llm.invoke(InvokeRequest(run_id="r", node_instance_id="", inputs={"srt_uri": str(source)}, output_dir=str(tmp_path / "out")))
assert response.status == "failed"
assert calls == []
@pytest.mark.integration
def test_real_llm_structured_translation():
"""真实接口校准 JSON 协议;无 Key 时跳过,仅使用短日文句子控制调用量。"""
import os
from dotenv import load_dotenv
load_dotenv()
if not os.getenv("LLM_API_KEY"):
pytest.skip("未配置 LLM_API_KEY")
result = llm.translate_lines(["こんにちは。", "ありがとうございます。"], {})
assert len(result) == 2 and all(result)
assert any(word in result[0] for word in ("", ""))
assert "" in result[1]