From df870b689f6a49ef7aa72003b5000ac4954a5464 Mon Sep 17 00:00:00 2001 From: zizi Date: Mon, 13 Jul 2026 23:29:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=86=E6=9E=B6:=20=E6=94=BE=E9=87=8F?= =?UTF-8?q?=E4=BA=8C=E4=BF=AE=E2=80=94=E2=80=94=E5=87=BA=E5=8D=A1=E6=8C=89?= =?UTF-8?q?=E5=9E=8B=E5=88=86=E6=89=B9(=E7=99=BE=E6=9D=A1=E7=BA=BF?= =?UTF-8?q?=E7=B4=A2=E5=85=A8=E5=9E=8B=E8=81=9A=E7=B1=BB=3D=E8=AE=A4?= =?UTF-8?q?=E7=9F=A5=E8=B6=85=E8=BD=BD=E9=80=80=E5=8C=96=E4=B8=BA=E9=80=90?= =?UTF-8?q?=E6=9D=A1=E8=BD=AC=E5=86=99,37=E7=AB=A0=E7=AA=97=E6=9B=BE?= =?UTF-8?q?=E5=87=BA74=E5=8D=A1)+=E8=BD=AF=E5=88=A0=E5=BB=B6=E5=90=8E?= =?UTF-8?q?=E5=88=B0=E6=9C=89=E5=8D=A1=E9=80=9A=E8=BF=87(=E9=98=B2?= =?UTF-8?q?=E6=96=B0=E5=8D=A1=E5=85=A8=E6=8B=92=E6=97=B6=E6=97=A7=E5=8D=A1?= =?UTF-8?q?=E5=87=80=E6=8D=9F=E5=A4=B1)+=E7=97=85=E7=97=87=E6=9C=BA?= =?UTF-8?q?=E6=A2=B0=E6=A3=80=E6=B5=8B=E9=87=8D=E8=AF=95(=E7=A9=BA?= =?UTF-8?q?=E6=89=B9/=E8=BD=AC=E5=86=99/=E4=B8=A2=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E9=94=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/parse-book/scripts/parse_ingest.py | 23 ++++-- .../skills/parse-book/scripts/parse_llm.py | 82 +++++++++++-------- 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/.claude/skills/parse-book/scripts/parse_ingest.py b/.claude/skills/parse-book/scripts/parse_ingest.py index 1df3856..dc44147 100644 --- a/.claude/skills/parse-book/scripts/parse_ingest.py +++ b/.claude/skills/parse-book/scripts/parse_ingest.py @@ -370,15 +370,9 @@ def cards(work_id, from_order, file_, model): (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, [], [] + # 先全量校验,收集通过集——软删旧卡延后到「确有新卡通过」才执行。 + # 教训(放量实测):软删在校验前时,新卡全拒(如 M3 整批丢实例键)=旧卡已删+新卡全拒=净损失 + passed, rejected = [], [] for i, c in enumerate(cards_in): reasons, retyped = validate_card(c, contracts, entity_names, src) # 实例域校验:章号必须落在本窗内——越窗章号=编造(跨窗同手法靠嵌入判重归并) @@ -412,6 +406,17 @@ def cards(work_id, from_order, file_, model): payload["跨窗待证"] = True # 审核环可见:结构装置只有单端实例 if n_drop: payload["越窗实例弃"] = n_drop # 审计:M3 报了窗外章号 + passed.append((i, payload, inss)) + # 确有通过卡才软删本窗旧卡(幂等重出;全拒/空批不动旧卡——0 卡保护的窗级版) + if passed: + 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 = 0, [] + for i, payload, inss in passed: # ── 受控点①②:嵌入判重(失败不阻断——判重是增强不是红线,卡不能因通道故障丢)── embed_text = build_embed_text(payload) vec = None diff --git a/.claude/skills/parse-book/scripts/parse_llm.py b/.claude/skills/parse-book/scripts/parse_llm.py index 8fd9233..95cab58 100644 --- a/.claude/skills/parse-book/scripts/parse_llm.py +++ b/.claude/skills/parse-book/scripts/parse_llm.py @@ -109,16 +109,20 @@ def scaffold_prompt(title, ch, ch_title, text, prev_entities): {text}""" -def window_cards_prompt(title, a, b, stage_outline, ch_outlines, ch_hints, contracts): - """窗级聚类出卡 prompt(B4-S3 核心资产;金标准七条纪律全落于此)。""" +def window_cards_prompt(title, a, b, stage_outline, ch_outlines, ch_hints, contracts, batch_note): + """窗级聚类出卡 prompt(B4-S3 核心资产;金标准七条纪律全落于此)。 + 放量教训:百条线索一次全型聚类=认知超载,M3 退化为逐条转写(37 章窗出 74 张卡)—— + 调用侧按型分批(batch_note 说明本批范围),每批线索几十条以内聚类才真实发生。""" return f"""【功能指令(parse-book 窗级聚类出卡+脱敏红线)】 -《{title}》第 {a}–{b} 章已完成章级分析。下面给你三层材料:阶段大纲、逐章细纲、逐章范式候选线索。 +《{title}》第 {a}–{b} 章已完成章级分析。下面给你三层材料:阶段大纲、逐章细纲、候选线索。 +{batch_note} 任务:把候选线索**聚类归并**成「公共范式卡」——同一手法在多章的多次出现归并为一张母卡,带全部实例章号;线索不足处可从细纲的伏笔账(埋·推·收)补证跨章装置。 {render_contracts(contracts)} 出卡纪律: -- 聚类优先:先把线索按「同一运作机制」分组(不同章的同类线索=同一卡的多个实例),再逐组判断是否值得出卡。一窗 0–6 张为常态,宁缺毋滥——聚不成组又不够突出的线索直接丢弃。 +- 聚类优先:先把线索按「同一运作机制」分组(不同章的同类线索=同一卡的多个实例),再逐组判断是否值得出卡。**本批最多 6 张,硬上限**——只出聚类后最能跨书复用的母卡;输出张数接近线索条数=你在逐条转写而不是聚类,整批作废。聚不成组又不够突出的线索直接丢弃。 +- **每张卡必须带 instances**(窗内章号+一句话定位);缺 instances 的卡=机械拒收。 - 跨章证据:结构装置(伏笔类)尽量给出埋与收两端的实例章号;只有单章实例的会被标「跨窗待证」降低可信度。 - 命名:卡名=作者口头会说的话(2–8 字,如「先抑后扬」「借刀杀人」),禁「XX式YY化ZZ」修饰语堆叠、禁书内专名、禁自造黑话。 - 摘要:一句话**通用手法陈述**(抹掉本书信息后换任何题材仍成立),禁复述本书剧情、禁与实例定位雷同。 @@ -279,36 +283,46 @@ def cards(work_id, from_order, model, redo): 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( - 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) - _, 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}") + # 按型分批出卡(放量实测:百条线索一次全型聚类=认知超载,退化为逐条转写)—— + # 每批只给该型合同+该型线索,型内聚类真实可行;「?」型线索单独一批(五型合同全给,让 M3 先归型) + by_type = {} + for h in hints: + t = h["线索"].get("型") + by_type.setdefault(t if t in PATTERN_TYPES else "?", []).append(h) + all_cards, win_in, win_out = [], 0, 0 + for t, hs in sorted(by_type.items()): + sub_contracts = contracts if t == "?" else {t: contracts[t]} + note = (f"本批只处理「{t}」型的 {len(hs)} 条线索(其余型另批处理),只出 {t} 型卡。" + if t != "?" else + f"本批是 {len(hs)} 条章级未归型的线索——先按各型判据归型,归不进五型的丢弃。") + try: + p = window_cards_prompt(title, a, b, stage_ol, ch_outlines, + json.dumps(hs, ensure_ascii=False, indent=1), sub_contracts, note) + data, usage = m3_json(p, model, ("cards",)) + win_in += usage.get("prompt_tokens", 0) + win_out += usage.get("completion_tokens", 0) + # 病症机械检测→带方重试一次(放量实测三病:方差空批/逐条转写/长清单丢实例键) + cs = data.get("cards") or [] + no_ins = sum(1 for c in cs if not (c.get("instances") or c.get("实例"))) + sick = ("输出了空数组,但本批有 %d 条候选线索——认真逐条聚类评估后再输出,确实全部不够格才允许空数组" + % len(hs) if (not cs and len(hs) >= 3) else + "输出了 %d 张卡——这是逐条转写不是聚类。重新按「同一运作机制」分组归并,只出最能跨书复用的母卡,最多 6 张" + % len(cs) if len(cs) > 8 else + "有 %d 张卡缺 instances(窗内章号+定位)——instances 是必填键,缺=全部被机械拒收。重新输出全部卡并逐张补上" + % no_ins if no_ins else None) + if sick: + data, usage = m3_json(p + "\n\n【重试】上次" + sick + "。", model, ("cards",)) + win_in += usage.get("prompt_tokens", 0) + win_out += usage.get("completion_tokens", 0) + cs = data.get("cards") or [] + all_cards += cs + click.echo(f" 批[{t}] 线索{len(hs)} → 卡{len(cs)}") + except RuntimeError as e: + click.echo(f" 批[{t}] M3 失败: {e}(本批线索丢失,重跑 --redo 可补)") + total_in += win_in + total_out += win_out + _, out = ingest("cards", work_id, a, {"cards": all_cards}, keyflag="--from-order") + click.echo(f"win#{a}–{b}(线索 {n_hint} 条,分 {len(by_type)} 批)\n {out}") click.echo(f"《{title}》窗级出卡完成;token in={total_in:,} out={total_out:,}")