feat: 批量分块流水线、本地模型显存让渡与任务列表分工
批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组, 组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS) 再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载; 产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点): 该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续, 用于实现阶段边界。 - nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after > LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警), 新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model(); 新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。 - src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败 节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持 QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死: 引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录; 每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。 - src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。 - 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生 N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run 语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列 (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置 推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。 - 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING 外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py 改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。 测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、 阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时 置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、 前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
This commit is contained in:
@@ -8,12 +8,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.batch import (
|
||||
KEEP_MODEL_FLAG,
|
||||
MARKER_NAME,
|
||||
SUBTITLE_EXTENSIONS,
|
||||
VIDEO_EXTENSIONS,
|
||||
@@ -26,7 +28,7 @@ from wov_app.batch import (
|
||||
scan_videos,
|
||||
)
|
||||
from wov_app.db import Database
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
from wov_sdk.models import InvokeRequest, InvokeResponse, WorkflowDefinition
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -70,6 +72,78 @@ def _published_db(tmp_path: Path, workflow_id: str = "wf") -> Database:
|
||||
return db
|
||||
|
||||
|
||||
def _staged_definition() -> WorkflowDefinition:
|
||||
"""三节点分阶段链路:prep(echo) → translate(llm) → post(echo),产物为 srt。"""
|
||||
return WorkflowDefinition.from_dict({
|
||||
"name": "分阶段流程",
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "prep", "node_type": "echo", "params": {"node_tag": "prep"},
|
||||
"inputs": {"file_uri": "input.video_uri"}},
|
||||
{"id": "translate", "node_type": "llm-translate", "params": {"node_tag": "translate"},
|
||||
"inputs": {"file_uri": "prep.file_uri"}},
|
||||
{"id": "post", "node_type": "echo", "params": {"node_tag": "post"},
|
||||
"inputs": {"file_uri": "translate.file_uri"}},
|
||||
],
|
||||
"edges": [{"from": "prep", "to": "translate"}, {"from": "translate", "to": "post"}],
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"cn_srt": "post.file_uri"},
|
||||
})
|
||||
|
||||
|
||||
def _staged_db(tmp_path: Path, workflow_id: str = "wf") -> Database:
|
||||
"""建好已发布的三节点分阶段工作流库。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({
|
||||
"id": workflow_id, "name": "分阶段流程", "description": "", "published": 1,
|
||||
"latest_version": 1,
|
||||
})
|
||||
db.create_workflow_version(workflow_id, 1, _staged_definition().to_dict())
|
||||
return db
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _recording_nodes(
|
||||
trace: list[tuple[str, str]],
|
||||
llm_flag_state: list[bool] | None = None,
|
||||
fail_stage: tuple[str, str] | None = None,
|
||||
flag_trace: list[tuple[str, bool]] | None = None,
|
||||
):
|
||||
"""把 echo / llm-translate 节点换成记录调用顺序的假节点(覆盖真实注册表条目)。
|
||||
|
||||
假节点把上游传来的视频名写成 payload.srt 透传给下一节点,因此每个阶段都
|
||||
知道自己在处理哪个视频;记录 (节点标签, 视频名),llm 节点额外记录
|
||||
keep_model.flag 是否存在;fail_stage 指定的 (标签, 视频名) 组合返回失败。
|
||||
"""
|
||||
registry.register_all()
|
||||
|
||||
def handler(request: InvokeRequest) -> InvokeResponse:
|
||||
tag = str(request.params.get("node_tag"))
|
||||
source = str(request.inputs.get("file_uri") or "")
|
||||
if source and Path(source).suffix.lower() in VIDEO_EXTENSIONS:
|
||||
# 首阶段的输入就是视频文件,后续阶段拿到的是上一阶段的 payload。
|
||||
video_name = Path(source).name
|
||||
else:
|
||||
video_name = Path(source).read_text(encoding="utf-8").strip() if source else ""
|
||||
trace.append((tag, video_name))
|
||||
run_root = Path(request.output_dir).parent.parent
|
||||
if llm_flag_state is not None and tag == "translate":
|
||||
llm_flag_state.append((run_root / KEEP_MODEL_FLAG).exists())
|
||||
if flag_trace is not None:
|
||||
flag_trace.append((tag, (run_root / KEEP_MODEL_FLAG).exists()))
|
||||
if fail_stage == (tag, video_name):
|
||||
return InvokeResponse(status="failed", error="模拟阶段失败")
|
||||
output_dir = Path(request.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = output_dir / "payload.srt"
|
||||
output.write_text(video_name, encoding="utf-8")
|
||||
return InvokeResponse(status="completed", outputs={"file_uri": str(output)})
|
||||
|
||||
for node_type in ("echo", "llm-translate"):
|
||||
registry.register(registry.get_node(node_type), handler)
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扫描与旁挂字幕判定
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -412,3 +486,265 @@ def test_worker_start_stop_idempotent(tmp_path: Path) -> None:
|
||||
# 验证结果
|
||||
assert first is second
|
||||
assert worker._thread is None
|
||||
|
||||
|
||||
def test_worker_processes_recovered_zombie_job(tmp_path: Path, monkeypatch) -> None:
|
||||
"""僵尸任务(COMPLETED 但明细仍 PENDING)被恢复后能真正处理完剩余视频。
|
||||
|
||||
曾出现「任务已完成、视频仍未处理」的僵尸状态(完成标记先于视频收尾写出),
|
||||
而引擎只拾取 QUEUED:不恢复就永远不会再处理那个视频。
|
||||
"""
|
||||
# 数据:已发布工作流 + 一个视频;创建任务后伪造成僵尸状态。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
registry.register_all()
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
db.update_batch_job(job_id, status="COMPLETED", progress=1.0,
|
||||
updated_at="2026-09-01T00:00:00+00:00")
|
||||
|
||||
# 测试过程:重启恢复把僵尸任务放回队列,引擎拾起后处理剩余视频。
|
||||
db.recover_interrupted_batch_jobs("2026-09-01T01:00:00+00:00")
|
||||
worker = BatchWorker(db, interval_seconds=999)
|
||||
worker._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:视频真的处理完、产物放到视频旁、任务保持完成。
|
||||
item = db.list_batch_videos(job_id)[0]
|
||||
assert item["status"] == "COMPLETED"
|
||||
assert db.get_batch_job(job_id)["status"] == "COMPLETED"
|
||||
assert list(folder.glob("movie.*")), "应在视频旁放置最终产物"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 引擎:分块流水线(阶段化执行)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_worker_runs_grouped_stage_pipeline(tmp_path: Path, monkeypatch) -> None:
|
||||
"""分块流水线:组内按节点顺序跑完全部视频,而不是每个视频跑完整链路。"""
|
||||
# 数据:3 个视频 + 三节点链路,分组大小 2(前两个一组、第三个一组)。
|
||||
folder = tmp_path / "videos"
|
||||
for name in ("a.mp4", "b.mp4", "c.mp4"):
|
||||
_make_video(folder / name)
|
||||
db = _staged_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_STAGE_GROUP_SIZE", 2)
|
||||
trace: list[tuple[str, str]] = []
|
||||
|
||||
# 测试过程:用记录调用顺序的假节点驱动引擎跑一轮。
|
||||
with _recording_nodes(trace):
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:组1 三个阶段各跑 a/b,再轮到组2 的 c。
|
||||
assert trace == [
|
||||
("prep", "a.mp4"), ("prep", "b.mp4"),
|
||||
("translate", "a.mp4"), ("translate", "b.mp4"),
|
||||
("post", "a.mp4"), ("post", "b.mp4"),
|
||||
("prep", "c.mp4"), ("translate", "c.mp4"), ("post", "c.mp4"),
|
||||
]
|
||||
# 三个视频都完成且产物按约定名落到视频旁。
|
||||
assert db.get_batch_job(job_id)["status"] == "COMPLETED"
|
||||
assert sorted(p.name for p in folder.glob("*.srt")) == ["a.CN.srt", "b.CN.srt", "c.CN.srt"]
|
||||
|
||||
|
||||
def test_worker_releases_local_llm_once_per_group(tmp_path: Path, monkeypatch) -> None:
|
||||
"""LLM 阶段结束由引擎统一释放显存:每组一次,而不是每个视频一次。"""
|
||||
# 数据:3 个视频 + 分组 2,记录释放调用与 LLM 调用时的常驻信号状态。
|
||||
folder = tmp_path / "videos"
|
||||
for name in ("a.mp4", "b.mp4", "c.mp4"):
|
||||
_make_video(folder / name)
|
||||
db = _staged_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_STAGE_GROUP_SIZE", 2)
|
||||
releases: list[str | None] = []
|
||||
monkeypatch.setattr(
|
||||
"wov_app.batch.release_local_model",
|
||||
lambda model=None: releases.append(model),
|
||||
)
|
||||
trace: list[tuple[str, str]] = []
|
||||
flag_state: list[bool] = []
|
||||
|
||||
# 测试过程
|
||||
with _recording_nodes(trace, llm_flag_state=flag_state):
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:两组各释放一次;每次 LLM 调用都在“保持常驻”信号下执行;信号已清理。
|
||||
assert releases == [None, None]
|
||||
assert flag_state == [True, True, True]
|
||||
assert not list((tmp_path / "storage").rglob(KEEP_MODEL_FLAG))
|
||||
|
||||
|
||||
def test_worker_defers_video_failed_in_earlier_stage(tmp_path: Path, monkeypatch) -> None:
|
||||
"""上一阶段失败的视频不在后续阶段重跑(避免 LLM 已常驻时重跑 ASR 抢显存)。"""
|
||||
# 数据:2 个视频(同一组)+ 三节点链路,prep 阶段让 a 失败。
|
||||
folder = tmp_path / "videos"
|
||||
for name in ("a.mp4", "b.mp4"):
|
||||
_make_video(folder / name)
|
||||
db = _staged_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_STAGE_GROUP_SIZE", 2)
|
||||
monkeypatch.setattr("wov_app.batch.release_local_model", lambda model=None: None)
|
||||
trace: list[tuple[str, str]] = []
|
||||
|
||||
# 测试过程
|
||||
with _recording_nodes(trace, fail_stage=("prep", "a.mp4")):
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:a 只在 prep 出现一次并记为 FAILED;b 三阶段跑完并落地产物。
|
||||
assert [entry for entry in trace if entry[1] == "a.mp4"] == [("prep", "a.mp4")]
|
||||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job_id)}
|
||||
assert videos["a.mp4"]["status"] == "FAILED"
|
||||
assert videos["b.mp4"]["status"] == "COMPLETED"
|
||||
assert (folder / "b.CN.srt").is_file()
|
||||
assert not (folder / "a.CN.srt").exists()
|
||||
assert db.get_batch_job(job_id)["failed"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 引擎:创建期间拾起任务(明细未登记完)的自愈
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_worker_requeues_job_without_details(tmp_path: Path, monkeypatch) -> None:
|
||||
"""任务行先于明细写入:拾起到无明细的任务时保持 QUEUED,不按空任务收尾。"""
|
||||
# 数据:只有任务行、还没写任何视频明细的批量任务。
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
db.create_batch_job({
|
||||
"id": "batch-registering", "folder_path": str(tmp_path), "workflow_id": "wf",
|
||||
"recursive": 1, "status": "QUEUED", "progress": 0, "total": 0, "done": 0,
|
||||
"failed": 0, "current_video": None, "error": None,
|
||||
"created_at": "2026-09-01T00:00:00+00:00", "updated_at": "2026-09-01T00:00:00+00:00",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
worker = BatchWorker(db, interval_seconds=999)
|
||||
worker._process_job(db.get_batch_job("batch-registering"))
|
||||
|
||||
# 验证结果:任务仍在排队等待登记完成,而不是被标成 COMPLETED。
|
||||
assert db.get_batch_job("batch-registering")["status"] == "QUEUED"
|
||||
assert db.next_queued_batch_job() is not None
|
||||
|
||||
|
||||
def test_worker_requeues_job_when_video_registered_mid_pass(tmp_path: Path, monkeypatch) -> None:
|
||||
"""明细在引擎处理中途才登记进来:本轮结束后置回 QUEUED,下一轮续跑完成。"""
|
||||
# 数据:1 个视频 + 单节点工作流;处理首个阶段时登记第二个视频(模拟创建中拾起)。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "first.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
registry.register_all()
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
worker = BatchWorker(db, interval_seconds=999)
|
||||
original_stage = worker._run_stage
|
||||
injected = {"done": False}
|
||||
|
||||
def stage_with_late_video(*args, **kwargs):
|
||||
# 模拟 create_job 仍在写明细:引擎快照之后新视频才出现在数据库里。
|
||||
if not injected["done"]:
|
||||
injected["done"] = True
|
||||
late_video = _make_video(folder / "second.mp4")
|
||||
db.create_batch_video({
|
||||
"id": "bv_late", "job_id": job_id, "video_path": str(late_video),
|
||||
"work_dir": str(tmp_path / "storage" / "batch" / job_id / "bv_late"),
|
||||
"run_id": None, "status": "PENDING", "error": None,
|
||||
"created_at": "2026-09-01T00:00:01+00:00", "updated_at": "2026-09-01T00:00:01+00:00",
|
||||
})
|
||||
return original_stage(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(worker, "_run_stage", stage_with_late_video)
|
||||
|
||||
# 测试过程:第一轮只看到 first.mp4。
|
||||
worker._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:job 被置回 QUEUED 等待下一轮,新视频还没被处理。
|
||||
assert db.get_batch_job(job_id)["status"] == "QUEUED"
|
||||
statuses = {Path(v["video_path"]).name: v["status"] for v in db.list_batch_videos(job_id)}
|
||||
assert statuses == {"first.mp4": "COMPLETED", "second.mp4": "PENDING"}
|
||||
|
||||
# 测试过程:下一轮引擎拾起后处理剩余视频并收尾。
|
||||
second = BatchWorker(db, interval_seconds=999)
|
||||
second._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:两个视频都完成、任务完成、产物都在视频旁(真实 echo 节点产物为 echo.txt)。
|
||||
statuses = {Path(v["video_path"]).name: v["status"] for v in db.list_batch_videos(job_id)}
|
||||
assert statuses == {"first.mp4": "COMPLETED", "second.mp4": "COMPLETED"}
|
||||
assert db.get_batch_job(job_id)["status"] == "COMPLETED"
|
||||
# 真实 echo 节点的最终产物保留原扩展名(非 .srt/.ass),按视频主名放置。
|
||||
assert list(folder.glob("first.*")) and list(folder.glob("second.*"))
|
||||
|
||||
|
||||
def test_worker_removes_empty_job_workspace_after_completion(tmp_path: Path, monkeypatch) -> None:
|
||||
"""任务全部完成后删掉任务级工作空间目录(每视频工作空间已各自清理)。"""
|
||||
# 数据:一个视频的批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
registry.register_all()
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
|
||||
# 测试过程
|
||||
BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:任务完成,任务级目录(收尾后只剩空壳)被删除。
|
||||
assert db.get_batch_job(job_id)["status"] == "COMPLETED"
|
||||
assert not (tmp_path / "storage" / "batch" / job_id).exists()
|
||||
|
||||
|
||||
def test_worker_keeps_job_workspace_when_video_failed(tmp_path: Path, monkeypatch) -> None:
|
||||
"""有失败视频时保留任务工作空间(失败视频的中间产物供断点重试)。"""
|
||||
# 数据:三节点链路,prep 阶段让视频失败。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
db = _staged_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_STAGE_GROUP_SIZE", 2)
|
||||
monkeypatch.setattr("wov_app.batch.release_local_model", lambda model=None: None)
|
||||
|
||||
# 测试过程
|
||||
with _recording_nodes([], fail_stage=("prep", "a.mp4")):
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:视频失败、任务工作空间仍在(可重试)。
|
||||
assert db.get_batch_job(job_id)["failed"] == 1
|
||||
assert (tmp_path / "storage" / "batch" / job_id).exists()
|
||||
|
||||
|
||||
def test_worker_clears_stale_keep_model_flag_before_stage(tmp_path: Path, monkeypatch) -> None:
|
||||
"""强杀残留的 keep_model.flag 不会带到后续阶段:非 LLM 节点不应看到它。"""
|
||||
# 数据:两节点链路 + 已存在的 run(工作空间里残留强杀时的 keep_model.flag)。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
db = _staged_db(tmp_path)
|
||||
work_root = tmp_path / "storage" / "batch"
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", work_root)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_STAGE_GROUP_SIZE", 8)
|
||||
monkeypatch.setattr("wov_app.batch.release_local_model", lambda model=None: None)
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
video = db.list_batch_videos(job_id)[0]
|
||||
run_id = "run_stale_flag"
|
||||
db.update_batch_video(video["id"], run_id=run_id, updated_at="2026-09-01T00:00:00+00:00")
|
||||
db.create_run({
|
||||
"id": run_id, "workflow_id": "wf", "workflow_version": 1, "status": "QUEUED",
|
||||
"current_node_id": None, "progress": 0.0, "error": None,
|
||||
"input_uri": str(folder / "a.mp4"), "param_overrides": None, "source": "batch",
|
||||
"created_at": "2026-09-01T00:00:00+00:00", "updated_at": "2026-09-01T00:00:00+00:00",
|
||||
})
|
||||
run_dir = Path(video["work_dir"]) / "runs" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / KEEP_MODEL_FLAG).write_text("", encoding="utf-8")
|
||||
flag_trace: list[tuple[str, bool]] = []
|
||||
|
||||
# 测试过程
|
||||
with _recording_nodes([], flag_trace=flag_trace):
|
||||
BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:prep(非 LLM)看不到残留标志;translate(LLM 阶段)才写入;
|
||||
# post(非 LLM)不再看到它。
|
||||
assert flag_trace == [("prep", False), ("translate", True), ("post", False)]
|
||||
|
||||
@@ -164,6 +164,22 @@ def test_list_runs_orders_by_created_at_desc(db_with_workflow: Database) -> None
|
||||
assert ids == ["new", "mid", "old"]
|
||||
|
||||
|
||||
def test_list_runs_excludes_batch_runs_by_default(db_with_workflow: Database) -> None:
|
||||
"""任务列表默认不含批量 run:它们属于批量页的任务明细,会把 20 条窗口占满。"""
|
||||
# 数据:一条上传任务 + 两条批量 run。
|
||||
db_with_workflow.create_run(_run("upload", created_at="2026-09-01T00:00:00+00:00"))
|
||||
db_with_workflow.create_run(_run("batch-1", source="batch", created_at="2026-09-02T00:00:00+00:00"))
|
||||
db_with_workflow.create_run(_run("batch-2", source="batch", created_at="2026-09-03T00:00:00+00:00"))
|
||||
|
||||
# 测试过程
|
||||
default_ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||||
all_ids = [r["id"] for r in db_with_workflow.list_runs(include_batch=True)]
|
||||
|
||||
# 验证结果:默认只列上传任务,显式要求时才包含批量 run。
|
||||
assert default_ids == ["upload"]
|
||||
assert all_ids == ["batch-2", "batch-1", "upload"]
|
||||
|
||||
|
||||
def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None:
|
||||
"""删除任务同时清理其产物记录。"""
|
||||
# 数据:任务 + 一条产物。
|
||||
@@ -330,3 +346,38 @@ def test_list_run_ids(db_with_workflow: Database) -> None:
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"]
|
||||
|
||||
|
||||
def test_recover_interrupted_batch_jobs_requeues_zombie_completed(db_with_workflow: Database) -> None:
|
||||
"""重启恢复:COMPLETED 但仍有未结束视频的僵尸任务也要置回 QUEUED。
|
||||
|
||||
曾出现「任务已完成、视频仍未处理」的僵尸状态:完成标记先于视频收尾写出,
|
||||
而引擎只拾取 QUEUED,剩余视频永久无人处理。
|
||||
"""
|
||||
# 数据:一个仍含 PENDING 视频的 COMPLETED 任务 + 一个全部结束的 COMPLETED 任务。
|
||||
db_with_workflow.create_batch_job({
|
||||
"id": "zombie", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||||
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
db_with_workflow.create_batch_video({
|
||||
"id": "zombie-v1", "job_id": "zombie", "video_path": "/videos/a.mp4",
|
||||
"work_dir": "/tmp/zombie", "status": "PENDING",
|
||||
"created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
db_with_workflow.create_batch_job({
|
||||
"id": "done", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||||
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
db_with_workflow.create_batch_video({
|
||||
"id": "done-v1", "job_id": "done", "video_path": "/videos/b.mp4",
|
||||
"work_dir": "/tmp/done", "status": "COMPLETED",
|
||||
"created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||||
|
||||
# 验证结果:只有僵尸任务被置回 QUEUED,真正完成的任务不受影响。
|
||||
assert count == 1
|
||||
assert db_with_workflow.get_batch_job("zombie")["status"] == "QUEUED"
|
||||
assert db_with_workflow.get_batch_job("done")["status"] == "COMPLETED"
|
||||
|
||||
@@ -191,6 +191,27 @@ def test_get_missing_run_returns_404(client: TestClient) -> None:
|
||||
assert client.get("/api/runs/nope").status_code == 404
|
||||
|
||||
|
||||
def test_list_runs_excludes_batch_runs_by_default(client: TestClient, tmp_path: Path) -> None:
|
||||
"""批量 run 不进任务管理默认列表(它们是批量任务明细,由批量页展示)。"""
|
||||
# 数据:一条上传任务 + 一条同工作流的批量 run。
|
||||
upload_id = _create_run(client)
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.create_run({
|
||||
"id": "run_batch_listed", "workflow_id": "echo-app", "workflow_version": 1,
|
||||
"status": "RUNNING", "current_node_id": "step", "progress": 0.0, "error": None,
|
||||
"input_uri": "/videos/movie.mp4", "param_overrides": None, "source": "batch",
|
||||
"created_at": "2026-09-09T00:00:00+00:00", "updated_at": "2026-09-09T00:00:00+00:00",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
default_ids = [item["id"] for item in client.get("/api/runs").json()]
|
||||
all_ids = [item["id"] for item in client.get("/api/runs", params={"include_batch": 1}).json()]
|
||||
|
||||
# 验证结果:默认列表只有上传任务,显式请求时包含批量 run。
|
||||
assert default_ids == [upload_id]
|
||||
assert set(all_ids) == {upload_id, "run_batch_listed"}
|
||||
|
||||
|
||||
def test_pause_and_resume_run(client: TestClient) -> None:
|
||||
"""暂停置 PAUSED、继续置 QUEUED,并写入/清除暂停信号文件。"""
|
||||
# 数据:一条任务。
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.db import Database
|
||||
from wov_app.main import app as fastapi_app
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
|
||||
@@ -308,3 +309,65 @@ def test_download_missing_product_returns_404(client: TestClient, tmp_path: Path
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_job_detail_reports_video_stage(client: TestClient, tmp_path: Path) -> None:
|
||||
"""详情给处理中的视频补阶段信息:第几阶段/共几阶段 + 中文标签。"""
|
||||
# 数据:三节点工作流(prep → translate → post),视频 run 停在第二阶段。
|
||||
definition = WorkflowDefinition.from_dict({
|
||||
"name": "分阶段流程", "version": 1,
|
||||
"nodes": [
|
||||
{"id": "prep", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
|
||||
{"id": "translate", "node_type": "llm-translate", "inputs": {"srt_uri": "prep.file_uri"}},
|
||||
{"id": "post", "node_type": "echo", "inputs": {"file_uri": "translate.file_uri"}},
|
||||
],
|
||||
"edges": [{"from": "prep", "to": "translate"}, {"from": "translate", "to": "post"}],
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"result": "post.file_uri"},
|
||||
}).to_dict()
|
||||
client.post("/api/admin/workflows", json={
|
||||
"id": "wf-staged", "name": "分阶段流程", "description": "", "definition": definition,
|
||||
})
|
||||
client.post("/api/admin/workflows/wf-staged/publish")
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf-staged", "recursive": True,
|
||||
}).json()["id"]
|
||||
db = Database(tmp_path / "wov.db")
|
||||
video = [v for v in db.list_batch_videos(job_id) if v["status"] != "SKIPPED"][0]
|
||||
db.update_batch_video(video["id"], status="RUNNING", run_id="run_stage", updated_at="2026-09-01T00:00:00+00:00")
|
||||
db.create_run({
|
||||
"id": "run_stage", "workflow_id": "wf-staged", "workflow_version": 1,
|
||||
"status": "RUNNING", "current_node_id": "translate", "progress": 0.3333,
|
||||
"error": None, "input_uri": str(folder / "movie.mp4"), "param_overrides": None,
|
||||
"source": "batch", "created_at": "2026-09-01T00:00:00+00:00",
|
||||
"updated_at": "2026-09-01T00:00:00+00:00",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
body = client.get(f"/api/batch/jobs/{job_id}").json()
|
||||
|
||||
# 验证结果:阶段序号/总数与节点类型对应的中文标签。
|
||||
item = [v for v in body["videos"] if v["status"] != "SKIPPED"][0]
|
||||
assert item["stage_label"] == "翻译"
|
||||
assert (item["stage_index"], item["stage_total"]) == (2, 3)
|
||||
|
||||
|
||||
def test_job_detail_omits_stage_for_unstarted_video(client: TestClient, tmp_path: Path) -> None:
|
||||
"""还没开始处理的视频没有阶段信息(前端显示占位符)。"""
|
||||
# 数据:一个 PENDING 视频(无 run)。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": True,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
body = client.get(f"/api/batch/jobs/{job_id}").json()
|
||||
|
||||
# 验证结果:阶段字段为空。
|
||||
item = [v for v in body["videos"] if v["status"] != "SKIPPED"][0]
|
||||
assert item["stage_label"] is None
|
||||
assert item["stage_index"] is None and item["stage_total"] is None
|
||||
|
||||
@@ -116,6 +116,72 @@ def test_topological_sort_diamond() -> None:
|
||||
assert set(order[1:3]) == {"b", "c"}
|
||||
|
||||
|
||||
def test_execute_run_stop_after_leaves_run_running_and_resumes(tmp_path: Path) -> None:
|
||||
"""分阶段执行:stop_after 指定阶段节点后停下(保持 RUNNING、不收尾),再次调用续跑完成。"""
|
||||
# 数据:a → b → c 三段 echo 链 + 最终别名。
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
],
|
||||
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "c"}],
|
||||
final_outputs={"result": "c.file_uri"},
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
source = tmp_path / "input.txt"
|
||||
source.write_text("分阶段内容", encoding="utf-8")
|
||||
db.create_run(_run("run-stage", str(source)))
|
||||
registry.register_all()
|
||||
scheduler = _scheduler(db, storage)
|
||||
|
||||
# 测试过程:第一阶段只执行到 b 为止。
|
||||
scheduler.execute_run("run-stage", stop_after="b")
|
||||
|
||||
# 验证结果:任务保持 RUNNING 未收尾,a/b 产物已登记,c 与最终别名都没有。
|
||||
staged = db.get_run("run-stage")
|
||||
assert staged["status"] == "RUNNING"
|
||||
assert staged["current_node_id"] == "b"
|
||||
names = {artifact["name"] for artifact in db.list_artifacts("run-stage")}
|
||||
assert {"a.file_uri", "b.file_uri"} <= names
|
||||
assert "c.file_uri" not in names
|
||||
assert "result" not in names
|
||||
|
||||
# 测试过程:不传 stop_after 时整条 DAG 跑完并收尾。
|
||||
scheduler.execute_run("run-stage")
|
||||
|
||||
# 验证结果:完成、进度 1.0、最终别名登记。
|
||||
finished = db.get_run("run-stage")
|
||||
assert finished["status"] == "COMPLETED"
|
||||
assert finished["progress"] == 1.0
|
||||
assert "result" in {artifact["name"] for artifact in db.list_artifacts("run-stage")}
|
||||
|
||||
|
||||
def test_execute_run_stop_after_unknown_node_marks_failed(tmp_path: Path) -> None:
|
||||
"""stop_after 指向不存在的节点时任务标 FAILED(不留下永远 RUNNING 的任务)。"""
|
||||
# 数据:单 echo 节点任务。
|
||||
definition = _definition(
|
||||
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
|
||||
edges=[],
|
||||
final_outputs={"result": "step.file_uri"},
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
source = tmp_path / "input.txt"
|
||||
source.write_text("内容", encoding="utf-8")
|
||||
db.create_run(_run("run-bad-stage", str(source)))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-bad-stage", stop_after="nope")
|
||||
|
||||
# 验证结果:FAILED 且错误说明阶段节点不存在。
|
||||
stored = db.get_run("run-bad-stage")
|
||||
assert stored["status"] == "FAILED"
|
||||
assert "stop_after" in (stored["error"] or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行:成功路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,9 +17,11 @@ import pytest
|
||||
from nodes.llm import (
|
||||
CHUNK_SIZE,
|
||||
MAX_BATCH_RETRIES,
|
||||
PauseRequested,
|
||||
_parse_translations,
|
||||
_system_prompt,
|
||||
invoke,
|
||||
release_local_model,
|
||||
translate_lines,
|
||||
)
|
||||
from wov_sdk.models import InvokeRequest
|
||||
@@ -28,6 +30,17 @@ from wov_sdk.models import InvokeRequest
|
||||
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_llm_env(monkeypatch):
|
||||
"""清掉外部泄漏的 LLM 路由变量,保证用例只受自己显式设置的环境变量影响。
|
||||
|
||||
全量跑时其它模块 import 应用会触发 load_dotenv(),把开发者 .env 里的
|
||||
LLM_API_BASE(可能指向本机 Ollama)带进来,从而改变端点判定与请求数量。
|
||||
"""
|
||||
for name in ("LLM_API_BASE", "LLM_MODEL", "LLM_UNLOAD_AFTER"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
"""假的 HTTP 响应:返回预置 JSON 体(供 urlopen mock 使用)。"""
|
||||
|
||||
@@ -70,6 +83,35 @@ def _capture_urlopen(calls: list[dict], responses: list[_FakeHTTPResponse]):
|
||||
return fake_urlopen
|
||||
|
||||
|
||||
def _capture_urlopen_routing_unload(
|
||||
calls: list[dict],
|
||||
responses: list[_FakeHTTPResponse],
|
||||
unload_error: Exception | None = None,
|
||||
):
|
||||
"""按 URL 分流的假 urlopen:卸载请求只记录,翻译请求按序返回预置响应。"""
|
||||
|
||||
def fake_urlopen(http_request, timeout=None):
|
||||
calls.append({
|
||||
"url": http_request.full_url,
|
||||
"body": json.loads(http_request.data.decode("utf-8")),
|
||||
"timeout": timeout,
|
||||
})
|
||||
if http_request.full_url.endswith("/api/generate"):
|
||||
if unload_error is not None:
|
||||
raise unload_error
|
||||
return _FakeHTTPResponse({"done": True})
|
||||
return responses.pop(0) if responses else _llm_reply([])
|
||||
|
||||
return fake_urlopen
|
||||
|
||||
|
||||
def _local_llm_env(monkeypatch) -> None:
|
||||
"""把 LLM 端点指向本地 Ollama(qwen3:30b-a3b)。"""
|
||||
monkeypatch.setenv("LLM_API_KEY", "")
|
||||
monkeypatch.setenv("LLM_API_BASE", "http://localhost:11434/v1/chat/completions")
|
||||
monkeypatch.setenv("LLM_MODEL", "qwen3:30b-a3b")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 提示词与响应解析(纯函数)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -363,6 +405,200 @@ def test_translate_lines_sends_bearer_key(monkeypatch) -> None:
|
||||
assert headers.get("authorization") == "Bearer sk-abc"
|
||||
|
||||
|
||||
def test_translate_lines_unloads_local_model_when_env_enabled(monkeypatch) -> None:
|
||||
"""开启 LLM_UNLOAD_AFTER 时翻译结束请求 Ollama 卸载模型,把显存让给 whisper。"""
|
||||
# 数据:本地端点 + 开启卸载。
|
||||
calls: list[dict] = []
|
||||
_local_llm_env(monkeypatch)
|
||||
monkeypatch.setenv("LLM_UNLOAD_AFTER", "1")
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translate_lines(["一"], {})
|
||||
|
||||
# 验证结果:翻译后向 Ollama 原生端点发 keep_alive=0 的卸载请求。
|
||||
assert [call["url"] for call in calls] == [
|
||||
"http://localhost:11434/v1/chat/completions",
|
||||
"http://localhost:11434/api/generate",
|
||||
]
|
||||
assert calls[1]["body"] == {"model": "qwen3:30b-a3b", "keep_alive": 0}
|
||||
|
||||
|
||||
def test_translate_lines_auto_unloads_loopback_endpoint(monkeypatch) -> None:
|
||||
"""端点在本机(loopback)时默认卸载:无需开关,默认就让出显存。"""
|
||||
# 数据:本地端点 + 不设置任何开关。
|
||||
calls: list[dict] = []
|
||||
_local_llm_env(monkeypatch)
|
||||
monkeypatch.delenv("LLM_UNLOAD_AFTER", raising=False)
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translate_lines(["一"], {})
|
||||
|
||||
# 验证结果:本机端点默认发出卸载请求。
|
||||
assert calls[-1]["url"] == "http://localhost:11434/api/generate"
|
||||
|
||||
|
||||
def test_translate_lines_does_not_unload_remote_endpoint(monkeypatch) -> None:
|
||||
"""云端端点默认不卸载:显存不由本机持有,多发请求只是噪声。"""
|
||||
# 数据:远程端点 + 不设置任何开关。
|
||||
calls: list[dict] = []
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions")
|
||||
monkeypatch.delenv("LLM_UNLOAD_AFTER", raising=False)
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translate_lines(["一"], {})
|
||||
|
||||
# 验证结果:只有翻译请求。
|
||||
assert [call["url"] for call in calls] == ["https://api.siliconflow.cn/v1/chat/completions"]
|
||||
|
||||
|
||||
def test_translate_lines_param_unload_after_enables_unload(monkeypatch) -> None:
|
||||
"""节点参数 unload_after=True 等效于环境变量开关。"""
|
||||
# 数据:只给节点参数。
|
||||
calls: list[dict] = []
|
||||
_local_llm_env(monkeypatch)
|
||||
monkeypatch.delenv("LLM_UNLOAD_AFTER", raising=False)
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translate_lines(["一"], {"unload_after": True})
|
||||
|
||||
# 验证结果
|
||||
assert calls[-1]["url"] == "http://localhost:11434/api/generate"
|
||||
|
||||
|
||||
def test_translate_lines_param_unload_after_false_overrides_default(monkeypatch) -> None:
|
||||
"""节点参数 unload_after=False 可显式关闭本机端点的默认卸载。"""
|
||||
# 数据:本机端点 + 节点参数显式关闭。
|
||||
calls: list[dict] = []
|
||||
_local_llm_env(monkeypatch)
|
||||
monkeypatch.setenv("LLM_UNLOAD_AFTER", "1")
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translate_lines(["一"], {"unload_after": False})
|
||||
|
||||
# 验证结果
|
||||
assert [call["url"] for call in calls] == ["http://localhost:11434/v1/chat/completions"]
|
||||
|
||||
|
||||
def test_translate_lines_keeps_translation_when_unload_fails(monkeypatch) -> None:
|
||||
"""卸载请求失败不影响译文(端点不支持卸载时只是跳过释放)。"""
|
||||
# 数据:远程端点显式开启卸载 + 卸载请求返回 404。
|
||||
calls: list[dict] = []
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LLM_UNLOAD_AFTER", "1")
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(
|
||||
calls,
|
||||
[_llm_reply([(1, "译文")])],
|
||||
unload_error=urllib.error.HTTPError(
|
||||
"https://api.example.com/api/generate", 404, "not found", {}, None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translated = translate_lines(["一"], {})
|
||||
|
||||
# 验证结果:译文正常返回,卸载失败只记录。
|
||||
assert translated == ["译文"]
|
||||
assert calls[-1]["url"].endswith("/api/generate")
|
||||
|
||||
|
||||
def test_translate_lines_keeps_model_loaded_in_staged_batch(monkeypatch) -> None:
|
||||
"""引擎标记阶段内保持常驻时不卸载模型:整批只加载一次,阶段结束统一释放。"""
|
||||
# 数据:本机端点(默认会卸载)+ keep_model_loaded=True。
|
||||
calls: list[dict] = []
|
||||
_local_llm_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
translate_lines(["一"], {}, keep_model_loaded=True)
|
||||
|
||||
# 验证结果:只发翻译请求,不发卸载请求。
|
||||
assert [call["url"] for call in calls] == ["http://localhost:11434/v1/chat/completions"]
|
||||
|
||||
|
||||
def test_translate_lines_aborts_between_batches_when_stop_requested(monkeypatch) -> None:
|
||||
"""暂停信号在两批之间生效:已完成的批保留,后续批不再发请求。"""
|
||||
# 数据:CHUNK_SIZE + 1 行(两批),第二次检查返回“应停止”。
|
||||
lines = [f"行{i}" for i in range(1, CHUNK_SIZE + 2)]
|
||||
calls: list[dict] = []
|
||||
checks = {"count": 0}
|
||||
|
||||
def stop_requested() -> bool:
|
||||
checks["count"] += 1
|
||||
return checks["count"] > 1
|
||||
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(
|
||||
calls,
|
||||
[_llm_reply([(i, f"t{i}") for i in range(1, CHUNK_SIZE + 1)])],
|
||||
),
|
||||
)
|
||||
|
||||
# 测试过程与验证结果:抛暂停异常,且只发出第一批的请求。
|
||||
with pytest.raises(PauseRequested):
|
||||
translate_lines(lines, {}, stop_requested=stop_requested)
|
||||
assert [call["url"] for call in calls] == ["https://api.siliconflow.cn/v1/chat/completions"]
|
||||
|
||||
|
||||
def test_release_local_model_unloads_loopback_endpoint(monkeypatch) -> None:
|
||||
"""阶段收尾释放模型:本机端点发 keep_alive=0 卸载请求,模型名参数优先。"""
|
||||
# 数据:本机端点 + 显式指定的模型名。
|
||||
calls: list[dict] = []
|
||||
_local_llm_env(monkeypatch)
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen_routing_unload(calls, []))
|
||||
|
||||
# 测试过程
|
||||
release_local_model("local/替换模型")
|
||||
|
||||
# 验证结果:命中 Ollama 原生卸载端点,使用传入的模型名。
|
||||
assert [(call["url"], call["body"]) for call in calls] == [
|
||||
("http://localhost:11434/api/generate", {"model": "local/替换模型", "keep_alive": 0}),
|
||||
]
|
||||
|
||||
|
||||
def test_release_local_model_skips_remote_endpoint(monkeypatch) -> None:
|
||||
"""云端端点不占本机显存,阶段收尾不发卸载请求。"""
|
||||
# 数据:云端端点。
|
||||
calls: list[dict] = []
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions")
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen_routing_unload(calls, []))
|
||||
|
||||
# 测试过程
|
||||
release_local_model()
|
||||
|
||||
# 验证结果:没有任何请求。
|
||||
assert calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# invoke 全流程
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -396,6 +632,36 @@ def test_invoke_translates_srt_and_writes_artifact(monkeypatch, tmp_path: Path)
|
||||
assert content.index("你好") < content.index("再见")
|
||||
|
||||
|
||||
def test_invoke_stops_on_pause_flag(monkeypatch, tmp_path: Path) -> None:
|
||||
"""run 根目录有暂停信号时节点中止且不写产物(调度器保持任务 PAUSED)。"""
|
||||
# 数据:一条真实 SRT + run 根目录下的 paused.flag。
|
||||
srt_path = tmp_path / "in.srt"
|
||||
srt_path.write_text("1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n", encoding="utf-8")
|
||||
run_root = tmp_path / "runs" / "run-paused"
|
||||
run_root.mkdir(parents=True, exist_ok=True)
|
||||
(run_root / "paused.flag").write_text("", encoding="utf-8")
|
||||
output_dir = run_root / "steps" / "translate"
|
||||
calls: list[dict] = []
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(
|
||||
urllib.request, "urlopen",
|
||||
_capture_urlopen_routing_unload(calls, [_llm_reply([(1, "译文")])]),
|
||||
)
|
||||
request = InvokeRequest(
|
||||
run_id="run-paused", node_instance_id="n", params={},
|
||||
inputs={"srt_uri": str(srt_path)}, output_dir=str(output_dir),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
response = invoke(request)
|
||||
|
||||
# 验证结果:failed 且原因为暂停;未调用 LLM、未写 cn.srt。
|
||||
assert response.status == "failed"
|
||||
assert "暂停" in (response.error or "")
|
||||
assert calls == []
|
||||
assert not (output_dir / "cn.srt").exists()
|
||||
|
||||
|
||||
def test_invoke_fails_without_input(tmp_path: Path) -> None:
|
||||
"""缺少 srt_uri 时失败。"""
|
||||
# 数据:空输入。
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""scripts/fix_zombie_batch_jobs.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:僵尸批量任务修复脚本(把「COMPLETED 但仍有未结束视频」的历史脏数据
|
||||
置回 QUEUED),可独立调用。用例在临时目录构造真实 SQLite 库与真实明细记录,
|
||||
调用脚本的真实函数而不是重写扫描逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.fix_zombie_batch_jobs import find_zombie_jobs, main
|
||||
from wov_app.db import Database
|
||||
|
||||
|
||||
def _db_with_workflow(tmp_path: Path) -> Database:
|
||||
"""建好已发布工作流(版本)的临时库:批量明细无外键约束,任务表需要它。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({"id": "wf", "name": "流程", "description": ""})
|
||||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||||
return db
|
||||
|
||||
|
||||
def _job(db: Database, job_id: str, status: str) -> None:
|
||||
"""登记一条批量任务。"""
|
||||
db.create_batch_job({
|
||||
"id": job_id, "folder_path": "/videos", "workflow_id": "wf", "recursive": 0,
|
||||
"status": status, "created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
|
||||
|
||||
def _video(db: Database, job_id: str, video_id: str, status: str) -> None:
|
||||
"""登记一条批量视频明细。"""
|
||||
db.create_batch_video({
|
||||
"id": video_id, "job_id": job_id, "video_path": f"/videos/{video_id}.mp4",
|
||||
"work_dir": f"/tmp/{video_id}", "status": status,
|
||||
"created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
|
||||
|
||||
def test_find_zombie_jobs_selects_completed_with_unfinished_videos(tmp_path: Path) -> None:
|
||||
"""只挑出 COMPLETED 但仍有未结束视频的任务,已真正完成的跳过。"""
|
||||
# 数据:僵尸任务(COMPLETED + PENDING)与真正完成的任务(COMPLETED + SKIPPED)。
|
||||
db = _db_with_workflow(tmp_path)
|
||||
_job(db, "zombie", "COMPLETED")
|
||||
_video(db, "zombie", "z1", "PENDING")
|
||||
_video(db, "zombie", "z2", "SKIPPED")
|
||||
_job(db, "done", "COMPLETED")
|
||||
_video(db, "done", "d1", "COMPLETED")
|
||||
_video(db, "done", "d2", "SKIPPED")
|
||||
|
||||
# 测试过程
|
||||
zombies = find_zombie_jobs(db)
|
||||
|
||||
# 验证结果:只有僵尸任务入选,且带出未结束明细。
|
||||
assert [str(job["id"]) for job, _ in zombies] == ["zombie"]
|
||||
assert [v["id"] for v in zombies[0][1]] == ["z1"]
|
||||
|
||||
|
||||
def test_main_apply_requeues_zombie_and_keeps_done(tmp_path: Path) -> None:
|
||||
"""--apply 把僵尸任务置回 QUEUED 并清 progress,正常完成的任务不动。"""
|
||||
# 数据:僵尸任务(1 完成 + 1 PENDING)与正常完成任务。
|
||||
db_path = tmp_path / "wov.db"
|
||||
db = Database(db_path)
|
||||
db.upsert_workflow({"id": "wf", "name": "流程", "description": ""})
|
||||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||||
_job(db, "zombie", "COMPLETED")
|
||||
db.update_batch_job("zombie", progress=1.0, total=1, done=0, updated_at="t1")
|
||||
_video(db, "zombie", "z1", "COMPLETED")
|
||||
_video(db, "zombie", "z2", "PENDING")
|
||||
_job(db, "done", "COMPLETED")
|
||||
_video(db, "done", "d1", "COMPLETED")
|
||||
|
||||
# 测试过程
|
||||
main(str(db_path), apply=True)
|
||||
|
||||
# 验证结果:僵尸任务可被引擎拾起,completed 明细与任务保持原状。
|
||||
zombie = db.get_batch_job("zombie")
|
||||
assert zombie["status"] == "QUEUED"
|
||||
assert zombie["progress"] == 0
|
||||
assert zombie["done"] == 1
|
||||
assert db.get_batch_job("done")["status"] == "COMPLETED"
|
||||
assert db.next_queued_batch_job()["id"] == "zombie"
|
||||
@@ -0,0 +1 @@
|
||||
"""web/assets/batch.js 的模块级测试。"""
|
||||
@@ -0,0 +1,154 @@
|
||||
"""web/assets/batch.js 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`web/assets/batch.js`(批量处理页的渲染逻辑:任务进度、操作按钮、
|
||||
视频明细表 HTML)。
|
||||
|
||||
实现方式:在 Node.js 子进程中加载真实 JS 文件并调用真实函数(不重写逻辑),
|
||||
验证渲染出的 HTML 内容;环境无 node 时跳过(保持跨平台可运行)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 仓库根与被测脚本。
|
||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
||||
APP_JS = WORKSPACE / "web" / "assets" / "app.js"
|
||||
BATCH_JS = WORKSPACE / "web" / "assets" / "batch.js"
|
||||
|
||||
# 用真实 JS 引擎执行调用的封装:按页面加载顺序执行 app.js(提供
|
||||
# escapeHtml/badge 等公共函数)与 batch.js,再调用后者的真实函数。
|
||||
# 渲染不需要真实 DOM,只提供脚本顶层引用到的 document 桩。
|
||||
_CALL_SCRIPT = """
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const sandbox = {
|
||||
module: { exports: {} },
|
||||
document: { addEventListener: () => {}, getElementById: () => null },
|
||||
setInterval: () => 0,
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
for (const path of [process.argv[1], process.argv[2]]) {
|
||||
vm.runInContext(fs.readFileSync(path, "utf8"), sandbox);
|
||||
}
|
||||
const payload = JSON.parse(process.argv[3]);
|
||||
const fn = sandbox.module.exports[payload.fn];
|
||||
process.stdout.write(JSON.stringify(fn(...payload.args)));
|
||||
"""
|
||||
|
||||
|
||||
def _run(fn: str, *args) -> object:
|
||||
"""在 node 中调用 batch.js 的真实函数并返回解析后的结果。"""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("环境没有 node,跳过 JS 模块测试")
|
||||
completed = subprocess.run(
|
||||
[
|
||||
node,
|
||||
"-e",
|
||||
_CALL_SCRIPT,
|
||||
str(APP_JS),
|
||||
str(BATCH_JS),
|
||||
json.dumps({"fn": fn, "args": list(args)}),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# videoDetailTable:详情明细表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _video(name: str, status: str) -> dict:
|
||||
"""构造一条明细数据(字段与 GET /api/batch/jobs/{id} 返回的一致)。"""
|
||||
return {
|
||||
"id": f"bv_{name}",
|
||||
"video_path": f"/videos/{name}",
|
||||
"status": status,
|
||||
"error": None,
|
||||
"finals": {},
|
||||
}
|
||||
|
||||
|
||||
def test_detail_table_lists_processing_and_completed_videos() -> None:
|
||||
"""正常明细:待处理与已完成的视频都出现在表格行里。"""
|
||||
# 数据:一个待处理、一个已完成。
|
||||
videos = [_video("a.mp4", "PENDING"), _video("c.mp4", "COMPLETED")]
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", videos)
|
||||
|
||||
# 验证结果
|
||||
assert "a.mp4" in html
|
||||
assert "c.mp4" in html
|
||||
assert "PENDING" in html and "COMPLETED" in html
|
||||
|
||||
|
||||
def test_detail_table_hides_skipped_videos() -> None:
|
||||
"""详情列表不展示 SKIPPED(视频旁已有字幕、本次未处理)的视频行。"""
|
||||
# 数据:待处理、跳过、完成各一个。
|
||||
videos = [
|
||||
_video("a.mp4", "PENDING"),
|
||||
_video("b.mp4", "SKIPPED"),
|
||||
_video("c.mp4", "COMPLETED"),
|
||||
]
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", videos)
|
||||
|
||||
# 验证结果:跳过的那行完全不出现。
|
||||
assert "a.mp4" in html
|
||||
assert "c.mp4" in html
|
||||
assert "b.mp4" not in html
|
||||
assert "SKIPPED" not in html
|
||||
|
||||
|
||||
def test_detail_table_with_only_skipped_shows_hint() -> None:
|
||||
"""整批视频都已被跳过时给出提示,而不是渲染空表格。"""
|
||||
# 数据:两个 SKIPPED。
|
||||
videos = [_video("a.mp4", "SKIPPED"), _video("b.mp4", "SKIPPED")]
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", videos)
|
||||
|
||||
# 验证结果:无表格行,只提示无待处理视频。
|
||||
assert "a.mp4" not in html and "b.mp4" not in html
|
||||
assert "<table" not in html
|
||||
assert "无待处理视频" in html
|
||||
|
||||
|
||||
def test_detail_table_shows_stage_for_running_video() -> None:
|
||||
"""处理中的视频显示阶段(第几阶段/共几阶段 + 中文标签)。"""
|
||||
# 数据:一个处理到第二阶段的视频(字段与 GET /api/batch/jobs/{id} 一致)。
|
||||
video = _video("a.mp4", "RUNNING")
|
||||
video.update({"stage_label": "转写", "stage_index": 2, "stage_total": 4})
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", [video])
|
||||
|
||||
# 验证结果:表头与单元格都带阶段信息。
|
||||
assert "<th>阶段</th>" in html
|
||||
assert "阶段 2/4 · 转写" in html
|
||||
|
||||
|
||||
def test_detail_table_shows_placeholder_without_stage() -> None:
|
||||
"""未开始的视频阶段列显示占位符(不报错)。"""
|
||||
# 数据:一个待处理视频(没有阶段字段)。
|
||||
video = _video("a.mp4", "PENDING")
|
||||
|
||||
# 测试过程
|
||||
html = _run("videoDetailTable", "batch_1", [video])
|
||||
|
||||
# 验证结果:阶段列为占位符。
|
||||
assert "<th>阶段</th>" in html
|
||||
assert "阶段 " not in html
|
||||
Reference in New Issue
Block a user