"""真实数据契约与夹具工具(供集成测试共用,不 mock 模型)。 本模块是"用真实数据复现问题"测试框架的公共底座。两类集成测试 (时间对齐 / 幻觉词与专名提示词)都只读取**用户提供的真实数据文件**, 绝不构造假音频/假模型/假翻译输出来凑覆盖率;数据缺失时测试整体跳过。 数据契约(用户按下述约定提供真实文件即可,无需改动测试代码): 1. 时间对齐数据(目录:testdata/alignment/) - 音频/视频素材:``testdata/alignment/.wav|.mp4|...``(真实语音) - 参考字幕:``testdata/alignment/.reference.srt``(人工校对的时间轴, 即"说话真实发生的时间"),SRT 标准格式 - 说明:测试对同一素材跑 whisper 节点(vad_filter 开/关两种配置), 把产出的 transcript.srt 与 reference.srt 做时间对齐评估,量化"过早/ 过晚"的程度。若已有 .env 的 LLM Key,也可顺带评估翻译链路。 2. 幻觉词与专有名词提示词规则数据(目录:testdata/prompt_rules/) - 日文字幕样本:``testdata/prompt_rules/.ja.srt``(真实视频的 日文 ASR 输出,含"谢谢观看/晚安"等收尾寒暄、以及"芒果"等专名) - 期望处理:``testdata/prompt_rules/.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/`` 下任意 ``.``(音频/视频), 且必须存在同名 ``.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/`` 下任意 ``.ja.srt``, 且必须存在同名 ``.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