zizi 9a513e79b8 feat(llm): 全局额度治理chat_governed——5h窗计钱计次/超$10自动切链/满4000次跳窗/共享账本,四调用方降级链上收
额度系统(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;深空升格样张入档。
2026-07-16 23:49:04 +08:00

378 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""parse-book skill:M3 直调拆书执行器(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)