按"测试规则"重写 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。
343 lines
15 KiB
Python
343 lines
15 KiB
Python
"""真实数据契约与量化工具(时间对齐 / 提示词规则,供集成测试共用,不 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",
|
||
]
|