feat: 真实数据时间对齐集成测试框架

- tests/realdata_contract.py:数据契约底座(SRT 解析/清洗、时间对齐量化
  指标 align_report、幻觉词/专名判定、提示词规则拼接)
- tests/test_integration_alignment.py:流水线产物 vs 硬字幕参考的时间对齐
  (真实数据复现"字幕过早/过晚",红→绿闭环)
- tests/test_integration_prompt_rules.py:寒暄幻觉/专名提示词规则测试
- scripts/extract_reference_srt.py:从烧录字幕视频自动提取参考时间轴
- testdata/REALDATA_README.md + alignment/*.reference.srt:真实参考字幕
  (视频素材较大,gitignore 不入库)
This commit is contained in:
2026-09-05 19:36:23 +08:00
parent d1557eca46
commit 078170c12a
9 changed files with 5658 additions and 0 deletions
+192
View File
@@ -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