#!/usr/bin/env python3 """clean skill:LLM 探测执行器——每窗一次 MiniMax-M3 调用,产出 deletions JSON。 读 clean_prep 产出的 manifest.json,逐窗调 M3 探测垃圾段(只报逐字原文,不改写), 写 /tmp/muse-clean//deletions-NNN.json。断点续跑:已有产物的窗自动跳过。 删除动作不在本脚本:由 clean_apply 守卫裁决执行。 """ import json import pathlib import sys import time import click # 统一走 llm skill 入口(trust_env/重试/剥离/JSON 容错都在那边) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts")) from llm import chat, extract_json # noqa: E402 OUT = pathlib.Path("/tmp/muse-clean") # 探测 prompt:与 SKILL.md 模板同源;要求纯 JSON 输出便于机器解析 PROMPT = """你是网文正文清洗探测器。下面是《{title}》第 {a}–{b} 章的原文(每章以【第N章 | 章题】标记行开头)。 找出**所有非正文垃圾**: - 书站广告及变体(如「一秒记住♂粒÷小÷说→网」「天才一秒记住本站地址」) - 网址/域名残留、"首发""手机版阅读""无弹窗"类导流语 - 求票拉票/求订阅收藏/打赏鸣谢段 - 章尾作者话(ps:… / PS:… / 附:…)、上架感言、请假条类整段 - 乱码水印、明显不属于故事的插入符号串 输出规则(只输出一个 JSON 对象,禁止任何其他文字): {{"deletions": [{{"chapter_order": 章序号, "exact": "待删段逐字原文", "reason": "简短理由"}}]}} 硬性要求: - `exact` 必须与原文**逐字一致**(含标点、空格),单项 ≤300 字;同一段垃圾一项,不合并多段; - 只报确定是垃圾的,拿不准的不报;**情节正文一个字都不许报**; - 【第N章 | 章题】标记行不报(即使含求票字样); - 若该窗没有垃圾,输出 {{"deletions": []}}。 原文开始: {text}""" @click.command() @click.option("--work-id", type=int, required=True) @click.option("--win", "wins", type=int, multiple=True, help="指定窗号(可多次);不给则全部窗") @click.option("--model", default="MiniMax-M3", show_default=True) @click.option("--force", is_flag=True, help="已有产物也重跑") def main(work_id, wins, model, force): d = OUT / str(work_id) manifest = json.loads((d / "manifest.json").read_text()) title = manifest["title"] targets = [m for m in manifest["windows"] if not wins or m["win"] in wins] total_in = total_out = n_del = 0 for m in targets: f_out = d / f"deletions-{m['win']:03d}.json" if f_out.exists() and not force: click.echo(f"win-{m['win']:03d} 已有产物,跳过(--force 重跑)", err=True) continue text = pathlib.Path(m["file"]).read_text() t0 = time.time() content, usage = chat( PROMPT.format(title=title, a=m["from"], b=m["to"], text=text), model=model) data = extract_json(content) dels = data.get("deletions", data if isinstance(data, list) else []) f_out.write_text(json.dumps({"deletions": dels}, ensure_ascii=False, indent=1)) total_in += usage.get("prompt_tokens", 0) total_out += usage.get("completion_tokens", 0) n_del += len(dels) click.echo(f"win-{m['win']:03d}(第{m['from']}–{m['to']}章)检出 {len(dels)} 段 " f"→ {f_out.name}({time.time() - t0:.0f}s)") click.echo(f"探测完成:{len(targets)} 窗共检出 {n_del} 段;" f"token in={total_in:,} out={total_out:,}") if __name__ == "__main__": try: main() except RuntimeError as e: click.echo(f"[llm错误] {e}", err=True) sys.exit(1)