From c3f9688ccb9b8e09abd0a83242a7882e49a7b85e Mon Sep 17 00:00:00 2001 From: zizi Date: Mon, 13 Jul 2026 21:22:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=86=E6=9E=B6:=20B4-S3=20=E5=87=BA?= =?UTF-8?q?=E5=8D=A1=E6=9D=83=E4=B8=8A=E7=A7=BB=E7=AA=97=E7=BA=A7=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=AB=A0=E7=BA=A7=E5=80=99=E9=80=89=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?+=E7=AA=97=E7=BA=A7=E8=81=9A=E7=B1=BB=E6=AF=8D=E5=8D=A1+?= =?UTF-8?q?=E9=97=B4=E9=9A=94=E6=9C=BA=E6=A2=B0=E8=AE=A1=E7=AE=97+?= =?UTF-8?q?=E5=B5=8C=E5=85=A5=E5=88=A4=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse_llm 拆 chapters/cards 两命令:章级一遍正文产细纲+实体+范式候选线索;窗级按大纲窗行聚类归并出母卡+多实例章号 - parse_ingest 新增 cards 窗级入库:实例域校验/间隔章号差机械追加(伪精确灭绝)/嵌入判重0.85归并终判/越合同字段裁剪降级/占位符机械检测 - parse_outline 幂等键改 from_order(流水号重跑漂移)+书末残窗必成窗+--to 限域 - 出卡环加拒卡带因重试+0卡可疑重试(M3 输出方差实测) - review_cards CRITERIA 补实测间隔可信例外+溯源豁免区说明(防审核假阳) - 章级判重名录随窗级聚类废除(重跑自噬两补丁退役) --- .claude/skills/parse-book/SKILL.md | 20 +- .../skills/parse-book/scripts/parse_ingest.py | 356 ++++++++++++++---- .../skills/parse-book/scripts/parse_llm.py | 246 +++++++----- .../parse-book/scripts/parse_outline.py | 34 +- .../review-cards/scripts/review_cards.py | 2 + README.md | 5 +- db/ddl/91-example实验私货.sql | 2 + db/ddl/93-example大纲聚合.sql | 4 +- 8 files changed, 481 insertions(+), 188 deletions(-) diff --git a/.claude/skills/parse-book/SKILL.md b/.claude/skills/parse-book/SKILL.md index 5c8d66e..5f874d2 100644 --- a/.claude/skills/parse-book/SKILL.md +++ b/.claude/skills/parse-book/SKILL.md @@ -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 -# ② 窗级大纲聚合(每 5–10 万字:多章细纲+正文→阶段大纲;单章对大纲层可能零贡献,不做章内比例强制) +# ① 章级 pass(细纲+实体+范式候选线索;断点续跑,重跑自动补失败章) +.venv/bin/python .claude/skills/parse-book/scripts/parse_llm.py chapters --work-id 4 --from 1 --to 50 +# ② 窗级大纲聚合(每 5–10 万字:多章细纲+正文→阶段大纲;书末残窗无论大小必成窗) .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 前章级出卡入口,仅留作回滚保险。 ## 步骤(自底向上,与创作期规划的自顶向下互为镜像) diff --git a/.claude/skills/parse-book/scripts/parse_ingest.py b/.claude/skills/parse-book/scripts/parse_ingest.py index 7be70ea..985e587 100644 --- a/.claude/skills/parse-book/scripts/parse_ingest.py +++ b/.claude/skills/parse-book/scripts/parse_ingest.py @@ -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-S2):craft 双模板条件必填/禁填 + 装置类型闭合枚举 + 禁复合标签 + 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_): - """脚手架入库:{细纲, 实体:[{型,名称,一句话摘要,备注?}]};比例约束校验(3–5%,超标拒绝)。""" + """章级入库:{细纲, 实体:[{型,名称,一句话摘要}], 线索:[{型,短名,线索,证据}]};比例约束校验。""" 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-S2):craft 双模板条件必填/禁填 + 装置类型闭合枚举 + 禁复合标签 - 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): diff --git a/.claude/skills/parse-book/scripts/parse_llm.py b/.claude/skills/parse-book/scripts/parse_llm.py index 480657e..b5c1730 100644 --- a/.claude/skills/parse-book/scripts/parse_llm.py +++ b/.claude/skills/parse-book/scripts/parse_llm.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 -"""parse-book skill:M3 直调拆书执行器——逐章两 pass(脚手架→范式),入库走 parse_ingest 守卫。 +"""parse-book skill:M3 直调拆书执行器(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(五型之一,拿不准填"?")、name(2–8 字短名,作者口头会说的话,禁书内专名禁修饰堆叠)、clue(一句话:这个写法怎么运作,用{PLACEHOLDERS}等抽象指代)、evidence(一句话:本章哪里这样写、为何突出,可用专名)。 +0–5 条/章,平庸章 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): + """窗级聚类出卡 prompt(B4-S3 核心资产;金标准七条纪律全落于此)。""" + return f"""【功能指令(parse-book 窗级聚类出卡+脱敏红线)】 +《{title}》第 {a}–{b} 章已完成章级分析。下面给你三层材料:阶段大纲、逐章细纲、逐章范式候选线索。 +任务:把候选线索**聚类归并**成「公共范式卡」——同一手法在多章的多次出现归并为一张母卡,带全部实例章号;线索不足处可从细纲的伏笔账(埋·推·收)补证跨章装置。 {render_contracts(contracts)} -纪律: -- 宁缺毋滥:一章 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} +出卡纪律: +- 聚类优先:先把线索按「同一运作机制」分组(不同章的同类线索=同一卡的多个实例),再逐组判断是否值得出卡。一窗 0–6 张为常态,宁缺毋滥——聚不成组又不够突出的线索直接丢弃。 +- 跨章证据:结构装置(伏笔类)尽量给出埋与收两端的实例章号;只有单章实例的会被标「跨窗待证」降低可信度。 +- 命名:卡名=作者口头会说的话(2–8 字,如「先抑后扬」「借刀杀人」),禁「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) diff --git a/.claude/skills/parse-book/scripts/parse_outline.py b/.claude/skills/parse-book/scripts/parse_outline.py index b44843f..2950d89 100644 --- a/.claude/skills/parse-book/scripts/parse_outline.py +++ b/.claude/skills/parse-book/scripts/parse_outline.py @@ -77,16 +77,22 @@ def cli(): @click.option("--window", type=int, default=80000, show_default=True, help="每窗目标字数(5–10万区间)") @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 审查 E2):window_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() diff --git a/.claude/skills/review-cards/scripts/review_cards.py b/.claude/skills/review-cards/scripts/review_cards.py index 4f85acc..25ac23a 100644 --- a/.claude/skills/review-cards/scripts/review_cards.py +++ b/.claude/skills/review-cards/scripts/review_cards.py @@ -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 分)。""" diff --git a/README.md b/README.md index d66ca54..1e62456 100644 --- a/README.md +++ b/README.md @@ -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 一律标 craft,prompt 两轮矫正无效,字段指纹改型又被「串型混填」挫败(一卡混两型字段);②重跑自噬——判重名录含本章旧卡+幂等软删+LLM 随机性组合损耗(已修:名录排除本章+0 卡不软删);③偶发脱敏违规/source 偷懒(守卫拦对+机械回填治)。 - **拆书流程增补(2026-07-13 创始人拍板+落地)**:①**窗级大纲聚合**——大纲不从单章抽(单章对大纲层可能零贡献),5–10 万字窗(多章细纲+正文)聚合一次,整本完后拿全体细纲终检(parse_outline.py + 93 表);②**三角色审核常设步骤**——番茄作家/起点作家/主编审核公共卡(成立性/AI 可用性/可参考性),**M3 执行**(review-cards skill);fable/opus 只做起量前校准+起量后总审核。校准已过:opus 三评审金标准入 golden/,M3 对照平均绝对偏差 0.45(合格线 0.5),四大系统病独立复现。首批 19 卡 M3 判定 pass 5 / revise 10 / reject 4。 - **金标准评审结论(拆书 prompt 的 B4 改造清单,见 review-cards/SKILL.md)**:同功 family 撞车(19 卡实为 10–12 个独立技法)、命名黑话+专名泄漏、伪精确数字、伏笔框架硬套场景手法、失败模式才是干货位、采样偏科开局——七条改进已档,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 快照,留档) diff --git a/db/ddl/91-example实验私货.sql b/db/ddl/91-example实验私货.sql index fcdfe1e..daf37f9 100644 --- a/db/ddl/91-example实验私货.sql +++ b/db/ddl/91-example实验私货.sql @@ -58,6 +58,8 @@ CREATE TABLE example_parse_scaffold ( chapter_id BIGINT NOT NULL, -- → muse_content_chapter.id outline_text TEXT, -- 逆推细纲(对原文 3–5% 字数) entities JSONB, -- 实体清单 [{type,name,brief}] + -- B4-S3(2026-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 '', diff --git a/db/ddl/93-example大纲聚合.sql b/db/ddl/93-example大纲聚合.sql index 54e5ab1..b3a9abe 100644 --- a/db/ddl/93-example大纲聚合.sql +++ b/db/ddl/93-example大纲聚合.sql @@ -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-S3(fable 审查 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 私货:拆书窗级大纲聚合(5–10万字/窗,细纲+正文→大纲;终检=全体细纲对账)';