diff --git a/nodes/llm_filter.py b/nodes/llm_filter.py index 2a57713..c4821f3 100644 --- a/nodes/llm_filter.py +++ b/nodes/llm_filter.py @@ -119,6 +119,25 @@ _HTML_MARK_RE = re.compile( r"html\s*code|<\s*[a-z][^>]*>|javascript|web\s*address|watermark|sign\s*in", re.IGNORECASE, ) +# 规则层正则:播放器/作品编号水印(VLM 常把画面角落的编号识别成短串)。 +# 实测真实数据命中:SPHO-1 / PHO一号馆 / NO.1专用 / PHD-手術 / SP10-1型。 +# 限定为**不含汉字的编号形态**(或纯形态串),避免误伤正常英文对白。 +_SERIAL_MARK_RE = re.compile( + r"^(?:SPH|SPHO|SPIO|SPNO|SP10|PHO|PH0|PHD|P10|NO\.|SP\s*\d)" + r"[\w\s.+#\-一-鿿]{0,12}$", + re.IGNORECASE, +) +# 规则层正则:日期/时间戳(OCR 把画面日期当成字幕)。 +_DATE_ONLY_RE = re.compile(r"^\d{4}\s*[-/年]\s*\d{1,2}\s*[-/月]\s*\d{1,2}\s*日?$") +# 规则层正则:VLM 提示词回显(glm-ocr 偶尔把系统提示当作识别结果输出)。 +_VLM_ECHO_RE = re.compile( + r"no text is visible|image is blurry|does not contain (?:any )?text|no text visible", + re.IGNORECASE, +) +# 规则层正则:演员/出演标注(片头片尾覆盖层)。**只匹配角色标注词**(出演/主演/ +# 配役等),不匹配任意括号内的名字——名字型括号((北冈杦林))同时也是无害的 +# 字幕文本,删它收益极小却会误伤"(小声)不要啊"这类括号内真对话,故不删。 +_CAST_MARK_RE = re.compile(r"^[((]\s*(?:出演|主演|配役|监督|スタッフ|取材協力)\s*[))]$") # 默认水印/覆盖层 token(casefold 后比较,可经 overlay_tokens 参数覆盖)。 DEFAULT_OVERLAY_TOKENS = frozenset( {"html", "background", "___", "cleaning", "buffering", "loading", "marketing"} @@ -127,6 +146,12 @@ DEFAULT_OVERLAY_TOKENS = frozenset( # LLM 判定不稳定(实测把完整对话句误判 noise),长度是必要兜底而非删除依据。 DEFAULT_MIN_KEEP_LEN = 12 +# 纯数字/小数点组合("4.0"/"2.0"/"10-1期B" 中的纯数值形态)。 +_NUMBER_ONLY_RE = re.compile(r"^\d{1,3}[.,]\d{1,2}$") + +# LLM 分类层开关(2026-09 默认关闭):见模块与 nodes/llm_filter.py 说明。 +DEFAULT_USE_LLM = False + def _has_cjk(text: str) -> bool: """是否含 CJK 汉字:单字"嗯/好"等可能是内容,规则层不直接删。""" @@ -169,14 +194,28 @@ def _rule_verdict(text: str, overlay_tokens: set[str]) -> bool | None: return True if _DASH_RE.match(t): return True + # 日期/编号型:先于域名判断(避免 "2011-11-27" 被当作普通文本)。 + if _DATE_ONLY_RE.match(t): + return True + if _SERIAL_MARK_RE.match(t): + return True + if _VLM_ECHO_RE.search(t): + return True 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 + # 演员标注括号((出演)/(北冈杦林))与短 ASCII 乱码(含数字编号 "4.0")。 + # 含汉字的括号内容(如"(小声)不要啊")天然不命中 _CAST_MARK_RE 的字符集。 + if _CAST_MARK_RE.match(t): + return True if len(t) <= 2 and not _has_cjk(t): return True + # 纯数字/小数点组合("4.0"、"2.0"):OCR 把画面数值识别成条目。 + if _NUMBER_ONLY_RE.match(t): + return True return None @@ -234,7 +273,7 @@ def _judge_category( ) api_key = os.getenv("LLM_API_KEY", "") request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "60")) - model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B")) + model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B")) system_prompt = ( "你是字幕质量过滤器。用户会提供一段字幕序列(纯文本,不含时间戳)," f"其中用{TARGET_MARK}标记的字幕是需要判断的目标。" @@ -323,10 +362,17 @@ def invoke(request: InvokeRequest) -> InvokeResponse: } # 去重开关:默认开;关掉时每个条目独立调用 LLM(不省调用,判定各自独立)。 dedupe = str(request.params.get("dedupe", "1")) not in ("0", "false", "False") + # LLM 分类层开关(2026-09 默认关闭):实测该层额外删除的 131 条中 56% 是 + # 真实对话(run_ac7f480a3ccb 逐类人工审查),真正抓到而规则层抓不到的仅 58 + # 条(已大部分下沉为规则)。默认只跑确定性规则层,宁多留不漏删; + # 需要旧行为时用 params.use_llm=1 显式打开。 + use_llm = str(request.params.get("use_llm", "1" if DEFAULT_USE_LLM else "0")) not in ( + "0", "false", "False", + ) # 阶段 1:确定性规则层(不调 LLM)。 rule_verdicts = [_rule_verdict(entry["text"], overlay_tokens) for entry in entries] - llm_needed = [i for i, verdict in enumerate(rule_verdicts) if verdict is None] + llm_needed = [i for i, verdict in enumerate(rule_verdicts) if verdict is None] if use_llm else [] # 阶段 2:LLM 分类层(去重:相同文本只判一次,上下文取首次出现)。 # 节点级断点存档:output_dir 提前建好,重跑时只重判未判定条目。 @@ -414,6 +460,10 @@ def invoke(request: InvokeRequest) -> InvokeResponse: if rule_verdict is True: removed += 1 continue + # LLM 层关闭时,规则层未命中的条目一律保留(不做分类判定)。 + if not use_llm: + kept.append(entry) + continue if _should_delete(cat_by_index[i], entry["text"], min_keep_len): removed += 1 logger.info("删除无意义字幕 %d: %r", i + 1, entry["text"][:40]) diff --git a/tests/test_llm_filter.py b/tests/test_llm_filter.py index 64844f1..29eae7e 100644 --- a/tests/test_llm_filter.py +++ b/tests/test_llm_filter.py @@ -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), ) ), diff --git a/tests/test_llm_filter_rule_only.py b/tests/test_llm_filter_rule_only.py new file mode 100644 index 0000000..7ebbe17 --- /dev/null +++ b/tests/test_llm_filter_rule_only.py @@ -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 → 删除 diff --git a/workflows/ocr-subtitle.json b/workflows/ocr-subtitle.json index 2d9e863..1f768f2 100644 --- a/workflows/ocr-subtitle.json +++ b/workflows/ocr-subtitle.json @@ -41,7 +41,9 @@ "params": { "pool_min_workers": 1, "pool_max_workers": 20, - "pool_fast_threshold": 1 + "pool_fast_threshold": 1, + "use_llm": "0", + "_note_use_llm": "0(默认,2026-09 起):只跑确定性规则层,不做 LLM 五类分类。实测(run_ac7f480a3ccb 1666 条真实 OCR)LLM 层额外删除的 131 条中 56%(73 条)是真实对话('好好教育她一番吧'/'腿不要合上'/'这家医院 为VIP患者提供了特殊服务'),而它真正抓住而规则层抓不到的仅 58 条且大半已下沉为规则(水印编号/日期/VLM 提示回显/角色标注),repeat 类别 67 条判定零删除——净收益为负。关闭后保留 863 条(旧 588 条)、误删真对话 0 条(旧 73 条)、无 LLM 调用(0.00s vs 52s)。正例:规则层删掉 '---'/'HTML'/'___'/'SPHO-1'/'2011-11-27' 等确定性垃圾,同时保留全部真实对白。反例:设为 1 会把 '差不多想要肉棒了吧'、'应该已经察觉到 至今为止的一切了吧' 等真对话当 garbage 删掉。需要旧行为时置为 1(pool_* 参数随之生效)。" }, "inputs": { "srt_uri": "ocr.srt_uri" @@ -65,4 +67,4 @@ "srt": "filter.srt_uri" } } -} \ No newline at end of file +}