diff --git a/.claude/skills/clean/SKILL.md b/.claude/skills/clean/SKILL.md index cd272a4..dcaf184 100644 --- a/.claude/skills/clean/SKILL.md +++ b/.claude/skills/clean/SKILL.md @@ -1,39 +1,39 @@ --- name: clean -description: LLM 辅助正文清洗——LLM 按窗口(约10万字)只输出待删垃圾段原文与理由,代码做精确匹配删除并全程留审计(example_clean_log)。LLM 不改写正文,删不删由守卫规则最终裁决。 +description: LLM 辅助正文清洗——MiniMax-M3 按窗口(约10万字)只输出待删垃圾段原文与理由,代码做精确匹配删除并全程留审计(example_clean_log)。LLM 不改写正文,删不删由四道守卫规则最终裁决。 --- # clean —— LLM 检测 + 代码执行的正文清洗 -分工(创始人方案 2026-07-13):**规则层**已在 import 解决结构性垃圾(水印正则/重贴章题/目录页/分页尾巴/残破实体);本 skill 处理**语义垃圾**——变体书站广告、作者拉票/PS 段、乱码水印等正则打不全的散落噪声。LLM 只当探测器(输出待删片段逐字原文),删除由脚本执行:零改写、可审计、可回放。 +分工(创始人方案 2026-07-13):**规则层**已在 import 解决结构性垃圾(水印正则/重贴章题/目录页/分页尾巴/残破实体);本 skill 处理**语义垃圾**——变体书站广告、作者拉票/PS 段、乱码水印等正则打不全的散落噪声。LLM 只当探测器(输出待删片段逐字原文),删除由脚本执行:零改写、可审计、可回放。探测模型=New-API `MiniMax-M3`(创始人拍板,经 llm skill 统一入口调用)。 ## 流程 ```bash # 1) 备窗口:按章对齐切窗(默认 ~10万字/窗,写 /tmp/muse-clean//win-*.txt + manifest) -.venv/bin/python .claude/skills/clean/scripts/clean_prep.py --work-id 5 --window 100000 +.venv/bin/python .claude/skills/clean/scripts/clean_prep.py --work-id 7 --window 100000 -# 2) 探测:每窗一次 LLM 调用(haiku,编排层派发;prompt 模板见下)→ 每窗一份 deletions JSON +# 2) 探测:每窗一次 M3 调用(prompt 内置于脚本;断点续跑,已有产物自动跳过) +.venv/bin/python .claude/skills/clean/scripts/clean_detect.py --work-id 7 [--win 1 --win 2] [--force] -# 3) 执行:精确匹配删除 + 审计入库 + 字数刷新(守卫规则见下) -.venv/bin/python .claude/skills/clean/scripts/clean_apply.py --work-id 5 --batch <批次号> --file /tmp/muse-clean/5/deletions-all.json +# 3) 执行:先 dry-run 对账出删除明细(质检样张),确认后去掉 --dry-run 落库 +.venv/bin/python .claude/skills/clean/scripts/clean_apply.py --work-id 7 --batch <批次号> --file /tmp/muse-clean/7/deletions-001.json [--dry-run] -# 4) 审查:删除对账(每章删了几段几字)+ 审计行抽查 -.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT reason, count(*), sum(length(removed_text)) FROM example_clean_log WHERE work_id=5 AND batch='<批次号>' GROUP BY reason" +# 4) 审查:删除对账(理由分布/字数)+ 审计行抽查 +.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT reason, count(*), sum(length(removed_text)) FROM example_clean_log WHERE work_id=7 AND batch='<批次号>' GROUP BY reason" ``` -## 探测 prompt 模板(编排层内联给 haiku) +## 四道守卫(脚本硬闸,LLM 建议 ≠ 必删;全部为首窗真跑实测病对症) -> 你是网文正文清洗探测器。下面是《书名》第 A–B 章的原文(含章题行)。找出**所有非正文垃圾**:书站广告及变体(如「一秒记住♂粒÷小÷说→网」)、网址残留、求票拉票/打赏鸣谢、章尾作者话(ps:…)、上架感言类整段、乱码水印。输出 deletions 数组,每项 `{chapter_order, exact, reason}`:`exact` 必须与原文**逐字一致**(含标点空白,≤300字,一段一项);只报垃圾,拿不准的不报;**情节正文一个字都不许报**。章题行不报(即使含求票字样——章题另行处理)。 +1. **长度界**:`exact` 4–500 字; +2. **章题保护**:不得含/命中所在章章题; +3. **单章 20% 顶**:单章累计删除超章长 20% → 整章回退不落库; +4. **行级垃圾特征**:exact 每一行必须命中通用垃圾词(ps头/感谢/打赏/求票/域名符号…,不含书内专名)——盗版源会把广告插进正文句子中间,M3 会把「广告+被截断正文」拼成一项,无特征行拆行跳过(宁漏删不误删)。 -## 守卫规则(脚本硬闸,LLM 建议 ≠ 必删) +匹配三级(对症 M3 两类实测病):精确命中 → 空白弹性(M3 折叠 `\n\n\n`→`\n\n`)→ 邻章 ±1 容错(长窗内偶发章号偏移)。三级全失败 → 拒绝。每次删除一行审计(原文/理由/模型/批次),源 txt 永远不动(可整书重导回滚);删完刷新 block/work 字数。 -- `exact` 长度 4–500 字;在指定章内**逐字命中**才删(命中多次全删并记 occurrences); -- 不得命中/包含章题行;单章累计删除 ≤ 章长 20%(超限整章建议弃用并报告); -- 每次删除一行审计(原文/理由/模型/批次),源 txt 永远不动(可整书重导回滚); -- 删完刷新 block.word_count 与 work.word_count。 +## 量级实测(2026-07-13 首窗定标) -## 量级参考(2026-07-13 估) - -- 拆书范围(5 书×前50章 ≈ 90万字)≈ 9–12 窗 ≈ 12 次 haiku 调用; -- 全语料(8 书 ≈ 3,300万字)≈ 340 窗 ≈ 350 次 haiku 调用(输入 ~35M token 量级)。 +- M3 中文 tokenizer ≈ **0.58 token/字**;10 万字窗 in≈5.9 万 token、out≈2k、耗时 ~24s; +- 机破星河前 50 章(12.9 万字,2 窗):39 条建议 → 40 段删除(拆行后)/2,188 字,误删 0; +- 全语料(8 书 ≈3,300 万字)≈ **340 窗 ≈ 340 次 M3 调用,输入 ~19M token**。 diff --git a/.claude/skills/clean/scripts/clean_apply.py b/.claude/skills/clean/scripts/clean_apply.py index 94ec064..982d469 100644 --- a/.claude/skills/clean/scripts/clean_apply.py +++ b/.claude/skills/clean/scripts/clean_apply.py @@ -2,6 +2,12 @@ """clean skill:精确匹配删除执行器(LLM 建议 ≠ 必删,守卫规则最终裁决)+ 审计入库。 输入 deletions JSON:[{chapter_order|chapter, exact, reason}](键名 ASCII,兼容中文键)。 + +匹配三级(M3 首窗实测的两类病对症): +1. 精确命中:exact 逐字在章内出现; +2. 空白弹性:文字逐字、连续空白段放宽为 \\s+(M3 会把原文 \\n\\n\\n 折叠成 \\n\\n); +3. 邻章容错:本章 1/2 级全失败时试 order±1 章(M3 在 10 万字窗内偶发章号偏移)。 +三级全失败 → 拒绝(安全方向:宁漏删不误删)。 """ import json import pathlib @@ -17,18 +23,53 @@ TENANT, ACTOR = 1, "1" MIN_LEN, MAX_LEN = 4, 500 MAX_CH_RATIO = 0.20 # 单章累计删除上限(占章长比) +# 守卫4:垃圾行强特征——exact 的每一行必须命中其一,否则视为疑混正文。 +# 首窗实测:盗版源会把广告插进正文句子中间,M3 会把「广告+被截断的正文」拼成一项报上来, +# 纯文本匹配防不住,必须按行做内容特征裁决(只用通用垃圾词,不含任何书内专名)。 +GARBAGE_LINE_PAT = re.compile( + r"^\s*[((]?\s*ps\b|^\s*[((]?\s*附[::]" + r"|感谢|打赏|盟主|书友|书评|拜谢|恳求|打滚|鸣谢" + r"|(?:月|推荐|评价|催更|票)票|起点币|求(?:收藏|推荐|订阅|票|月票)|拉票|冲榜" + r"|龙套|上架|[aA]签|签约|加更|[一二三四五六七八九十]更|更新时间|码字|存稿|请假|感言" + r"|www|http|[\._]{2,}|→|♂|÷|一秒记住|记住本站|无弹窗|手机版|首发|最新章节|整理|转载", + re.I) + + +def flex_pattern(exact): + """空白弹性正则:非空白文字逐字 escape,连续空白段 → \\s+。 + + 只放宽空白,文字仍须逐字一致——不会匹配到不同文字的正文。 + """ + parts = [p for p in re.split(r"\s+", exact) if p] + if not parts: + return None + return re.compile(r"\s+".join(re.escape(p) for p in parts)) + + +def try_remove(text, exact): + """在 text 中删除 exact(精确→弹性两级)。返回 (新text, 命中次数, 实删字数, 方式)。""" + n = text.count(exact) + if n: + return text.replace(exact, ""), n, len(exact) * n, "精确" + pat = flex_pattern(exact) + if pat: + new, n = pat.subn("", text) + if n: + return new, n, len(text) - len(new), "空白弹性" + return text, 0, 0, "" + @click.command() @click.option("--work-id", type=int, required=True) @click.option("--batch", required=True, help="清洗批次号(审计用)") @click.option("--file", "file_", type=click.Path(exists=True), required=True) -@click.option("--model", default="haiku", show_default=True, help="建议来源模型(审计标注)") +@click.option("--model", default="MiniMax-M3", show_default=True, help="建议来源模型(审计标注)") @click.option("--dry-run", is_flag=True, help="只对账不落库") def main(work_id, batch, file_, model, dry_run): raw = json.loads(pathlib.Path(file_).read_text()) if isinstance(raw, dict): raw = raw.get("deletions") or raw.get("删除") or [] - # 按章分组 + # 按章分组(键名 ASCII 为主,兼容中文键) by_ch = {} for d in raw: no = d.get("chapter_order") or d.get("chapter") or d.get("章序") @@ -37,58 +78,97 @@ def main(work_id, batch, file_, model, dry_run): continue by_ch.setdefault(int(no), []).append((exact, d.get("reason") or d.get("理由") or "")) - stats = {"删除段": 0, "删除字数": 0, "拒绝": []} + # 一次拉齐所有目标章及其邻章(±1),在内存统一匹配,减少 DB 往返 + targets = sorted({n for no in by_ch for n in (no - 1, no, no + 1) if n >= 1}) + stats = {"删除段": 0, "删除字数": 0, "拒绝": [], "弹性命中": 0, "邻章命中": 0} with psycopg.connect(DSN) as conn: + rows = conn.execute( + """SELECT c.order_no, c.id, c.title, b.id, b.content_text + FROM muse_content_chapter c JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=FALSE + WHERE c.tenant_id=%s AND c.work_id=%s AND c.deleted=FALSE AND c.order_no = ANY(%s)""", + (TENANT, work_id, targets)).fetchall() + # chapters[no] = {ch_id, title, blk_id, text(可变), orig(原文), removed(累计), audit(审计行)} + chapters = {r[0]: {"ch_id": r[1], "title": r[2], "blk_id": r[3], + "text": r[4], "orig": r[4], "removed": 0, "audit": []} for r in rows} + for no, dels in sorted(by_ch.items()): - row = conn.execute( - """SELECT c.id, c.title, b.id, b.content_text - FROM muse_content_chapter c JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=FALSE - WHERE c.tenant_id=%s AND c.work_id=%s AND c.order_no=%s AND c.deleted=FALSE""", - (TENANT, work_id, no)).fetchone() - if not row: + if no not in chapters: stats["拒绝"].append(f"#{no}: 章不存在") continue - ch_id, ch_title, blk_id, text = row - new_text = text - ch_removed = 0 - applied = [] for exact, reason in dels: # 守卫1:长度界 if not (MIN_LEN <= len(exact) <= MAX_LEN): stats["拒绝"].append(f"#{no}: 长度越界({len(exact)}) {exact[:24]!r}") continue - # 守卫2:不得含/命中章题 - if ch_title and ch_title in exact: - stats["拒绝"].append(f"#{no}: 含章题 {exact[:24]!r}") + # 守卫4:行级垃圾特征——无特征的行=疑混正文 + lines = [ln for ln in exact.split("\n") if ln.strip()] + plain = [ln for ln in lines if not GARBAGE_LINE_PAT.search(ln)] + if plain and len(lines) == 1: + stats["拒绝"].append(f"#{no}: 无垃圾特征疑正文 {exact[:24]!r}") continue - n = new_text.count(exact) - if n == 0: - stats["拒绝"].append(f"#{no}: 未命中 {exact[:24]!r}") - continue - new_text = new_text.replace(exact, "") - ch_removed += len(exact) * n - applied.append((exact, n, reason)) - # 守卫3:单章删除比例硬顶 - if ch_removed > len(text) * MAX_CH_RATIO: - stats["拒绝"].append(f"#{no}: 累计删除 {ch_removed} 字超章长20%上限,整章放弃") + if plain: + # 拆行降级:只删有垃圾特征的行,疑正文行跳过并显式报告(宁漏删不误删) + for ln in plain: + stats["拒绝"].append(f"#{no}: 拆行跳过疑正文行 {ln[:24]!r}") + units = [(ln, reason + "(拆行)") for ln in lines + if GARBAGE_LINE_PAT.search(ln) and MIN_LEN <= len(ln) <= MAX_LEN] + else: + units = [(exact, reason)] + for u_exact, u_reason in units: + # 本章两级匹配 → 邻章容错(章号偏移是 LLM 在长窗里的已知病) + hit_no = None + for cand in (no, no - 1, no + 1): + ch = chapters.get(cand) + if not ch: + continue + # 守卫2:不得含该章章题(按实际匹配章判断) + if ch["title"] and ch["title"] in u_exact: + stats["拒绝"].append(f"#{no}: 含章题 {u_exact[:24]!r}") + hit_no = -1 # 标记已裁决,不再试邻章 + break + new, n, removed, how = try_remove(ch["text"], u_exact) + if n: + ch["text"] = new + ch["removed"] += removed + note = u_reason + if how == "空白弹性": + stats["弹性命中"] += 1 + note += "(空白弹性)" + if cand != no: + stats["邻章命中"] += 1 + note += f"(报#{no}实删#{cand})" + ch["audit"].append((u_exact, n, note)) + stats["删除段"] += 1 + stats["删除字数"] += removed + hit_no = cand + break + if hit_no is None: + stats["拒绝"].append(f"#{no}: 未命中 {u_exact[:24]!r}") + + # 守卫3:单章删除比例硬顶——超顶整章回退不落库 + dirty = [] + for no, ch in sorted(chapters.items()): + if not ch["audit"]: continue - if not applied: + if ch["removed"] > len(ch["orig"]) * MAX_CH_RATIO: + stats["删除段"] -= len(ch["audit"]) + stats["删除字数"] -= ch["removed"] + stats["拒绝"].append(f"#{no}: 累计删除 {ch['removed']} 字超章长20%上限,整章回退") continue - new_text = re.sub(r'\n{3,}', '\n\n', new_text) # 删除后合并多余空行 - stats["删除段"] += len(applied) - stats["删除字数"] += ch_removed - if dry_run: - continue - wc = len(re.sub(r'\s', '', new_text)) - conn.execute("UPDATE muse_content_block SET content_text=%s, word_count=%s, updater=%s WHERE id=%s", - (new_text, wc, ACTOR, blk_id)) - for exact, n, reason in applied: - conn.execute( - """INSERT INTO example_clean_log (work_id, chapter_id, batch, removed_text, occurrences, - reason, model, creator, updater, tenant_id) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (work_id, ch_id, batch, exact, n, reason[:200], model, ACTOR, ACTOR, TENANT)) + ch["text"] = re.sub(r"\n{3,}", "\n\n", ch["text"]) # 删除后合并多余空行 + dirty.append((no, ch)) + if not dry_run: + for no, ch in dirty: + wc = len(re.sub(r"\s", "", ch["text"])) + conn.execute("UPDATE muse_content_block SET content_text=%s, word_count=%s, updater=%s WHERE id=%s", + (ch["text"], wc, ACTOR, ch["blk_id"])) + for exact, n, note in ch["audit"]: + conn.execute( + """INSERT INTO example_clean_log (work_id, chapter_id, batch, removed_text, occurrences, + reason, model, creator, updater, tenant_id) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (work_id, ch["ch_id"], batch, exact, n, note[:200], model, ACTOR, ACTOR, TENANT)) conn.execute( """UPDATE muse_content_work w SET word_count=( SELECT COALESCE(sum(b.word_count),0) FROM muse_content_block b @@ -96,9 +176,16 @@ def main(work_id, batch, file_, model, dry_run): WHERE w.id=%s""", (TENANT, ACTOR, work_id)) conn.commit() mode = "(dry-run 未落库)" if dry_run else "" - click.echo(f"清洗执行{mode}: 删除 {stats['删除段']} 段 / {stats['删除字数']:,} 字;拒绝 {len(stats['拒绝'])} 条") + click.echo(f"清洗执行{mode}: 删除 {stats['删除段']} 段 / {stats['删除字数']:,} 字" + f"(弹性命中 {stats['弹性命中']}、邻章命中 {stats['邻章命中']});拒绝 {len(stats['拒绝'])} 条") for r in stats["拒绝"][:12]: click.echo(f" [拒] {r}") + if dry_run: + # dry-run 打印完整删除明细:门①人工质检的数据源 + click.echo("── 删除明细(质检用) ──") + for no, ch in dirty: + for exact, n, note in ch["audit"]: + click.echo(f" [删] #{no} ×{n} {note or '(无理由)'}\n {exact!r}") if __name__ == "__main__": diff --git a/.claude/skills/clean/scripts/clean_detect.py b/.claude/skills/clean/scripts/clean_detect.py new file mode 100644 index 0000000..a03e3e3 --- /dev/null +++ b/.claude/skills/clean/scripts/clean_detect.py @@ -0,0 +1,82 @@ +#!/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) diff --git a/.claude/skills/llm/SKILL.md b/.claude/skills/llm/SKILL.md new file mode 100644 index 0000000..bdba6fe --- /dev/null +++ b/.claude/skills/llm/SKILL.md @@ -0,0 +1,33 @@ +--- +name: llm +description: New-API LLM 调用统一入口(默认 MiniMax-M3)。管线内所有内容生产型 LLM 调用(清洗探测、拆书抽取等)必须经此 skill 发起,不得裸调外部服务;主会话模型只负责固化提示词、发起调用与守卫验证。 +--- + +# llm —— New-API 统一调用入口 + +创始人拍板(2026-07-13):清洗与拆书的内容生产 LLM **全部走 New-API 的 MiniMax-M3**;主会话(Fable5)只固化 agent/提示词/skill 与发起调用。本 skill 是唯一出口。 + +## 用法 + +```bash +# CLI:读 prompt 文件调用,内容打到 stdout(token 用量与重试日志走 stderr) +.venv/bin/python .claude/skills/llm/scripts/llm.py chat --prompt-file /tmp/p.txt [--model MiniMax-M3] [--max-tokens 32000] [--out /tmp/resp.txt] [--extract-json] + +# 其他 skill 内 import(clean_detect / parse 直调脚本的标准姿势) +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts")) +from llm import chat, extract_json +``` + +## 内建保障(调用方不必重复实现) + +- `trust_env=False`:本机代理环境变量会劫持内网直连(教训固化); +- 超时 900s + 网络错/5xx/429 指数退避重试 2 次; +- `` 剥离:推理模型可能把思考混进 content; +- `extract_json()` 三级容错:直接解析 → 首尾括号截取 → json-repair 兜底(修中文引号/缺逗号/尾逗号——opus 试拆已实测两类 JSON 病,任何模型都可能犯); +- 每次调用向 stderr 打印 token 用量与耗时(成本审计)。 + +## 凭据与约束 + +- token 为 New-API **普通令牌**(明文入仓是仓库政策);严禁改用管理令牌打 /v1。 +- 可用模型以 `/v1/models` 为准(2026-07-13 在列:MiniMax-M3 / M2.x 系 / deepseek-v4-* / glm-5.2 / Qwen 嵌入与重排)。 +- 嵌入调用不走本 skill(已有 embed skill,模型与维度钉死)。 diff --git a/.claude/skills/llm/scripts/llm.py b/.claude/skills/llm/scripts/llm.py new file mode 100644 index 0000000..f80cfff --- /dev/null +++ b/.claude/skills/llm/scripts/llm.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""llm skill:New-API 统一调用入口(默认 MiniMax-M3)。 + +管线内所有内容生产型 LLM 调用(清洗探测/拆书抽取)必须经此入口: +- trust_env=False(本机代理环境变量会劫持内网直连,教训固化); +- 超时 + 指数退避重试; 剥离;JSON 三级容错提取(json-repair 兜底); +- 每次调用向 stderr 打印 token 用量与耗时(成本审计),stdout 只出内容。 +""" +import json +import pathlib +import re +import sys +import time + +import click +import requests + +BASE = "http://100.64.0.8:3000" +# New-API 普通令牌(仓库政策允许明文;严禁换管理令牌打 /v1) +TOKEN = "sk-DyVqO3lDmEvQZ3PqGpbNaaaHZHhbh0xaHRIiynhYSmVlLHl2" +DEFAULT_MODEL = "MiniMax-M3" + + +def chat(prompt, model=DEFAULT_MODEL, max_tokens=32000, temperature=0.2, + retries=2, timeout=900): + """单轮对话,返回 (content, usage)。网络错/5xx/429 指数退避重试。 + + content 已剥离 (推理模型可能把思考混进正文)。 + """ + s = requests.Session() + s.trust_env = False # 本机代理 env 会劫持内网直连 + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": max_tokens, + "temperature": temperature, + } + last_err = None + for attempt in range(retries + 1): + try: + t0 = time.time() + r = s.post(f"{BASE}/v1/chat/completions", + headers={"Authorization": f"Bearer {TOKEN}"}, + json=payload, timeout=timeout) + # 429/5xx 属于可重试的服务端瞬时问题 + if r.status_code in (429,) or r.status_code >= 500: + last_err = f"HTTP {r.status_code}: {r.text[:200]}" + raise requests.RequestException(last_err) + r.raise_for_status() + data = r.json() + content = data["choices"][0]["message"]["content"] or "" + content = re.sub(r".*?", "", content, flags=re.S).strip() + usage = data.get("usage", {}) + print(f"[llm] {model} in={usage.get('prompt_tokens', '?')} " + f"out={usage.get('completion_tokens', '?')} " + f"耗时{time.time() - t0:.0f}s finish={data['choices'][0].get('finish_reason')}", + file=sys.stderr) + return content, usage + except (requests.RequestException, KeyError, json.JSONDecodeError) as e: + last_err = str(e) + if attempt < retries: + wait = 8 * (2 ** attempt) + print(f"[llm] 第{attempt + 1}次失败({last_err[:120]}),{wait}s 后重试", + file=sys.stderr) + time.sleep(wait) + raise RuntimeError(f"LLM 调用重试耗尽: {last_err}") + + +def extract_json(text): + """JSON 三级容错提取:直接解析 → 首尾括号截取 → json-repair 兜底。 + + opus 试拆实测过两类 JSON 病(中文引号、缺逗号)——任何模型都可能犯,统一在此兜住。 + """ + try: + return json.loads(text) + except json.JSONDecodeError: + pass + # 剥 markdown 代码围栏后按最外层大括号/中括号截取 + t = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.M) + for a, b in (("{", "}"), ("[", "]")): + i, j = t.find(a), t.rfind(b) + if i != -1 and j > i: + frag = t[i:j + 1] + try: + return json.loads(frag) + except json.JSONDecodeError: + import json_repair + return json_repair.loads(frag) + import json_repair + return json_repair.loads(t) + + +@click.group() +def cli(): + pass + + +@cli.command("chat") +@click.option("--prompt-file", type=click.Path(exists=True), required=True) +@click.option("--model", default=DEFAULT_MODEL, show_default=True) +@click.option("--max-tokens", type=int, default=32000, show_default=True) +@click.option("--temperature", type=float, default=0.2, show_default=True) +@click.option("--out", type=click.Path(), help="内容写文件(不给则打 stdout)") +@click.option("--extract-json", "extract_", is_flag=True, help="容错提取 JSON 后输出") +def chat_cmd(prompt_file, model, max_tokens, temperature, out, extract_): + prompt = pathlib.Path(prompt_file).read_text() + content, _ = chat(prompt, model=model, max_tokens=max_tokens, temperature=temperature) + if extract_: + content = json.dumps(extract_json(content), ensure_ascii=False, indent=1) + if out: + pathlib.Path(out).write_text(content) + click.echo(f"已写 {out}({len(content):,} 字符)", err=True) + else: + click.echo(content) + + +if __name__ == "__main__": + try: + cli() + except RuntimeError as e: + click.echo(f"[llm错误] {e}", err=True) + sys.exit(1) diff --git a/CLAUDE.md b/CLAUDE.md index 74bd16f..049a860 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ ## 角色纪律 - **主会话只编排与裁决,不亲手写创作内容**。设定、大纲、正文、知识卡一律出自 `.claude/agents/` 的智能体;主会话负责组装上下文(经 `read-context`)、派发、校验产出格式、向用户呈报。 -- 派发子代理必须显式传 model:创作/评审/抽取 = `opus`,纯机械活 = `haiku`。 +- 派发子代理必须显式传 model:创作/评审 = `opus`,纯机械活 = `haiku`。 +- **清洗与拆书的内容生产 LLM 全部走 New-API `MiniMax-M3`**(创始人 2026-07-13 拍板):经 `llm` skill 统一入口调用,主会话只固化提示词/skill 与发起调用,不派 opus/haiku 子代理做这两类内容生产。 ## 双轨纪律(本仓的命根子) diff --git a/README.md b/README.md index cd1d34f..cfb267c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ agent-example/ | import | 导入解析:txt→回目正则静态分章→作品/章行入库(调 db) | 导入解析旅程 | B1 交付;C8 用户旧稿复用 | | embed | New-API 嵌入封装:Qwen3-Embedding-8B、`dimensions:1024`、`--noproxy`、批量+失败重试 | AI 网关(嵌入) | B2 交付 | | search | 向量检索:意图→embed→pgvector 相似度召回→授权过滤(仅已确认+已绑定)+字段裁剪→带相似度分的结果集 | 知识检索 | B3 交付 | +| llm | New-API LLM 统一调用入口(默认 `MiniMax-M3`):trust_env=False、重试、`` 剥离、JSON 三级容错(json-repair 兜底)、token 用量审计;清洗/拆书等内容生产调用一律经此 | AI 网关(生成) | 已交付(2026-07-13) | +| clean | LLM 探测+代码执行的正文清洗:M3 每窗(~10万字)只报待删段逐字原文→四道守卫裁决删除→审计入 example_clean_log;源 txt 不动可回滚 | 导入清洗旅程 | P0/P1 已交付(2026-07-13) | **功能 skill ×9**(prompt 三段式的功能段;每只必带「元数据驱动」节写明 schema 怎么控制它;链登记表见 `meta/chains/README.md`): @@ -221,10 +223,10 @@ flowchart LR - **A 阶段全收口**:muse-example 库+pgvector 0.8.5(容器重建恢复命令见 `db/连接信息.md`);24 表(主仓一致 20+example_* 5,含 92-清洗日志表;映射见 `db/表映射.md`);23 型 schema 入库(294 字段行,字段级 aiContext 在 visibility_policy.policy_snapshot)。 - **B1 基本收口**:8 本参考书全本入库(希泊尼战纪1496 / 星环使命1722 / 机动风暴673 / 机武风暴686 / 机破星河2250 / 深空之影594 / 超神机械师 / 机战无限2901 章;星环使命为创始人 2026-07-13 新增)。源损坏修复经历:双章区间号「第2373-2374章」、分页尾巴「(第1/1页)」、残破实体「&bp;」、纯中文数字章题+重贴+目录页(机动风暴)——全部进 import 解析器规则。基础面验收工具=`quality_report.py`(字数/分章/章题/段落/垃圾嫌疑扫描)。 -- **书单拍板(2026-07-13 创始人)**:先试拆几本书前几章给创始人质检(试拆流水线=workflow 编排+逐章两 pass:scaffold(opus)→ingest(haiku)→patterns(opus)→ingest(haiku);脚本存 session workflows/,状态在 example_parse_task/scaffold/draft 表),质检过再放量。 -- **清洗方案拍板(2026-07-13 创始人)**:语义垃圾走「LLM 探测输出待删段原文+代码精确匹配删除」=clean skill(窗口≈10万字/次调用,守卫规则+审计表 example_clean_log;机破星河最脏 879 求票+306 PS,win 文件已备 /tmp/muse-clean/7/)。 -- **在飞任务(compact 时)**:超神机械师重导(修其 14 个百字以下章);试拆 workflow(机动风暴 1–3 章,ch1 脚手架已过校验:细纲 5.0%/实体 6)。 -- **未决**:①试拆结果质检(含范式卡样张)②清洗范围拍板(仅拆书范围 ≈12 次 haiku vs 全语料 ≈350 次)③B2 放量确认闸(编排+成本呈报后创始人拍)。 +- **M3 拍板(2026-07-13 创始人)**:清洗与拆书的内容生产 LLM **全部改走 New-API `MiniMax-M3`**(llm skill 统一入口,密钥入仓);主会话(Fable5)只固化 agent/提示词/skill 与发起调用。清洗范围=**所有书**;删除前创始人质检样张(门①);**拆书先不放量**(试拆 M3 重做后质检=门②,B2 硬停等新指令)。执行计划:`docs/2026-07-13-M3清洗拆书执行计划.md`。 +- **清洗 P0/P1 收口(2026-07-13)**:llm skill(M3 入口+json-repair 三级容错)+ clean_detect(每窗一调、断点续跑)+ clean_apply 四道守卫(长度界/章题/单章20%顶/**行级垃圾特征拆行**——盗版源把广告插进正文句中,M3 会拼「广告+截断正文」,已实测拦住)+ 三级匹配(精确/空白弹性/邻章±1)。实测定标:M3 中文 0.58 token/字,10 万字窗 in≈5.9 万 token 耗时 24s;全语料 ≈340 调用/~19M token。机破星河前 50 章演示已备:40 段/2,188 字待删(dry-run),误删 0。 +- **opus 试拆基准(workflow 已完成)**:机动风暴 1–3 章,范式卡 2 张入库(ch2/ch3 各 1 拒 1 越合同)+ch1 脚手架;opus 也犯 JSON 中文引号/缺逗号病(已被 llm skill 容错吸收)。此产物只作 M3 对比基准,不算正式拆书。 +- **未决**:①门① 清洗样张质检(明细在 /tmp/muse-clean/7/dryrun-00*.txt,已呈报)→过门后 P2 全语料放量②门② M3 试拆样张质检(P3 固化后跑 5 本×前 3 章)③B2 放量(硬停,等创始人新指令)。 - **教训入档**:StructuredOutput 工具 schema 属性名仅限 ASCII(中文键 API 400)——schema ASCII 键+入库脚本键名归一;Tailscale 长事务需 keepalive+批量写(逐行两万往返曾半死 16 分钟)。 ## 九·旧(2026-07-10 快照,留档) diff --git a/docs/2026-07-13-M3清洗拆书执行计划.md b/docs/2026-07-13-M3清洗拆书执行计划.md new file mode 100644 index 0000000..38dd018 --- /dev/null +++ b/docs/2026-07-13-M3清洗拆书执行计划.md @@ -0,0 +1,75 @@ +# M3 清洗+拆书执行计划(2026-07-13,待创始人批准后开工) + +> 版本 v1 | 读者:创始人 | 边界:只覆盖「全语料清洗 + 试拆管线 M3 化」,不含 B2 放量拆书(已拍:先不批量拆) + +## 一、拍板回执(本计划的前提) + +| 拍板项 | 结论 | +|--------|------| +| 清洗/拆书的内容生产 LLM | **全部改为 New-API 的 MiniMax-M3 直调**(已验通:模型在列、JSON 输出干净) | +| Fable5(主会话)角色 | 只做四件事:固化 agent/提示词、写 skill、发起调用、守卫验证与呈报——**不亲自生产内容** | +| 密钥 | New-API token 明文写入 skill 脚本(仓库政策允许) | +| 清洗范围 | **所有书**(8 本 ≈3,300 万字) | +| 清洗结果 | **删除落库前必须创始人质检样张** | +| 拆书 | 先不批量拆;试拆改用 M3 重做,opus 已产的机动风暴第 1 章留作对比基准 | + +## 二、分工模型 + +```mermaid +flowchart LR + A[Fable5 主会话
固化skill/提示词
发起调用/呈报] -->|每窗一调| B[MiniMax-M3
只当探测器/抽取器
输出JSON] + B --> C[脚本守卫
精确匹配/长度界/20%顶
泄漏检查/审计入库] + C -->|样张| D[创始人
质检确认门] + D -->|放行| C +``` + +- **M3 永远不直接改库**:清洗它只报「待删段逐字原文」,拆书它只报「脚手架/范式卡 JSON」;落库动作全部由带守卫的脚本执行,LLM 建议 ≠ 必删/必收。 +- 源 txt 永远不动,任何一步可整书重导回滚。 + +## 三、步骤(从最基础开始) + +### P0 — llm skill 固化 + 首窗真跑(我自行决策,无确认门) + +1. 新建 `.claude/skills/llm/`:封装 New-API chat 调用(默认 MiniMax-M3)——超时/重试、`` 剥离、JSON 提取容错、token 用量审计打印。所有后续调用走这一个入口,不裸调。 +2. clean/parse-book 两个 skill 的文档与参数改口:模型标注 haiku→MiniMax-M3,编排方式从「Workflow 派子代理」改为「skill 脚本直调 M3」。 +3. **首窗真跑**:机破星河(最脏书)win-001(第 1–39 章,10.1 万字)探测一次 → 验证窗口不超限、JSON 可解析、exact 逐字命中率。 + - 若 10 万字窗超限或命中率差 → 我自行降窗(如 5 万字/窗,总调用数约 ×2,量级仍可接受),呈报时说明。 + +### P1 — 清洗演示(★确认门①:创始人质检) + +4. 机破星河前 ~50 章:M3 探测 → `clean_apply --dry-run` 对账 → **呈报样张**:删什么/理由/次数、守卫拒了什么、我抽查的误伤扫描结论。 +5. **等质检放行**后该批真删落库,呈报前后对比(字数变化、审计行、quality_report 复扫)。 + +### P2 — 清洗放量(所有书;门①过后我自行执行) + +6. 8 本 ≈340 窗 ≈350 次 M3 调用(输入 ~35M token;若降窗则 ~700 次)。断点续跑(manifest 标记已处理窗),书间顺序跑,每书收口报一行。 +7. 收口交**全局清洗报告**(每书删除段/字数/理由分布/拒绝率/抽查样张 + 垃圾残留复扫对比)→ 创始人复核。此为呈报非阻塞门:源 txt 可回滚,发现异常再处理。 + +### P3 — 拆书管线 M3 化 + 试拆(★确认门②:创始人质检样张) + +8. 把 workflow 里的 scaffold/patterns 提示词(含 extractor 身份段、五型合同)移植固化为 parse-book skill 的 M3 直调脚本,状态仍全在库(example_parse_task),断点续跑。**skill 固化可与 P2 放量并行**(不花 M3 调用)。 +9. 试拆:5 本试拆书(机动风暴/超神机械师/机战无限/深空之影/星环使命)各前 3 章 = 15 章 ≈30 次 M3 调用。机动风暴第 1 章与 opus 基准**并排呈报**(细纲/实体/范式卡样张 + 泄漏检查)。 +10. **等质检**。质检后呈报 B2 放量编排与成本量级,**硬停等新指令**(已拍:先不批量拆)。 + +## 四、确认门汇总 + +| 类型 | 事项 | +|------|------| +| **需创始人确认才继续** | 门① 清洗样张质检(P1→P2 真删与放量的前提);门② M3 试拆样张质检;B2 放量拆书(硬停,等新指令);单章删除超 20% 顶等守卫异常若成批出现(升级呈报) | +| **我自行决策** | skill 实现细节(重试/容错/断点续跑)、窗口大小调整、dry-run 与 1–2 次量级的试跑、演示书选择、框架文档同步与「框架:」提交推送 | +| **纪律不变** | 库内 LLM 产物一律不 commit;确认=commit 仅创始人指令触发 | + +## 五、成本量级(全部 MiniMax-M3) + +| 事项 | 调用数 | 输入量级 | +|------|--------|----------| +| P0 首窗试跑 | 1 | ~0.06M token(实测:10万字窗 in=5.9万 out=2k 耗时24s) | +| P1 清洗演示(机破前 50 章) | 2 | ~0.08M token(实测收口) | +| P2 清洗放量(8 本全语料) | ~340 | **~19M token**(实测 M3 中文 0.58 token/字,比预估砍半;输出极小) | +| P3 试拆(5 本×3 章×两 pass) | ~30 | ~0.5M token | + +## 六、风险与回滚 + +- **误删正文**:三层防护——M3 只报逐字原文、守卫(4–500 字/不碰章题/单章≤20%)、门① 人工质检;全程审计入 example_clean_log,源 txt 不动可整书重导。 +- **M3 长窗退化**:P0 首窗真跑先验,退化即降窗,不带病放量。 +- **M3 拆书质量不如 opus**:试拆并排对比呈报,由创始人裁决质量是否可接受,不预设结论。 diff --git a/requirements.txt b/requirements.txt index 29556ff..e5aa4c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ psycopg[binary] requests pyyaml click +json-repair