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。
This commit is contained in:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
+18
View File
@@ -0,0 +1,18 @@
"""测试公共设施包(供各模块测试复用,不含业务断言与隐式全局副作用)。
本包提供三类公共能力,供需要真实数据或环境隔离的模块测试使用:
1. `realdata_contract`:真实数据契约(时间对齐素材、提示词规则素材的探查
与量化工具),只读取用户提供的真实文件,缺失时测试整体跳过;
2. `env_isolation`:环境与临时目录隔离,让依赖 `wov_app.config` 路径常量的
模块测试可以自建独立数据目录,不依赖全局 conftest;
3. `srt_entries`SRT 条目解析(秒为单位),供参考字幕与产物字幕共用。
规则要求:模块专用数据放各模块目录下的 `data/`,本包只放真正跨模块复用的
工具与跨模块共享素材(`tests/shared/data/`)。本包**不注册 pytest 钩子**
隔离能力由各模块测试显式调用,保证每个模块目录可独立运行。
"""
from __future__ import annotations
__all__: list[str] = []
+19
View File
@@ -0,0 +1,19 @@
"""公共设施包的 pytest 配置(仅测试进程级隔离,不注册 autouse fixture)。
由于 `wov_app.config` 在导入时锁定路径常量,这里在 pytest 启动早期把应用
数据目录指向进程级临时目录,避免任何测试写入真实 `data/`。该配置属于
`tests/shared/` 包本身,不构成"模块测试依赖外部配置":模块目录内的用例
如需自己的临时目录,仍在模块 fixture 中用 `env_isolation` 显式创建。
"""
from __future__ import annotations
import atexit
from tests.shared import env_isolation
# 进程启动即隔离,保证后续任何 import wov_app.config 都拿到测试路径。
_ROOT = env_isolation.configure_environment()
# 进程退出时清理临时目录。
atexit.register(env_isolation.cleanup_data_root, _ROOT)
@@ -0,0 +1,103 @@
7
00:00:17,766 --> 00:00:21,828
辛苦了 上午的检查已经OK了
16
00:00:31,980 --> 00:00:34,011
谢谢你 松井小姐
17
00:00:34,010 --> 00:00:37,564
还有没有什么困扰 或者奇怪的地方吗
18
00:00:38,071 --> 00:00:39,594
没问题啦
19
00:00:39,594 --> 00:00:42,640
总感觉果林前辈每次都会问这个呢
20
00:00:42,640 --> 00:00:43,148
总感觉果林前辈 每次都会问这个呢
21
00:00:43,147 --> 00:00:46,193
是呢 抱歉
22
00:00:48,223 --> 00:00:49,239
V
23
00:00:49,239 --> 00:00:51,777
那么 松井小姐
24
00:00:51,777 --> 00:00:52,792
V
25
00:00:52,792 --> 00:00:55,838
你来我们医院也才一个月吧
26
00:00:56,345 --> 00:00:59,391
记得挺快嘛 没有啦
27
00:01:00,914 --> 00:01:04,467
因为果林前辈教得好呀
28
00:01:04,467 --> 00:01:08,527
因为 你和患者们 也已经完全打成一片了
29
00:01:09,036 --> 00:01:11,574
我是北冈果林
30
00:01:11,574 --> 00:01:16,143
在这家综合医院工作的护士
31
00:01:16,650 --> 00:01:19,188
她叫松井日奈子
32
00:01:19,188 --> 00:01:24,264
是一个月前开始 在这间医院工作的新人护士
33
00:01:25,279 --> 00:01:28,324
上吧 上吧
34
00:01:28,832 --> 00:01:33,908
不行 受不了 好了 上吧
35
00:01:37,462 --> 00:01:40,000
状态不错
36
00:01:40,000 --> 00:01:42,538
请进
37
00:01:47,107 --> 00:01:49,645
早安
38
00:01:50,660 --> 00:01:52,183
金山先生
39
00:01:52,183 --> 00:01:57,259
昨晚你住院时我不在没能照顾到你非常抱歉
40
00:01:57,259 --> 00:02:00,304
我是医务室长权藤 请多关照
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
1
00:00:00,000 --> 00:00:01,440
---
2
00:00:01,440 --> 00:00:04,320
SUB 001
3
00:00:05,760 --> 00:00:06,240
---
4
00:00:06,240 --> 00:00:09,120
SUB 002
5
00:00:09,600 --> 00:00:10,080
---
+61
View File
@@ -0,0 +1,61 @@
"""环境与临时目录隔离(供需要 `wov_app.config` 路径常量的模块测试使用)。
规则要求测试可独立运行、不依赖全局 conftest,数据与产物目录就是测试代码
所在目录:本模块把"创建独立数据目录并把应用指向它"封装成显式调用的函数,
由各模块测试在自己的 fixture 里调用。
失败模式说明:`wov_app.config` 在**导入时**读取环境变量并锁定路径常量
DATA_DIR / DB_PATH / STORAGE_DIR)。因此隔离必须在**首次导入** config
之前完成:
- 模块级导入的测试文件(如 `from wov_app.config import STORAGE_DIR`)应在
文件顶部用 `configure_environment()`(不带 tmp_path 的进程级隔离)设置;
- 测试函数内才导入 `wov_app` 的文件,可用 `isolated_data_dir(tmp_path)`
在导入前覆盖到每个用例自己的临时目录。
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
# 进程级临时根目录(进程退出即清理),供 `configure_environment()` 使用。
_PROCESS_ROOT: Path | None = None
def process_data_root() -> Path:
"""返回(必要时创建)进程级测试数据根目录。
与全局 conftest 的区别:这里不使用 pytest 的 autouse fixture,也不在
导入时自动改环境变量;模块测试显式调用后才生效,因此单个模块目录
可以脱离其他测试独立运行。
"""
global _PROCESS_ROOT
if _PROCESS_ROOT is None:
_PROCESS_ROOT = Path(tempfile.mkdtemp(prefix="vrsub-test-"))
return _PROCESS_ROOT
def configure_environment(data_root: Path | None = None) -> Path:
"""把应用的数据目录、数据库、存储与后台服务指向独立测试目录。
必须在导入 `wov_app.config`(或任何会导入它的模块)之前调用,
否则路径常量已按默认值锁定。返回实际使用的数据根目录。
"""
root = data_root or process_data_root()
os.environ["WOV_DATA_DIR"] = str(root / "data")
os.environ["WOV_DB_PATH"] = str(root / "data" / "wov.db")
os.environ["WOV_STORAGE_DIR"] = str(root / "storage")
# 默认关闭自动种子与后台线程:测试显式控制执行时机,避免与断言竞态。
os.environ["WOV_AUTO_SEED"] = "0"
os.environ["WOV_SCHEDULER_ENABLED"] = "0"
os.environ["WOV_CLEANUP_ENABLED"] = "0"
os.environ["WOV_BATCH_ENABLED"] = "0"
return root
def cleanup_data_root(root: Path) -> None:
"""清理测试数据根目录(忽略错误,进程退出或 fixture 收尾时调用)。"""
shutil.rmtree(root, ignore_errors=True)
+131
View File
@@ -0,0 +1,131 @@
"""GPU 显存探测与不足时跳过(供真实模型集成测试使用)。
真实模型(faster-whisper + CUDA)需要足够的显存;显存被其他进程占用或本身
偏小时,推理会在中途抛 "CUDA failed with error out of memory"。这属于**外部
运行环境状态**,不是被测代码的行为,按测试规则应跳过而不是判失败。
两层防护:
1. `require_gpu_memory()`:运行前按模型体量估算所需显存,不足则跳过;
2. `skip_on_cuda_oom()`:运行中若仍发生显存不足(被其他进程动态抢占),
也会被识别并转为跳过,而不是让整个套件判红。
探测不到 NVIDIA GPU(无 `nvidia-smi`,例如纯 CPU 机器)时**不做显存检查**:
faster-whisper 的 `device=auto` 会回退 CPU,此时测试应当照常执行。
"""
from __future__ import annotations
import shutil
import subprocess
from contextlib import contextmanager
from pathlib import Path
import pytest
# 推理时的额外开销系数:CTranslate2 除权重外还需推理工作区、激活与 CUDA 上下文。
# 实测标定(V2 权重 2.87GB,6GB 卡):推理前占用约 1120 MiB,峰值约 5217 MiB
# → 峰值增量约 4097 MiB ≈ 权重 × 1.39。系数取 1.45 覆盖 5% 分配余量,
# 使 6GB 卡(可用约 4.6GB)刚好放行、而更小的卡会被拦下。
#
# 注意:这只给出"是否可能跑完"的预估。实测在临界卡上(余量仅数百 MB)
# 即使预估通过仍可能因分配碎片化抛 CUDA OOM,所以还需要
# `skip_on_cuda_oom` / `require_node_result` 作为运行中的兼底。
_MEMORY_FACTOR = 1.45
def available_gpu_memory_mb() -> int | None:
"""返回当前可用的最大显存(MB);无 NVIDIA GPU 或探测失败时返回 None。
多卡时取可用显存最大的那张卡(推理默认只用一张卡)。
"""
if shutil.which("nvidia-smi") is None:
return None
try:
completed = subprocess.run(
[
"nvidia-smi",
"--query-gpu=memory.free",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
if completed.returncode != 0:
return None
values: list[int] = []
for line in completed.stdout.splitlines():
stripped = line.strip()
if stripped.isdigit():
values.append(int(stripped))
return max(values) if values else None
def required_memory_mb(model_dir: Path) -> int:
"""按模型权重体量估算推理所需显存(MB)。"""
weights = model_dir / "model.bin"
size_mb = weights.stat().st_size / (1024 * 1024) if weights.is_file() else 0.0
return int(size_mb * _MEMORY_FACTOR)
def require_gpu_memory(model_dir: Path) -> None:
"""显存不足以跑完该模型时跳过测试;无 GPU 时不检查(回退 CPU 执行)。"""
free = available_gpu_memory_mb()
if free is None:
return # 无 NVIDIA GPUdevice=auto 会走 CPU,无需显存检查
needed = required_memory_mb(model_dir)
if free < needed:
pytest.skip(
f"可用显存不足(需约 {needed} MB,当前可用 {free} MB),"
f"跳过真实模型集成测试以避免 OOM"
)
def is_cuda_oom(text: str) -> bool:
"""判断错误文本是否为显存不足(CUDA OOM)。"""
lowered = text.lower()
return "out of memory" in lowered or "cuda failed" in lowered
@contextmanager
def skip_on_cuda_oom():
"""运行真实推理;中途发生 CUDA OOM 时转为跳过(显存被动态抢占的场景)。"""
try:
yield
except Exception as exc: # noqa: BLE001 - 需要按消息识别 OOM
if is_cuda_oom(str(exc)):
pytest.skip(f"GPU 显存不足({exc}),跳过真实模型集成测试")
raise
def require_node_result(response, model_dir: Path) -> None:
"""校验节点响应:显存不足转为跳过,其它失败照常抛出由断言处理。
节点(如 whisper)会把推理异常包装成 `status="failed"` 的响应,因此
OOM 不会以异常形式冒泡;这里统一识别并跳过。
"""
if getattr(response, "status", "") != "completed" and is_cuda_oom(
str(getattr(response, "error", ""))
):
pytest.skip(
f"GPU 显存不足({getattr(response, 'error', '')}),跳过真实模型集成测试"
)
def fits_with_margin(model_dir: Path, safety_mb: int = 512) -> bool:
"""显存是否充裕到可承受分块/多次推理(预留 safety_mb 余量)。
用于区分两种用例写法:
- 显存充裕:按生产默认走分块路径(更接近线上配置);
- 显存临界:退化为整段单次推理,或直接跳过——分块会产生更多分配峰值,
在临界卡上易因碎片化触发 CUDA OOM(实测同一峰值下 chunk 失败而单次成功)。
无 NVIDIA GPU 时返回 False(此时应改用 CPU 友好配置,而非依赖显存)。
"""
free = available_gpu_memory_mb()
if free is None:
return False
return free >= required_memory_mb(model_dir) + safety_mb
+83
View File
@@ -0,0 +1,83 @@
"""真实 LLM 服务的可用性判定(供真实 LLM 集成测试共用)。
真实 LLM 集成测试有两个外部前提:配置了 `LLM_API_KEY`、且服务端可用。
两者都属于**外部环境状态**(密钥、余额、配额、网络),不是被测代码的行为;
按测试规则,这类外部状态缺失时应跳过而不是把缺陷计到代码头上。
本模块把该判定收敛到一处,避免各测试各自实现、语义漂移:
- `require_llm_credentials()`:无 Key 时直接跳过;
- `skip_on_service_unavailable()`:把鉴权/余额/限流类 HTTP 错误(401/402/403/429
转成跳过(附带原因),其余错误(如 5xx、网络异常、返回结构错误)照常失败,
以保证真实回归仍能被发现。
"""
from __future__ import annotations
import os
import urllib.error
from contextlib import contextmanager
import pytest
# 视为"服务端不可用/账号不可用"的 HTTP 状态码:
# 401 未授权、402 需要付费(余额/额度耗尽)、403 禁止访问、429 限流。
_UNAVAILABLE_CODES = frozenset({401, 402, 403, 429})
def require_llm_credentials() -> None:
"""未配置 LLM_API_KEY 时跳过真实 LLM 集成测试。"""
if not os.getenv("LLM_API_KEY"):
pytest.skip("未配置 LLM_API_KEY,跳过真实 LLM 集成测试")
@contextmanager
def skip_on_service_unavailable():
"""执行真实 LLM 调用;鉴权/余额/限流类错误转为跳过,其余错误向上抛出。
这样既不会因账户余额或临时限流把测试套件判红(外部状态问题),
也不会掩盖真正的回归(返回结构错误、代码异常仍会失败)。
"""
try:
yield
except urllib.error.HTTPError as exc:
if exc.code in _UNAVAILABLE_CODES:
pytest.skip(f"LLM 服务/账号当前不可用(HTTP {exc.code}),跳过真实集成测试")
raise
def probe_llm_or_skip(model: str | None = None) -> None:
"""向真实 LLM 接口发一次最小请求,服务/账号不可用时跳过测试。
用途:被测函数(如 `nodes/subtitle_correction.correct_entry`)出于生产
需要会把调用异常吞掉并返回空串,测试因此无法从返回值区分"服务不可用"
"模型没有泛化"。此探针在断言之前把外部状态问题显式暴露出来并跳过,
使断言只针对真实的能力回归。
探测失败判定与 `skip_on_service_unavailable` 一致(401/402/403/429 跳过);
其余错误(5xx、网络、返回结构异常)向上抛出,仍视为需要修复的问题。
"""
import json as _json
import urllib.request
require_llm_credentials()
api_base = os.getenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions")
api_key = os.getenv("LLM_API_KEY", "")
body = {
"model": model or os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B"),
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1,
"enable_thinking": False,
}
request = urllib.request.Request(
api_base,
data=_json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
method="POST",
)
with skip_on_service_unavailable():
try:
with urllib.request.urlopen(request, timeout=30) as response:
_json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError:
raise
+342
View File
@@ -0,0 +1,342 @@
"""真实数据契约与量化工具(时间对齐 / 提示词规则,供集成测试共用,不 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",
]
+65
View File
@@ -0,0 +1,65 @@
"""SRT 条目解析(纯函数,按秒返回,供参考字幕与产物字幕共用)。
与 `nodes/srt.py` 的区别:`nodes/srt.py` 保留原始毫秒字符串供生产链路
序列化;本模块把时间戳换算为**秒浮点数**,用于测试中的时间对齐计算与
篇幅统计,不参与生产输出。
"""
from __future__ import annotations
import re
# 匹配 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
@@ -0,0 +1,127 @@
"""真实语音时间对齐集成测试(数据 → 测试过程 → 验证结果)。
覆盖模块:`nodes/whisper.py`(真实转写)+ `tests/shared/realdata_contract.py`
(对齐量化工具)。这是模块级直测的补充:用真实模型 + 真实音频 + 人工校对
参考字幕,量化字幕时间轴与"说话真实发生时间"的偏差。
数据契约:`tests/shared/data/alignment/` 下 `<name>.wav|.mp4` 与其同名
`<name>.reference.srt`(人工校对)。素材缺失或本地无模型时跳过。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from nodes.whisper import _local_model_candidates, invoke
from tests.shared.realdata_contract import (
TIER1_TOLERANCE_SECONDS,
align_report,
alignment_candidates,
clean_reference,
)
from tests.shared.srt_entries import parse_srt_entries
from wov_sdk.models import InvokeRequest
def _model_available() -> Path | None:
"""返回可用的 V2 权重目录(缺失则返回 None 供跳过)。
只接受 V2 权重:V3 已全面停用(均改用 V2),留在盘上的 V3 目录不得被
测试使用,否则测的不是线上实际运行的模型。
"""
from tests.nodes.test_whisper.test_transcribe import _v2_model_candidates
for candidate in _v2_model_candidates():
if candidate.is_dir() and (candidate / "model.bin").is_file():
return candidate
return None
def _sample_dir() -> Path | None:
"""返回包含对齐素材的目录(共享数据目录;缺失时返回 None)。"""
directory = Path(__file__).resolve().parents[2] / "shared" / "data" / "alignment"
return directory if directory.is_dir() else None
@pytest.mark.integration
def test_real_alignment_candidates_discovered() -> None:
"""对齐素材探查:能找到成对的媒体与参考字幕(数据契约可用)。"""
# 数据:共享数据目录下的真实素材。
directory = _sample_dir()
if directory is None:
pytest.skip("缺少 tests/shared/data/alignment 目录")
# 测试过程
candidates = alignment_candidates(directory)
# 验证结果:至少一对,且每对都有同名 reference.srt。
assert candidates, "应至少有一对素材 + 参考字幕"
for media in candidates:
assert media.with_suffix(".reference.srt").is_file()
@pytest.mark.integration
def test_real_whisper_alignment_within_tolerance(tmp_path) -> None:
"""真实转写产物 vs 人工参考字幕:时间偏差在容差内且无系统性偏移。
这验证"字幕时间与说话时刻对齐"这一核心功能,使用真实模型与真实音频。
"""
# 数据:最小的真实对齐素材(避免长视频拖慢测试)。
directory = _sample_dir()
model_dir = _model_available()
if directory is None or model_dir is None:
pytest.skip("缺少对齐素材或本地 whisper 权重,跳过")
# 显存不足时跳过(转写整段真实音频需要显存,属外部环境状态)。
from tests.shared.gpu_memory import require_gpu_memory, require_node_result
require_gpu_memory(model_dir)
candidates = alignment_candidates(directory)
if not candidates:
pytest.skip("没有成对的对齐素材")
media = min(candidates, key=lambda p: p.stat().st_size)
reference = clean_reference(
parse_srt_entries(media.with_suffix(".reference.srt").read_text(encoding="utf-8"))
)
if not reference:
pytest.skip("参考字幕为空")
# 测试过程:走真实生产链路——先提音(视频/任意容器 → 16kHz 单声道 WAV),
# 再转写。whisper 只接受 WAV,直接喂 mp4 会解析失败(对齐素材多为 mp4)。
from nodes.ffmpeg import invoke as extract_audio
audio = extract_audio(InvokeRequest(
run_id="alignment", node_instance_id="ffmpeg-1", params={},
inputs={"video_uri": str(media)}, output_dir=str(tmp_path / "audio"),
))
require_node_result(audio, model_dir)
assert audio.status == "completed", audio.error
response = invoke(InvokeRequest(
run_id="alignment", node_instance_id="whisper-1",
params={
"language": "ja",
# 本用例目标是"时间轴对齐":整段单次解码(chunk_seconds=0)避免
# 分块上下文带来的额外显存与上下文损失;分块路径本身已由
# whisper 模块测试覆盖。实测 6GB 卡上该配置稳定完成。
"chunk_seconds": 0,
"vad_filter": True,
"model_path": str(model_dir),
},
inputs={"audio_uri": audio.outputs["audio_uri"]},
output_dir=str(tmp_path / "out"),
))
require_node_result(response, model_dir)
assert response.status == "completed", response.error
produced = parse_srt_entries(open(response.outputs["srt_uri"], encoding="utf-8").read())
report = align_report(media.stem, True, produced, reference)
# 验证结果(基线由 V2 模型实测标定,见 docs/testing.md):
# - 产出条数与参考规模同量级(不丢整段、不大量幻觉);
# - 平均绝对偏差在真实转写抖动范围内;
# - 中位偏差不构成系统性偏早/偏晚。
assert produced, "真实转写应产出字幕"
assert len(produced) >= len(reference) * 0.5, report.format_summary()
assert report.mean_abs_error <= 2.0, report.format_summary()
assert not report.consistently_early, report.format_summary()
assert not report.consistently_late, report.format_summary()
@@ -0,0 +1,88 @@
"""tests/shared/srt_entries.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`tests/shared/srt_entries.py`(按秒解析 SRT 条目,供各模块测试
计算时间轴与统计),是测试公共设施中的一个独立功能单元,拥有独立测试目录。
"""
from __future__ import annotations
from pathlib import Path
from tests.shared.srt_entries import parse_srt_entries
DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "alignment"
def test_parses_single_cue_with_seconds() -> None:
"""单条字幕解析为秒级时间轴与正文。"""
# 数据:一条标准 SRT。
text = "1\n00:00:01,500 --> 00:00:02,250\n你好\n"
# 测试过程
entries = parse_srt_entries(text)
# 验证结果
assert entries == [{"start": 1.5, "end": 2.25, "text": "你好"}]
def test_keeps_empty_text_entry_with_timeline() -> None:
"""空正文条目仍计入并保留时间轴(翻译补空占位需要)。"""
# 数据:空正文 + 有正文两条。
text = "1\n00:00:01,000 --> 00:00:02,000\n\n2\n00:00:03,000 --> 00:00:04,000\n有词\n"
# 测试过程
entries = parse_srt_entries(text)
# 验证结果
assert len(entries) == 2
assert entries[0]["text"] == ""
assert entries[1]["text"] == "有词"
def test_joins_multiline_text_with_space() -> None:
"""多行正文合并为空格连接(便于关键词检索与统计)。"""
# 数据:两行正文。
text = "1\n00:00:01,000 --> 00:00:02,000\n第一行\n第二行\n"
# 测试过程与验证结果
assert parse_srt_entries(text)[0]["text"] == "第一行 第二行"
def test_stops_entry_at_next_timestamp_or_index() -> None:
"""条目在下一个时间轴或序号行处结束(不跨条吞并)。"""
# 数据:三条紧凑排列(无多余空行)。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n\n"
"3\n00:00:05,000 --> 00:00:06,000\n\n"
)
# 测试过程
entries = parse_srt_entries(text)
# 验证结果
assert [e["text"] for e in entries] == ["", "", ""]
def test_parses_real_reference_subtitle_file() -> None:
"""真实参考字幕文件:条数等于时间轴行数(无丢失)。"""
# 数据:共享数据目录下的真实参考字幕。
path = DATA_DIR / "sample.reference.srt"
if not path.is_file():
import pytest
pytest.skip(f"缺少数据文件 {path}")
text = path.read_text(encoding="utf-8")
# 测试过程
entries = parse_srt_entries(text)
# 验证结果
assert len(entries) == sum(1 for line in text.splitlines() if "-->" in line)
def test_empty_input_returns_empty_list() -> None:
"""空文本返回空列表。"""
# 数据:空字符串。
# 测试过程与验证结果
assert parse_srt_entries("") == []