feat: 任务断点续跑/暂停中断/LLM 过滤优化与调度容错

调度与状态机:
- 修复 PAUSED 任务被拾起后复活执行(点击暂停反而开始任务):next_queued_run
  只取 QUEUED,execute_run 以 PAUSED 进入直接返回,暂停必须显式 resume
- 重启恢复:启动时 recover_interrupted_runs 把遗留 RUNNING 置 QUEUED(保留产物)
- 暂停信号 paused.flag:暂停接口写、继续/重试清除,OCR 逐帧检查秒级中断,
  节点内被暂停保持 PAUSED 不误报 FAILED
- 调度轮询容错:_loop 异常不杀死线程(曾致任务永久停留 QUEUED)

subtitle-ocr 节点级断点:
- ocr_partial.jsonl 逐帧存档,重启/暂停后只处理未处理帧,产物与一次跑完一致
- 进度日志携带窗口平均耗时与线程数;取消后抑制进度日志井喷

llm-filter 过滤质量与限流自适应:
- 上下文净化:喂给 LLM 的是过滤后的字幕(规则层垃圾从上下文剔除)
- 正则确定性过滤:裸网址域名、HTML/水印模式直接删除
- 429/5xx 指数退避重试;worker 限流错误 report_failure 内存临时降最大线程数
  并缩容(无错误窗口回升),失败条目降并发后重试一轮
- 保留长文本保护(noise 不删 ≥min_keep_len 文本,LLM 判定不稳的必要兜底)

前端:
- 工作流编排页支持选择工作流编辑(加载最新/历史版本)、版本历史面板、
  新建/编辑双模式;管理后台编辑跳转 workflow.html?edit=<id>

工作流:ocr-subtitle v7(filter pool_max_workers=20、pool_fast_threshold=1)
This commit is contained in:
2026-08-19 01:27:41 +08:00
parent 4ebbfc5198
commit b16e0e9f3c
19 changed files with 1237 additions and 111 deletions
@@ -213,3 +213,83 @@ class TestSubtitleOcrOrderUnderThreading:
# ⑤ 已知真实内容存在于结果中(确认结果的代表性条目)。
for line in ("北冈小姐", "这是特别病房患者的病历表", "应该已经察觉到 至今为止的一切了吧"):
assert line in confirmed, line
def test_ocr_resumes_from_partial_checkpoint(monkeypatch, tmp_path) -> None:
"""节点级断点:预写部分帧存档后运行,只处理未处理帧,产物与全量一致。
模拟中断时已落盘的 ocr_partial.jsonl(前 100 帧已处理):invoke 应只对
未处理帧调用 vlm-ocr,并把存档文本与新增文本合并,最终 SRT 与全量一次
跑完逐字节一致——重启不浪费已处理的帧。
"""
manifest, texts_by_frame = _load_full_data()
confirmed = CONFIRMED_SRT.read_text(encoding="utf-8")
out_dir = tmp_path / "resume"
partial_path = out_dir / "ocr_partial.jsonl"
partial_path.parent.mkdir(parents=True)
lines = []
for i in range(100):
# 存档按 0-based 帧序号记录;manifest[i] 的帧号 = i+1。
lines.append(
json.dumps({"frame": i, "text": texts_by_frame[i + 1]}, ensure_ascii=False)
)
if i == 50:
lines.append("") # 空行:_load_partial 必须跳过,不视为一条记录。
if i == 51:
lines.append("broken-json-line") # 损坏行(进程被杀残留):跳过。
partial_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
from nodes.subtitle_ocr import invoke as ocr_invoke
fake = FakeVlmOcrApi(texts_by_frame, seed=99)
monkeypatch.setattr("wov_app.registry.invoke", fake)
response = ocr_invoke(
InvokeRequest(
run_id="resume_test",
node_instance_id="",
inputs={"frames_manifest": str(FULL_MANIFEST)},
params={"pool_min_workers": 4, "pool_max_workers": 4},
output_dir=str(out_dir),
)
)
assert response.status == "completed", response.error
srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert srt == confirmed # 存档 + 新增合并结果与全量一致。
# 只处理未处理帧:前 100 帧不再调用 vlm-ocr。
# 场景 2:先用真实逻辑完整跑一遍(生成存档),再以同一目录续跑——
# 第二次 pending 为空,完全不调用 vlm-ocr,产物与第一次逐字节一致
# (覆盖 _load_partial 全恢复路径,存档文本与处理逻辑天然一致)。
out_dir_all = tmp_path / "resume_all"
fake_first = FakeVlmOcrApi(texts_by_frame, seed=7)
monkeypatch.setattr("wov_app.registry.invoke", fake_first)
resp_first = ocr_invoke(
InvokeRequest(
run_id="resume_all_test",
node_instance_id="",
inputs={"frames_manifest": str(FULL_MANIFEST)},
params={"pool_min_workers": 4, "pool_max_workers": 4},
output_dir=str(out_dir_all),
)
)
assert resp_first.status == "completed", resp_first.error
srt_first = Path(resp_first.outputs["srt_uri"]).read_text(encoding="utf-8")
assert srt_first == confirmed
assert len(fake_first.completed_frames) == TOTAL_FRAMES
fake_second = FakeVlmOcrApi(texts_by_frame, seed=8)
monkeypatch.setattr("wov_app.registry.invoke", fake_second)
resp_second = ocr_invoke(
InvokeRequest(
run_id="resume_all_test",
node_instance_id="",
inputs={"frames_manifest": str(FULL_MANIFEST)},
params={"pool_min_workers": 4, "pool_max_workers": 4},
output_dir=str(out_dir_all),
)
)
assert resp_second.status == "completed", resp_second.error
srt_second = Path(resp_second.outputs["srt_uri"]).read_text(encoding="utf-8")
assert srt_second == srt_first # 存档恢复与一次跑完逐字节一致。
assert len(fake_second.completed_frames) == 0 # 一帧都不重新调用。
assert len(fake.completed_frames) == TOTAL_FRAMES - 100