fix: llm-filter 默认关闭 LLM 分类层,误删真实对话从 73 条降为 0
逐类人工审查真实任务 run_ac7f480a3ccb(1666 条 OCR 输出)后确认 LLM 五类分类层性价比为负: - 规则层删除 782 条(47%),几乎全对(---/HTML/___/编号等); - LLM 层额外删除 131 条,其中 **73 条(56%)是真实对话**, 如"好好教育她一番吧""腿不要合上""这家医院 为VIP患者提供了特殊服务"; - 它真正抓住而规则层抓不到的仅 58 条且大半可正则化; - repeat 类别 67 条判定、0 条删除,形同虚设; - 长文本保护/上下文净化/去重/429 退避/断点存档等机制全是在给 不稳定的分类器兜底,误删量超过净收益。 改动: 1. 规则层下沉原 LLM 层抓到的确定性模式:水印编号(SPHO-1/PHO一号馆)、 日期与数值(2011-11-27/4.0)、VLM 提示回显(no text is visible)、 角色标注((出演))。刻意不删(北冈果林)这类演员名括号——无法与 (小声)不要啊 可靠区分,且其本身是无害字幕文本; 2. 新增 use_llm 参数并**默认关闭** LLM 分类层,需要旧行为时显式开启; ocr-subtitle 工作流显式声明 use_llm=0 并附 _note_use_llm 理由。 真实数据实测:保留 863 条(旧 588)、误删真对话 0 条(旧 73)、 LLM 调用 0 次(旧 680 次/52 秒)。
This commit is contained in:
+15
-12
@@ -382,7 +382,7 @@ def test_invoke_filters_and_renumbers(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -413,7 +413,7 @@ def test_invoke_rules_skip_llm(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -438,7 +438,7 @@ def test_invoke_dedup_single_llm_call(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -463,7 +463,7 @@ def test_invoke_dedupe_disabled(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={"dedupe": "0"},
|
||||
params={"dedupe": "0", "use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -480,7 +480,7 @@ def test_invoke_context_size_param(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={"context_size": 1},
|
||||
params={"context_size": 1, "use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -507,7 +507,7 @@ def test_invoke_overlay_tokens_param(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={"overlay_tokens": ["cleaning"]},
|
||||
params={"overlay_tokens": ["cleaning"], "use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -530,7 +530,7 @@ def test_invoke_overlay_tokens_json_string(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={"overlay_tokens": '["xxlogo", "xx"]'},
|
||||
params={"overlay_tokens": '["xxlogo", "xx"]', "use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -575,7 +575,7 @@ def test_invoke_llm_error(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -605,7 +605,7 @@ def test_invoke_retries_rate_limited_entries(monkeypatch, tmp_path) -> None:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -651,11 +651,12 @@ def test_real_run_rules_and_dialogue_regression(monkeypatch, tmp_path) -> None:
|
||||
if not srt.is_file():
|
||||
pytest.skip("缺少 testdata/ocr_srt_run_ac7f480a3ccb.srt,跳过回归测试")
|
||||
fake = _patch_llm(monkeypatch, decision_fn=lambda body: "dialogue")
|
||||
# 显式开启 LLM 层(默认已关闭,本用例验证的是"规则层+LLM 层"的旧行为)。
|
||||
response = invoke(
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
@@ -737,7 +738,9 @@ class _FailOnTarget429:
|
||||
|
||||
|
||||
def _llm_invoke(srt_text: str, out: Path) -> tuple[object, Path]:
|
||||
"""用给定 SRT 文本构造并执行一次 llm-filter invoke,返回 (响应, 输入文件)。"""
|
||||
"""用给定 SRT 文本构造并执行一次 llm-filter invoke,返回 (响应, 输入文件)。
|
||||
|
||||
该辅助函数专供 LLM 分类层用例使用,因此显式开启 use_llm(默认关闭)。"""
|
||||
srt = out.parent / "in.srt"
|
||||
srt.write_text(srt_text, encoding="utf-8")
|
||||
return (
|
||||
@@ -745,7 +748,7 @@ def _llm_invoke(srt_text: str, out: Path) -> tuple[object, Path]:
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="",
|
||||
inputs={"srt_uri": str(srt)},
|
||||
params={},
|
||||
params={"use_llm": "1"},
|
||||
output_dir=str(out),
|
||||
)
|
||||
),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""llm-filter 规则层扩展与 LLM 层默认关闭(2026-09 决策)。
|
||||
|
||||
背景与数据(真实任务 run_ac7f480a3ccb,1666 条 OCR 输出)
|
||||
----------------------------------------------------------
|
||||
逐类人工审查后确认 LLM 五类分类层**性价比为负**:
|
||||
|
||||
- 规则层删除 782 条(47%),几乎全对(`---`/`HTML`/`___`/`Cleaning`/编号等);
|
||||
- LLM 层额外删除 131 条,其中 **73 条(56%)是真实对话**
|
||||
(`好好教育她一番吧`/`腿不要合上`/`差不多想要肉棒了吧`/`这家医院 为VIP患者提供了特别服务` 等);
|
||||
- 它真正抓到而规则层抓不到的仅 58 条,其中大半是可正则化的水印残余
|
||||
(`SPHO-1`/`PHO一号馆`/`ITMMA PRO`/`(出演)`/日期),剩余价值 <20 条;
|
||||
- `repeat` 类别形同虚设(67 条判定、0 条删除),长文本保护等五套补丁机制
|
||||
全部是在给不稳定的分类器兜底。
|
||||
|
||||
因此本次改动:
|
||||
1. **把 LLM 层没抓到、但可确定性识别的模式下沉到规则层**(水印编号、
|
||||
日期、VLM 提示泄漏、演员标注括号);
|
||||
2. **LLM 分类层默认关闭**(`use_llm` 参数,默认关),只保留规则层;
|
||||
需要时可按工作流参数显式打开。
|
||||
|
||||
保留 884 条而不是 588 条,多出的条目里可能混有水印残余,但**不再误删真对话**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from nodes.llm_filter import (
|
||||
DEFAULT_OVERLAY_TOKENS,
|
||||
_rule_verdict,
|
||||
invoke,
|
||||
parse_srt,
|
||||
)
|
||||
from wov_sdk.models import InvokeRequest
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent
|
||||
# 真实 OCR 回归数据(1699 行 1666 条),用于量化"规则层不误删对话"。
|
||||
REAL_OCR = WORKSPACE / "testdata/ocr_srt_run_ac7f480a3ccb.srt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 规则层扩展:原 LLM 层抓到、但可确定性识别的模式
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rule_层删除水印编号() -> None:
|
||||
"""VLM 把画面水印编号识别成短串(SPHO-1/PHO一号馆/NO.1专用)→ 规则层删除。"""
|
||||
tokens = set(DEFAULT_OVERLAY_TOKENS)
|
||||
for text in (
|
||||
"SPH", "SP10+", "SP10-1型", "SPH-1专用", "SPIO-1 FEB", "NO.1专用",
|
||||
"PHD-手術", "P10一手机", "PHD一专用", "PH0一1瓶盖", "PHO一号馆",
|
||||
"SPHO一专用", "SPHO-1", "SPNO-1",
|
||||
):
|
||||
assert _rule_verdict(text, tokens) is True, text
|
||||
# 反例:正常英文/编号样式的真实对白不受影响(含 CJK 或较长英文短语)。
|
||||
for text in ("青沼君 好可爱", "再见了 再见", "SPA 的感觉真好"):
|
||||
assert _rule_verdict(text, tokens) is None, text
|
||||
|
||||
|
||||
def test_rule_层删除日期与编号型乱码() -> None:
|
||||
"""OCR 把画面日期/时间戳识别成条目(2011-11-27、4.0)→ 规则层删除。"""
|
||||
tokens = set(DEFAULT_OVERLAY_TOKENS)
|
||||
for text in ("2011-11-27", "2011/11/27", "2011年11月27日", "4.0", "2.0"):
|
||||
assert _rule_verdict(text, tokens) is True, text
|
||||
# 反例:含汉字的日期/数值样式不删(可能是真实内容)。
|
||||
for text in ("10月14岁", "应该有 4.0 就好了"):
|
||||
assert _rule_verdict(text, tokens) is None, text
|
||||
|
||||
|
||||
def test_rule_层删除VLM提示泄漏() -> None:
|
||||
"""VLM(glm-ocr)偶尔把系统性提示词回显成识别文本 → 规则层删除。"""
|
||||
tokens = set(DEFAULT_OVERLAY_TOKENS)
|
||||
for text in (
|
||||
"No text is visible in the provided image.",
|
||||
"The image is blurry and does not contain",
|
||||
"no text visible",
|
||||
):
|
||||
assert _rule_verdict(text, tokens) is True, text
|
||||
# 反例:正常英文对白(含 CJK 上下文)不受影响。
|
||||
assert _rule_verdict("谢谢你 松井小姐", tokens) is None
|
||||
|
||||
|
||||
def test_rule_层只删除角色标注_不删除括号内名字() -> None:
|
||||
"""只删角色标注词((出演));括号内的名字/对白**保留**(避免误删)。
|
||||
|
||||
实测数据里(北冈果林)这类演员名括号与"(小声)不要啊"无法用正则可靠
|
||||
区分,而前者本身是无害字幕文本、删除收益极小,因此宁可全留。
|
||||
"""
|
||||
tokens = set(DEFAULT_OVERLAY_TOKENS)
|
||||
for text in ("(出演)", "(主演)", "(监督)"):
|
||||
assert _rule_verdict(text, tokens) is True, text
|
||||
for text in ("(北冈果林)", "(叶山小百合)", "(松井日奈子)", "(我该怎么办)", "(小声)不要啊"):
|
||||
assert _rule_verdict(text, tokens) is None, text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM 层默认关闭
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_规则层不误删真实对话() -> None:
|
||||
"""真实数据回归:规则层保留全部含 CJK 的对话条,不误删。
|
||||
|
||||
这是本次改动的核心不变式——旧实现(规则+LLM)会删掉 73 条真对话,
|
||||
新规则层必须一条都不删。用真实 1666 条 OCR 输出验证。
|
||||
"""
|
||||
if not REAL_OCR.is_file():
|
||||
import pytest
|
||||
|
||||
pytest.skip("真实 OCR 回归数据缺失")
|
||||
import re
|
||||
|
||||
entries = parse_srt(REAL_OCR.read_text(encoding="utf-8"))
|
||||
tokens = set(DEFAULT_OVERLAY_TOKENS)
|
||||
deleted_dialogue = []
|
||||
for entry in entries:
|
||||
text = entry["text"]
|
||||
# 含 >=4 汉字、且不是明显编号/水印形态的,视为真实对话。
|
||||
if len(re.findall(r"[\u4e00-\u9fff]", text)) >= 4 and not re.match(
|
||||
r"^(?:98|SPH|SPHO|SPIO|SPNO|SP10|PHO|PH0|PHD|P10|NO\.|\d)", text
|
||||
):
|
||||
if _rule_verdict(text, tokens) is True:
|
||||
deleted_dialogue.append(text)
|
||||
assert not deleted_dialogue, f"规则层误删真实对话: {deleted_dialogue[:10]}"
|
||||
|
||||
|
||||
def test_invoke_默认不调用LLM(monkeypatch, tmp_path) -> None:
|
||||
"""默认(不传 use_llm)不调用 LLM:规则层外的一律保留,不做分类判定。"""
|
||||
calls: list[object] = []
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
calls.append(args)
|
||||
raise AssertionError("默认配置不应调用 LLM")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", _boom)
|
||||
source = tmp_path / "in.srt"
|
||||
source.write_text(
|
||||
"1\n00:00:01,000 --> 00:00:02,000\n---\n\n"
|
||||
"2\n00:00:03,000 --> 00:00:04,000\n好好教育她一番吧\n\n"
|
||||
"3\n00:00:05,000 --> 00:00:06,000\nSPHO-1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
response = invoke(
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="", inputs={"srt_uri": str(source)},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
assert response.status == "completed", response.error
|
||||
assert not calls, "默认不应有任何 LLM 调用"
|
||||
text = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
assert "好好教育她一番吧" in text # 真实对话保留
|
||||
assert "SPHO-1" not in text # 水印编号由规则层删除
|
||||
assert response.outputs["kept"] == 1
|
||||
assert response.outputs["removed"] == 2
|
||||
|
||||
|
||||
def test_invoke_use_llm开启时仍走分类(monkeypatch, tmp_path) -> None:
|
||||
"""显式 use_llm=1 时保留原 LLM 分类能力(可回退到旧行为)。"""
|
||||
sent: list[dict] = []
|
||||
|
||||
class Response:
|
||||
def __init__(self):
|
||||
self._payload = json.dumps(
|
||||
{"choices": [{"message": {"content": "garbage"}}]}
|
||||
).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
def _open(request, **kwargs):
|
||||
sent.append(json.loads(request.data))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", _open)
|
||||
source = tmp_path / "in.srt"
|
||||
source.write_text(
|
||||
"1\n00:00:01,000 --> 00:00:02,000\n好好教育她一番吧\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
response = invoke(
|
||||
InvokeRequest(
|
||||
run_id="r", node_instance_id="", inputs={"srt_uri": str(source)},
|
||||
params={"use_llm": "1"}, output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
assert response.status == "completed", response.error
|
||||
assert sent, "use_llm=1 时应调用 LLM"
|
||||
assert response.outputs["kept"] == 0 # LLM 判 garbage → 删除
|
||||
Reference in New Issue
Block a user