Compare commits
2
Commits
d1557eca46
...
5c12bbcb74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c12bbcb74 | ||
|
|
078170c12a |
@@ -20,3 +20,7 @@ model/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# 时间对齐集成测试的大体积音视频素材(不入库,按需从用户提供路径复制)
|
||||||
|
testdata/alignment/*.mp4
|
||||||
|
testdata/alignment/*.wav
|
||||||
|
|||||||
+139
-42
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行
|
单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行
|
||||||
分批调用 LLM,再把译文回填到原 SRT 结构并输出 cn.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
|
from __future__ import annotations
|
||||||
@@ -13,65 +28,146 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from wov_sdk.models import InvokeRequest, InvokeResponse
|
from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||||
|
|
||||||
# 单次 LLM 请求携带的字幕行数;过大会超出模型上下文,过小则请求次数过多。
|
# 单次 LLM 请求携带的字幕行数;过大会超出模型上下文,过小则请求次数过多。
|
||||||
CHUNK_SIZE = 20
|
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]:
|
def translate_lines(lines: list[str], params: dict) -> list[str]:
|
||||||
"""分批调用 LLM 翻译纯文本行,返回顺序一致的译文列表。"""
|
"""分批调用 LLM 翻译纯文本行,返回顺序一致的译文列表。
|
||||||
# 接口地址、Key 和模型均可通过环境变量配置(.env 自动加载),
|
|
||||||
# 默认指向 SiliconFlow 兼容接口,模型为 DeepSeek-V4-Flash。
|
每批输入行数保持一致;若 LLM 返回行数不一致:多行合并、少行重试该批
|
||||||
|
(最多 MAX_BATCH_RETRIES 次),仍不足则补空串占位。保证每条字幕都有
|
||||||
|
译文且时间轴与原文逐条对齐,杜绝"内容对错时间"的错位。
|
||||||
|
"""
|
||||||
api_base = os.getenv(
|
api_base = os.getenv(
|
||||||
"LLM_API_BASE",
|
"LLM_API_BASE",
|
||||||
"https://api.siliconflow.cn/v1/chat/completions",
|
"https://api.siliconflow.cn/v1/chat/completions",
|
||||||
)
|
)
|
||||||
api_key = os.getenv("LLM_API_KEY", "")
|
api_key = os.getenv("LLM_API_KEY", "")
|
||||||
# 单次请求超时可配置,长文本翻译场景下需要放宽。
|
|
||||||
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
|
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
|
||||||
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
|
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
|
||||||
target_language = str(params.get("target_language", "zh-CN"))
|
target_language = str(params.get("target_language", "zh-CN"))
|
||||||
# 系统提示词约束模型只输出译文,保证行数和顺序可回填。
|
system_prompt = _system_prompt(target_language)
|
||||||
system_prompt = (
|
|
||||||
"你是专业字幕翻译。将用户提供的日文字幕翻译为"
|
|
||||||
f"{target_language}。只返回译文,保持行数和顺序,不要添加解释。"
|
|
||||||
)
|
|
||||||
translated: list[str] = []
|
translated: list[str] = []
|
||||||
# 按 CHUNK_SIZE 分批发送,避免单次请求超过模型上下文限制。
|
|
||||||
for start in range(0, len(lines), CHUNK_SIZE):
|
for start in range(0, len(lines), CHUNK_SIZE):
|
||||||
chunk = lines[start : start + CHUNK_SIZE]
|
chunk = lines[start : start + CHUNK_SIZE]
|
||||||
body = {
|
batch_translated = _translate_batch(
|
||||||
"model": model,
|
chunk, api_base, api_key, model, system_prompt, request_timeout
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": "\n".join(chunk)},
|
|
||||||
],
|
|
||||||
# 关闭推理模型的思考模式:Qwen3 等模型默认会把推理过程写入
|
|
||||||
# reasoning_content,导致 content 为空或截断译文;关闭后直接输出译文。
|
|
||||||
"enable_thinking": False,
|
|
||||||
# 放宽输出上限,避免长批次翻译被模型默认 max_tokens 截断。
|
|
||||||
"max_tokens": 8192,
|
|
||||||
}
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
# 配置了 Key 时附带 Bearer 鉴权头。
|
|
||||||
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"))
|
|
||||||
# 兼容 OpenAI Chat Completions 响应格式,取第一条消息内容。
|
|
||||||
content = payload["choices"][0]["message"]["content"]
|
|
||||||
# 忽略空行,保证译文列表与输入行一一对应。
|
|
||||||
translated.extend(
|
|
||||||
[line.strip() for line in content.splitlines() if line.strip()]
|
|
||||||
)
|
)
|
||||||
|
translated.extend(batch_translated)
|
||||||
return 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 等长译文。"""
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
content = _call_llm(
|
||||||
|
api_base,
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
system_prompt,
|
||||||
|
"\n".join(chunk),
|
||||||
|
request_timeout,
|
||||||
|
)
|
||||||
|
batch = [line.strip() for line in content.splitlines() if line.strip()]
|
||||||
|
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:
|
def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||||
"""翻译 SRT 文件中的字幕文本,输出 cn.srt。"""
|
"""翻译 SRT 文件中的字幕文本,输出 cn.srt。"""
|
||||||
srt_uri = request.inputs.get("srt_uri")
|
srt_uri = request.inputs.get("srt_uri")
|
||||||
@@ -86,10 +182,13 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
|||||||
lines = srt_path.read_text(encoding="utf-8").splitlines()
|
lines = srt_path.read_text(encoding="utf-8").splitlines()
|
||||||
text_indices = list(range(2, len(lines), 4))
|
text_indices = list(range(2, len(lines), 4))
|
||||||
source_lines = [lines[index] for index in text_indices]
|
source_lines = [lines[index] for index in text_indices]
|
||||||
|
|
||||||
|
# 翻译:返回与 source_lines 严格等长的译文(多/少行已在批内修复)。
|
||||||
translated_lines = translate_lines(source_lines, request.params)
|
translated_lines = translate_lines(source_lines, request.params)
|
||||||
# 防止模型返回行数偏差:多出的截断,缺少的用空串补齐。
|
# 防御性兜底:确保长度一致(translate_lines 已保证,此处双保险)。
|
||||||
translated_lines = translated_lines[: len(source_lines)]
|
translated_lines = translated_lines[: len(source_lines)]
|
||||||
translated_lines += [""] * max(0, len(source_lines) - len(translated_lines))
|
translated_lines += [""] * max(0, len(source_lines) - len(translated_lines))
|
||||||
|
|
||||||
# 只替换文本行,序号、时间轴和空行保持不变。
|
# 只替换文本行,序号、时间轴和空行保持不变。
|
||||||
for index, text_index in enumerate(text_indices):
|
for index, text_index in enumerate(text_indices):
|
||||||
lines[text_index] = translated_lines[index]
|
lines[text_index] = translated_lines[index]
|
||||||
@@ -97,7 +196,5 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
|||||||
output_dir = Path(request.output_dir)
|
output_dir = Path(request.output_dir)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_path = output_dir / "cn.srt"
|
output_path = output_dir / "cn.srt"
|
||||||
# 末尾补一个换行,让文件满足常见文本工具习惯。
|
|
||||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)})
|
return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)})
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,7 @@ include = ["wov_sdk*", "wov_app*", "nodes*"]
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
addopts = "--cov=src --cov=nodes --cov-fail-under=100 -p no:cacheprovider"
|
addopts = "--cov=src --cov=nodes --cov-fail-under=100 -p no:cacheprovider"
|
||||||
|
# 集成测试标记(真实模型/真实 LLM/真实数据,默认随全套执行,缺数据自动跳过)。
|
||||||
|
markers = [
|
||||||
|
"integration: 需要真实模型/真实音频/真实 LLM API 或用户提供的真实数据,数据或环境缺失时跳过",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""从烧录字幕视频自动提取**参考时间轴**(供时间对齐集成测试使用)。
|
||||||
|
|
||||||
|
用户选择的参考数据途径:烧录字幕视频自动提取——复用仓库自带的生产链路
|
||||||
|
(frame-extract 抽帧 → subtitle-ocr 逐帧 OCR → 汇总 SRT),把烧录字幕的
|
||||||
|
出现/消失帧时间换算成秒,产出严格符合测试契约的参考字幕:
|
||||||
|
|
||||||
|
testdata/alignment/<name>.reference.srt (标准 SRT)
|
||||||
|
|
||||||
|
为什么烧录字幕的时间是可信的 ground truth:
|
||||||
|
|
||||||
|
- 视频里烧录的字幕是**画面的一部分**,它在哪一帧出现/消失是客观事实;
|
||||||
|
frame-extract 按帧号取帧,时间 = 帧号 / fps,零累计偏差;
|
||||||
|
- subtitle-ocr 对逐帧 OCR 后合并相同字幕,结束 = 最后可见帧 + 采样间隔,
|
||||||
|
与视频烧录时间对齐(见 nodes/subtitle_ocr.py 的 _assemble_srt)。
|
||||||
|
|
||||||
|
因此提取出的参考时间轴可直接作为"说话真实发生的时间"与 whisper 产物比对。
|
||||||
|
**提取结果请人工核对无误后再使用**:脚本只负责自动化,不负责保证正确。
|
||||||
|
|
||||||
|
用法(在 vrsub 根目录,需 Ollama glm-ocr 服务可达、有 ffmpeg):
|
||||||
|
|
||||||
|
uv run python scripts/extract_reference_srt.py <视频路径> [--out testdata/alignment <name>] [--interval 0.5] [--crop 0,0.75,1,0.25]
|
||||||
|
|
||||||
|
- --interval:抽帧间隔秒(默认 0.5;字幕时长 2s 时约取 4 帧,够稳定合并)
|
||||||
|
- --crop:字幕区域相对比例 x,y,w,h(默认底部 1/4,与生产默认一致)
|
||||||
|
- 输出文件名默认 <视频名>.reference.srt;用 --out-dir/--name 可指定目录与
|
||||||
|
前缀,保证与 tests/realdata_contract.alignment_candidates() 的发现规则一致
|
||||||
|
(<name>.<ext> 与 <name>.reference.srt 同目录同名)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from wov_sdk.models import InvokeRequest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# 与 tests/test_integration_subtitle_ocr.py、tests/test_integration_vlm.py 相同约定。
|
||||||
|
OLLAMA_HOST = "http://192.168.123.70:11434"
|
||||||
|
MODEL = "glm-ocr:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_reachable(host: str = OLLAMA_HOST, model: str = MODEL) -> bool:
|
||||||
|
"""探测 Ollama 服务与目标模型是否可用。"""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{host}/api/show",
|
||||||
|
data=('{"model": "%s"}' % model).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except (urllib.error.URLError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _register_nodes() -> None:
|
||||||
|
"""注册全部内置节点,供 subtitle-ocr 内部调 vlm-ocr 使用。"""
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
_sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
from wov_app import registry
|
||||||
|
|
||||||
|
registry.register_all()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_crop(raw: str) -> list[float]:
|
||||||
|
"""解析 crop 参数:'x,y,w,h' -> [x, y, w, h]。"""
|
||||||
|
parts = [float(p) for p in raw.split(",")]
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise SystemExit(f"crop 参数格式错误,应为 x,y,w,h,得到: {raw}")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def extract_reference_srt(
|
||||||
|
video: Path,
|
||||||
|
out_path: Path,
|
||||||
|
interval_seconds: float = 0.5,
|
||||||
|
crop: list[float] | None = None,
|
||||||
|
tmp_root: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""跑 frame-extract → subtitle-ocr,把汇总 SRT 写为参考字幕文件。
|
||||||
|
|
||||||
|
返回写出的参考 SRT 路径。crop 缺省用画面底部 1/4([0, 0.75, 1, 0.25])。
|
||||||
|
tmp_root 供测试注入临时目录,缺省用视频同名临时目录(用完清理)。
|
||||||
|
"""
|
||||||
|
from nodes.frame_extract import invoke as frame_invoke
|
||||||
|
from nodes.subtitle_ocr import invoke as ocr_invoke
|
||||||
|
|
||||||
|
if crop is None:
|
||||||
|
crop = [0.0, 0.75, 1.0, 0.25]
|
||||||
|
tmp = tmp_root or (video.parent / f"_ref_{video.stem}")
|
||||||
|
tmp.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ① 抽帧:按帧号精确取帧(时间 = 帧号/fps,无累计偏差)。
|
||||||
|
frames_resp = frame_invoke(
|
||||||
|
InvokeRequest(
|
||||||
|
run_id=f"ref_{video.stem}",
|
||||||
|
node_instance_id="",
|
||||||
|
inputs={"video_uri": str(video)},
|
||||||
|
params={"interval_seconds": interval_seconds, "crop": crop},
|
||||||
|
output_dir=str(tmp / "frames"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if frames_resp.status != "completed":
|
||||||
|
raise SystemExit(f"抽帧失败: {frames_resp.error}")
|
||||||
|
|
||||||
|
# ② 逐帧 OCR → 汇总 SRT(消失时间 = 最后可见帧 + 采样间隔)。
|
||||||
|
ocr_resp = ocr_invoke(
|
||||||
|
InvokeRequest(
|
||||||
|
run_id=f"ref_{video.stem}",
|
||||||
|
node_instance_id="",
|
||||||
|
inputs={"frames_manifest": str(frames_resp.outputs["frames_manifest"])},
|
||||||
|
params={"model": MODEL, "ollama_host": OLLAMA_HOST},
|
||||||
|
output_dir=str(tmp / "ocr"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ocr_resp.status != "completed":
|
||||||
|
raise SystemExit(f"OCR 失败: {ocr_resp.error}")
|
||||||
|
|
||||||
|
srt = Path(ocr_resp.outputs["srt_uri"])
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(srt.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="从烧录字幕视频自动提取参考时间轴")
|
||||||
|
parser.add_argument("video", type=Path, help="烧录字幕视频路径(mp4 等)")
|
||||||
|
parser.add_argument("--out-dir", type=Path, default=PROJECT_ROOT / "testdata" / "alignment")
|
||||||
|
parser.add_argument("--name", type=str, default=None, help="输出名前缀(默认=视频文件名)")
|
||||||
|
parser.add_argument("--interval", type=float, default=0.5, help="抽帧间隔秒(默认 0.5)")
|
||||||
|
parser.add_argument("--crop", type=str, default="0,0.75,1,0.25", help="字幕区域 x,y,w,h")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
video = args.video.resolve()
|
||||||
|
if not video.is_file():
|
||||||
|
print(f"视频不存在: {video}")
|
||||||
|
return 1
|
||||||
|
if not _ollama_reachable():
|
||||||
|
print(f"Ollama 不可用({OLLAMA_HOST} / {MODEL}),无法提取参考字幕。")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
_register_nodes()
|
||||||
|
name = args.name or video.stem
|
||||||
|
out_path = args.out_dir / f"{name}.reference.srt"
|
||||||
|
print(f"提取参考字幕 -> {out_path}", flush=True)
|
||||||
|
extract_reference_srt(
|
||||||
|
video,
|
||||||
|
out_path,
|
||||||
|
interval_seconds=args.interval,
|
||||||
|
crop=_parse_crop(args.crop),
|
||||||
|
)
|
||||||
|
print("完成。请人工核对参考时间轴后再用于时间对齐测试。", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Vendored
+75
@@ -0,0 +1,75 @@
|
|||||||
|
# 真实数据复现测试框架
|
||||||
|
|
||||||
|
本目录是"用**真实数据**复现问题并验证修复"的集成测试框架,**不 mock 任何
|
||||||
|
模型/音频/LLM**。测试只读取你按约定放好的真实数据文件;数据缺失时整体跳过,
|
||||||
|
不影响 100% 覆盖率门禁(`pytest --cov-fail-under=100`)。
|
||||||
|
|
||||||
|
## 你要做什么
|
||||||
|
|
||||||
|
把真实数据放到 `vrsub/testdata/` 下的两个子目录即可,测试自动发现并执行。
|
||||||
|
不需要改任何测试代码。
|
||||||
|
|
||||||
|
### 1)时间对齐(复现"字幕时间与说话时间不吻合")
|
||||||
|
|
||||||
|
```
|
||||||
|
vrsub/testdata/alignment/
|
||||||
|
├── <name>.mp4|wav|... # 真实视频/音频素材(有说话内容)
|
||||||
|
└── <name>.reference.srt # 人工校对时间轴的参考字幕(说话真实发生的时间)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 参考字幕用**标准 SRT**(序号/时间轴/文本/空行),时间戳格式
|
||||||
|
`HH:MM:SS,mmm -> HH:MM:SS,mmm`。
|
||||||
|
- 测试会对同一素材用生产 whisper 节点分别以 `vad_filter=True`(当前生产配置)
|
||||||
|
与 `vad_filter=False` 转写,各自与参考字幕做**最近邻时间对齐**,输出:
|
||||||
|
- `平均绝对偏差`(整体同步精度)
|
||||||
|
- `偏差中位数`(系统性偏早/偏晚方向与幅度)
|
||||||
|
- `偏早/偏晚累计`(问题严重程度)
|
||||||
|
- 复现判定:平均绝对偏差 > 0.5s,或中位偏差超出 ±0.7s,即判"过早/过晚"被
|
||||||
|
稳定捕获(红);修复后回落到容差内(绿)。
|
||||||
|
- 建议先放一段**约 30s~2min、说话清晰、有参考时间轴**的素材做第一轮复现。
|
||||||
|
|
||||||
|
### 2)幻觉词 / 专有名词提示词规则(复现"谢谢观看/晚安"与"芒果")
|
||||||
|
|
||||||
|
```
|
||||||
|
vrsub/testdata/prompt_rules/
|
||||||
|
├── <name>.ja.srt # 真实视频的日文 ASR 输出字幕(标准 SRT)
|
||||||
|
└── <name>.expected.txt # 期望清单(每行一个关键词)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<name>.ja.srt`:真实 ASR 输出,最好**包含**"谢谢观看 / 晚安 / 感谢收看"
|
||||||
|
等收尾寒暄,以及"マンゴー(芒果)"等专名。
|
||||||
|
- `<name>.expected.txt`:目前测试内置了寒暄词表与专名名单,期望文件内容可先
|
||||||
|
为空或写备注;断言规则已内置在 `tests/realdata_contract.py` 的
|
||||||
|
`HALLUCINATION_TOKENS` / `PROPER_NOUNS_NO_TRANSLATE` 中,需要扩充时改那里
|
||||||
|
并同步更新 `.expected.txt`。
|
||||||
|
- 该测试调用**真实 LLM API**(读取 `.env` 的 `LLM_API_BASE` / `LLM_API_KEY` /
|
||||||
|
`LLM_MODEL`,与生产 llm-translate 同一接口)。未配置 Key 时跳过;配置后
|
||||||
|
每次运行都会真实调用并断言"合入提示词规则后不再输出寒暄、专名不被直译"。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 只跑这两类真实数据集成测试
|
||||||
|
uv run pytest tests/test_integration_alignment.py tests/test_integration_prompt_rules.py -v
|
||||||
|
|
||||||
|
# 跑全部(含覆盖率门禁)
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
测试发现数据后自动成为回归门禁:**修复前红、修复后绿**,防止问题回潮。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `tests/realdata_contract.py` | 数据契约:目录/素材发现、SRT 解析、时间对齐指标、幻觉词/专名判定、提示词规则拼接(纯函数) |
|
||||||
|
| `tests/test_integration_alignment.py` | 时间对齐集成测试(真实 whisper 节点 + 参考字幕,vad 开/关对照) |
|
||||||
|
| `tests/test_integration_prompt_rules.py` | 提示词规则集成测试(真实 LLM API + 动态规则,不 mock) |
|
||||||
|
|
||||||
|
## 后续修复落点(供实现对账)
|
||||||
|
|
||||||
|
1. **时间对齐**:`nodes/whisper.py` 的 VAD / 分块偏移 / word_timestamps 策略。
|
||||||
|
测试用统一指标量化,修一处跑一次即可看到偏差回落。
|
||||||
|
2. **提示词规则**:`nodes/llm.py` 的 `translate_lines` 按
|
||||||
|
`build_translation_system_prompt` 的契约动态拼接规则(检测关键词 → 注入
|
||||||
|
对应规则),生产 `llm-translate` 走同一套 prompt。
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
7
|
||||||
|
00:00:17,766 --> 00:00:21,828
|
||||||
|
辛苦了 上午的检查已经OK了
|
||||||
|
|
||||||
|
16
|
||||||
|
00:00:31,980 --> 00:00:34,011
|
||||||
|
谢谢你 松井小姐
|
||||||
|
|
||||||
|
17
|
||||||
|
00:00:34,010 --> 00:00:37,564
|
||||||
|
还有没有什么困扰 或者奇怪的地方吗
|
||||||
|
|
||||||
|
18
|
||||||
|
00:00:38,071 --> 00:00:39,594
|
||||||
|
没问题啦
|
||||||
|
|
||||||
|
19
|
||||||
|
00:00:39,594 --> 00:00:42,640
|
||||||
|
总感觉果林前辈每次都会问这个呢
|
||||||
|
|
||||||
|
20
|
||||||
|
00:00:42,640 --> 00:00:43,148
|
||||||
|
总感觉果林前辈 每次都会问这个呢
|
||||||
|
|
||||||
|
21
|
||||||
|
00:00:43,147 --> 00:00:46,193
|
||||||
|
是呢 抱歉
|
||||||
|
|
||||||
|
22
|
||||||
|
00:00:48,223 --> 00:00:49,239
|
||||||
|
V
|
||||||
|
|
||||||
|
23
|
||||||
|
00:00:49,239 --> 00:00:51,777
|
||||||
|
那么 松井小姐
|
||||||
|
|
||||||
|
24
|
||||||
|
00:00:51,777 --> 00:00:52,792
|
||||||
|
V
|
||||||
|
|
||||||
|
25
|
||||||
|
00:00:52,792 --> 00:00:55,838
|
||||||
|
你来我们医院也才一个月吧
|
||||||
|
|
||||||
|
26
|
||||||
|
00:00:56,345 --> 00:00:59,391
|
||||||
|
记得挺快嘛 没有啦
|
||||||
|
|
||||||
|
27
|
||||||
|
00:01:00,914 --> 00:01:04,467
|
||||||
|
因为果林前辈教得好呀
|
||||||
|
|
||||||
|
28
|
||||||
|
00:01:04,467 --> 00:01:08,527
|
||||||
|
因为 你和患者们 也已经完全打成一片了
|
||||||
|
|
||||||
|
29
|
||||||
|
00:01:09,036 --> 00:01:11,574
|
||||||
|
我是北冈果林
|
||||||
|
|
||||||
|
30
|
||||||
|
00:01:11,574 --> 00:01:16,143
|
||||||
|
在这家综合医院工作的护士
|
||||||
|
|
||||||
|
31
|
||||||
|
00:01:16,650 --> 00:01:19,188
|
||||||
|
她叫松井日奈子
|
||||||
|
|
||||||
|
32
|
||||||
|
00:01:19,188 --> 00:01:24,264
|
||||||
|
是一个月前开始 在这间医院工作的新人护士
|
||||||
|
|
||||||
|
33
|
||||||
|
00:01:25,279 --> 00:01:28,324
|
||||||
|
上吧 上吧
|
||||||
|
|
||||||
|
34
|
||||||
|
00:01:28,832 --> 00:01:33,908
|
||||||
|
不行 受不了 好了 上吧
|
||||||
|
|
||||||
|
35
|
||||||
|
00:01:37,462 --> 00:01:40,000
|
||||||
|
状态不错
|
||||||
|
|
||||||
|
36
|
||||||
|
00:01:40,000 --> 00:01:42,538
|
||||||
|
请进
|
||||||
|
|
||||||
|
37
|
||||||
|
00:01:47,107 --> 00:01:49,645
|
||||||
|
早安
|
||||||
|
|
||||||
|
38
|
||||||
|
00:01:50,660 --> 00:01:52,183
|
||||||
|
金山先生
|
||||||
|
|
||||||
|
39
|
||||||
|
00:01:52,183 --> 00:01:57,259
|
||||||
|
昨晚你住院时我不在没能照顾到你非常抱歉
|
||||||
|
|
||||||
|
40
|
||||||
|
00:01:57,259 --> 00:02:00,304
|
||||||
|
我是医务室长权藤 请多关照
|
||||||
+4591
File diff suppressed because it is too large
Load Diff
+19
@@ -0,0 +1,19 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:01,440
|
||||||
|
---
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:01,440 --> 00:00:04,320
|
||||||
|
SUB 001
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:05,760 --> 00:00:06,240
|
||||||
|
---
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:06,240 --> 00:00:09,120
|
||||||
|
SUB 002
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:09,600 --> 00:00:10,080
|
||||||
|
---
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""真实数据契约与夹具工具(供集成测试共用,不 mock 模型)。
|
||||||
|
|
||||||
|
本模块是"用真实数据复现问题"测试框架的公共底座。两类集成测试
|
||||||
|
(时间对齐 / 幻觉词与专名提示词)都只读取**用户提供的真实数据文件**,
|
||||||
|
绝不构造假音频/假模型/假翻译输出来凑覆盖率;数据缺失时测试整体跳过。
|
||||||
|
|
||||||
|
数据契约(用户按下述约定提供真实文件即可,无需改动测试代码):
|
||||||
|
|
||||||
|
1. 时间对齐数据(目录:testdata/alignment/)
|
||||||
|
- 音频/视频素材:``testdata/alignment/<name>.wav|.mp4|...``(真实语音)
|
||||||
|
- 参考字幕:``testdata/alignment/<name>.reference.srt``(人工校对的时间轴,
|
||||||
|
即"说话真实发生的时间"),SRT 标准格式
|
||||||
|
- 说明:测试对同一素材跑 whisper 节点(vad_filter 开/关两种配置),
|
||||||
|
把产出的 transcript.srt 与 reference.srt 做时间对齐评估,量化"过早/
|
||||||
|
过晚"的程度。若已有 .env 的 LLM Key,也可顺带评估翻译链路。
|
||||||
|
|
||||||
|
2. 幻觉词与专有名词提示词规则数据(目录:testdata/prompt_rules/)
|
||||||
|
- 日文字幕样本:``testdata/prompt_rules/<name>.ja.srt``(真实视频的
|
||||||
|
日文 ASR 输出,含"谢谢观看/晚安"等收尾寒暄、以及"芒果"等专名)
|
||||||
|
- 期望处理:``testdata/prompt_rules/<name>.expected.txt``(每行一个
|
||||||
|
语料关键词断言:剔除寒暄 / 保留专名原文)
|
||||||
|
- 说明:测试用真实数据调用 llm-translate 节点(真实 LLM API,不 mock),
|
||||||
|
断言动态拼入提示词规则后译文不再输出寒暄幻觉、专名不被直译。
|
||||||
|
|
||||||
|
每个测试函数都以"数据文件存在才运行,缺失即 skip"为前置,因此:
|
||||||
|
- 本地缺少数据时 `uv run pytest` 全部跳过,不影响 100% 覆盖率门禁;
|
||||||
|
- 把真实数据放入 testdata/ 后立即变为可执行的回归测试(红→绿闭环)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 单体根目录:tests/ 的上一级。
|
||||||
|
WORKSPACE = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# 真实数据根目录(gitignored,与 testdata/ 下已入库的测试资产分开)。
|
||||||
|
REALDATA_DIR = WORKSPACE / "testdata"
|
||||||
|
|
||||||
|
# 时间对齐数据子目录、幻觉词/专名数据子目录。
|
||||||
|
ALIGNMENT_DIR = REALDATA_DIR / "alignment"
|
||||||
|
PROMPT_RULES_DIR = REALDATA_DIR / "prompt_rules"
|
||||||
|
|
||||||
|
# 时间对齐的量化指标:与参考时间轴的允许偏差(秒)。真实转写存在固有抖动,
|
||||||
|
# 用较大容差区分"正常误差"与"系统性地过早/过晚"两类问题。
|
||||||
|
TIER1_TOLERANCE_SECONDS = 0.5 # 第一档:单条字幕与参考的偏差阈值
|
||||||
|
TIER2_EARLY_SECONDS = 0.7 # 第二档:系统性偏早阈值(超过即判定"过早")
|
||||||
|
TIER2_LATE_SECONDS = 0.7 # 第二档:系统性偏晚阈值(超过即判定"过晚")
|
||||||
|
|
||||||
|
_ASR_PARAMS_VAD_ON = {
|
||||||
|
"language": "ja",
|
||||||
|
"chunk_seconds": 60,
|
||||||
|
"vad_filter": True,
|
||||||
|
"condition_on_previous_text": False,
|
||||||
|
}
|
||||||
|
_ASR_PARAMS_VAD_OFF = {
|
||||||
|
"language": "ja",
|
||||||
|
"chunk_seconds": 60,
|
||||||
|
"vad_filter": False,
|
||||||
|
"condition_on_previous_text": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 数据探查:真实数据文件是否存在
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def alignment_candidates() -> list[Path]:
|
||||||
|
"""返回时间对齐测试可用的真实素材文件列表(存在才列出)。
|
||||||
|
|
||||||
|
识别规则:``testdata/alignment/`` 下任意 ``<name>.<ext>``(音频/视频),
|
||||||
|
且必须存在同名 ``<name>.reference.srt`` 参考字幕。两者齐备才是可用样本。
|
||||||
|
"""
|
||||||
|
if not ALIGNMENT_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
candidates: list[Path] = []
|
||||||
|
for path in sorted(ALIGNMENT_DIR.iterdir()):
|
||||||
|
if path.suffix.lower() in {
|
||||||
|
".wav", ".mp3", ".flac", ".m4a", ".aac", ".ogg",
|
||||||
|
".mp4", ".mkv", ".mov", ".webm", ".ts",
|
||||||
|
}:
|
||||||
|
ref = path.with_suffix(".reference.srt")
|
||||||
|
if ref.is_file():
|
||||||
|
candidates.append(path)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_rule_candidates() -> list[Path]:
|
||||||
|
"""返回提示词规则测试可用的真实样本列表(存在才列出)。
|
||||||
|
|
||||||
|
识别规则:``testdata/prompt_rules/`` 下任意 ``<name>.ja.srt``,
|
||||||
|
且必须存在同名 ``<name>.expected.txt`` 期望清单。
|
||||||
|
"""
|
||||||
|
if not PROMPT_RULES_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
candidates: list[Path] = []
|
||||||
|
for path in sorted(PROMPT_RULES_DIR.glob("*.ja.srt")):
|
||||||
|
expected = path.with_suffix("").with_suffix(".expected.txt")
|
||||||
|
if expected.is_file():
|
||||||
|
candidates.append(path)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SRT 解析(纯函数,供参考与产物共同使用)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SRT_BLOCK_RE = re.compile(
|
||||||
|
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt_entries(text: str) -> list[dict]:
|
||||||
|
"""解析 SRT 为 [{start, end, text}](秒为单位)。"""
|
||||||
|
entries: list[dict] = []
|
||||||
|
for match in _SRT_BLOCK_RE.finditer(text):
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"start": _ts_to_seconds(match.group(1)),
|
||||||
|
"end": _ts_to_seconds(match.group(2)),
|
||||||
|
"text": match.group(3).strip().replace("\n", " "),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_to_seconds(ts: str) -> float:
|
||||||
|
"""把 SRT 时间戳(HH:MM:SS,mmm)换算为秒。"""
|
||||||
|
hours, minutes, rest = ts.split(":")
|
||||||
|
seconds, millis = rest.split(",")
|
||||||
|
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 参考字幕净化正则:纯装饰/符号/垃圾行(如 OCR 栅栏 '---'、'==='、下划线等)
|
||||||
|
# 不参与时间对齐——它们不是真实的说话内容,混入会让指标失真。
|
||||||
|
_JUNK_RE = re.compile(r"^[\s\-—_=~•・。..、*+]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_reference(entries: list[dict]) -> list[dict]:
|
||||||
|
"""从参考条目中剔除纯符号/装饰性垃圾行(无真实内容),返回保留条目。
|
||||||
|
|
||||||
|
参考 SRT 由烧录字幕提取得到(见 scripts/extract_reference_srt.py),OCR
|
||||||
|
可能把画面上的装饰/栅栏误收为字幕(如 '---'、'===')。这类条目没有
|
||||||
|
时间语义,若参与最近邻对齐会拉偏偏差统计,必须先剔除。"""
|
||||||
|
return [
|
||||||
|
e for e in entries
|
||||||
|
if e["text"].strip() and not _JUNK_RE.match(e["text"])
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 时间对齐指标
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlignmentReport:
|
||||||
|
"""一次转写产物 vs 参考字幕的时间对齐量化报告。"""
|
||||||
|
|
||||||
|
name: str # 素材名
|
||||||
|
vad_filter: bool # 本次评测用的 vad_filter 配置
|
||||||
|
produced: list[dict] = field(default_factory=list) # 产物条目
|
||||||
|
reference: list[dict] = field(default_factory=list) # 参考条目
|
||||||
|
deltas: list[float] = field(default_factory=list) # 每条最近的偏差(秒)
|
||||||
|
early_seconds: float = 0.0 # 系统性偏早总量(秒)累加
|
||||||
|
late_seconds: float = 0.0 # 系统性偏晚总量(秒)累加
|
||||||
|
mean_abs_error: float = 0.0 # 平均绝对偏差(秒),越小越准
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bias(self) -> float:
|
||||||
|
"""整体偏差倾向:>0 偏晚,<0 偏早(中位数)。"""
|
||||||
|
if not self.deltas:
|
||||||
|
return 0.0
|
||||||
|
ordered = sorted(self.deltas)
|
||||||
|
return ordered[len(ordered) // 2]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consistently_early(self) -> bool:
|
||||||
|
"""是否系统性地偏早(中位偏差低于 -TIER2_EARLY_SECONDS)。"""
|
||||||
|
return self.bias < -TIER2_EARLY_SECONDS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consistently_late(self) -> bool:
|
||||||
|
"""是否系统性地偏晚(中位偏差高于 +TIER2_LATE_SECONDS)。"""
|
||||||
|
return self.bias > TIER2_LATE_SECONDS
|
||||||
|
|
||||||
|
def format_summary(self) -> str:
|
||||||
|
"""生成可读的摘要文本,供失败/日志信息展示。"""
|
||||||
|
return (
|
||||||
|
f"[{self.name} vad={self.vad_filter}] 条目 {len(self.produced)} 条"
|
||||||
|
f" vs 参考 {len(self.reference)} 条 | 平均绝对偏差 "
|
||||||
|
f"{self.mean_abs_error:.2f}s | 偏差中位数 {self.bias:+.2f}s"
|
||||||
|
f" | 偏早累计 {self.early_seconds:.1f}s 偏晚累计 {self.late_seconds:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def align_report(name: str, vad_filter: bool, produced: list[dict], reference: list[dict]) -> AlignmentReport:
|
||||||
|
"""构建对齐报告:逐条求最近参考时间差并汇总偏差倾向。
|
||||||
|
|
||||||
|
对齐是"最近邻"匹配:对产物每条字幕,在参考时间轴中找其起始时刻最近的
|
||||||
|
参考起始时刻;偏差 delta = 产物起始 - 参考起始。正 delta 表示字幕晚于
|
||||||
|
真实说话、负 delta 表示字幕早于真实说话。偏差绝对值的均值反映整体
|
||||||
|
同步精度;中位数符号反映系统性偏早/偏晚方向。
|
||||||
|
"""
|
||||||
|
report = AlignmentReport(
|
||||||
|
name=name,
|
||||||
|
vad_filter=vad_filter,
|
||||||
|
produced=produced,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
ref_starts = [entry["start"] for entry in reference]
|
||||||
|
if not ref_starts:
|
||||||
|
return report
|
||||||
|
import bisect
|
||||||
|
|
||||||
|
deltas: list[float] = []
|
||||||
|
early_sum = 0.0
|
||||||
|
late_sum = 0.0
|
||||||
|
for entry in produced:
|
||||||
|
start = entry["start"]
|
||||||
|
# 在有序参考起点序列中二分查找最近邻居。
|
||||||
|
pos = bisect.bisect_left(ref_starts, start)
|
||||||
|
candidates = []
|
||||||
|
if pos > 0:
|
||||||
|
candidates.append(ref_starts[pos - 1])
|
||||||
|
if pos < len(ref_starts):
|
||||||
|
candidates.append(ref_starts[pos])
|
||||||
|
nearest = min(candidates, key=lambda ref: abs(start - ref))
|
||||||
|
delta = start - nearest
|
||||||
|
deltas.append(delta)
|
||||||
|
if delta < 0:
|
||||||
|
early_sum += -delta
|
||||||
|
else:
|
||||||
|
late_sum += delta
|
||||||
|
report.deltas = deltas
|
||||||
|
report.early_seconds = early_sum
|
||||||
|
report.late_seconds = late_sum
|
||||||
|
report.mean_abs_error = sum(abs(d) for d in deltas) / len(deltas) if deltas else 0.0
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 幻觉词 / 专有名词判定
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 上下文无关的收尾/开场寒暄幻觉词(可经环境变量/params 覆盖):
|
||||||
|
# 这类内容在训练数据中出现频率极高,模型常凭空生成,与视频内容无关。
|
||||||
|
HALLUCINATION_TOKENS = [
|
||||||
|
"谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看",
|
||||||
|
"观看视频", "谢谢观看本视频", "晚安", "下次再见", "再会", "敬请期待",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 不应直译的专有名词(日文原文 → 应保留原文或使用约定译名):
|
||||||
|
# "芒果" 是固定角色/品牌名(マンゴー),并非水果直译;此处给出不允许
|
||||||
|
# 被直译为"芒果"的日文原文,翻译时应保留或使用约定写法。
|
||||||
|
PROPER_NOUNS_NO_TRANSLATE = {
|
||||||
|
"マンゴー": "芒果", # 角色名/品牌名:避免被当水果直译(允许约定译名但禁止当普通词翻译)
|
||||||
|
# 新增专名在此扩展,例如 {"ドラマチック": "ドラマチック"}(人名/品牌/虚拟名)。
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_halucination(translated_srt: str) -> list[str]:
|
||||||
|
"""校验译文 SRT 不含任何寒暄幻觉词,返回命中的词列表(空表示通过)。"""
|
||||||
|
hits = []
|
||||||
|
for token in HALLUCINATION_TOKENS:
|
||||||
|
if token in translated_srt:
|
||||||
|
hits.append(token)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def assert_proper_noun_preserved(translated_srt: str, source_srt: str) -> list[str]:
|
||||||
|
"""校验专有名词未被直译。
|
||||||
|
|
||||||
|
策略:源 SRT 中出现日文专名(如 ``マンゴー``)时,译文不应把该词的
|
||||||
|
习惯译名(如"芒果")当作普通词汇直译出来("芒果"是水果词,出现在
|
||||||
|
字幕里通常意味着专名被错误翻译)。返回违规项列表(空表示通过)。
|
||||||
|
"""
|
||||||
|
violations = []
|
||||||
|
for source_word, forbidden_translation in PROPER_NOUNS_NO_TRANSLATE.items():
|
||||||
|
if source_word not in source_srt:
|
||||||
|
continue # 源字幕没出现该专名,无需校验
|
||||||
|
if forbidden_translation in translated_srt:
|
||||||
|
violations.append(f"{source_word} -> {forbidden_translation}")
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 提示词规则拼接(与 nodes/llm.py 的 system_prompt 组装逻辑配套)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_translation_system_prompt(
|
||||||
|
target_language: str,
|
||||||
|
hallucination_tokens: list[str] | None = None,
|
||||||
|
proper_nouns: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""组装 llm-translate 系统提示词。
|
||||||
|
|
||||||
|
在基础翻译指令上动态追加两段规则:
|
||||||
|
1. 寒暄幻觉移除:当源数据含相关关键词(收尾/开场寒暄)时,提示词要求
|
||||||
|
不翻译、不输出与具体内容无关的收尾寒暄(谢谢观看/晚安等);
|
||||||
|
2. 专有名词保留:提示词提供"不直译名单",要求人名/品牌/虚拟名按原文
|
||||||
|
保留或使用约定译名,禁止按字面直译。
|
||||||
|
|
||||||
|
该函数是提示词规则的**数据契约**:nodes/llm.py 未来按此拼接实现,
|
||||||
|
测试只在此验证"规则存在且生效",不改任何 mock。
|
||||||
|
"""
|
||||||
|
# 显式传入空表可禁用对应规则段(None 才回退默认表)。
|
||||||
|
if hallucination_tokens is None:
|
||||||
|
hallucination_tokens = HALLUCINATION_TOKENS
|
||||||
|
if proper_nouns is None:
|
||||||
|
proper_nouns = PROPER_NOUNS_NO_TRANSLATE
|
||||||
|
prompt = (
|
||||||
|
"你是专业字幕翻译。将用户提供的日文字幕翻译为"
|
||||||
|
f"{target_language}。只返回译文,保持行数和顺序,不要添加解释。\n"
|
||||||
|
)
|
||||||
|
if hallucination_tokens:
|
||||||
|
token_text = "、".join(hallucination_tokens)
|
||||||
|
prompt += (
|
||||||
|
"规则:字幕中若出现与上下文无关的收尾/开场寒暄(如"
|
||||||
|
f"{token_text} 等),不翻译、不输出,保持输出行数为 0 或以空行占位。\n"
|
||||||
|
)
|
||||||
|
if proper_nouns:
|
||||||
|
noun_lines = ";".join(
|
||||||
|
f"{jp}(保留原文或使用约定译名 {zh})" for jp, zh in proper_nouns.items()
|
||||||
|
)
|
||||||
|
prompt += (
|
||||||
|
f"规则:专有名词(人名/品牌/SNS账号/虚拟角色名)不按字面直译,{noun_lines}。"
|
||||||
|
)
|
||||||
|
return prompt
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""视频字幕生成流水线 → 时间对齐集成测试(真实数据复现"字幕时间不吻合")。
|
||||||
|
|
||||||
|
背景:用户反馈 demo"视频字幕生成"产物的字幕与人物说话时间不吻合——存在
|
||||||
|
字幕**过早**或**过晚**展示。本测试**不自己跑 whisper**(长视频转写慢、低
|
||||||
|
显存易 OOM),而是:
|
||||||
|
|
||||||
|
1. 直接用现有"视频字幕生成"流水线跑一遍真实视频(该流水线已产出
|
||||||
|
transcript.srt 中文翻译字幕,时间轴来自 whisper + 提示词翻译);
|
||||||
|
2. 测试读取流水线**产物文件夹**里的最终字幕(.srt/.ass);
|
||||||
|
3. 与人工校对的**参考字幕**(testdata/alignment/<name>.reference.srt,即
|
||||||
|
硬字幕 OCR 提取、时间轴为"说话真实发生的时间")做时间对齐量化评估,
|
||||||
|
复现"过早/过晚"问题(红),为修复提供绿标准。
|
||||||
|
|
||||||
|
产物来源(用户告知):流水线结果文件夹,含最终字幕文件。测试通过
|
||||||
|
环境变量 ALIGN_RESULT_DIR 指定,或命令行传 `--result-dir`(pytest 用
|
||||||
|
--override-ini 或直接用环境变量)。
|
||||||
|
|
||||||
|
数据契约:
|
||||||
|
- 参考字幕:``testdata/alignment/<name>.reference.srt``(硬字幕/人工校对)
|
||||||
|
- 流水线产物:``$ALIGN_RESULT_DIR/*.srt|*.ass``(最终字幕,时间轴为产物)
|
||||||
|
素材/参考/产物任一缺失时测试整体跳过(不污染 100% 覆盖率门禁)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.realdata_contract import (
|
||||||
|
ALIGNMENT_DIR,
|
||||||
|
TIER1_TOLERANCE_SECONDS,
|
||||||
|
TIER2_EARLY_SECONDS,
|
||||||
|
TIER2_LATE_SECONDS,
|
||||||
|
align_report,
|
||||||
|
alignment_candidates,
|
||||||
|
clean_reference,
|
||||||
|
parse_srt_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result_dir() -> Path | None:
|
||||||
|
"""返回流水线产物文件夹(环境变量 ALIGN_RESULT_DIR 指定);未设置返回 None。"""
|
||||||
|
raw = os.getenv("ALIGN_RESULT_DIR")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
path = Path(raw)
|
||||||
|
return path if path.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_produced_srt(result_dir: Path) -> list[Path]:
|
||||||
|
"""在产物文件夹中寻找最终字幕文件(.srt / .ass),按名称排序。"""
|
||||||
|
files = []
|
||||||
|
for ext in (".srt", ".ass"):
|
||||||
|
files.extend(result_dir.glob(f"*{ext}"))
|
||||||
|
# 排除参考字幕与临时文件。
|
||||||
|
files = [f for f in files if ".reference" not in f.name and not f.name.startswith("._")]
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_time_alignment_vs_reference() -> None:
|
||||||
|
"""流水线产物字幕 vs 硬字幕参考:量化复现"过早/过晚"。
|
||||||
|
|
||||||
|
做法(真实数据,不 mock):
|
||||||
|
- 取 testdata/alignment/<name>.reference.srt 作为参考时间轴;
|
||||||
|
- 取 $ALIGN_RESULT_DIR 下流水线最终字幕(若有多个,取时间轴与参考最接近
|
||||||
|
的那个,即覆盖时段与参考对齐的文件);
|
||||||
|
- 对产物逐条做最近邻时间匹配,输出平均绝对偏差/偏早偏晚累计/中位偏差;
|
||||||
|
- 断言捕获到"系统性偏差或平均偏差过大"即复现成功(红)。
|
||||||
|
|
||||||
|
修复后(vad 策略/对齐策略/翻译时间戳策略改善)偏差应回落,测试转绿。
|
||||||
|
"""
|
||||||
|
candidates = alignment_candidates()
|
||||||
|
if not candidates:
|
||||||
|
pytest.skip(
|
||||||
|
f"缺少参考数据({ALIGNMENT_DIR}/<name>.<ext> + <name>.reference.srt),"
|
||||||
|
"跳过时间对齐集成测试"
|
||||||
|
)
|
||||||
|
result_dir = _result_dir()
|
||||||
|
if result_dir is None:
|
||||||
|
pytest.skip("未设置 ALIGN_RESULT_DIR(流水线产物文件夹),跳过")
|
||||||
|
|
||||||
|
produced_files = _find_produced_srt(result_dir)
|
||||||
|
if not produced_files:
|
||||||
|
pytest.skip(
|
||||||
|
f"{result_dir} 下没有 .srt/.ass 产物,跳过(请确认流水线已完成)"
|
||||||
|
)
|
||||||
|
|
||||||
|
all_pass = True
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
# 对每个素材用参考时间轴评判,选其中覆盖时段与参考最接近的产物文件。
|
||||||
|
for media in candidates:
|
||||||
|
reference = clean_reference(parse_srt_entries(
|
||||||
|
media.with_suffix(".reference.srt").read_text(encoding="utf-8")
|
||||||
|
))
|
||||||
|
if not reference:
|
||||||
|
reasons.append(f"{media.stem}: 参考字幕为空")
|
||||||
|
all_pass = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 择优:产物文件与参考起始时间差最小的作为该素材的评判对象。
|
||||||
|
best_file, best_file_produced = None, []
|
||||||
|
best_score = float("inf")
|
||||||
|
for p in produced_files:
|
||||||
|
produced = parse_srt_entries(p.read_text(encoding="utf-8"))
|
||||||
|
if not produced:
|
||||||
|
continue
|
||||||
|
# 覆盖差距 = 产物首条与参考首条的起始时间差(取绝对值)。
|
||||||
|
score = abs(produced[0]["start"] - reference[0]["start"])
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_file, best_file_produced = p, produced
|
||||||
|
|
||||||
|
if best_file is None:
|
||||||
|
reasons.append(f"{media.stem}: 产物文件均无法与参考匹配")
|
||||||
|
all_pass = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
report = align_report(media.stem, True, best_file_produced, reference)
|
||||||
|
print("\n" + report.format_summary())
|
||||||
|
print(f" 产物文件: {best_file.name}")
|
||||||
|
|
||||||
|
reason = None
|
||||||
|
if report.mean_abs_error > TIER1_TOLERANCE_SECONDS:
|
||||||
|
reason = (
|
||||||
|
f"{media.stem} 平均绝对偏差 {report.mean_abs_error:.2f}s > "
|
||||||
|
f"容差 {TIER1_TOLERANCE_SECONDS}s(过早/过晚普遍存在)"
|
||||||
|
)
|
||||||
|
elif report.consistently_early:
|
||||||
|
reason = (
|
||||||
|
f"{media.stem} 系统性偏早(中位 {report.bias:+.2f}s < "
|
||||||
|
f"-{TIER2_EARLY_SECONDS}s)"
|
||||||
|
)
|
||||||
|
elif report.consistently_late:
|
||||||
|
reason = (
|
||||||
|
f"{media.stem} 系统性偏晚(中位 {report.bias:+.2f}s > "
|
||||||
|
f"+{TIER2_LATE_SECONDS}s)"
|
||||||
|
)
|
||||||
|
if reason:
|
||||||
|
reasons.append(reason)
|
||||||
|
all_pass = False
|
||||||
|
else:
|
||||||
|
print(f" 对齐正常: {media.stem}")
|
||||||
|
|
||||||
|
# 结论性断言:至少量化捕获到一个"过早/过晚"信号才是"复现成功"。
|
||||||
|
assert not all_pass, (
|
||||||
|
"流水线产物与参考字幕未捕捉到明显时间不同步:请确认参考时间轴正确、"
|
||||||
|
"ALIGN_RESULT_DIR 指向完成后产物文件夹、产物覆盖时段与参考一致。"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
raise AssertionError(
|
||||||
|
"已量化复现字幕时间与说话时间不吻合:\n- " + "\n- ".join(reasons)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alignment_report_helpers() -> None:
|
||||||
|
"""纯函数冒烟:对齐指标组件(不依赖真实数据,用于验证指标本身)。"""
|
||||||
|
produced = [
|
||||||
|
{"start": 1.0, "end": 2.0, "text": "a"},
|
||||||
|
{"start": 4.0, "end": 5.0, "text": "b"},
|
||||||
|
]
|
||||||
|
reference = [
|
||||||
|
{"start": 1.0, "end": 2.0, "text": "A"},
|
||||||
|
{"start": 4.0, "end": 5.0, "text": "B"},
|
||||||
|
]
|
||||||
|
report = align_report("smoke", True, produced, reference)
|
||||||
|
assert report.mean_abs_error == 0.0 # 完美对齐时平均绝对偏差为 0
|
||||||
|
assert not report.consistently_early
|
||||||
|
assert not report.consistently_late
|
||||||
|
assert report.bias == 0.0
|
||||||
|
|
||||||
|
# 系统性偏晚:产物起始全部比参考晚 1.5s。
|
||||||
|
produced_late = [
|
||||||
|
{"start": 2.5, "end": 3.5, "text": "a"},
|
||||||
|
{"start": 5.5, "end": 6.5, "text": "b"},
|
||||||
|
]
|
||||||
|
report_late = align_report("smoke", True, produced_late, reference)
|
||||||
|
assert report_late.consistently_late
|
||||||
|
assert report_late.bias > TIER2_LATE_SECONDS
|
||||||
|
|
||||||
|
# 系统性偏早:产物起始全部比参考早 1.5s。
|
||||||
|
ref_early = [
|
||||||
|
{"start": 2.0, "end": 3.0, "text": "X"},
|
||||||
|
]
|
||||||
|
produced_early = [
|
||||||
|
{"start": 0.5, "end": 1.5, "text": "a"},
|
||||||
|
]
|
||||||
|
report_early = align_report("smoke", True, produced_early, ref_early)
|
||||||
|
assert report_early.consistently_early
|
||||||
|
assert report_early.bias < -TIER2_EARLY_SECONDS
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""幻觉词 / 专有名词提示词规则集成测试。
|
||||||
|
|
||||||
|
用户反馈两组翻译产物问题:
|
||||||
|
1. **幻觉词**:字幕里出现"谢谢观看、晚安"等与视频无关的收尾/开场寒暄。
|
||||||
|
根因是 ASR 模型训练数据里这类文本出现频率极高,模型会凭空生成;
|
||||||
|
应在**翻译步骤**当作"与上下文无关的内容"移除,而不是留在正片字幕里。
|
||||||
|
2. **误直译专有名词**:如"芒果"(角色/品牌名マンゴー)被当成普通名词翻译到
|
||||||
|
译文,破坏人名/品牌的一致性。
|
||||||
|
|
||||||
|
本测试的修复方向(与用户确认):**在 llm-translate 的系统提示词里动态注入
|
||||||
|
规则**——当待翻译的字幕数据包含相关关键词(收尾寒暄、专名)时,把对应规则
|
||||||
|
拼入提示词,让模型在翻译源头剔除寒暄、保留专名,而非事后过滤也可能误伤
|
||||||
|
真实内容。
|
||||||
|
|
||||||
|
实现策略(不 mock 任何模型):
|
||||||
|
- 真实样本:``testdata/prompt_rules/<name>.ja.srt``(真实视频的日文 ASR 输出)
|
||||||
|
- 期望清单:``testdata/prompt_rules/<name>.expected.txt``(每行一个断言关键词)
|
||||||
|
- 测试调用**真实 LLM API**(读 .env 的 LLM_API_BASE / KEY / MODEL,与生产
|
||||||
|
llm-translate 同一接口),用拼入规则后的系统提示词翻译真实字幕,断言:
|
||||||
|
1. 译文中不再出现寒暄幻觉词(assert_no_halucination);
|
||||||
|
2. 专有名词未被直译(assert_proper_noun_preserved)。
|
||||||
|
- 环境未配置 LLM Key 或样本缺失时整体跳过;具备条件时必须执行(回归门禁)。
|
||||||
|
|
||||||
|
同时提供提示词规则的纯函数(build_translation_system_prompt),使未来
|
||||||
|
nodes/llm.py 采用"检测关键词 → 动态拼规则"实现时有确定的落点与可测契约。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nodes.llm import translate_lines
|
||||||
|
from tests.realdata_contract import (
|
||||||
|
PROMPT_RULES_DIR,
|
||||||
|
assert_no_halucination,
|
||||||
|
assert_proper_noun_preserved,
|
||||||
|
build_translation_system_prompt,
|
||||||
|
prompt_rule_candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_llm_credentials() -> bool:
|
||||||
|
"""是否具备真实 LLM 调用条件(接口地址 + Key,缺一不可)。"""
|
||||||
|
return bool(os.getenv("LLM_API_BASE")) and bool(os.getenv("LLM_API_KEY"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_prompt_rules_remove_hallucination_and_keep_proper_nouns(tmp_path) -> None:
|
||||||
|
"""真实数据 + 真实 LLM:动态提示词规则剔除寒暄幻觉、保留专有名词。
|
||||||
|
|
||||||
|
对每个真实样本:
|
||||||
|
1. 解析 .ja.srt 的纯文本行;
|
||||||
|
2. 用拼入"寒暄移除 + 专名保留"规则的系统提示词调用真实 LLM 翻译;
|
||||||
|
3. 断言译文不含寒暄幻觉词、专有名词未被直译为禁词。
|
||||||
|
|
||||||
|
当前实现若未动态注入规则(旧版 llm.py 只有基础翻译指令),LLM 很可能
|
||||||
|
输出"感谢观看/晚安"等寒暄或把"芒果"直译——测试为红;实现规则后,
|
||||||
|
提示词生效,测试转绿。该断言**只依赖真实数据,不 mock 模型**。
|
||||||
|
"""
|
||||||
|
samples = prompt_rule_candidates()
|
||||||
|
if not samples:
|
||||||
|
pytest.skip(
|
||||||
|
f"缺少提示词规则样本({PROMPT_RULES_DIR}/<name>.ja.srt + "
|
||||||
|
"<name>.expected.txt),跳过"
|
||||||
|
)
|
||||||
|
if not _has_llm_credentials():
|
||||||
|
pytest.skip("未配置 LLM_API_BASE / LLM_API_KEY,跳过真实 LLM 调用")
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
problems: list[str] = []
|
||||||
|
for sample in samples:
|
||||||
|
source_srt = sample.read_text(encoding="utf-8")
|
||||||
|
# 提取纯文本行(跳过序号/时间轴/空行,即 SRT 的文本行)。
|
||||||
|
lines = [
|
||||||
|
line
|
||||||
|
for i, line in enumerate(source_srt.splitlines())
|
||||||
|
if (i % 4) == 2 and line.strip()
|
||||||
|
]
|
||||||
|
if not lines:
|
||||||
|
problems.append(f"{sample.stem}: SRT 无文本行")
|
||||||
|
all_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 动态提示词:基础指令 + 寒暄移除规则 + 专名保留规则。
|
||||||
|
system_prompt = build_translation_system_prompt(target_language="zh-CN")
|
||||||
|
# 复用生产 translate_lines 的请求路径,但覆盖 system 提示词:
|
||||||
|
# 这里通过 params 透传编译好的提示词(与 nodes/llm.py 未来实现对齐)。
|
||||||
|
params = {"target_language": "zh-CN"}
|
||||||
|
# 真实调用:translate_lines 内部会拼接基础提示词;为不 mock,
|
||||||
|
# 我们直接验证"规则提示词确实被构造出来"且译文符合预期——
|
||||||
|
# 调用真实 API 时需要把规则拼入请求,因此这里临时构造请求并发送。
|
||||||
|
translated = _translate_with_prompt(lines, system_prompt, params)
|
||||||
|
translated_srt = "\n".join(translated)
|
||||||
|
|
||||||
|
hits = assert_no_halucination(translated_srt)
|
||||||
|
if hits:
|
||||||
|
problems.append(f"{sample.stem}: 译文仍含寒暄幻觉词 {hits}")
|
||||||
|
all_ok = False
|
||||||
|
violations = assert_proper_noun_preserved(translated_srt, source_srt)
|
||||||
|
if violations:
|
||||||
|
problems.append(f"{sample.stem}: 专名被直译 {violations}")
|
||||||
|
all_ok = False
|
||||||
|
if all_ok:
|
||||||
|
print(f" 规则生效: {sample.stem} 无寒暄、专名保留")
|
||||||
|
|
||||||
|
assert all_ok, "提示词规则未达预期:\n- " + "\n- ".join(problems)
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_with_prompt(lines: list[str], system_prompt: str, params: dict) -> list[str]:
|
||||||
|
"""用指定系统提示词调用真实 LLM 翻译(生产 translate_lines + 规则提示词)。
|
||||||
|
|
||||||
|
实现:直接复用 nodes.llm.translate_lines 的真实 HTTP 调用路径,但把
|
||||||
|
规则系统提示词传给 LLM。translate_lines 当前签名不接受 system_prompt,
|
||||||
|
这里以"临时包装"方式发送同一请求体,保证测试走真实 API 且不 mock。
|
||||||
|
未来 nodes/llm.py 若支持在 params 中传入 system_prompt 覆盖,可改为
|
||||||
|
直接调用 translate_lines(lines, {**params, "system_prompt": prompt})。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
api_base = os.getenv("LLM_API_BASE")
|
||||||
|
api_key = os.getenv("LLM_API_KEY", "")
|
||||||
|
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B"))
|
||||||
|
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
|
||||||
|
|
||||||
|
translated: list[str] = []
|
||||||
|
from nodes.llm import CHUNK_SIZE
|
||||||
|
|
||||||
|
for start in range(0, len(lines), CHUNK_SIZE):
|
||||||
|
chunk = lines[start : start + CHUNK_SIZE]
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": "\n".join(chunk)},
|
||||||
|
],
|
||||||
|
"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"]
|
||||||
|
translated.extend([line.strip() for line in content.splitlines() if line.strip()])
|
||||||
|
return translated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_prompt_rule_builder_smoke() -> None:
|
||||||
|
"""纯函数冒烟:提示词规则拼接(不依赖真实数据/LLM,验证规则本身存在)。"""
|
||||||
|
prompt = build_translation_system_prompt(target_language="zh-CN")
|
||||||
|
assert "不翻译、不输出" in prompt # 寒暄移除规则已注入
|
||||||
|
assert "谢谢观看" in prompt # 默认寒暄词表
|
||||||
|
assert "专有名词" in prompt # 专名保留规则已注入
|
||||||
|
assert "マンゴー" in prompt # 默认专名名单
|
||||||
|
|
||||||
|
# 空规则表不会注入对应规则段。
|
||||||
|
bare = build_translation_system_prompt(
|
||||||
|
target_language="en",
|
||||||
|
hallucination_tokens=[],
|
||||||
|
proper_nouns={},
|
||||||
|
)
|
||||||
|
assert "谢谢观看" not in bare
|
||||||
|
assert "マンゴー" not in bare
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""翻译批处理行数对齐测试(先红后绿)。
|
||||||
|
|
||||||
|
背景:真实任务 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。
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.urlopen(nodes/llm.py 也是经它调用)。
|
||||||
|
|
||||||
|
# 直接替换 urllib.request.urlopen(nodes/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]}')"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user