236 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""parse-book skill:M3 直调拆书执行器——逐章两 pass(脚手架→范式),入库走 parse_ingest 守卫。
创始人拍板(2026-07-13):拆书内容生产 LLM=New-API MiniMax-M3(经 llm skill),
不再派 opus/haiku 子代理。本脚本把 workflow 版的提示词资产(身份段/实体判据/五型合同)
固化为直调版:脚本自己取数(正文/前文实体/已积累卡名录内联进 prompt)、调 M3、
容错解析 JSON、经 parse_ingest 机械校验入库(比例硬顶/归型/字段越合同/15连字泄漏全在那边)。
断点续跑:按 example_parse_task 状态机逐章跳过已 done 的 pass;失败记录不阻断后续章。
"""
import json
import pathlib
import re
import subprocess
import sys
import click
import psycopg
# 统一走 llm skill 入口(trust_env/重试/<think>剥离/JSON 容错都在那边)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
from llm import chat, extract_json # noqa: E402
DSN = ("postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-example"
"?keepalives=1&keepalives_idle=15&keepalives_interval=5&keepalives_count=3")
TENANT = 1
HERE = pathlib.Path(__file__).resolve().parent
TMP = pathlib.Path("/tmp/muse-parse")
# ── 提示词资产(自 workflow 试拆版移植,与 A3 库内合同同源) ──────────────
IDENTITY = """你是知识抽取员(extractor),分析槽位的默认绑定件。产出全部是草稿。
元数据纪律:schema 有什么字段你就抽什么,schema 没有的不抽——字段合同就是抽取 checklist,不自造结构;归型走各型「判据」;归不进任何型的候选=枚举缺口,如实报不硬塞;每字段要有正文证据,置信度低标「?」。
通则:以正文为准,不脑补正文没写的;基础字段规范填;采纳正文≠确认知识。"""
ENTITY_CRITERIA = ("实体型判据(脚手架级,只要 型/名称/一句话摘要):character=具名可指认的行动主体;"
"location=有名字的地点/星球/设施;faction=组织/国家/军团/公司;"
"power_system=力量体系/科技体系/修炼阶梯(体系本身,非招式);"
"item=有名字且有跨章戏份的装备/机甲/物品;event=已发生的重大事件(战役/事故/仪式)。"
"立卡门槛:有跨章戏份潜力;一次性龙套与单场景道具不收。")
CONTRACTS = {
"combat": {"中文名": "打斗桥段", "判据": "以武力/超自然力分胜负的对抗场景范式 ‖ 非武力博弈(商战/权谋/斗嘴)→scene_pattern;情绪弧主导→emotion", "字段": [
{"key": "对抗类型", "说明": "斗法/斗宝/阵战/围杀/车轮战/追杀"}, {"key": "开局态势", "说明": "强弱差及其理由——没有落差就没有戏"}, {"key": "节拍结构", "说明": "回合推进——试探/僵持/变数/分胜负各占多少"}, {"key": "反转机制", "说明": "靠什么翻盘——法宝/援军/情报/环境/代价兑换"}, {"key": "代价结构", "说明": "赢者付出什么——白赢是范式的失败"}, {"key": "败者处理", "说明": "死/逃/收服/结仇"}]},
"craft": {"中文名": "叙事技法", "判据": "单点装置——删去它场景仍成立、读者体验变平 ‖ 承载整场戏→桥段三型;跨章公式→trope", "字段": [
{"key": "装置类型", "说明": "伏笔/契诃夫之枪/信息差/重复意象/倒计时/身份错认"}, {"key": "埋设手法", "说明": "如何放得自然——挂在什么载体上不显眼又可回指"}, {"key": "履约方式", "说明": "何时、以什么形式兑现;兑现时如何唤起读者记忆"}, {"key": "间隔纪律", "说明": "埋与收的典型距离;太近廉价、太远失效"}, {"key": "失败模式", "说明": "这个装置最常见的用砸方式"}]},
"emotion": {"中文名": "情感桥段", "判据": "以情绪弧为主体的场景(告白/离别/爆发) ‖ 武力分胜负→combat;关系实体本身→character_relation", "字段": [
{"key": "情绪类型", "说明": "愤怒/悲怆/温情/羞耻/释然/敬畏"}, {"key": "铺垫结构", "说明": "情绪蓄水的台阶——哪几步把水位抬起来"}, {"key": "爆发点设计", "说明": "临界事件与引爆台词/动作的形态"}, {"key": "收束方式", "说明": "爆发后如何落地——转场/留白/反差"}, {"key": "常见失误", "说明": "这类情绪戏最常写砸的地方"}]},
"scene_pattern": {"中文名": "通用桥段", "判据": "承载一场戏的通用场景公式(非打斗非情感主导):拍卖/比试/谈判/夜袭/审讯/宴会 ‖ 武力对抗→combat;情绪弧→emotion", "字段": [
{"key": "场景类型", "说明": "拍卖/比试/谈判/夜袭/审讯/宴会/交易"}, {"key": "参与结构", "说明": "几方角色与各自诉求——冲突的来源"}, {"key": "推进节拍", "说明": "这场戏的标准起承转合"}, {"key": "变数设计", "说明": "打破常规走向的手段"}, {"key": "出口设计", "说明": "各方如何离场,留下什么后续"}]},
"trope": {"中文名": "套路", "判据": "跨章/跨作品复用的情节公式(扮猪吃虎/废柴逆袭/打脸循环),有可枚举的步骤链 ‖ 单场景装置→craft;单场戏→桥段三型", "字段": [
{"key": "公式步骤", "说明": "可枚举的步骤链——每步做什么"}, {"key": "适用条件", "说明": "什么设定/关系下这个套路才成立"}, {"key": "变体谱系", "说明": "常见变形与升级版"}, {"key": "失效风险", "说明": "读者疲劳点;用几次会腻"}, {"key": "组合搭配", "说明": "常与哪些套路/桥段连用"}]},
}
def scaffold_prompt(title, ch, ch_title, text, prev_entities):
ents = json.dumps(prev_entities, ensure_ascii=False) if prev_entities else "(第一章,为空)"
# 绝对字数上限比抽象比例对 LLM 更可执行(首轮实测 M3 按比例会写到 9.8%–19%)
wc = len(text.replace("\n", "").replace(" ", ""))
cap = max(60, int(wc * 0.05))
return f"""【muse 创作实验台·拆书 2b·脚手架 pass】
{IDENTITY}
【功能指令(parse-book 逐章内环)】
对参考书《{title}》第 {ch} 章《{ch_title}》(正文附后,约 {wc} 字):
1) 逆推本章细纲:章目标/关键事件/出场角色/伏笔动作(埋·推·收)/章末钩子。**细纲全文不得超过 {cap} 字**(章正文的 5%;超标会被校验脚本机械退回)。细纲是结构骨架,不是缩写复述——用短语与分号,不写完整句子。
2) 抽实体增量(脚手架级索引):{ENTITY_CRITERIA}
判重:下方是前文已收录实体,不重报(除非本章给出新身份,则在一句话摘要里并入)。
【前文实体索引】
{ents}
【输出规则(只输出一个 JSON 对象,禁止任何其他文字)】
{{"outline": "细纲文本", "entities": [{{"type": "六型之一", "name": "名称", "brief": "一句话摘要"}}]}}
【本章正文】
{text}"""
def patterns_prompt(title, ch, ch_title, text, outline, existing_cards):
cards = json.dumps(existing_cards, ensure_ascii=False) if existing_cards else "(尚无)"
return f"""【muse 创作实验台·拆书 2b·范式 pass】
{IDENTITY}
【功能指令(parse-book 范式拆取+脱敏红线)】
对参考书《{title}》第 {ch} 章《{ch_title}》(正文附后),在脚手架细纲之上拆「范式五型」卡:
{json.dumps(CONTRACTS, ensure_ascii=False)}
纪律:
- 宁缺毋滥:一章 0–3 张为常态,只收本章表现突出、可跨书复用的写法;平庸章可出 0 张。
- 归型走判据;五型之外一律不出卡。**先按判据定 type,再只用该 type 字段表里的中文 key 填 fields——混用他型字段=机械拒卡**(如 trope 的「公式步骤」不得出现在 craft 卡里)。
- **五型都是候选,不要把一切归成 craft**:整场武力对抗→combat;整场情绪戏→emotion;拍卖/谈判/审讯等场景公式→scene_pattern;跨章复用的情节公式→trope;craft 只留给「单点装置」(删去它场景仍成立)。type 值必须与 fields 所用字段表同型。
- **脱敏红线**:只写抽象结构与手法归纳,严禁抄录原文;≥15 连续字与原文重合=校验脚本机械拒卡。卡名与字段值用「主角/强敌/导师」等抽象指代,**不得出现书内专名**(人名/地名/机甲名/组织名)——专名只允许出现在 source.anchor 里。
- fields 按该型字段合同的中文 key 填;没证据的 key 省略,不编造。
- 判重:下方已积累卡名录,不与之重复立同义卡。
【本章细纲(脚手架 pass 产出)】
{outline}
【已积累范式卡名录】
{cards}
【输出规则(只输出一个 JSON 对象,禁止任何其他文字;没有值得立的卡时 cards 给空数组)】
{{"cards": [{{"type": "craft|combat|emotion|scene_pattern|trope", "name": "抽象手法名(无书内专名)", "brief": "一句话摘要", "fields": {{"中文合同key": "值"}}, "source": {{"book": "{title}", "chapter": "第{ch}章 {ch_title}", "anchor": "一句话情节定位(抽象指代)"}}}}]}}
【本章正文】
{text}"""
def m3_json(prompt, model, need_keys):
"""调 M3 → 容错提取 JSON → 形状校验;不合格带错误提示重试 1 次。"""
content, usage = chat(prompt, model=model)
for retry in range(2):
try:
data = extract_json(content)
if all(k in data for k in need_keys):
return data, usage
err = f"缺少必需键 {need_keys}"
except Exception as e: # json_repair 也救不回来的输出
err = str(e)[:200]
if retry == 0:
content, u2 = chat(prompt + f"\n\n【重试提示】上次输出无法解析({err}),请严格按输出规则只输出一个 JSON 对象。",
model=model)
usage = {k: usage.get(k, 0) + u2.get(k, 0) for k in set(usage) | set(u2)}
raise RuntimeError(f"JSON 形状重试仍失败: {err}")
def ingest(kind, work_id, ch, payload):
"""写临时文件 → parse_ingest 机械校验入库;返回 (是否成功, 输出文本)。"""
TMP.mkdir(parents=True, exist_ok=True)
f = TMP / f"{work_id}-{ch}-{kind}.json"
f.write_text(json.dumps(payload, ensure_ascii=False, indent=1))
r = subprocess.run([sys.executable, str(HERE / "parse_ingest.py"), kind,
"--work-id", str(work_id), "--chapter-order", str(ch), "--file", str(f)],
capture_output=True, text=True)
return r.returncode == 0, (r.stdout + r.stderr).strip()
@click.command()
@click.option("--work-id", type=int, required=True)
@click.option("--from", "from_", type=int, required=True)
@click.option("--to", type=int, required=True)
@click.option("--model", default="MiniMax-M3", show_default=True)
def main(work_id, from_, to, model):
# 任务行就绪(幂等)
subprocess.run([sys.executable, str(HERE / "parse_ingest.py"), "init-tasks",
"--work-id", str(work_id), "--from", str(from_), "--to", str(to)],
capture_output=True, text=True)
with psycopg.connect(DSN) as conn:
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
total_in = total_out = 0
for ch in range(from_, to + 1):
with psycopg.connect(DSN) as conn:
row = conn.execute(
"""SELECT c.id, c.title, b.content_text, t.scaffold_status, t.pattern_status
FROM muse_content_chapter c
JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=FALSE
JOIN example_parse_task t ON t.chapter_id=c.id AND t.tenant_id=c.tenant_id
WHERE c.tenant_id=%s AND c.work_id=%s AND c.order_no=%s AND c.deleted=FALSE""",
(TENANT, work_id, ch)).fetchone()
if not row:
click.echo(f"#{ch} 章或任务行不存在,跳过")
continue
ch_id, ch_title, text, s_st, p_st = row
# 前文实体索引(紧凑:型/名称/摘要),判重用
prev = [e for (ents,) in conn.execute(
"""SELECT s.entities FROM example_parse_scaffold s
JOIN muse_content_chapter c ON c.id=s.chapter_id
WHERE s.tenant_id=%s AND s.work_id=%s AND c.order_no<%s AND s.deleted=FALSE""",
(TENANT, work_id, ch)).fetchall() for e in ents]
# 判重名录排除本章自己的卡:否则重跑时 M3 对着自己首轮的卡判重不出卡,
# ingest 幂等软删旧卡后插 0 张——卡被自己删了(重跑自噬,实测病)
cards = conn.execute(
"""SELECT draft_payload->>'型', draft_payload->>'名称' FROM muse_knowledge_draft
WHERE tenant_id=%s AND source_type='parse_book' AND deleted=FALSE
AND NOT (source_id=%s AND (draft_payload->>'章序')::int=%s)""",
(TENANT, work_id, ch)).fetchall()
outline = None
if s_st == "done":
r = conn.execute(
"SELECT outline_text FROM example_parse_scaffold WHERE tenant_id=%s AND chapter_id=%s AND deleted=FALSE",
(TENANT, ch_id)).fetchone()
outline = r[0] if r else None
# pass1 脚手架(断点续跑:done 跳过)
if s_st != "done" or outline is None:
try:
p1 = scaffold_prompt(title, ch, ch_title, text, prev)
data, usage = m3_json(p1, model, ("outline", "entities"))
total_in += usage.get("prompt_tokens", 0)
total_out += usage.get("completion_tokens", 0)
ok, out = ingest("scaffold", work_id, ch, data)
# 比例超标是 M3 高频病:带压缩指令重试 1 次(首轮实测 10/15 章超标)
if not ok and "细纲比例" in out:
cap = max(60, int(len(re.sub(r"\s", "", text)) * 0.05))
data, usage = m3_json(
p1 + f"\n\n【重试】上次细纲 {len(data['outline'])} 字超标被退回。"
f"压缩到 {cap} 字以内:只留章目标/关键事件/伏笔动作/钩子,删掉一切修饰与过程描述。",
model, ("outline", "entities"))
total_in += usage.get("prompt_tokens", 0)
total_out += usage.get("completion_tokens", 0)
ok, out = ingest("scaffold", work_id, ch, data)
click.echo(f" {out}")
if not ok:
continue # 已记 task failed,重跑本命令补
outline = data["outline"]
except RuntimeError as e:
click.echo(f" #{ch} 脚手架 M3 失败: {e}")
continue
# pass2 范式(done 跳过;卡被守卫拒属正常,不算失败)
if p_st != "done":
try:
data, usage = m3_json(patterns_prompt(title, ch, ch_title, text, outline, cards), model,
("cards",))
total_in += usage.get("prompt_tokens", 0)
total_out += usage.get("completion_tokens", 0)
# source 机械回填:book/chapter 是调用侧确定数据,不依赖 LLM 自觉
#(实测 M3 偶发 source=null 整批被拒);anchor 是内容性字段,缺时用 brief 兜底
for c in data.get("cards") or []:
src = c.get("source") or {}
c["source"] = {"book": src.get("book") or title,
"chapter": src.get("chapter") or f"第{ch}章 {ch_title}",
"anchor": src.get("anchor") or c.get("brief") or ""}
_, out = ingest("patterns", work_id, ch, data)
click.echo(f" {out}")
except RuntimeError as e:
click.echo(f" #{ch} 范式 M3 失败: {e}")
click.echo(f"《{title}》{from_}–{to} 章完成;token in={total_in:,} out={total_out:,}")
if __name__ == "__main__":
try:
main()
except (psycopg.Error, RuntimeError) as e:
click.echo(f"[错误] {type(e).__name__}: {e}", err=True)
sys.exit(1)