Files
vrsub/tests/shared/realdata_contract.py
T
cat-shark 8a715a8064 test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、
不保留全局 conftest.py、测试过程只调用真实生产代码。

结构(73 个文件、30 个模块目录、477 用例):
- tests/nodes/  15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/
  subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/
  proper_nouns/adaptive_pool/vad_profiler/echo);
- tests/app/    11 个模块目录(db/scheduler/batch/maintenance/registry/seed/
  storage/config/logging/main/routers 三组 API);
- tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。

测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore
的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。

顺带发现并修复三个真实缺陷:
- nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位),
  改为正文行遇时间戳行即报错;
- src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI
  抛 ValueError 导致任务误判失败,改为同时捕获;
- nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT,
  改用生产模块 nodes/srt.py。

真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py
(运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py
(无 Key / 余额 / 限流转跳过)。全量 477 passed。
2026-09-13 15:40:56 +08:00

343 lines
15 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. 时间对齐数据(跨模块共享,默认目录:`tests/shared/data/alignment/`
- 音频/视频素材:`<name>.wav|.mp4|...`(真实语音)
- 参考字幕:`<name>.reference.srt`(人工校对的时间轴)
- 说明:对同一素材跑 whisper 节点(vad_filter 开/关两种配置),把产出
的 transcript.srt 与 reference.srt 做时间对齐评估,量化"过早/过晚"程度。
2. 幻觉词与专有名词提示词规则数据(默认目录:`tests/shared/data/prompt_rules/`
- 日文字幕样本:`<name>.ja.srt`(真实视频的日文 ASR 输出)
- 期望处理:`<name>.expected.txt`(每行一个语料关键词断言)
- 说明:用真实数据调用 llm-translate 节点(真实 LLM API,不 mock),
断言动态规则让译文不再输出寒暄幻觉、专名不被直译。
数据目录可通过环境变量 `WOV_TESTDATA_DIR` 覆盖(例如把素材放在外部盘),
也可由测试显式传入目录参数;两种方式都不依赖全局 conftest。
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
# 测试代码根目录(tests/):本文件位于 tests/shared/realdata_contract.py。
TESTS_DIR = Path(__file__).resolve().parent.parent
# 仓库根目录(tests/ 的上一级),用于定位迁移期仍在旧位置的素材。
WORKSPACE = TESTS_DIR.parent
# 真实数据根目录(新的模块化位置):跨模块共享素材放 tests/shared/data/。
# 可通过环境变量 WOV_TESTDATA_DIR 覆盖(例如把大体积素材放外部盘)。
SHARED_DATA_DIR = Path(
os.getenv("WOV_TESTDATA_DIR", str(TESTS_DIR / "shared" / "data"))
)
def data_roots() -> list[Path]:
"""返回要扫描的数据根目录(跨模块共享素材位置)。"""
return [SHARED_DATA_DIR] if SHARED_DATA_DIR.is_dir() else []
def _subdir(name: str) -> list[Path]:
"""在全部数据根目录下找同名子目录(存在才返回)。"""
return [root / name for root in data_roots() if (root / name).is_dir()]
# 时间对齐数据子目录、幻觉词/专名数据子目录(迁移期指向新位置的默认值,
# 实际探查由 alignment_candidates / prompt_rule_candidates 扫描全部根目录)。
ALIGNMENT_DIR = SHARED_DATA_DIR / "alignment"
PROMPT_RULES_DIR = SHARED_DATA_DIR / "prompt_rules"
# 时间对齐的量化指标:与参考时间轴的允许偏差(秒)。真实转写存在固有抖动,
# 用较大容差区分"正常误差"与"系统性地过早/过晚"两类问题。
TIER1_TOLERANCE_SECONDS = 0.5 # 第一档:单条字幕与参考的偏差阈值
TIER2_EARLY_SECONDS = 0.7 # 第二档:系统性偏早阈值(超过即判定"过早")
TIER2_LATE_SECONDS = 0.7 # 第二档:系统性偏晚阈值(超过即判定"过晚")
# ---------------------------------------------------------------------------
# 数据探查:真实数据文件是否存在
# ---------------------------------------------------------------------------
def alignment_candidates(directory: Path | None = None) -> list[Path]:
"""返回时间对齐测试可用的真实素材文件列表(存在才列出)。
识别规则:目录下任意 `<name>.<ext>`(音频/视频),且必须存在同名
`<name>.reference.srt` 参考字幕。两者齐备才是可用样本。
不传 directory 时扫描全部数据根目录(新位置优先,旧位置兼容)。
"""
bases = [directory] if directory else _subdir("alignment")
candidates: list[Path] = []
for base in bases:
if not base.is_dir():
continue
for path in sorted(base.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() and path not in candidates:
candidates.append(path)
return candidates
def prompt_rule_candidates(directory: Path | None = None) -> list[Path]:
"""返回提示词规则测试可用的真实样本列表(存在才列出)。
识别规则:目录下任意 `<name>.ja.srt`,且必须存在同名
`<name>.expected.txt` 期望清单。不传 directory 时扫描全部数据根目录。
"""
bases = [directory] if directory else _subdir("prompt_rules")
candidates: list[Path] = []
for base in bases:
if not base.is_dir():
continue
for path in sorted(base.glob("*.ja.srt")):
expected = path.with_suffix("").with_suffix(".expected.txt")
if expected.is_file() and path not in candidates:
candidates.append(path)
return candidates
# ---------------------------------------------------------------------------
# 时间对齐指标
# ---------------------------------------------------------------------------
@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
# ---------------------------------------------------------------------------
# 参考字幕净化
# ---------------------------------------------------------------------------
# 参考字幕净化正则:纯装饰/符号/垃圾行(如 OCR 栅栏 '---'、'==='、下划线等)
# 不参与时间对齐——它们不是真实的说话内容,混入会让指标失真。
_JUNK_RE = __import__("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"])
]
# ---------------------------------------------------------------------------
# 幻觉词 / 专有名词判定
# ---------------------------------------------------------------------------
# 上下文无关的收尾/开场寒暄幻觉词(可经环境变量/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
# ---------------------------------------------------------------------------
# 兼容导出
# ---------------------------------------------------------------------------
# SRT 条目解析实现放在同包的 srt_entries.py;这里再导出一次,方便调用方
# 只导入一个模块即可获得"数据契约 + 解析工具"。
from tests.shared.srt_entries import parse_srt_entries # noqa: E402
__all__ = [
"ALIGNMENT_DIR",
"PROMPT_RULES_DIR",
"SHARED_DATA_DIR",
"WORKSPACE",
"data_roots",
"alignment_candidates",
"prompt_rule_candidates",
"AlignmentReport",
"align_report",
"clean_reference",
"parse_srt_entries",
"HALLUCINATION_TOKENS",
"PROPER_NOUNS_NO_TRANSLATE",
"assert_no_halucination",
"assert_proper_noun_preserved",
"build_translation_system_prompt",
]