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
+95 -30
View File
@@ -19,8 +19,9 @@
上下文取首次出现位置,结果缓存复用——修复"同一句字幕 5 留 6 删"
判定不一致,同时把长视频的 LLM 调用量降到唯一文本数。
4. **长文本保护**:长度 ≥ min_keep_len 的文本,仅 garbage/overlay 两个
明确垃圾类别可删,noise 不构成删除依据,防止长对话被误删。
4. **上下文净化**:喂给 LLM 的上下文是**过滤后的字幕**——规则层确定性的
垃圾(横线/HTML/网址/水印等)从上下文中剔除,只留下有意义的对白,
避免覆盖层垃圾污染 LLM 的场景判断导致误删真实对话。
参考真实任务 run_ac7f480a3ccb2026-08OCR 1666 条):旧实现把 394 条
真实对话当噪声删掉(占删除 35%)、同文本判定不一致;新实现按上述机制
@@ -32,6 +33,8 @@ from __future__ import annotations
import json
import os
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
@@ -72,11 +75,22 @@ DELETE_CATEGORIES = {CATEGORY_GARBAGE, CATEGORY_OVERLAY, CATEGORY_NOISE}
_DASH_RE = re.compile(r"^[\s\-—_~=•・。..、]+$")
# 规则层正则:URL / 邮箱。
_URL_OR_MAIL_RE = re.compile(r"^(https?://|www\.)\S+$|^[\w.+-]+@[\w.-]+\.\w+$")
# 规则层正则:裸网址/域名(含中文夹杂的注册地址,如 "水火地址 489155.com")。
_DOMAIN_RE = re.compile(
r"[\w-]+\.(?:com|net|org|cn|tv|me|io|xyz|cc|top|info|biz)(?:[/\s.,;:!?)]|$)",
re.IGNORECASE,
)
# 规则层正则:HTML/脚本/播放器水印模式(OCR 常把网页界面识别成这类文本)。
_HTML_MARK_RE = re.compile(
r"html\s*code|<\s*[a-z][^>]*>|javascript|web\s*address|watermark|sign\s*in",
re.IGNORECASE,
)
# 默认水印/覆盖层 tokencasefold 后比较,可经 overlay_tokens 参数覆盖)。
DEFAULT_OVERLAY_TOKENS = frozenset(
{"html", "background", "___", "cleaning", "buffering", "loading"}
{"html", "background", "___", "cleaning", "buffering", "loading", "marketing"}
)
# 长文本保护阈值:≥ 该长度的文本,noise 类别不构成删除依据。
# LLM 判定不稳定(实测把完整对话句误判 noise),长度是必要兜底而非删除依据。
DEFAULT_MIN_KEEP_LEN = 12
@@ -111,16 +125,19 @@ def serialize_srt(entries: list[dict]) -> str:
def _rule_verdict(text: str, overlay_tokens: set[str]) -> bool | None:
"""确定性规则层:返回 True(删除)/ None(交给 LLM 多维判断)。
规则覆盖 OCR 噪声的稳定模式:空文本、纯横线装饰、URL/邮箱、水印 token
HTML/background 等)、单双 ASCII 字符。含 CJK 的短文本不算垃圾,
因为"嗯/好"等可能是内容;其余情况返回 None 交由 LLM 分类。
规则覆盖 OCR 噪声的稳定模式:空文本、纯横线装饰、URL/邮箱、裸网址
域名、HTML/水印模式(html code/标签/javascript 等)、水印 token、单双
ASCII 字符。含 CJK 的短文本不算垃圾,因为"嗯/好"等可能是内容;
其余情况返回 None 交由 LLM 分类。
"""
t = text.strip()
if not t:
return True
if _DASH_RE.match(t):
return True
if _URL_OR_MAIL_RE.match(t):
if _URL_OR_MAIL_RE.match(t) or _DOMAIN_RE.search(t):
return True
if _HTML_MARK_RE.search(t):
return True
if t.casefold() in overlay_tokens:
return True
@@ -141,9 +158,10 @@ def _dedup_key(text: str) -> str:
def _should_delete(category: str, text: str, min_keep_len: int) -> bool:
"""按 LLM 类别与长文本保护决定是否删除。
repeat/dialogue 一律保留;garbage/overlay 一律删除(含长文本——
这两个是明确的垃圾信号);noise 对短文本删除,但 ≥min_keep_len 的
长文本不删(noise 太模糊,不足以推翻一句完整台词)。
repeat/dialogue 一律保留;garbage/overlay 一律删除(明确的垃圾信号);
noise 对短文本删除,但 ≥min_keep_len 的长文本不删——LLM 判定不稳定,
完整对话句常被误判 noise,长度保护是必要兜底(实测移除后新增误删
124 条真实长对话)。长度只用于"保护",不用于"删除"
"""
if category not in DELETE_CATEGORIES:
return False
@@ -151,9 +169,9 @@ def _should_delete(category: str, text: str, min_keep_len: int) -> bool:
return False
return True
def _judge_category(
entries: list[dict], index: int, context_size: int, params: dict
entries: list[dict], index: int, context_size: int, params: dict,
overlay_tokens: set[str] | None = None,
) -> str:
"""调用 LLM 把目标字幕归入五类之一,返回类别词(未识别回退 dialogue)。
@@ -164,10 +182,16 @@ def _judge_category(
start = max(0, index - context_size)
end = min(len(entries), index + context_size + 1)
target_pos = index - start
lines = [
f"{TARGET_MARK}{text}" if pos == target_pos else text
for pos, text in enumerate(entry["text"] for entry in entries[start:end])
]
# 上下文净化:喂给 LLM 的是**过滤后的字幕**——相邻条目若被确定性规则层
# 识别为垃圾(横线/HTML/水印 token/URL/裸域名等)直接从上下文中剔除,
# 避免覆盖层垃圾污染 LLM 对整段场景的判断(误删相邻的真实对话)。
tokens = overlay_tokens if overlay_tokens is not None else DEFAULT_OVERLAY_TOKENS
lines = []
for pos, entry in enumerate(entries[start:end]):
if pos != target_pos and _rule_verdict(entry["text"], tokens) is True:
continue
text = entry["text"]
lines.append(f"{TARGET_MARK}{text}" if pos == target_pos else text)
# LLM 兼容接口配置:地址/Key/模型/超时均可通过环境变量覆盖(默认 SiliconFlow)。
api_base = os.getenv(
@@ -186,6 +210,8 @@ def _judge_category(
"noise:与上下文无关、无实际语义的杂项\n"
"repeat:内容性重复(如语气词、呻吟、重复的感叹或对话),属于内容本身\n"
"dialogue:正常对话\n"
"字幕序列已经过确定性规则过滤(装饰性横线、HTML、网址/水印等已被剔除),"
"请只依据剩下的对话内容判断目标字幕,不要臆测被过滤掉的部分。\n"
"只输出一个类别英文单词,不要输出其他内容。"
)
body = {
@@ -210,8 +236,23 @@ def _judge_category(
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=request_timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
# 429(限流)与 5xx(服务端错误)时指数退避重试:请求被限流时让出时间,
# 使窗口平均响应变慢,触发自适应线程池"慢响应减线程",并发自动回落到
# 限流配额内;最多尝试 3 次,耗尽仍失败则抛出,由调用方(任务)重新处理。
max_attempts = 3
retry_delay = 1.0
for attempt in range(max_attempts):
try:
with urllib.request.urlopen(request, timeout=request_timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
break
except urllib.error.HTTPError as exc:
if exc.code != 429 and not (500 <= exc.code < 600):
raise
if attempt >= max_attempts - 1:
raise
time.sleep(retry_delay)
retry_delay *= 2
content = str(payload["choices"][0]["message"]["content"]).strip().lower()
# 精确匹配五个类别词(兼容"garbagexxx"这类多余输出)。
for category in _ALL_CATEGORIES:
@@ -226,8 +267,8 @@ def _judge_category(
def invoke(request: InvokeRequest) -> InvokeResponse:
"""过滤 SRT 中多余/无意义的字幕,产物为 filtered.srt。
流程:规则层(确定性删除)→ LLM 层(去重后按唯一文本多维分类)→
长文本保护 → 保留条重新编号输出。
流程:规则层(确定性删除)→ LLM 层(去重后按唯一文本多维分类
上下文为过滤后的字幕)→ 保留条重新编号输出。
"""
srt_uri = request.inputs.get("srt_uri")
if not srt_uri:
@@ -267,13 +308,23 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
else:
pool_indices = llm_needed
# 单条判断的工作函数:返回类别词。
# 单条判断的工作函数:返回类别词overlay_tokens 用于上下文净化
def judge_one(index: int) -> str:
return _judge_category(entries, index, context_size, request.params)
try:
return _judge_category(entries, index, context_size, request.params, overlay_tokens)
except urllib.error.HTTPError as exc:
# 限流/服务端错误:通知线程池临时降低最大并发,避免持续超配额。
if exc.code == 429 or 500 <= exc.code < 600:
pool.report_failure()
raise
# 进度日志:打印已判定条数、总数平均处理速度(条/s
def log_progress(done: int, total: int, rate: float) -> None:
logger.info("字幕判定进度 %d/%d 条 (%.1f 条/s)", done, total, rate)
# 进度日志:打印已判定条数、总数平均处理速度(条/s、最近窗口
# 平均单条耗时与当前线程数(与 OCR 节点同一回调协议)。
def log_progress(done: int, total: int, rate: float, avg_time: float, workers: int) -> None:
logger.info(
"字幕判定进度 %d/%d 条 (%.1f 条/s, 平均 %.2fs/条, 线程 %d/%d)",
done, total, rate, avg_time, workers, pool.max_workers,
)
# 自适应并发调用 LLM:按实测负载弹性伸缩,避免压垮 LLM 接口。
pool = AdaptiveThreadPool(
@@ -287,11 +338,25 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
)
categories = pool.map(pool_indices)
# 限流/服务端错误已在 worker 内 report_failure 临时降并发:把失败的
# 条目在收紧后的并发下重试一轮(_judge_category 内还有 429/5xx 退避),
# 二次仍失败才整体失败——避免 20 并发一拥而上被限流打挂整个任务。
failed = [
index for index, category in zip(pool_indices, categories)
if isinstance(category, Exception)
]
if failed:
logger.info("判定失败 %d 条,降并发后重试", len(failed))
retried = pool.map(failed)
for index, category in zip(failed, retried):
if isinstance(category, Exception):
# 二次仍失败(限流持续/非限流错误):整体失败,由任务重试。
return InvokeResponse(status="failed", error=str(category))
cat_by_index[index] = category
for index, category in zip(pool_indices, categories):
# 并行下 LLM 异常被线程池隔离为异常结果:任一条失败即整体失败,
# 避免静默输出未过滤结果。
# 首次失败的条目已在重试分支处理,这里只登记成功结果。
if isinstance(category, Exception):
return InvokeResponse(status="failed", error=str(category))
continue
cat_by_index[index] = category
if dedupe:
# 去重填充:与首次出现同键的条目复用同一类别,保证判定一致。
@@ -299,11 +364,11 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
if i not in cat_by_index:
cat_by_index[i] = cat_by_index[first_of_key[_dedup_key(entries[i]["text"])]]
# 阶段 3:合并规则与 LLM 判定,应用长文本保护并输出。
# 阶段 3:合并规则与 LLM 判定,输出保留条
kept: list[dict] = []
removed = 0
for i, (entry, rule_verdict) in enumerate(zip(entries, rule_verdicts)):
# 规则层命中即删(True);未命中则按 LLM 类别与长文本保护判定。
# 规则层命中即删(True);未命中则按 LLM 类别判定。
if rule_verdict is True:
removed += 1
continue