额度系统(item4,创始人2026-07-16拍板):llm.py 新增 chat_governed 统一入口——按5小时窗(00/05/10/15/20,末窗20-24)计钱计次;MiniMax(M3/M2.7)超$10/窗自动切 glm-5.2→deepseek-v4-flash;满4000次/窗自动睡到下一窗续跑;共享账本 example_llm_quota 原子累加,多分片共用一本;连接短开短关(LLM/睡眠期不持DB连接,治Tailscale空转)。parse_llm/parse_outline/parse_ingest/review_cards 各自的降级链上收复用统一路由,保各自异常契约(SensitiveHardStop/SensitiveError/判重保守keep)。新增离线单测 test_quota.py(9例:窗口归窗/计价缓存折扣/六条路由)+DDL db/ddl/95。 主代理亲验上线:9条离线单测亲跑全过;真调用对账端到端闭合(算11.04配额单位≈服务端实扣11);账本两笔原子累加正确;校准噪音已清空、额度从零起。 review_cards.py 一并携带批9c崩溃修复(连接读/算/写三段拆分+分片--shard+敏感降级),五书全量重审赖此收官。批9c收官锚点入 README§九+docs方案doc;深空升格样张入档。
378 lines
25 KiB
Python
378 lines
25 KiB
Python
#!/usr/bin/env python3
|
||
"""parse-book skill:M3 直调拆书执行器(B4-S3 重构:出卡权上移窗级)。
|
||
|
||
创始人拍板(2026-07-13):拆书内容生产 LLM=New-API MiniMax-M3(经 llm skill),
|
||
不再派 opus/haiku 子代理。B4 fable 审查裁决(2026-07-13):章级逐章出卡有三同根病
|
||
(同功 family 撞车/单章证不成跨章公式/间隔数字伪精确),治法=章级只产「范式候选线索」,
|
||
出卡在窗级聚类归并(复用大纲窗切分)——同一手法多章多次出现归并为一张母卡+实例章号,
|
||
间隔数字由实例章号差机械计算。
|
||
|
||
管线三步(顺序依赖):
|
||
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
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
|
||
import click
|
||
import psycopg
|
||
|
||
# 统一走 llm skill 入口(trust_env/重试/<think>剥离/JSON 容错都在那边)
|
||
# 敏感/额度降级链已上收 llm.chat_governed(全局统一治理),本模块不再自持降级链
|
||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
|
||
from llm import chat_governed, extract_json # noqa: E402
|
||
|
||
DSN = ("postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-example"
|
||
"?keepalives=1&keepalives_idle=15&keepalives_interval=5&keepalives_count=3")
|
||
TENANT = 1
|
||
HERE = pathlib.Path(__file__).resolve().parent
|
||
TMP = pathlib.Path("/tmp/muse-parse")
|
||
|
||
# ── 提示词资产(合同一律 load_contracts 动态渲染,禁手写——CONTRACTS 漂移冤案教训) ──
|
||
|
||
IDENTITY = """你是知识抽取员(extractor),分析槽位的默认绑定件。产出全部是草稿。
|
||
元数据纪律:schema 有什么字段你就抽什么,schema 没有的不抽——字段合同就是抽取 checklist,不自造结构;归型走各型「判据」;归不进任何型的候选=枚举缺口,如实报不硬塞;每字段要有正文证据,置信度低标「?」。
|
||
通则:以正文为准,不脑补正文没写的;基础字段规范填;采纳正文≠确认知识。"""
|
||
|
||
ENTITY_CRITERIA = ("实体型判据(脚手架级,只要 型/名称/一句话摘要):character=具名可指认的行动主体;"
|
||
"location=有名字的地点/星球/设施;faction=组织/国家/军团/公司;"
|
||
"power_system=力量体系/科技体系/修炼阶梯(体系本身,非招式);"
|
||
"item=有名字且有跨章戏份的装备/机甲/物品;event=已发生的重大事件(战役/事故/仪式)。"
|
||
"立卡门槛:有跨章戏份潜力;一次性龙套与单场景道具不收。")
|
||
|
||
PATTERN_TYPES = ("craft", "combat", "emotion", "scene_pattern", "trope")
|
||
|
||
# 基础公共字段(yudao 惯例列,出卡时由 ingest/payload 承载,不进抽取字段表)
|
||
BASE_KEYS = {"名称", "别名", "一句话摘要", "标签", "来源", "状态", "例证出处"}
|
||
|
||
# 抽象指代白名单(金标准结论:M3 自造代号如"A角色"破坏可读性;专名只允许进实例定位)
|
||
PLACEHOLDERS = "主角/对手/强敌/导师/盟友/队友/配角/反派/长辈/宝物/装备/机关/势力/秘密/危机"
|
||
|
||
|
||
def load_contracts(conn):
|
||
"""从库内 schema 版本快照动态渲染五型合同——prompt 与 ingest 守卫同源。
|
||
|
||
教训(B4 fable 审查坐实的 bug 级冤案):此前 prompt 手写合同与库内字段名漂移
|
||
(emotion/scene_pattern/trope 三型全对不上),M3 正确产出的 11 张非 craft 卡
|
||
全部被守卫按另一套字段名冤杀——「prompt 自立合同」违反元数据驱动公理,禁止再犯。
|
||
"""
|
||
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.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}
|
||
return contracts
|
||
|
||
|
||
def render_contracts(contracts):
|
||
"""合同渲染为 markdown 表格形态(比单行 JSON dump 对 LLM 遵从率更友好)。"""
|
||
parts = []
|
||
for t, c in contracts.items():
|
||
rows = "\n".join(f"| {f['key']} | {f.get('说明', '')} |" for f in c["字段"])
|
||
parts.append(f"### {t}({c['中文名']})\n判据:{c['判据']}\n\n"
|
||
f"| 字段 key | 说明 |\n|---|---|\n{rows}")
|
||
return "\n\n".join(parts)
|
||
|
||
|
||
def scaffold_prompt(title, ch, ch_title, text, prev_entities):
|
||
# 前文实体压缩(创始人 2026-07-14):判重只需名字清单,不必重发型+摘要全档——
|
||
# 全量 json 到后期每章膨胀到 15 万+ token(深空第 557 章 2851 实体≈15 万),是次数被压的真凶
|
||
_names = sorted({(e.get("名称") or e.get("name") or "").strip() for e in prev_entities} - {""}) if prev_entities else []
|
||
ents = "、".join(_names) if _names else "(第一章,为空)"
|
||
wc = len(text.replace("\n", "").replace(" ", ""))
|
||
cap = max(60, int(wc * 0.05)) # 绝对字数上限(纯比例约束 M3 会写超,首轮实测 9.8%–19%)
|
||
# 缓存友好(read-context「稳定度递减吃前缀缓存」):固定规则段全部前置吃前缀缓存,
|
||
# 章号/字数/上限/前文实体/正文(每章必变)一律尾置;cap 值随材料尾置、规则段只说「见文末上限」以保持固定
|
||
return f"""【功能指令(parse-book 章级 pass:细纲+实体+范式候选线索)】
|
||
对参考书的当前章(书名/章号/正文见文末材料区)做三件事:
|
||
1) 逆推本章细纲:章目标/关键事件/出场角色/伏笔动作(埋·推·收)/章末钩子。**细纲全文不得超过文末给出的字数上限**(约本章正文的 5%;超标会被校验脚本机械退回)。细纲是结构骨架,不是缩写复述——用短语与分号,不写完整句子。
|
||
2) 抽实体增量(脚手架级索引):{ENTITY_CRITERIA}
|
||
判重:文末给出前文已收录实体的**名字清单**,清单内的实体本章不重报(若本章赋予其新身份,可在摘要里点明);清单外的本章新实体照抽。
|
||
3) 报范式候选线索(**只报线索不出卡**,出卡由窗级聚类另做):本章表现突出、疑似可跨书复用的写法。五型候选:craft=单点叙事装置(删去它场景仍成立);combat=整场武力对抗的打法;emotion=整场情绪戏的推进;scene_pattern=拍卖/谈判/审讯等场景公式;trope=跨章复用的情节公式。
|
||
每条线索给四项:type(五型之一,拿不准填"?")、name(2–8 字短名,作者口头会说的话,禁书内专名禁修饰堆叠)、clue(一句话:这个写法怎么运作,用{PLACEHOLDERS}等抽象指代)、evidence(一句话:本章哪里这样写、为何突出,可用专名)。
|
||
0–5 条/章,平庸章 0 条正常;门槛比出卡低——拿不准的报上来,窗级聚类会过滤。
|
||
|
||
【输出规则(只输出一个 JSON 对象,禁止任何其他文字)】
|
||
{{"outline": "细纲文本", "entities": [{{"type": "六型之一", "name": "名称", "brief": "一句话摘要"}}], "hints": [{{"type": "五型之一或?", "name": "短名", "clue": "运作机制一句话", "evidence": "本章证据一句话"}}]}}
|
||
|
||
━━━ 本章材料(每章不同,非规则)━━━
|
||
【当前章】《{title}》第 {ch} 章《{ch_title}》|正文约 {wc} 字|细纲上限 {cap} 字
|
||
【前文实体名字清单(判重用,已在列的不重报)】
|
||
{ents}
|
||
【本章正文】
|
||
{text}"""
|
||
|
||
|
||
def window_cards_prompt(title, a, b, stage_outline, ch_outlines, ch_hints, contracts, batch_note):
|
||
"""窗级聚类出卡 prompt(B4-S3 核心资产;金标准七条纪律全落于此)。
|
||
放量教训:百条线索一次全型聚类=认知超载,M3 退化为逐条转写(37 章窗出 74 张卡)——
|
||
调用侧按型分批(batch_note 说明本批范围),每批线索几十条以内聚类才真实发生。
|
||
缓存友好(read-context「稳定度递减吃前缀缓存」):合同+七条纪律+输出格式(上千字、
|
||
跨书跨窗完全一致)全部前置吃前缀缓存;书名/章范围/本批说明/三层材料(每窗每批必变)尾置。"""
|
||
return f"""【功能指令(parse-book 窗级聚类出卡+脱敏红线)】
|
||
把一个窗口内已完成章级分析的候选线索**聚类归并**成「公共范式卡」——同一手法在多章的多次出现归并为一张母卡,带全部实例章号;线索不足处可从细纲的伏笔账(埋·推·收)补证跨章装置。书名、章范围、本批范围与三层材料(阶段大纲/逐章细纲/候选线索)均见文末材料区。
|
||
|
||
{render_contracts(contracts)}
|
||
|
||
出卡纪律:
|
||
- 聚类优先:先把线索按「同一运作机制」分组(不同章的同类线索=同一卡的多个实例),再逐组判断是否值得出卡。**本批最多 6 张,硬上限**——只出聚类后最能跨书复用的母卡;输出张数接近线索条数=你在逐条转写而不是聚类,整批作废。聚不成组又不够突出的线索直接丢弃。
|
||
- **每张卡必须带 instances**(窗内章号+一句话定位);缺 instances 的卡=机械拒收。
|
||
- 跨章证据:结构装置(伏笔类)尽量给出埋与收两端的实例章号;只有单章实例的会被标「跨窗待证」降低可信度。
|
||
- 命名:卡名=作者口头会说的话(2–8 字,如「先抑后扬」「借刀杀人」),禁「XX式YY化ZZ」修饰语堆叠、禁书内专名、禁自造黑话。
|
||
- 摘要:一句话**通用手法陈述**(抹掉本书信息后换任何题材仍成立),禁复述本书剧情、禁与实例定位雷同。
|
||
- 抽象指代白名单:{PLACEHOLDERS}——白名单外的自造代号(如"A角色""X道具")不要用。
|
||
- 可迁移性是卡的本体:说不清「作者这样做、读者会怎样」的因果=不是范式,整张卡不出;带此类字段(如原理)的型按其字段说明的句式写——**句式里的 X/Y/Z 是占位符,必须替换成本卡的具体内容**,字面保留「做Y」「读者会Z」=机械拒卡。
|
||
- 实例只报章号+一句话定位;**严禁在任何字段里自报间隔章数**——间隔由校验脚本按实例章号差机械计算,自己编数字=伪精确。
|
||
- fields 只允许使用该卡 type 在上方合同表里列出的中文 key,没证据的 key 省略不编造。**先按判据定 type,再对照该型的表格填字段**——把别的型才有的 key(哪怕内容再有价值)塞进本型=机械拒卡;上一批实测拒卡主因就是给非 craft 卡塞了 craft 才有的字段。
|
||
- 脱敏红线:只写抽象结构与手法归纳,严禁抄录原文(≥15 连续字与原文重合=机械拒卡);书内专名只允许出现在实例定位(anchor)里。
|
||
|
||
【输出规则(只输出一个 JSON 对象,禁止任何其他文字;没有值得立的卡时 cards 给空数组)】
|
||
{{"cards": [{{"type": "craft|combat|emotion|scene_pattern|trope", "name": "短名", "brief": "一句话通用摘要", "fields": {{"中文合同key": "值"}}, "instances": [{{"ch": 章号数字, "anchor": "一句话情节定位(可用专名)"}}]}}]}}
|
||
|
||
━━━ 本窗材料(每窗每批不同,非规则)━━━
|
||
《{title}》第 {a}–{b} 章。{batch_note}
|
||
|
||
【阶段大纲】
|
||
{stage_outline}
|
||
|
||
【逐章细纲】
|
||
{ch_outlines}
|
||
|
||
【逐章候选线索】
|
||
{ch_hints}"""
|
||
|
||
|
||
# 敏感降级链(历史常量):已并入 llm.BUDGET_CHAIN 全局统一链,由 chat_governed 内部消化,
|
||
# m3_json 不再自己遍历它;保留定义仅作历史留痕,勿再用于逐链换模型。
|
||
FALLBACK_MODELS = ["MiniMax-M2.7", "deepseek-v4-flash"]
|
||
|
||
|
||
class SensitiveHardStop(Exception):
|
||
"""主模型 + 两个降级备选全部撞敏感——按创始人指令硬停整个解析并汇报(不跳过、不硬扛)。"""
|
||
|
||
|
||
def m3_json(prompt, model, need_keys, system=IDENTITY):
|
||
"""调模型 → JSON 容错提取 → 形状校验(不合格带提示重试 1 次)。
|
||
|
||
敏感/额度降级已上收 llm.chat_governed(全局统一降级链 + 额度治理):撞内容安全或模型不可用
|
||
时由 chat_governed 沿全局链自动换模型、并按窗口预算/调用数治理;全链耗尽(used 为 None)时本
|
||
函数抛 SensitiveHardStop 交上层硬停(parse_upgrade/parse_salvage 靠它跳窗)。JSON 形状问题
|
||
非敏感,不换模型(原样抛 RuntimeError,上层按章跳过)。返回 (data, usage) 契约不变。"""
|
||
content, usage, used = chat_governed(prompt, model=model, system=system)
|
||
if used is None: # 全局降级链全部耗尽(内容安全/不可用)——保留硬停语义
|
||
raise SensitiveHardStop(f"chat_governed 全局降级链全部耗尽(内容安全或模型不可用),model={model}")
|
||
# 拿到内容:JSON 形状校验,不合格带提示重试 1 次(仍走 chat_governed 全局治理)
|
||
for retry in range(2):
|
||
try:
|
||
data = extract_json(content)
|
||
if all(k in data for k in need_keys):
|
||
return data, usage
|
||
err = f"缺少必需键 {need_keys}"
|
||
except Exception as e: # json_repair 也救不回来的输出
|
||
err = str(e)[:200]
|
||
if retry == 0:
|
||
content, u2, used2 = chat_governed(
|
||
prompt + f"\n\n【重试提示】上次输出无法解析({err}),请严格按输出规则只输出一个 JSON 对象。",
|
||
model=model, system=system)
|
||
if used2 is None:
|
||
break # 重试提示也触发全链耗尽:当作该轮失败,落到下方 RuntimeError
|
||
# 只合并数值键:上游 usage 带嵌套 dict(如 *_tokens_details),dict+dict 会崩(放量实测)
|
||
usage = {k: usage.get(k, 0) + u2.get(k, 0) for k in set(usage) | set(u2)
|
||
if isinstance(usage.get(k, 0), (int, float)) and isinstance(u2.get(k, 0), (int, float))}
|
||
# JSON 形状最终失败(非敏感):不换模型,原样抛(上层按章跳过)
|
||
raise RuntimeError(f"JSON 形状重试仍失败({used}): {err}")
|
||
|
||
|
||
def ingest(kind, work_id, key, payload, keyflag="--chapter-order"):
|
||
"""写临时文件 → parse_ingest 机械校验入库;返回 (是否成功, 输出文本)。"""
|
||
TMP.mkdir(parents=True, exist_ok=True)
|
||
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), keyflag, str(key), "--file", str(f)],
|
||
capture_output=True, text=True)
|
||
return r.returncode == 0, (r.stdout + r.stderr).strip()
|
||
|
||
|
||
@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 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]
|
||
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
|
||
FROM muse_content_chapter c
|
||
JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=FALSE
|
||
JOIN example_parse_task t ON t.chapter_id=c.id AND t.tenant_id=c.tenant_id
|
||
WHERE c.tenant_id=%s AND c.work_id=%s AND c.order_no=%s AND c.deleted=FALSE""",
|
||
(TENANT, work_id, ch)).fetchone()
|
||
if not row:
|
||
click.echo(f"#{ch} 章或任务行不存在,跳过")
|
||
continue
|
||
ch_id, ch_title, text, s_st = 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]
|
||
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)
|
||
click.echo(f" {out}")
|
||
except SensitiveHardStop as e:
|
||
# 降级链(主+2 备)全撞敏感——按创始人指令硬停该书解析并汇报,不跳过、不硬扛
|
||
with psycopg.connect(DSN) as conn:
|
||
conn.execute(
|
||
"""UPDATE example_parse_task SET scaffold_status='failed', error_message=%s
|
||
WHERE tenant_id=%s AND work_id=%s AND chapter_id=(
|
||
SELECT id FROM muse_content_chapter
|
||
WHERE tenant_id=%s AND work_id=%s AND order_no=%s)""",
|
||
(str(e)[:500], TENANT, work_id, TENANT, work_id, ch))
|
||
conn.commit()
|
||
click.echo(f" ⛔ #{ch} 敏感降级链全失败:{e}")
|
||
click.echo(f"《{title}》解析按指令硬停于 #{ch}(此前完成章已落库;该章需人工定夺)")
|
||
return
|
||
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
|
||
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)
|
||
# 按型分批出卡(放量实测:百条线索一次全型聚类=认知超载,退化为逐条转写)——
|
||
# 每批只给该型合同+该型线索,型内聚类真实可行;「?」型线索单独一批(五型合同全给,让 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:,}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
cli()
|
||
except (psycopg.Error, RuntimeError) as e:
|
||
click.echo(f"[错误] {type(e).__name__}: {e}", err=True)
|
||
sys.exit(1)
|