Files
vrsub/tests/realdata_contract.py
T
cat-shark fcdcfe020b fix: SRT 解析器正确处理空文本字幕条目
旧正则把空文本条目与下一行合并,导致真实集成测试偶发
"译文条数 1439 != 原文 1440"(翻译补空/空字幕使条目被吞并)。
改为按时间轴行分块解析:序号缺省、空文本也独立计数,时间轴不丢失。
2026-09-05 22:32:15 +08:00

362 lines
16 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.
"""真实数据契约与夹具工具(供集成测试共用,不 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 时间轴行(时间戳 --> 时间戳),作为条目边界。
_SRT_TIME_LINE_RE = re.compile(
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})",
)
def parse_srt_entries(text: str) -> list[dict]:
"""解析 SRT 为 [{start, end, text}](秒为单位)。
按行分块:一个条目 = 序号行 + 时间轴行 + 若干文本行(可空)。即使文本为
空串(如翻译补空占位、空字幕)也计入一条,时间轴不丢失。"""
entries: list[dict] = []
lines = text.splitlines()
index = 0
while index < len(lines):
line = lines[index].strip()
# 跳过序号行与空行,找时间轴行。
if not line or not _SRT_TIME_LINE_RE.search(line):
index += 1
continue
match = _SRT_TIME_LINE_RE.search(line)
start = _ts_to_seconds(match.group(1))
end = _ts_to_seconds(match.group(2))
index += 1
# 收集后续非序号、非时间轴的文本行(可空/多行),直到空行或序号行。
text_parts: list[str] = []
while index < len(lines):
nxt = lines[index].strip()
if not nxt:
break # 空行:条目结束
if _SRT_TIME_LINE_RE.search(nxt):
break # 下一个时间轴:条目结束
if nxt.isdigit():
break # 下一个序号:条目结束
text_parts.append(nxt)
index += 1
entries.append(
{
"start": start,
"end": end,
"text": " ".join(text_parts),
}
)
index += 1
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