框架: B4-S3 出卡权上移窗级——章级候选线索+窗级聚类母卡+间隔机械计算+嵌入判重

- parse_llm 拆 chapters/cards 两命令:章级一遍正文产细纲+实体+范式候选线索;窗级按大纲窗行聚类归并出母卡+多实例章号
- parse_ingest 新增 cards 窗级入库:实例域校验/间隔章号差机械追加(伪精确灭绝)/嵌入判重0.85归并终判/越合同字段裁剪降级/占位符机械检测
- parse_outline 幂等键改 from_order(流水号重跑漂移)+书末残窗必成窗+--to 限域
- 出卡环加拒卡带因重试+0卡可疑重试(M3 输出方差实测)
- review_cards CRITERIA 补实测间隔可信例外+溯源豁免区说明(防审核假阳)
- 章级判重名录随窗级聚类废除(重跑自噬两补丁退役)
This commit is contained in:
zizi 2026-07-13 21:22:11 +08:00
parent 8ed0f5b2e7
commit c3f9688ccb
8 changed files with 481 additions and 188 deletions

View File

@ -31,18 +31,22 @@ disable-model-invocation: true
- **顺序性只来自增量判重**(新实体要对着已积累实体判重合并),细纲逆推本身章间独立——先顺序跑保正确,并行化留作后续优化;
- 进度每 10 章报一行(章号/新实体数/累计分型统计)。
**M3 直调形态(创始人 2026-07-13 拍板,现行)**:循环体不再派 opus/haiku 子代理,改为 `scripts/parse_llm.py` 直调 New-API `MiniMax-M3`(经 llm skill——脚本自己取数(正文/前文实体/已积累卡名录内联进 prompt→ M3 两 pass脚手架→范式`parse_ingest` 机械校验入库(比例硬顶/归型/字段越合同/15连字泄漏。断点续跑按 example_parse_task 状态机逐章跳过已 done 的 pass
**M3 直调形态(创始人 2026-07-13 拍板,现行)**:循环体不再派 opus/haiku 子代理,改为 `scripts/parse_llm.py` 直调 New-API `MiniMax-M3`(经 llm skill
**放量全流程(每书四步,均 M3**
**出卡权上移窗级B4-S3 重构,现行)**:章级逐章出卡有三同根病(同功撞车/单章证不成跨章公式/间隔数字伪精确),治法=章级只产「范式候选线索」(并入脚手架 pass,正文只过一遍),出卡在窗级聚类归并——同一手法多章多次出现归并成一张母卡+实例章号,**间隔章数由实例章号差机械计算**M3 禁自报数字)。跨窗/跨书同手法靠**嵌入判重**(初筛 ≥0.85 → M3 归并终判 merge/keep,拿不准保留。大纲窗行example_parse_outline,幂等键=窗起始章)是出卡窗的唯一切分依据。
**放量全流程(每书五步,均 M3**
```bash
# ① 逐章两 pass脚手架→范式;断点续跑,重跑自动补失败章)
.venv/bin/python .claude/skills/parse-book/scripts/parse_llm.py --work-id 4 --from 1 --to 50
# ② 窗级大纲聚合(每 510 万字:多章细纲+正文→阶段大纲;单章对大纲层可能零贡献,不做章内比例强制
# ① 章级 pass细纲+实体+范式候选线索;断点续跑,重跑自动补失败章)
.venv/bin/python .claude/skills/parse-book/scripts/parse_llm.py chapters --work-id 4 --from 1 --to 50
# ② 窗级大纲聚合(每 510 万字:多章细纲+正文→阶段大纲;书末残窗无论大小必成窗
.venv/bin/python .claude/skills/parse-book/scripts/parse_outline.py window --work-id 4
# ③ 全书拆完:终检(逐窗细纲对账大纲 + 跨段连贯性纵览)
# ③ 窗级聚类出卡(窗=②的窗行;线索+细纲+阶段大纲→母卡;守卫+判重在 parse_ingest cards
.venv/bin/python .claude/skills/parse-book/scripts/parse_llm.py cards --work-id 4
# ④ 全书拆完:终检(逐窗细纲对账大纲 + 跨段连贯性纵览)
.venv/bin/python .claude/skills/parse-book/scripts/parse_outline.py check --work-id 4
# ④ 公共卡三角色审核(番茄作家/起点作家/主编,M3 常设步骤;见 review-cards skill
# 公共卡三角色审核(番茄作家/起点作家/主编,M3 常设步骤;见 review-cards skill
.venv/bin/python .claude/skills/review-cards/scripts/review_cards.py review --batch <批次> --work-id 4
# 进度
.venv/bin/python .claude/skills/parse-book/scripts/parse_ingest.py progress
@ -50,7 +54,7 @@ disable-model-invocation: true
审核纪律:常设审核=M3已用 opus 金标准校准,偏差 0.45 达标fable/opus 只做起量前校准与起量后一次总审核(门禁与优化,不进流程循环)。
试拆实测病理档放量前须知M3 细纲按比例会写超(治=绝对字数上限+压缩重试type 一律标 craft 且串型混填(prompt 矫正无效,治=B4 环按金标准清单改造 prompt/schema重跑自噬治=判重名录排除本章+0 卡不软删source 偷懒(治=机械回填)
试拆实测病理档放量前须知M3 细纲按比例会写超(治=绝对字数上限+压缩重试type 一律标 craft 且串型混填(治=合同动态渲染+字段指纹改型+窗级聚类);重跑自噬(治=窗级幂等软删+0 卡不软删,章级判重名录已随 S3 废除source 偷懒(治=出处由实例机械生成。parse_ingest 的 patterns 命令是 S3 前章级出卡入口,仅留作回滚保险
## 步骤(自底向上,与创作期规划的自顶向下互为镜像)

View File

@ -1,9 +1,14 @@
#!/usr/bin/env python3
"""parse-book 配套确定性脚本:拆书产物校验入库 + 任务状态机B2)。
"""parse-book 配套确定性脚本:拆书产物校验入库 + 任务状态机B2/B4-S3)。
职责边界extractor(LLM) 只产结构化 JSON 文件不碰库本脚本做机械校验后写库
字段 key 合法性对库内字段合同五型归型出处必填**脱敏红线 15 连字检测**硬阻断
状态全在库example_parse_task断点续跑与幂等按章
状态全在库example_parse_task断点续跑与幂等按章/按窗
B4-S3 起管线两级scaffold章级细纲+实体+范式候选线索 cards窗级聚类母卡
本脚本仅在两个受控点调用 LLM/嵌入服务不产内容
新卡嵌入判重与检索基座共用 相似度 0.85 时的归并判定输入两卡 JSON输出 merge/keep
patterns 命令是 S3 前的章级出卡入口保留作回滚保险新管线不再使用
"""
import hashlib
import json
@ -15,11 +20,20 @@ import click
import psycopg
from psycopg.types.json import Jsonb
# 受控点依赖:嵌入走 embed skill 同一实现(同模型同维),归并判定走 llm skill 统一入口
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "embed" / "scripts"))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
from embed_drafts import _session, build_embed_text, embed_texts # noqa: E402
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, ACTOR = 1, "1"
PATTERN_TYPES = {"craft", "combat", "emotion", "scene_pattern", "trope"} # 拍板①:首轮只拆五型
NGRAM = 15 # 脱敏红线≥15 连续字与原文重合=违规parse-book skill
EMBED_MODEL = "Qwen/Qwen3-Embedding-8B"
SIM_MERGE = 0.85 # 嵌入判重:≥该值触发 M3 归并判定(阈值未校准,实战收集中)
SIM_MARK = 0.75 # [MARK, MERGE) 区间只标记不判定(校准带宽,审核环可见)
def norm_scaffold(data: dict) -> dict:
@ -29,15 +43,27 @@ def norm_scaffold(data: dict) -> dict:
for e in data.get("实体") or data.get("entities") or []:
ents.append({"": e.get("") or e.get("type"), "名称": e.get("名称") or e.get("name"),
"一句话摘要": e.get("一句话摘要") or e.get("brief")})
return {"细纲": data.get("细纲") or data.get("outline") or "", "实体": ents}
hints = []
for h in data.get("线索") or data.get("hints") or []:
hints.append({"": h.get("") or h.get("type"), "短名": h.get("短名") or h.get("name"),
"线索": h.get("线索") or h.get("clue"), "证据": h.get("证据") or h.get("evidence")})
return {"细纲": data.get("细纲") or data.get("outline") or "", "实体": ents, "线索": hints}
def norm_card(c: dict) -> dict:
"""范式卡键名归一ASCII→中文"""
"""范式卡键名归一ASCII→中文;窗级卡带实例数组 [{章,定位}]"""
src = c.get("出处") or c.get("source") or {}
inss = []
for ins in c.get("实例") or c.get("instances") or []:
try:
ch_no = int(ins.get("") or ins.get("ch"))
except (TypeError, ValueError):
continue # 章号非数字=编造,丢弃该实例
inss.append({"": ch_no, "定位": ins.get("定位") or ins.get("anchor") or ""})
return {"": c.get("") or c.get("type"), "名称": c.get("名称") or c.get("name"),
"一句话摘要": c.get("一句话摘要") or c.get("brief"),
"字段": c.get("字段") or c.get("fields") or {},
"实例": inss,
"出处": {"书名": src.get("书名") or src.get("book"),
"回目": src.get("回目") or src.get("chapter"),
"定位": src.get("定位") or src.get("anchor")}}
@ -66,6 +92,16 @@ def field_contract(conn, ttype):
return {r[0] for r in rows}
def load_entity_names(conn, work_id):
"""本书专名词典scaffold 实体名就是现成专名表(短专名泄漏 15 连字抓不到,
金标准抓到 4 海拉/千亿夸克级泄漏出处/实例定位字段豁免设计允许专名只进出处"""
names = {e.get("名称") or e.get("name") for (ents,) in conn.execute(
"""SELECT entities FROM example_parse_scaffold
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE""",
(TENANT, work_id)).fetchall() for e in ents}
return {n for n in names if n and len(n) >= 2}
def leak_check(card_texts, source_text):
"""脱敏机械检查:卡内任一文本值含与原文 ≥NGRAM 连续字重合 → 返回违规片段。"""
src = re.sub(r'\s', '', source_text)
@ -78,6 +114,68 @@ def leak_check(card_texts, source_text):
return None
def validate_card(c, contracts, entity_names, src_text):
"""卡片通用机械守卫(章级 patterns 与窗级 cards 共用——守卫同源,禁两处各写一套)。
返回 (拒卡原因列表, 改型审计|None)c 可能被就地改型"""
t = c.get("")
reasons, retyped = [], None
if t not in PATTERN_TYPES:
reasons.append(f"型不合法:{t}(首轮只拆五型)")
if not c.get("名称"):
reasons.append("缺名称")
fields = c.get("字段") or {}
if t in contracts and fields:
illegal = set(fields) - contracts[t]
if illegal:
# 字段指纹改型LLM 的 type 标注不可靠(实测 M3 系统性全标 craft
# 五型字段 key 互不重叠——字段集合全命中唯一他型合同时,以字段为准改型
fits = [t2 for t2 in PATTERN_TYPES
if t2 != t and fields and set(fields) <= contracts[t2]]
if len(fits) == 1:
retyped = f"{t}{fits[0]}(字段指纹改型)"
c[""] = t = fits[0]
else:
# 裁剪降级B4-S3 实测M3 往 trope 塞「读者收益」类他型增益字段屡教不改):
# 越界字段剥离(内容在本型无处安放,不该陪葬整卡),审计留痕;
# 裁掉必填字段的情况由下方 craft 双模板必填校验兜底拒卡
for k in illegal:
fields.pop(k)
c["裁剪字段"] = sorted(illegal)
# 守卫B4-S2craft 双模板条件必填/禁填 + 装置类型闭合枚举 + 禁复合标签
if t == "craft" and not reasons:
form = (fields.get("装置形态") or "").strip()
dtype = (fields.get("装置类型") or "").strip()
if form not in ("结构装置", "场景手法"):
reasons.append(f"装置形态非法:{form!r}(须为 结构装置/场景手法)")
elif form == "结构装置":
miss = [k for k in ("埋设手法", "回收点", "记忆维持", "间隔纪律") if not fields.get(k)]
if miss:
reasons.append(f"结构装置必填缺失:{miss}")
else: # 场景手法:禁填间隔纪律(同场景闭环没有间隔,硬填必出伪纪律)
miss = [k for k in ("复用节奏", "异质化要求", "单章上限") if not fields.get(k)]
if miss:
reasons.append(f"场景手法必填缺失:{miss}")
if fields.get("间隔纪律"):
reasons.append("场景手法禁填间隔纪律(伪纪律来源)")
if dtype:
if any(sep in dtype for sep in ("+", "", "/", "")):
reasons.append(f"装置类型禁复合标签:{dtype!r}(只填最主要的一个)")
elif dtype not in ("伏笔", "契诃夫之枪", "信息差", "重复意象", "倒计时", "身份错认", "非装置"):
reasons.append(f"装置类型不在闭合枚举:{dtype!r}")
# 原理句式占位符检测实测病M3 把模板「当X时做Y因为读者会Z」的占位符字面抄进卡
if re.search(r"[做当会][XYZ]", str(fields.get("原理") or "")):
reasons.append("原理含未展开的模板占位符X/Y/Z 须替换为具体内容)")
# 脱敏红线 + 专名词典(实例定位/出处豁免——设计允许专名只进定位)
texts = [x for x in (c.get("名称"), c.get("一句话摘要"), *fields.values()) if x]
leak = leak_check(texts, src_text)
if leak:
reasons.append(f"脱敏违规(≥{NGRAM}连字重合):「{leak}")
hit_names = [n for n in entity_names if any(n in str(x) for x in texts)]
if hit_names:
reasons.append(f"专名泄漏(本书实体名):{hit_names[:3]}")
return reasons, retyped
def set_task(conn, work_id, chapter_id, **cols):
"""推进任务状态机attempt 自增)。"""
sets = ", ".join(f"{k}=%s" for k in cols)
@ -124,7 +222,7 @@ def init_tasks(work_id, from_, to):
@click.option("--chapter-order", type=int, required=True)
@click.option("--file", "file_", type=click.Path(exists=True), required=True)
def scaffold(work_id, chapter_order, file_):
"""脚手架入库:{细纲, 实体:[{型,名称,一句话摘要,备注?}]}比例约束校验35%,超标拒绝)"""
"""章级入库:{细纲, 实体:[{型,名称,一句话摘要}], 线索:[{型,短名,线索,证据}]};比例约束校验"""
data = norm_scaffold(json.loads(pathlib.Path(file_).read_text()))
with psycopg.connect(DSN) as conn:
ch_id, src = chapter_of(conn, work_id, chapter_order)
@ -141,17 +239,25 @@ def scaffold(work_id, chapter_order, file_):
for e in ents:
if not e.get("名称") or not e.get(""):
raise click.ClickException(f"实体缺 名称/型: {e}")
# 范式候选线索B4-S3候选层宽容处置——坏行丢弃计数不整批拒
hints, n_bad = [], 0
for h in data.get("线索") or []:
if h.get("短名") and h.get("线索") and (h.get("") in PATTERN_TYPES or h.get("") == "?"):
hints.append(h)
else:
n_bad += 1
conn.execute(
"""INSERT INTO example_parse_scaffold (work_id, chapter_id, outline_text, entities,
creator, updater, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s)
pattern_hints, creator, updater, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (tenant_id, chapter_id)
DO UPDATE SET outline_text=EXCLUDED.outline_text, entities=EXCLUDED.entities,
deleted=FALSE, updater=EXCLUDED.updater""",
(work_id, ch_id, outline, Jsonb(ents), ACTOR, ACTOR, TENANT))
pattern_hints=EXCLUDED.pattern_hints, deleted=FALSE, updater=EXCLUDED.updater""",
(work_id, ch_id, outline, Jsonb(ents), Jsonb(hints), ACTOR, ACTOR, TENANT))
set_task(conn, work_id, ch_id, scaffold_status="done", error_message=None)
conn.commit()
click.echo(f"scaffold✓ work={work_id} ch#{chapter_order}: 细纲{len(outline)}字({ratio:.1%}) 实体{len(ents)}")
click.echo(f"scaffold✓ work={work_id} ch#{chapter_order}: 细纲{len(outline)}字({ratio:.1%}) "
f"实体{len(ents)} 线索{len(hints)}" + (f"(丢弃坏行{n_bad})" if n_bad else ""))
@cli.command()
@ -159,8 +265,7 @@ def scaffold(work_id, chapter_order, file_):
@click.option("--chapter-order", type=int, required=True)
@click.option("--file", "file_", type=click.Path(exists=True), required=True)
def patterns(work_id, chapter_order, file_):
"""范式卡入库:[{型∈五型, 名称, 一句话摘要, 字段{…}, 出处{书名,回目,定位}}] → draft(pending)。
机械硬阻断归型合法字段 key 合法库内合同出处必填15 连字脱敏检测"""
"""【旧管线·回滚保险】章级范式卡入库B4-S3 后由窗级 cards 替代,新管线勿用。"""
raw = json.loads(pathlib.Path(file_).read_text())
if isinstance(raw, dict): # 兼容 {cards:[…]}/{卡:[…]} 包装
raw = raw.get("cards") or raw.get("") or []
@ -171,16 +276,9 @@ def patterns(work_id, chapter_order, file_):
ch_id, src = chapter_of(conn, work_id, chapter_order)
contracts = {t: field_contract(conn, t) for t in PATTERN_TYPES}
book = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
# 专名词典B4-S2本书 scaffold 实体名就是现成专名表——短专名泄漏 15 连字抓不到
#(金标准抓到 4 张「海拉/千亿夸克」级泄漏),出处字段豁免(设计允许专名只进出处)
entity_names = {e.get("名称") or e.get("name") for (ents,) in conn.execute(
"""SELECT entities FROM example_parse_scaffold
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE""",
(TENANT, work_id)).fetchall() for e in ents}
entity_names = {n for n in entity_names if n and len(n) >= 2}
entity_names = load_entity_names(conn, work_id)
# 幂等:重跑本章 = 软删本章旧 draft。
# 但新轮 0 卡时保留旧卡——LLM 判卡有随机性重跑「0 卡」不应清掉上轮已验证的产出
#(实测组合病:判重名录+随机性+软删 → 每次重跑都损耗卡)
if cards:
conn.execute(
"""UPDATE muse_knowledge_draft SET deleted=TRUE, updater=%s
@ -189,67 +287,20 @@ def patterns(work_id, chapter_order, file_):
(ACTOR, TENANT, work_id, str(chapter_order)))
ok, rejected = 0, []
for i, c in enumerate(cards):
t = c.get("")
reasons = []
if t not in PATTERN_TYPES:
reasons.append(f"型不合法:{t}(首轮只拆五型)")
if not c.get("名称"):
reasons.append("缺名称")
reasons, retyped = validate_card(c, contracts, entity_names, src)
src_ref = c.get("出处") or {}
if not (src_ref.get("书名") and src_ref.get("回目")):
reasons.append("出处不完整(需书名+回目)")
fields = c.get("字段") or {}
retyped = None
if t in contracts and fields:
illegal = set(fields) - contracts[t]
if illegal:
# 字段指纹改型LLM 的 type 标注不可靠(实测 M3 系统性全标 craft
# 五型字段 key 互不重叠——字段集合全命中唯一他型合同时,以字段为准改型
fits = [t2 for t2 in PATTERN_TYPES
if t2 != t and fields and set(fields) <= contracts[t2]]
if len(fits) == 1:
retyped = f"{t}{fits[0]}(字段指纹改型)"
c[""] = t = fits[0]
else:
reasons.append(f"字段越合同:{sorted(illegal)}")
# 守卫B4-S2craft 双模板条件必填/禁填 + 装置类型闭合枚举 + 禁复合标签
if t == "craft" and not reasons:
form = (fields.get("装置形态") or "").strip()
dtype = (fields.get("装置类型") or "").strip()
if form not in ("结构装置", "场景手法"):
reasons.append(f"装置形态非法:{form!r}(须为 结构装置/场景手法)")
elif form == "结构装置":
miss = [k for k in ("埋设手法", "回收点", "记忆维持", "间隔纪律") if not fields.get(k)]
if miss:
reasons.append(f"结构装置必填缺失:{miss}")
else: # 场景手法:禁填间隔纪律(同场景闭环没有间隔,硬填必出伪纪律)
miss = [k for k in ("复用节奏", "异质化要求", "单章上限") if not fields.get(k)]
if miss:
reasons.append(f"场景手法必填缺失:{miss}")
if fields.get("间隔纪律"):
reasons.append("场景手法禁填间隔纪律(伪纪律来源)")
if dtype and t == "craft":
if any(sep in dtype for sep in ("+", "", "/", "")):
reasons.append(f"装置类型禁复合标签:{dtype!r}(只填最主要的一个)")
elif dtype not in ("伏笔", "契诃夫之枪", "信息差", "重复意象", "倒计时", "身份错认", "非装置"):
reasons.append(f"装置类型不在闭合枚举:{dtype!r}")
texts = [c.get("名称"), c.get("一句话摘要"), *fields.values()]
leak = leak_check([x for x in texts if x], src)
if leak:
reasons.append(f"脱敏违规(≥{NGRAM}连字重合):「{leak}")
# 专名词典拒卡:卡文本(不含出处)命中本书实体名 → 拒
hit_names = [n for n in entity_names
if any(n in str(x) for x in texts if x)]
if hit_names:
reasons.append(f"专名泄漏(本书实体名):{hit_names[:3]}")
if reasons:
rejected.append({"": c.get("名称") or f"#{i}", "原因": reasons})
continue
payload = {"": t, "名称": c["名称"], "一句话摘要": c.get("一句话摘要", ""),
"字段": fields, "出处": src_ref, "目标库": "公共范式库",
payload = {"": c[""], "名称": c["名称"], "一句话摘要": c.get("一句话摘要", ""),
"字段": c.get("字段") or {}, "出处": src_ref, "目标库": "公共范式库",
"章序": chapter_order, "来源": f"拆书@{book}", "状态": "草稿"}
if retyped:
payload["改型"] = retyped # 审计:机械改型可追溯
if c.get("裁剪字段"):
payload["裁剪字段"] = c["裁剪字段"] # 审计:越合同字段被剥离入库
cid = f"parse-{work_id}-{chapter_order}-{i}-" + hashlib.sha256(
json.dumps(payload, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:8]
conn.execute(
@ -259,7 +310,7 @@ def patterns(work_id, chapter_order, file_):
ON CONFLICT (tenant_id, command_id) WHERE command_id IS NOT NULL DO NOTHING""",
(Jsonb(payload), work_id, cid, ACTOR, ACTOR, TENANT))
ok += 1
set_task(conn, work_id, ch_id, pattern_status="done" if not rejected else "done",
set_task(conn, work_id, ch_id, pattern_status="done",
error_message=None if not rejected else f"拒卡{len(rejected)}: " + json.dumps(rejected, ensure_ascii=False)[:900])
conn.commit()
click.echo(f"patterns✓ work={work_id} ch#{chapter_order}: 入库{ok}{len(rejected)}")
@ -267,6 +318,165 @@ def patterns(work_id, chapter_order, file_):
click.echo(f" [拒] {r['']}: {'; '.join(r['原因'])}")
def merge_judge(new_payload, old_payload, model="MiniMax-M3"):
"""受控 LLM 点②:嵌入初筛 ≥SIM_MERGE 后的归并终判(输入两卡 JSON输出 merge/keep
双保险设计embedding 只做初筛是否同一手法由 LLM 单靠阈值必产生错误合并"""
prompt = ("两张「公共范式卡」由不同窗/不同书各自独立归纳JSON 附后)。判断它们是否为同一写作手法:\n"
"- 运作机制相同(哪怕载体/题材/叫法不同)→ merge\n"
"- 机制不同、或适用场景本质不同 → keep。拿不准时选 keep错误合并比重复卡更伤\n"
'输出规则(只输出一个 JSON 对象):{"verdict": "merge""keep", "reason": "一句话依据"}\n\n'
f"【卡A已入库\n{json.dumps(old_payload, ensure_ascii=False)}\n\n"
f"【卡B新卡\n{json.dumps(new_payload, ensure_ascii=False)}")
try:
content, _ = chat(prompt, model=model)
data = extract_json(content)
return data.get("verdict"), data.get("reason", "")
except Exception as e: # 判定失败=keep不阻断入库宁重复不误并
return "keep", f"归并判定调用失败({type(e).__name__}),默认保留"
@cli.command()
@click.option("--work-id", type=int, required=True)
@click.option("--from-order", "from_order", type=int, required=True, help="窗起始章(对应大纲窗行)")
@click.option("--file", "file_", type=click.Path(exists=True), required=True)
@click.option("--model", default="MiniMax-M3", show_default=True, help="归并判定用模型")
def cards(work_id, from_order, file_, model):
"""窗级母卡入库B4-S3{cards:[{型,名称,摘要,字段,实例[{章,定位}]}]}。
守卫=章级全部 + 窗级新增实例域校验 / 间隔章数机械计算伪精确灭绝/ 嵌入判重归并判定"""
raw = json.loads(pathlib.Path(file_).read_text())
if isinstance(raw, dict):
raw = raw.get("cards") or raw.get("") or []
cards_in = [norm_card(c) for c in raw]
with psycopg.connect(DSN) as conn:
win = conn.execute(
"""SELECT to_order FROM example_parse_outline
WHERE tenant_id=%s AND work_id=%s AND from_order=%s AND deleted=FALSE""",
(TENANT, work_id, from_order)).fetchone()
if not win:
raise click.ClickException(f"窗不存在: work={work_id} from={from_order}——先跑 parse_outline window")
to_order = win[0]
book = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
# 窗内正文拼接15 连字红线对照源:卡可能引到窗内任何一章)
src = "".join(t for (t,) in conn.execute(
"""SELECT 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 BETWEEN %s AND %s
AND c.deleted=FALSE ORDER BY c.order_no""",
(TENANT, work_id, from_order, to_order)).fetchall())
contracts = {t: field_contract(conn, t) for t in PATTERN_TYPES}
entity_names = load_entity_names(conn, work_id)
# 幂等:重出本窗 = 软删本窗旧卡0 卡保留旧卡,纪律同章级)
if cards_in:
conn.execute(
"""UPDATE muse_knowledge_draft SET deleted=TRUE, updater=%s
WHERE tenant_id=%s AND source_type='parse_book' AND source_id=%s
AND draft_payload->>'窗起'=%s AND deleted=FALSE""",
(ACTOR, TENANT, work_id, str(from_order)))
sess = _session() # 嵌入通道(判重+新卡即时嵌入共用)
ok, merged, rejected = 0, [], []
for i, c in enumerate(cards_in):
reasons, retyped = validate_card(c, contracts, entity_names, src)
# 实例域校验:章号必须落在本窗内——越窗章号=编造(跨窗同手法靠嵌入判重归并)
inss = [ins for ins in c.get("实例") or [] if from_order <= ins[""] <= to_order]
n_drop = len(c.get("实例") or []) - len(inss)
if not inss:
reasons.append("实例为空或章号全部越窗(窗级卡必须带窗内实例)")
fields = c.get("字段") or {}
# 间隔章数机械计算伪精确灭绝结构装置实例≥2 → 章号差为权威值追加;
# 单实例结构装置=本窗证据不全(可能收在后续窗),标「跨窗待证」入库不拒
cross_pend = False
if not reasons and c.get("") == "craft" and fields.get("装置形态") == "结构装置":
ch_nos = sorted({ins[""] for ins in inss})
if len(ch_nos) >= 2:
fields["间隔纪律"] = ((fields.get("间隔纪律") or "").strip()
+ f"(实测:#{ch_nos[0]}埋→#{ch_nos[-1]}收,隔{ch_nos[-1] - ch_nos[0]}章)").strip()
else:
cross_pend = True
if reasons:
rejected.append({"": c.get("名称") or f"#{i}", "原因": reasons})
continue
payload = {"": c[""], "名称": c["名称"], "一句话摘要": c.get("一句话摘要", ""),
"字段": fields, "实例": inss,
"出处": {"书名": book, "回目": f"{inss[0]['']}", "定位": inss[0]["定位"]},
"目标库": "公共范式库", "窗起": from_order, "来源": f"拆书@{book}", "状态": "草稿"}
if retyped:
payload["改型"] = retyped
if c.get("裁剪字段"):
payload["裁剪字段"] = c["裁剪字段"] # 审计:越合同字段被剥离入库
if cross_pend:
payload["跨窗待证"] = True # 审核环可见:结构装置只有单端实例
if n_drop:
payload["越窗实例弃"] = n_drop # 审计M3 报了窗外章号
# ── 受控点①②:嵌入判重(失败不阻断——判重是增强不是红线,卡不能因通道故障丢)──
embed_text = build_embed_text(payload)
vec = None
try:
vecs, bad = embed_texts(sess, [embed_text])
vec = None if bad else vecs[0]
except Exception:
pass
if vec is not None:
top = conn.execute(
"""SELECT d.id, d.draft_payload, 1-(e.embedding<=>%s::vector) AS score
FROM example_knowledge_embedding e
JOIN muse_knowledge_draft d ON d.id=e.draft_id
WHERE e.tenant_id=%s AND e.deleted=FALSE AND d.deleted=FALSE
AND d.source_type='parse_book'
ORDER BY score DESC LIMIT 1""", (json.dumps(vec), TENANT)).fetchone()
if top and top[2] >= SIM_MERGE:
old_id, old_payload, score = top
verdict, why = merge_judge(payload, old_payload, model)
if verdict == "merge":
# 归并=旧卡追加实例(跨书时带书名)+完整审计(含被归并卡全文,可逆)
add = [dict(ins, =book) for ins in inss]
old_payload["实例"] = (old_payload.get("实例") or []) + add
old_payload.setdefault("归并审计", []).append(
{"相似度": round(float(score), 4), "判定依据": why, "被归并卡": payload})
conn.execute(
"UPDATE muse_knowledge_draft SET draft_payload=%s, updater=%s WHERE id=%s",
(Jsonb(old_payload), ACTOR, old_id))
merged.append(f"{payload['名称']}》并入已有卡#{old_id}{old_payload.get('名称')}》({score:.2f}) {why}")
continue
payload["判重"] = {"相似卡": old_payload.get("名称"), "相似度": round(float(score), 4),
"判定": "keep", "依据": why}
elif top and top[2] >= SIM_MARK:
payload["判重"] = {"相似卡": top[1].get("名称"), "相似度": round(float(top[2]), 4),
"判定": "未达判定线"}
cid = f"parse-{work_id}-w{from_order}-{i}-" + hashlib.sha256(
json.dumps(payload, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:8]
row = conn.execute(
"""INSERT INTO muse_knowledge_draft (work_id, draft_type, draft_payload, status,
source_type, source_id, command_id, creator, updater, tenant_id)
VALUES (0,'entity',%s,'pending','parse_book',%s,%s,%s,%s,%s)
ON CONFLICT (tenant_id, command_id) WHERE command_id IS NOT NULL DO NOTHING
RETURNING id""",
(Jsonb(payload), work_id, cid, ACTOR, ACTOR, TENANT)).fetchone()
# 新卡即时嵌入(下一窗/下一书判重立即可见;失败留给 embed skill 批量补)
if row and vec is not None:
h = hashlib.sha256(f"{embed_text}|{EMBED_MODEL}".encode()).hexdigest()
conn.execute(
"""INSERT INTO example_knowledge_embedding
(draft_id, content_hash, embed_text, model, dimensions, embedding,
creator, updater, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (tenant_id, content_hash, model) DO NOTHING""",
(row[0], h, embed_text, EMBED_MODEL, 1024, json.dumps(vec), ACTOR, ACTOR, TENANT))
ok += 1
# 窗内章 pattern_status 批量置 done窗级出卡完成=这些章的范式阶段完成)
conn.execute(
"""UPDATE example_parse_task SET pattern_status='done', updater=%s
WHERE tenant_id=%s AND work_id=%s AND chapter_id IN (
SELECT id FROM muse_content_chapter
WHERE tenant_id=%s AND work_id=%s AND order_no BETWEEN %s AND %s AND deleted=FALSE)""",
(ACTOR, TENANT, work_id, TENANT, work_id, from_order, to_order))
conn.commit()
click.echo(f"cards✓ work={work_id} win#{from_order}{to_order}: 入库{ok} 归并{len(merged)}{len(rejected)}")
for m in merged:
click.echo(f" [归并] {m}")
for r in rejected:
click.echo(f" [拒] {r['']}: {'; '.join(r['原因'])}")
@cli.command()
@click.option("--work-id", type=int)
def progress(work_id):

View File

@ -1,12 +1,18 @@
#!/usr/bin/env python3
"""parse-book skillM3 直调拆书执行器——逐章两 pass脚手架→范式入库走 parse_ingest 守卫
"""parse-book skillM3 直调拆书执行器B4-S3 重构:出卡权上移窗级)
创始人拍板2026-07-13拆书内容生产 LLM=New-API MiniMax-M3 llm skill
不再派 opus/haiku 子代理本脚本把 workflow 版的提示词资产身份段/实体判据/五型合同
固化为直调版脚本自己取数正文/前文实体/已积累卡名录内联进 prompt M3
容错解析 JSON parse_ingest 机械校验入库比例硬顶/归型/字段越合同/15连字泄漏全在那边
不再派 opus/haiku 子代理B4 fable 审查裁决2026-07-13章级逐章出卡有三同根病
同功 family 撞车/单章证不成跨章公式/间隔数字伪精确治法=章级只产范式候选线索
出卡在窗级聚类归并复用大纲窗切分同一手法多章多次出现归并为一张母卡+实例章号
间隔数字由实例章号差机械计算
断点续跑 example_parse_task 状态机逐章跳过已 done pass失败记录不阻断后续章
管线三步顺序依赖
chapters 逐章一次 M3细纲+实体+范式候选线索 example_parse_scaffold正文只过一遍
parse_outline window窗级大纲聚合 example_parse_outline=出卡窗的 SoT
cards 逐窗一次 M3窗内线索+细纲+阶段大纲 聚类出母卡 parse_ingest cards 守卫入库
断点续跑chapters example_parse_task.scaffold_status 跳过cards 按窗内是否已有活卡跳过
"""
import json
import pathlib
@ -27,7 +33,7 @@ TENANT = 1
HERE = pathlib.Path(__file__).resolve().parent
TMP = pathlib.Path("/tmp/muse-parse")
# ── 提示词资产(自 workflow 试拆版移植,与 A3 库内合同同源) ──────────────
# ── 提示词资产(合同一律 load_contracts 动态渲染禁手写——CONTRACTS 漂移冤案教训) ──
IDENTITY = """你是知识抽取员(extractor),分析槽位的默认绑定件。产出全部是草稿。
元数据纪律schema 有什么字段你就抽什么schema 没有的不抽字段合同就是抽取 checklist不自造结构归型走各型判据归不进任何型的候选=枚举缺口如实报不硬塞每字段要有正文证据置信度低标?
@ -44,6 +50,9 @@ PATTERN_TYPES = ("craft", "combat", "emotion", "scene_pattern", "trope")
# 基础公共字段yudao 惯例列,出卡时由 ingest/payload 承载,不进抽取字段表)
BASE_KEYS = {"名称", "别名", "一句话摘要", "标签", "来源", "状态", "例证出处"}
# 抽象指代白名单金标准结论M3 自造代号如"A角色"破坏可读性;专名只允许进实例定位)
PLACEHOLDERS = "主角/对手/强敌/导师/盟友/队友/配角/反派/长辈/宝物/装备/机关/势力/秘密/危机"
def load_contracts(conn):
"""从库内 schema 版本快照动态渲染五型合同——prompt 与 ingest 守卫同源。
@ -54,11 +63,12 @@ def load_contracts(conn):
"""
contracts = {}
for t in PATTERN_TYPES:
# 走 active_version_id激活版本=治理权威),与 ingest 守卫同版本语义——
# 「最新版本行」在铺了新版未激活时会与守卫劈叉
snap = conn.execute(
"""SELECT v.field_contract_snapshot FROM muse_meta_schema_version v
JOIN muse_meta_schema s ON s.id=v.schema_id
WHERE s.tenant_id=%s AND s.schema_key=%s AND v.deleted=FALSE
ORDER BY v.id DESC LIMIT 1""", (TENANT, t)).fetchone()[0]
JOIN muse_meta_schema s ON s.active_version_id=v.id
WHERE s.tenant_id=%s AND s.schema_key=%s""", (TENANT, t)).fetchone()[0]
fields = [f for f in snap.get("特有字段", []) if f.get("key") not in BASE_KEYS]
contracts[t] = {"中文名": snap.get("中文名", t), "判据": snap.get("判据", ""),
"字段": fields}
@ -80,47 +90,55 @@ def scaffold_prompt(title, ch, ch_title, text, prev_entities):
# 绝对字数上限比抽象比例对 LLM 更可执行(首轮实测 M3 按比例会写到 9.8%19%
wc = len(text.replace("\n", "").replace(" ", ""))
cap = max(60, int(wc * 0.05))
return f"""【功能指令(parse-book 逐章内环)】
对参考书{title} {ch} {ch_title}正文附后 {wc}
return f"""【功能指令(parse-book 章级 pass细纲+实体+范式候选线索)】
对参考书{title} {ch} {ch_title}正文附后 {wc} 做三件事
1) 逆推本章细纲章目标/关键事件/出场角色/伏笔动作(··)/章末钩子**细纲全文不得超过 {cap} **章正文的 5%超标会被校验脚本机械退回细纲是结构骨架不是缩写复述用短语与分号不写完整句子
2) 抽实体增量脚手架级索引{ENTITY_CRITERIA}
判重下方是前文已收录实体不重报除非本章给出新身份则在一句话摘要里并入
3) 报范式候选线索**只报线索不出卡**出卡由窗级聚类另做本章表现突出疑似可跨书复用的写法五型候选craft=单点叙事装置(删去它场景仍成立)combat=整场武力对抗的打法emotion=整场情绪戏的推进scene_pattern=拍卖/谈判/审讯等场景公式trope=跨章复用的情节公式
每条线索给四项type五型之一拿不准填"?"name28 字短名作者口头会说的话禁书内专名禁修饰堆叠clue一句话这个写法怎么运作{PLACEHOLDERS}等抽象指代evidence一句话本章哪里这样写为何突出可用专名
05 /平庸章 0 条正常门槛比出卡低拿不准的报上来窗级聚类会过滤
前文实体索引
{ents}
输出规则只输出一个 JSON 对象禁止任何其他文字
{{"outline": "细纲文本", "entities": [{{"type": "六型之一", "name": "名称", "brief": "一句话摘要"}}]}}
{{"outline": "细纲文本", "entities": [{{"type": "六型之一", "name": "名称", "brief": "一句话摘要"}}], "hints": [{{"type": "五型之一或?", "name": "短名", "clue": "运作机制一句话", "evidence": "本章证据一句话"}}]}}
本章正文
{text}"""
def patterns_prompt(title, ch, ch_title, text, outline, existing_cards, contracts):
cards = json.dumps(existing_cards, ensure_ascii=False) if existing_cards else "(尚无)"
return f"""【功能指令(parse-book 范式拆取+脱敏红线)】
对参考书{title} {ch} {ch_title}正文附后在脚手架细纲之上拆范式五型
def window_cards_prompt(title, a, b, stage_outline, ch_outlines, ch_hints, contracts):
"""窗级聚类出卡 promptB4-S3 核心资产;金标准七条纪律全落于此)。"""
return f"""【功能指令(parse-book 窗级聚类出卡+脱敏红线)】
{title} {a}{b} 章已完成章级分析下面给你三层材料阶段大纲逐章细纲逐章范式候选线索
任务把候选线索**聚类归并**公共范式卡同一手法在多章的多次出现归并为一张母卡带全部实例章号线索不足处可从细纲的伏笔账··补证跨章装置
{render_contracts(contracts)}
纪律
- 宁缺毋滥一章 03 张为常态只收本章表现突出可跨书复用的写法平庸章可出 0
- 归型走判据五型之外一律不出卡**先按判据定 type再只用该 type 字段表里的中文 key fields混用他型字段=机械拒卡** trope 公式步骤不得出现在 craft 卡里
- **五型都是候选不要把一切归成 craft**整场武力对抗combat整场情绪戏emotion拍卖/谈判/审讯等场景公式scene_pattern跨章复用的情节公式tropecraft 只留给单点装置删去它场景仍成立type 值必须与 fields 所用字段表同型
- **脱敏红线**只写抽象结构与手法归纳严禁抄录原文15 连续字与原文重合=校验脚本机械拒卡卡名与字段值用主角/强敌/导师等抽象指代**不得出现书内专名**人名/地名/机甲名/组织名专名只允许出现在 source.anchor
- fields 按该型字段合同的中文 key 没证据的 key 省略不编造
- 判重下方已积累卡名录不与之重复立同义卡
本章细纲(脚手架 pass 产出)
{outline}
出卡纪律
- 聚类优先先把线索按同一运作机制分组不同章的同类线索=同一卡的多个实例再逐组判断是否值得出卡一窗 06 张为常态宁缺毋滥聚不成组又不够突出的线索直接丢弃
- 跨章证据结构装置伏笔类尽量给出埋与收两端的实例章号只有单章实例的会被标跨窗待证降低可信度
- 命名卡名=作者口头会说的话28 先抑后扬借刀杀人XX式YY化ZZ修饰语堆叠禁书内专名禁自造黑话
- 摘要一句话**通用手法陈述**抹掉本书信息后换任何题材仍成立禁复述本书剧情禁与实例定位雷同
- 抽象指代白名单{PLACEHOLDERS}白名单外的自造代号"A角色""X道具"不要用
- 可迁移性是卡的本体说不清作者这样做读者会怎样的因果=不是范式整张卡不出带此类字段如原理的型按其字段说明的句式写**句式里的 X/Y/Z 是占位符必须替换成本卡的具体内容**字面保留做Y读者会Z=机械拒卡
- 实例只报章号+一句话定位**严禁在任何字段里自报间隔章数**间隔由校验脚本按实例章号差机械计算自己编数字=伪精确
- fields 只允许使用该卡 type 在上方合同表里列出的中文 key没证据的 key 省略不编造**先按判据定 type再对照该型的表格填字段**把别的型才有的 key哪怕内容再有价值塞进本型=机械拒卡上一批实测拒卡主因就是给非 craft 卡塞了 craft 才有的字段
- 脱敏红线只写抽象结构与手法归纳严禁抄录原文15 连续字与原文重合=机械拒卡书内专名只允许出现在实例定位(anchor)
已积累范式卡名录
{cards}
阶段大纲
{stage_outline}
逐章细纲
{ch_outlines}
逐章候选线索
{ch_hints}
输出规则只输出一个 JSON 对象禁止任何其他文字没有值得立的卡时 cards 给空数组
{{"cards": [{{"type": "craft|combat|emotion|scene_pattern|trope", "name": "抽象手法名(无书内专名)", "brief": "一句话摘要", "fields": {{"中文合同key": ""}}, "source": {{"book": "{title}", "chapter": "{ch}{ch_title}", "anchor": "一句话情节定位(抽象指代)"}}}}]}}
本章正文
{text}"""
{{"cards": [{{"type": "craft|combat|emotion|scene_pattern|trope", "name": "短名", "brief": "一句话通用摘要", "fields": {{"中文合同key": ""}}, "instances": [{{"ch": 章号数字, "anchor": "一句话情节定位(可用专名)"}}]}}]}}"""
def m3_json(prompt, model, need_keys, system=IDENTITY):
@ -141,35 +159,40 @@ def m3_json(prompt, model, need_keys, system=IDENTITY):
raise RuntimeError(f"JSON 形状重试仍失败: {err}")
def ingest(kind, work_id, ch, payload):
def ingest(kind, work_id, key, payload, keyflag="--chapter-order"):
"""写临时文件 → parse_ingest 机械校验入库;返回 (是否成功, 输出文本)。"""
TMP.mkdir(parents=True, exist_ok=True)
f = TMP / f"{work_id}-{ch}-{kind}.json"
f = TMP / f"{work_id}-{key}-{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)],
"--work-id", str(work_id), keyflag, str(key), "--file", str(f)],
capture_output=True, text=True)
return r.returncode == 0, (r.stdout + r.stderr).strip()
@click.command()
@click.group()
def cli():
"""M3 直调拆书(章级线索 → 窗级出卡)"""
@cli.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):
def chapters(work_id, from_, to, model):
"""章级 pass逐章一次 M3细纲+实体+范式候选线索)。正文只过这一遍。"""
# 任务行就绪(幂等)
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]
contracts = load_contracts(conn) # prompt 与 ingest 守卫同源(库内 schema 快照)
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
"""SELECT c.id, c.title, b.content_text, t.scaffold_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
@ -178,77 +201,118 @@ def main(work_id, from_, to, model):
if not row:
click.echo(f"#{ch} 章或任务行不存在,跳过")
continue
ch_id, ch_title, text, s_st, p_st = row
ch_id, ch_title, text, s_st = row
if s_st == "done":
click.echo(f"#{ch} 脚手架已完成,跳过")
continue
# 前文实体索引(紧凑:型/名称/摘要),判重用
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"))
try:
p1 = scaffold_prompt(title, ch, ch_title, text, prev)
data, usage = m3_json(p1, model, ("outline", "entities", "hints"))
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", "hints"))
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}")
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:,}")
@cli.command()
@click.option("--work-id", type=int, required=True)
@click.option("--from-order", "from_order", type=int, help="只出该窗(窗起始章);不给则全部窗")
@click.option("--model", default="MiniMax-M3", show_default=True)
@click.option("--redo", is_flag=True, help="窗内已有活卡也重出(默认跳过=断点续跑)")
def cards(work_id, from_order, model, redo):
"""窗级出卡:逐窗一次 M3 聚类归并(窗=example_parse_outline 行,先跑 parse_outline window"""
with psycopg.connect(DSN) as conn:
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
contracts = load_contracts(conn) # prompt 与 ingest 守卫同源(库内 schema 快照)
sql = """SELECT from_order, to_order, outline_text, window_no FROM example_parse_outline
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE"""
args = [TENANT, work_id]
if from_order:
sql += " AND from_order=%s"
args.append(from_order)
wins = conn.execute(sql + " ORDER BY from_order", args).fetchall()
if not wins:
click.echo("无大纲窗行——先跑 parse_outline.py window窗是出卡的切分依据")
return
total_in = total_out = 0
for a, b, stage_ol, wno in wins:
with psycopg.connect(DSN) as conn:
# 断点续跑:窗内已有活卡(本窗出的)则跳过
if not redo and conn.execute(
"""SELECT 1 FROM muse_knowledge_draft WHERE tenant_id=%s AND source_type='parse_book'
AND source_id=%s AND draft_payload->>'窗起'=%s AND deleted=FALSE LIMIT 1""",
(TENANT, work_id, str(a))).fetchone():
click.echo(f"win#{a}{b} 已有活卡,跳过(--redo 强制重出)")
continue
# pass2 范式done 跳过;卡被守卫拒属正常,不算失败)
if p_st != "done":
try:
rows = conn.execute(
"""SELECT c.order_no, c.title, s.outline_text, s.pattern_hints
FROM muse_content_chapter c
JOIN example_parse_scaffold s ON s.chapter_id=c.id AND s.deleted=FALSE
WHERE c.tenant_id=%s AND c.work_id=%s AND c.order_no BETWEEN %s AND %s
AND c.deleted=FALSE ORDER BY c.order_no""",
(TENANT, work_id, a, b)).fetchall()
if len(rows) < (b - a + 1):
have = {r[0] for r in rows}
click.echo(f"win#{a}{b} 缺章级脚手架 {[n for n in range(a, b + 1) if n not in have][:10]},先补 chapters")
continue
ch_outlines = "\n".join(f"{no}章《{ct}》:{o}" for no, ct, o, _ in rows)
hints = [{"": no, "线索": h} for no, _, _, hs in rows for h in (hs or [])]
n_hint = len(hints)
ch_hints = (json.dumps(hints, ensure_ascii=False, indent=1) if hints
else "(各章均未报线索——只依据细纲伏笔账判断,出不了卡就给空数组)")
try:
p = window_cards_prompt(title, a, b, stage_ol, ch_outlines, ch_hints, contracts)
data, usage = m3_json(p, model, ("cards",))
total_in += usage.get("prompt_tokens", 0)
total_out += usage.get("completion_tokens", 0)
# 0 卡可疑重试实测病M3 输出方差大,同材料一次 10 卡一次秒回空数组):
# 线索 ≥3 条却 0 卡=大概率分叉到偷懒路径,重试一次;仍 0 卡则接受(真无卡)
if not (data.get("cards") or []) and n_hint >= 3:
data, usage = m3_json(
patterns_prompt(title, ch, ch_title, text, outline, cards, contracts),
p + "\n\n【重试】上次输出了空数组,但本窗有 %d 条候选线索——请认真逐条聚类评估后再输出;"
"确实全部不够格才允许空数组。" % n_hint, model, ("cards",))
total_in += usage.get("prompt_tokens", 0)
total_out += usage.get("completion_tokens", 0)
_, out = ingest("cards", work_id, a, data, keyflag="--from-order")
# 拒卡带原因重试一次:拒卡原因机械明确(占位符/越合同),带回 prompt 大概率可救;
# 放量时每窗调用贵,救回率优先于省一次调用
rej_lines = [ln for ln in out.splitlines() if ln.strip().startswith("[拒]")]
if rej_lines:
data, usage = m3_json(
p + "\n\n【重试】上次输出有以下卡被机械校验拒收,逐条按原因修复后**重新输出全部卡**"
"(被拒的修好,没被拒的原样保留):\n" + "\n".join(rej_lines),
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:,}")
_, out = ingest("cards", work_id, a, data, keyflag="--from-order")
click.echo(f"win#{a}{b}(线索 {n_hint} 条)\n {out}")
except RuntimeError as e:
click.echo(f"win#{a}{b} 窗级 M3 失败: {e}")
click.echo(f"{title}》窗级出卡完成token in={total_in:,} out={total_out:,}")
if __name__ == "__main__":
try:
main()
cli()
except (psycopg.Error, RuntimeError) as e:
click.echo(f"[错误] {type(e).__name__}: {e}", err=True)
sys.exit(1)

View File

@ -77,16 +77,22 @@ def cli():
@click.option("--window", type=int, default=80000, show_default=True, help="每窗目标字数510万区间")
@click.option("--model", default="MiniMax-M3", show_default=True)
@click.option("--from", "from_", type=int, default=1, help="起始章(断点续跑给上次结束章+1")
def window(work_id, window, model, from_):
@click.option("--to", "to_", type=int, help="结束章(限定已拆域,防止切窗吞进没有细纲的章;默认全书)")
def window(work_id, window, model, from_, to_):
"""按字数切窗聚合大纲(窗内章须已有细纲;已有窗大纲的窗跳过=断点续跑)。"""
with psycopg.connect(DSN) as conn:
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s", (work_id,)).fetchone()[0]
rows = [r for r in load_chapters(conn, work_id) if r[0] >= from_]
done = {r[0] for r in conn.execute(
"SELECT window_no FROM example_parse_outline WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchall()}
# 章序切窗(与清洗窗同思路:章对齐)
win_no, cur, cur_len = max(done, default=0), [], 0
all_rows = load_chapters(conn, work_id)
last_order = all_rows[-1][0] if all_rows else 0 # 全书末章(书末残窗判断用)
rows = [r for r in all_rows if r[0] >= from_ and (to_ is None or r[0] <= to_)]
# 幂等键=from_order内容锚定B4-S3/fable 审查 E2window_no 是流水号,
# 断点续跑或参数变化时会漂移导致覆盖错窗——只作展示序号,不作跳过依据
prior = conn.execute(
"SELECT from_order, window_no FROM example_parse_outline WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchall()
done = {r[0] for r in prior}
# 章序切窗(与清洗窗同思路:章对齐);全量重跑序号从头计,续跑接上次
win_no, cur, cur_len = (0 if from_ <= 1 else max((r[1] for r in prior), default=0)), [], 0
for no, ct, text, outline in rows:
cur.append((no, ct, text, outline))
cur_len += len(text)
@ -94,16 +100,18 @@ def window(work_id, window, model, from_):
win_no += 1
_do_window(conn, work_id, title, win_no, cur, model, done)
cur, cur_len = [], 0
if cur and cur_len >= window * 0.4: # 尾窗过小并入上窗的策略:足够大才单独成窗
# 尾段:已到全书末章 → 无论大小独立成窗(全书拆完时尾段必须落窗,否则永远留白);
# 未到书末 → 足够大才成窗,不足留待与后续章合窗
if cur and (cur[-1][0] == last_order or cur_len >= window * 0.4):
win_no += 1
_do_window(conn, work_id, title, win_no, cur, model, done)
elif cur:
click.echo(f"尾段 {len(cur)} 章({cur_len:,} 字)不足半窗,留待与后续章合窗")
click.echo(f"尾段 {len(cur)} 章({cur_len:,} 字)不足半窗且未到书末,留待与后续章合窗")
def _do_window(conn, work_id, title, win_no, chs, model, done):
if win_no in done:
click.echo(f"win-{win_no:02d} 已有大纲,跳过")
if chs[0][0] in done: # 幂等按窗起始章判断(不按流水号)
click.echo(f"win-{win_no:02d}(第{chs[0][0]}章起)已有大纲,跳过")
return
missing = [no for no, _, _, o in chs if not o]
if missing:
@ -124,8 +132,8 @@ def _do_window(conn, work_id, title, win_no, chs, model, done):
"""INSERT INTO example_parse_outline (work_id, window_no, from_order, to_order, outline_text,
creator, updater, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (tenant_id, work_id, window_no)
DO UPDATE SET outline_text=EXCLUDED.outline_text, from_order=EXCLUDED.from_order,
ON CONFLICT (tenant_id, work_id, from_order)
DO UPDATE SET outline_text=EXCLUDED.outline_text, window_no=EXCLUDED.window_no,
to_order=EXCLUDED.to_order, deleted=FALSE, check_status='pending', updater=EXCLUDED.updater""",
(work_id, win_no, chs[0][0], chs[-1][0], ol, ACTOR, ACTOR, TENANT))
conn.commit()

View File

@ -37,9 +37,11 @@ CRITERIA = """审查判定要点(每张卡都过一遍):
2. 命名黑话检测卡名是"作者会说的话"还是"XX式YY化ZZ" AI 修饰语堆叠摘要是通用手法陈述还是原书剧情复述
3. 标签判定装置类型是否乱贴信息差=必须存在角色A知道而B/读者不知道的落差契诃夫之枪=预先可见放置+后续引爆身份错认=必须有人把A当成B
4. 伪精确检测"隔两章以上"这类数字是从原文数出来的还是编的编的=危险的 false precision
例外实测#a埋→#b收隔n章」标注的数字是校验脚本按实例章号机械计算的权威值可信
5. 同功重复与本批其他卡是否同一手法换皮先知降压/面瘫外化这类 family
6. 干货位检查失败模式字段是否比手法本身更值钱说明手法总结空洞
7. 专名泄漏字段里出现原书人名/地名/数字/=破坏跨书复用
豁免区实例定位与出处里的专名是设计允许的溯源用不算泄漏只审名称/摘要/字段
8. 场景走位判定间隔纪律/复用节奏写明埋收同场景闭环不是跨章装置而是场景内走位
可参考性上限 2 反例"借道具看直播"卡把单场走位包装成范式金标准 2.67 """

View File

@ -230,9 +230,10 @@ flowchart LR
- **B2 试拆收口2026-07-13门②已呈报**5 本×前 3 章 M3 直调完成——scaffold 15/15 全过(绝对字数上限+压缩重试对症后,细纲 2.9%7.8%);范式卡三轮迭代后活卡 19 张(样张 `docs/试拆-门②样张.md`opus 同章基准 `docs/试拆-opus基准-机动风暴.md`)。**M3 三病理已档**:①归型偏科——内容有型区分但 type 一律标 craftprompt 两轮矫正无效,字段指纹改型又被「串型混填」挫败(一卡混两型字段);②重跑自噬——判重名录含本章旧卡+幂等软删+LLM 随机性组合损耗(已修:名录排除本章+0 卡不软删);③偶发脱敏违规/source 偷懒(守卫拦对+机械回填治)。
- **拆书流程增补2026-07-13 创始人拍板+落地)**:①**窗级大纲聚合**——大纲不从单章抽单章对大纲层可能零贡献510 万字窗(多章细纲+正文聚合一次整本完后拿全体细纲终检parse_outline.py + 93 表);②**三角色审核常设步骤**——番茄作家/起点作家/主编审核公共卡(成立性/AI 可用性/可参考性),**M3 执行**review-cards skillfable/opus 只做起量前校准+起量后总审核。校准已过opus 三评审金标准入 golden/M3 对照平均绝对偏差 0.45(合格线 0.5),四大系统病独立复现。首批 19 卡 M3 判定 pass 5 / revise 10 / reject 4。
- **金标准评审结论(拆书 prompt 的 B4 改造清单,见 review-cards/SKILL.md**:同功 family 撞车19 卡实为 1012 个独立技法)、命名黑话+专名泄漏、伪精确数字、伏笔框架硬套场景手法、失败模式才是干货位、采样偏科开局——七条改进已档,B4 环实施。
- **B4 收敛环2026-07-13 创始人拍板「先收敛质量」,进行中)**fable 独立审查完成(报告=`docs/2026-07-13-B4-fable独立审查报告.md`,S1-S6 实施序)。**头号发现=CONTRACTS 漂移冤案**parse_llm 手写合同与库内字段名漂移,M3 正确产出的 11 张非 craft 卡全被守卫冤杀——「craft 偏科」一半是冤案。**已完成**S1 修地基(合同库内动态渲染 prompt 与守卫同源+system 角色分离+审核 idx 匹配;验证=重跑 15 章拒卡 14→0、scene_pattern 首次存活 8 张);S2 schema 批craft 双模板「装置形态」条件必填禁填+装置类型闭合枚举判定测试+原理/触发条件/读者收益/回收点/记忆维持/滥用反例/迁移用例新字段+emotion 目标情绪+**pacing 启用 8 字段**「黄金三章画像/开局策略/上架节奏切换/张力曲线/爽点间隔…」=创始人抽取分层议题的书级归属;ingest 三守卫已验);S4 审核环CRITERIA#8 场景走位+机械降档回测零误伤+分页 ≤30。**待做**S3 管线重构scaffold 扩候选 hints+窗级聚类出卡+embedding 判重+间隔机械计算——出卡权上移到判据能证成的窗口→S5 参数 A/B范式线 temp 0.2vs1.0)→复跑校准。
- **B4 收敛环2026-07-13 创始人拍板「先收敛质量」,S1-S4 全部完成)**fable 独立审查完成(报告=`docs/2026-07-13-B4-fable独立审查报告.md`,S1-S6 实施序)。**头号发现=CONTRACTS 漂移冤案**parse_llm 手写合同与库内字段名漂移,M3 正确产出的 11 张非 craft 卡全被守卫冤杀——「craft 偏科」一半是冤案。**已完成**S1 修地基(合同库内动态渲染 prompt 与守卫同源+system 角色分离+审核 idx 匹配;验证=重跑 15 章拒卡 14→0、scene_pattern 首次存活 8 张);S2 schema 批craft 双模板「装置形态」条件必填禁填+装置类型闭合枚举判定测试+原理/触发条件/读者收益/回收点/记忆维持/滥用反例/迁移用例新字段+emotion 目标情绪+**pacing 启用 8 字段**「黄金三章画像/开局策略/上架节奏切换/张力曲线/爽点间隔…」=创始人抽取分层议题的书级归属;ingest 三守卫已验);S4 审核环CRITERIA#8 场景走位+机械降档回测零误伤+分页 ≤30+豁免区说明防假阳);**S3 管线重构(本轮完成并验收)**——见下条。**待做**S5 参数 A/B范式线 temp 0.2vs1.0;M3 输出方差的根治口→S6 pacing 书级抽取(等整本拆完)。
- **S3 管线重构收口2026-07-13,验收报告=`docs/2026-07-13-S3窗级出卡验收报告.md`**:出卡权上移窗级——章级只产「范式候选线索」(并入 scaffold pass,正文只过一遍,放量每书省 ~8M 输入 token,窗级(=大纲窗行,幂等键改 from_order+书末残窗必成窗)聚类归并出母卡+多实例章号,**间隔章数由实例章号差机械计算**(伪精确灭绝),入库前嵌入判重≥0.85→M3 归并终判,0.75-0.85 标记)。**验收5 书×3 章重拆对比)**型偏科痊愈craft 27/32→14/39,trope 0→15;命名全短名;摘要通用化;间隔全机械溯源;跨书近亲 3 对被判重标记;审核 pass 11/39,top 4.33×2 超旧金标准最高 4.0。**本轮实战修四病**(各带机械防线,见验收报告):纪律措辞串型→裁剪降级;占位符字面抄写→机械检测+拒卡重试环;M3 输出方差(同料 0 卡vs10 卡→0 卡可疑重试;审核豁免区假阳→CRITERIA 补说明。管线定版=五步chapters→window→cards→check→review,SKILL.md §放量全流程)。
- **抽取窗口×型映射(创始人议题定版)**:单章=6 实体型+范式候选 hint;窗级5-10万字=五型出卡聚类+trope+character_relation;书级=pacing/style/终检。原理:**抽取窗口必须 ≥ 该型判据的证成窗口**trope 判据是跨章公式却曾在单章抽=永远证不成)。
- **未决**①B2 放量(硬停,等 B4 收敛完+创始人指令②机动残留变体与感言章处理方式③S5 温度 A/B 依赖 S3
- **未决**①B2 放量(硬停,等创始人指令;B4 质量收敛主体已完成,S5 参数实验可与放量并行或先行,由创始人定)②机动残留变体与感言章处理方式③跨书同功母卡归并(「设定投喂」家族 3 书各一张,相似度 0.6-0.7 不到预警线)=放量后公共面课题
- **教训入档**StructuredOutput 工具 schema 属性名仅限 ASCII中文键 API 400——schema ASCII 键+入库脚本键名归一Tailscale 长事务需 keepalive+批量写(逐行两万往返曾半死 16 分钟)。
## 九·旧2026-07-10 快照,留档)

View File

@ -58,6 +58,8 @@ CREATE TABLE example_parse_scaffold (
chapter_id BIGINT NOT NULL, -- → muse_content_chapter.id
outline_text TEXT, -- 逆推细纲(对原文 35% 字数)
entities JSONB, -- 实体清单 [{type,name,brief}]
-- B4-S32026-07-13出卡权上移窗级——章级只产「范式候选线索」窗级聚类归并出卡
pattern_hints JSONB NOT NULL DEFAULT '[]', -- 候选线索 [{type,name,clue,evidence}]
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '',

View File

@ -17,7 +17,9 @@ CREATE TABLE IF NOT EXISTS example_parse_outline (
update_time TIMESTAMP NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_example_parse_outline UNIQUE (tenant_id, work_id, window_no)
-- B4-S3fable 审查 E2幂等键用 from_order内容锚定而非 window_no流水号
-- 断点续跑/参数变化时会漂移导致覆盖错窗window_no 只作展示序号
CONSTRAINT uk_example_parse_outline UNIQUE (tenant_id, work_id, from_order)
);
COMMENT ON TABLE example_parse_outline IS 'example 私货拆书窗级大纲聚合510万字/窗,细纲+正文→大纲;终检=全体细纲对账)';