zizi daac544211 实现: 为升格全书重建建立可恢复备份
在统一作品锁下导出并校验七域状态,绑定正文、窗口、字段合同与代码身份;提供事务回滚演练和双确认恢复入口,防止 reset 前后出现并发混写或不可恢复状态。
2026-07-21 17:02:11 +08:00

1991 lines
115 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:作品面升格执行器(方案 v6,创始人 2026-07-14 认可)。
把参考书自己的实体/关系/大纲按「全实体统一生长」机制抽进正式候选层:
一实体一卡(不分全卡/轻卡),每窗流程=
⓪ 机械预扫(零 AI):全库已知名字在本窗正文精确扫描 → 在场已知实体子集
① 实体观察(AI×1):新名字(顺带产初卡)/ 已知实体新信息 / 纯出场登记
② 判重(机械+观察自判):别名表/留档表精确查 → 疑似别名转观察材料
③ 立卡门槛:跨章戏份才立卡(单章龙套进出场留档,跨窗合计跨章再补立)
④ 卡更新(AI×0-3):只对有新信息的卡,每批≤6 张,读全卡→只回变更字段→三类合并
⑤ 关系增量(AI×1):核心角色两两关系变化 → 滚进关系卡(甲乙锚草稿编号)
⑥ 机械收尾:出场留档/窗状态/卡水位/覆写审计
防膨胀(第四轮评审 G1/G5):观察调用只带「窗内命中」实体索引(不带全库);
窗切割限 12 章/3.5 万字双闸。防重跑自噬:同窗重跑先按审计撤销再重写。
连接三段式(洞①):每窗读→算→写切短连接,DB 连接绝不跨 LLM/嵌入 HTTP 存活(更新/关系的窗事务内 M3
调用是既有已接受豁免、keepalives 兜底,本次不动)。嵌入:默认关(试跑期延后);开 --semantic-dedup 则
边抽边嵌——本窗新建/更新卡窗事务后增量嵌入落库,后窗即可语义召回前窗刚长成的卡。
命令:
windows --work-id N 机械切正文窗(幂等,from_chapter 锚)
run --work-id N [--max-windows K] [--max-calls M] 按窗顺序跑,断点续跑
status --work-id N 进度
"""
import hashlib
import json
import pathlib
import re
import sys
import unicodedata
from copy import deepcopy
import click
import psycopg
# 复用章级管线的敏感降级链与 llm 入口(trust_env/重试/JSON 容错同源)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
from parse_llm import m3_json, SensitiveHardStop, IDENTITY, TENANT, DSN # noqa: E402
from upgrade_work_lock import UpgradeWorkLockUnavailable, upgrade_work_lock # noqa: E402
# 语义判重(P1)复用 embed skill 的嵌入通道(同模型同维、与检索端语义对齐)——
# 只在开启 --semantic-dedup 时才真调,默认关(试跑期嵌入延后,见文件头注释);
# build_embed_text/MODEL/DIM/ACTOR 供嵌段(洞①边抽边嵌)复用检索端同源文本构造器与列常量。
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "embed" / "scripts"))
from embed_drafts import (_session as _embed_session, embed_texts, build_embed_text, # noqa: E402
MODEL as EMBED_MODEL, DIM as EMBED_DIM, ACTOR as EMBED_ACTOR)
# ── 窗切割参数(方案 §五B:3–5 万字/窗、10–15 章,取保守双闸防观察输出过载)──
WIN_MAX_CHARS = 35000 # 单窗正文字数上限
WIN_MAX_CHAPS = 12 # 单窗章数上限
UPDATE_BATCH = 6 # 卡更新每批张数上限(防长清单丢字段)
SOURCE_TYPE = "upgrade_book" # 独立来源标记:与范式卡 parse_book 隔离判重/检索/确认
# 作品面七型(六实体型+关系型;大纲卡全书收尾单独做,不在窗循环内)
ENTITY_TYPES = ("character", "location", "item", "faction", "power_system", "event")
RELATION_TYPE = "character_relation"
# 追加类字段白名单(值为数组的字段一律追加)。「演变历程」是里程碑对象数组,走独立对象
# 合并路径(MILESTONE_FIELDS);其余是字符串条目数组(历史上带 [窗N] 前缀)。「大事记」「经历」
# 是历史孤儿名(任何 schema 都没定义、会被合同守卫裁掉),保留仅为向后兼容旧数据、不再新用——
# 升格卡改造(2026-07-17)后已发生台阶统一记入「演变历程」。
APPEND_FIELDS = {"成长弧线", "演变轨迹", "大事记", "经历", "演变历程"}
# 里程碑对象数组字段:条目是 {章,台阶,周期} 结构化对象,去重按台阶内容、排序按真实章号
# (不用运行时窗号)——升格卡改造 P0 落点,区别于上面的字符串条目追加字段。
MILESTONE_FIELDS = {"演变历程"}
# 生命周期枚举(设计稿 §4.2):每条里程碑「周期」的取值域。
LIFECYCLE = ("登场", "成长", "高光", "退场", "结局")
# 语义判重(P1,设计稿 §8.2):召回同书近邻相似度 ≥ 此阈值才交 M3 终判。
# 0.78→0.60(2026-07-18 补3洞零落库小样实测校准):改名场景(洞① 病例)查询侧是初观察薄快照、
# 存储侧是长成的厚卡,文本不对称压低余弦——同实体改名对实测 0.63-0.66("联邦生物机甲技术"vs
# 卡「生物机甲」0.6565),0.78 永不触发语义层;无关对实测 ≤0.51,0.60 落在分离带内留边距。
# 放宽只增终判候选量,并卡仍须 M3 终判"同型同一实体"才发生(跨型/无关由终判把关)。
DEDUP_SIM_THRESHOLD = 0.60
CHAP_BIG = 10 ** 9 # 章号缺失/待人工的里程碑,排序时排到最后
STEP_MAX = 80 # 里程碑「台阶」机械字数上限(洞③):提示词目标 ≤40 字,机械上限 80,双层防跑飞长文
# ── 库内合同(元数据驱动公理:prompt 与守卫同源,禁手写合同)──
def load_entity_contracts(conn):
"""加载作品面七型的字段合同(走 active_version_id,与章级管线同语义)。"""
contracts = {}
for t in ENTITY_TYPES + (RELATION_TYPE,):
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]
contracts[t] = {"中文名": snap.get("中文名", t), "判据": snap.get("判据", ""),
"字段": [f for f in snap.get("特有字段", [])]}
return contracts
def render_entity_contracts(contracts, types):
"""合同渲染为 markdown 表(观察/更新 prompt 共用)。"""
parts = []
for t in types:
c = contracts[t]
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)
# ── 窗切割(机械,零 AI)──
def cut_windows(conn, work_id):
"""按章边界贪心切正文窗:累计超 3.5 万字或 12 章即断窗。幂等(from_chapter 锚)。"""
rows = conn.execute(
"""SELECT c.order_no, COALESCE(b.word_count, length(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.deleted=FALSE
ORDER BY c.order_no""", (TENANT, work_id)).fetchall()
wins, cur, chars = [], [], 0
for order_no, wc in rows:
if cur and (chars + (wc or 0) > WIN_MAX_CHARS or len(cur) >= WIN_MAX_CHAPS):
wins.append((cur[0], cur[-1]))
cur, chars = [], 0
cur.append(order_no)
chars += (wc or 0)
if cur:
wins.append((cur[0], cur[-1]))
n = 0
for i, (a, b) in enumerate(wins, 1):
r = conn.execute(
"""INSERT INTO example_upgrade_window
(work_id, window_no, from_chapter, to_chapter, tenant_id)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (tenant_id, work_id, from_chapter) DO NOTHING""",
(work_id, i, a, b, TENANT))
n += r.rowcount
conn.commit()
return len(wins), n
# ── 每窗材料与已知名加载 ──
def load_window_material(conn, work_id, a, b):
"""返回窗正文拼接文本与按绝对章号索引的原始正文。"""
rows = conn.execute(
"""SELECT c.order_no, c.title, b2.content_text
FROM muse_content_chapter c
JOIN muse_content_block b2 ON b2.chapter_id=c.id AND b2.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()
return (
"\n\n".join(f"## 第{o}章 {t}\n{x}" for o, t, x in rows),
{int(order_no): str(content or "") for order_no, _, content in rows},
)
def load_window_text(conn, work_id, a, b):
"""兼容旧调用:只返回带章标题的窗内正文拼接文本。"""
return load_window_material(conn, work_id, a, b)[0]
def load_known(conn, work_id):
"""加载判重底册:名字索引、按卡完整合法别名集合与 presence 留档出场章。
aliases_by_draft 同时汇总 payload 与独立别名表,供后续正文实体命中过滤使用。"""
name_map = {}
aliases_by_draft = {}
for did, entity_type, name, brief, aliases in conn.execute(
"""SELECT id,
draft_payload->>'type',
draft_payload->>'名称',
COALESCE(draft_payload->>'一句话摘要',''),
COALESCE(draft_payload->'别名','[]'::jsonb)
FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s AND deleted=FALSE""",
(TENANT, work_id, SOURCE_TYPE)).fetchall():
t, nm = entity_type or "", (name or "").strip()
if nm:
name_map[nm] = (did, t, brief)
for raw_alias in aliases or []:
alias = _clean_alias(raw_alias)
if alias:
name_map[alias] = (did, t, brief)
aliases_by_draft.setdefault(did, set()).add(alias)
for cn, al in conn.execute(
"SELECT canonical_name, alias FROM example_upgrade_alias "
"WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchall():
alias = _clean_alias(al)
if cn in name_map and alias:
target = name_map[cn]
name_map[alias] = target
aliases_by_draft.setdefault(target[0], set()).add(alias)
presence = {}
for t, nm, ch in conn.execute(
"SELECT entity_type, name, chapter_no FROM example_upgrade_presence "
"WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchall():
presence.setdefault((t, nm), set()).add(ch)
return name_map, presence, aliases_by_draft
def prescan(name_map, text):
"""机械预扫(G1 防膨胀核心):只把「本窗正文出现」的已知实体带进观察调用。"""
hit = {}
for nm, (did, t, brief) in name_map.items():
if nm and nm in text:
# 同卡多名字只留一条(正名优先:先插入的是正名)
hit.setdefault(did, (nm, t, brief))
return {nm: (did, t, brief) for did, (nm, t, brief) in hit.items()}
# ── 提示词(缓存友好:固定规则前置,正文窗次之且窗内多调用共享,任务尾置)──
def observe_prompt(contracts, title, a, b, text, onstage):
onstage_lines = "\n".join(f"- {t}|{nm}|{brief}" for nm, (_, t, brief) in onstage.items()) or "(无)"
return f"""【功能指令(parse-book 作品面升格·实体观察)】
通读本窗正文,产出三类结果(只输出一个 JSON 对象):
1) 新名字:正文出现、但「在场已知实体」清单里没有的实体(六型:{"/".join(ENTITY_TYPES)})。每个给:型、名称、别名、一句话摘要、按该型合同能填的字段(有正文证据才填)、出场章号。若你怀疑它其实是清单中某已知实体的别名/改名/化名,填「疑似别名指向」。**凡该型合同含「演变历程」字段的新实体,必须给「演变历程」的首条登场里程碑**(章=首次出场的真实章号,台阶=以什么身份/形态/状态登场的一句话 ≤40 字,周期=登场);本窗另见其进化台阶的,照常在「演变历程」里多给几条。
2) 已知实体新信息:清单中实体在本窗的实质新信息(境界变化/性格显露/重大经历/立场转变),一条 ≤60 字观察点。没有实质新信息的不要报。
3) 纯出场:清单中实体本窗出现但无实质新信息的,只报名称+出场章。
纪律:一次性龙套(单章无名或仅路过)不报进新名字;实体判据与字段以合同为准,无证据不填;不脑补。数值(战力/指数/排名等)必须正文原样出现才可写,禁止推算或编造。组织改组/合并产生的新组织是**新实体**(走新名字),不是旧组织的别名。「疑似别名指向」只在确为同一实体改名/化名时填。
里程碑纪律(凡填「演变历程」字段必守):每条是对象 {{"章": 正文原样出现的真实章号(整数如 420,跨多章连续事件用区间字符串如 "420-423"), "台阶": "进化到什么+靠什么关键事件的一句话(≤40 字,只说跃迁+关键事件,不展开过程)", "周期": 登场/成长/高光/退场/结局 之一, "证据": "所标章节正文原样连续短句(8–30字)"}};**证据必须逐字来自所填章号对应正文,系统会机械核验,错章、缺证据或改写证据均拒收入库;证据只用于校验,不写入卡体**。必须用真实章号,禁止 [窗N] 窗号、禁止"本窗/近期/前段"这类相对指代(卡会脱离运行环境被单独阅读);无法给出章号与原文证据时不要输出该里程碑;当前态字段(品阶/能力与限制/摘要等)只写"现在什么样"的干净值,历史进化流水一律进「演变历程」、不许塞进当前态字段。**体系级纪律(power_system/faction 这类体系/组织卡必守)**:其「演变历程」只收**体系级**变化(代际更替/规则改写/整体兴衰/版图重划);某台具体机甲的战斗、某名成员的晋升这类**个体单位事件**绝不写进体系/组织卡,只记进各自实体卡(无卡则走新名字或纯出场),防止体系卡被单位流水账污染。
【六型字段合同】
{render_entity_contracts(contracts, ENTITY_TYPES)}
【输出规则(只输出一个 JSON 对象)】
{{"新名字": [{{"型": "character", "名称": "", "别名": [], "一句话摘要": "", "字段": {{}}, "出场章": [章号], "疑似别名指向": ""}}],
"已知实体新信息": [{{"名称": "", "观察点": "", "出场章": [章号]}}],
"纯出场": [{{"名称": "", "出场章": [章号]}}]}}
━━━ 本窗材料(每窗不同,非规则)━━━
《{title}》第 {a}–{b} 章正文:
{text}
【在场已知实体】(预扫命中,判重参照)
{onstage_lines}"""
def update_prompt(contracts, title, a, b, text, cards_with_obs):
cards_json = json.dumps([{"draft_id": d, "当前卡": p, "本窗观察点": o}
for d, p, o in cards_with_obs], ensure_ascii=False, indent=1)
return f"""【功能指令(parse-book 作品面升格·卡增量更新)】
下列每张卡给出「当前卡全文」与「本窗观察点」。对照本窗正文,**只输出需要变更的字段**:
- 覆写类字段(性格底色/说话方式/当前状态等标量):**必须输出该字段完整的新全量值**——旧值里仍然成立的信息要保留进新值,禁止只写"新增…"式增量(那会把旧信息抹掉);
- 里程碑字段(演变历程):每当实体发生境界/代际/形态/能力的跃迁,或到达登场/高光/退场/结局节点,**追加**一条里程碑对象 {{"章": 真实章号(整数如 420 或跨章区间字符串 "420-423"), "台阶": "进化到什么+靠什么关键事件的一句话(≤40 字,只说跃迁+关键事件,不展开过程)", "周期": 登场/成长/高光/退场/结局 之一, "证据": "所标章节正文原样连续短句(8–30字)"}}——只输出本窗**新增**里程碑(不重抄旧条目,不输出 _win 等内部键);证据必须逐字来自所填章号对应正文,系统机械核验后丢弃证据字段,错章、缺证据或改写证据均拒收;章必须是正文原样章号、**禁 [窗N] 与"本窗/近期"相对指代**;无法给出章号与证据时不要输出该里程碑;并入观察材料里的别名/判重「初卡材料」时,其中「登场」条目**若本卡演变历程已有登场里程碑则不再重复追加**,其余台阶照常判断追加;**体系级纪律**:power_system/faction 这类体系/组织卡的演变历程只收体系级变化(代际更替/规则改写/整体兴衰/版图重划),某台具体机甲的战斗、某名成员的晋升这类个体单位事件绝不写进体系/组织卡;
- 其他追加类字段(成长弧线=未来计划、演变轨迹等数组):只输出本窗新增条目(不要重抄旧条目,不要自己加 [窗N] 前缀,系统会加);
- **当前态字段干净纪律**:品阶/能力与限制/当前状态/一句话摘要等只写"现在是什么样"的全量当前值——历史进化流水一律进「演变历程」里程碑,**绝不把成长史塞进当前态字段**(这是老卡把升级线塞进「戏剧作用/流转计划/跨体系换算」污染字段语义的病根,务必避免);
- 没有变化的字段不要输出;整卡无实质变化则不输出该卡;
- 该实体的身份/处境已发生重大变化时,把「一句话摘要」也作为变更字段输出(写当前态全量)。
纪律:以正文为证据,不脑补;数值必须正文原样出现,禁止推算;字段 key 必须来自该型合同;**禁止「本窗/本段/近期」等相对时间指代**——写绝对状态或带章号(卡会脱离本窗被单独阅读);只写**该实体自己**的信息(他人的任命/心理/变化不得写入本卡);每卡别名有新发现可在「别名新增」里给——只收**该实体自己**的新别名(他人对它的称呼算,它对别的实体的称呼不算),且必须是可在正文原样出现的纯名字(禁带括号注释与说明文字,禁单字)。
【相关型字段合同】
{render_entity_contracts(contracts, sorted({p.get("type") for _, p, _ in cards_with_obs} & set(ENTITY_TYPES)))}
【输出规则(只输出一个 JSON 对象;即使本批所有卡都无变化,也必须输出 {{"更新": []}},不得省略"更新"键)】
{{"更新": [{{"draft_id": 数字, "变更字段": {{"字段key": "新值或新增条目数组"}}, "别名新增": []}}]}}
━━━ 本窗材料 ━━━
《{title}》第 {a}–{b} 章正文:
{text}
【待更新的卡】
{cards_json}"""
def relation_prompt(contracts, title, a, b, text, char_cards, existing_rels):
chars = "\n".join(f"- draft_id={d}|{p.get('名称')}|{p.get('一句话摘要', '')}"
for d, p in char_cards)
rels = "\n".join(f"- {r.get('甲方名称')} × {r.get('乙方名称')}:{r.get('关系类型', '')}"
for _, r in existing_rels) or "(暂无)"
return f"""【功能指令(parse-book 作品面升格·人物关系增量)】
基于本窗正文,报告下列核心角色**两两之间**的关系变化(新建立的关系 / 已有关系的演变)。
只报有正文证据的实质变化;没有变化输出空数组。
【character_relation 字段合同】
{render_entity_contracts(contracts, [RELATION_TYPE])}
【输出规则(只输出一个 JSON 对象;甲乙用 draft_id 指认)】
{{"关系": [{{"甲方": 数字, "乙方": 数字, "关系类型": "", "本窗演变": "≤60字", "其他字段": {{}}}}]}}
━━━ 本窗材料 ━━━
《{title}》第 {a}–{b} 章正文:
{text}
【核心角色】
{chars}
【已有关系(避免重报建立)】
{rels}"""
# ── 合并与审计(三类字段演进 + 撤销依据)──
WIN_PREFIX_RE = None # 延迟编译(模块顶部 import re 已有)
def _strip_prefix(s):
"""剥条目行首的窗号类前缀(可能多重堆叠;含模型自造的 [窗本窗] 等变体),返回纯内容。"""
import re as _re
global WIN_PREFIX_RE
if WIN_PREFIX_RE is None:
# [窗…]/[本窗…] 任意变体全剥(窗29实测模型自造 "[窗本窗]",仅数字版剥不掉;
# 深空实测又造 "[窗387-388]" 章号范围变体,7 字符超旧上限 6——放宽到 12)
WIN_PREFIX_RE = _re.compile(r"^(?:\[[窗本][^\]]{0,12}\]\s*)+")
return WIN_PREFIX_RE.sub("", str(s)).strip()
TAIL_DEBRIS_RE = None # 尾部 JSON 拼接残渣(延迟编译)
def _strip_tail(s):
"""剥条目尾部的 JSON 拼接残渣(批9 样张走查实证:李锋等 20 张卡 59 处条目
尾挂 ", / '] / "} 等符号——模型把结构化输出的收尾符号带进了条目文本)。反复剥直到干净。"""
import re as _re
global TAIL_DEBRIS_RE
if TAIL_DEBRIS_RE is None:
TAIL_DEBRIS_RE = _re.compile(r"""(?:",|'\]|"\]|"}|'}|',)\s*$""")
t = str(s).rstrip()
while True:
m = TAIL_DEBRIS_RE.search(t)
if not m:
return t
t = t[:m.start()].rstrip()
def _clean_alias(al):
"""别名机械准入(抽检#6):拒收含括号注释/超长的备忘录式别名——预扫精确匹配永不命中=死数据。
单字别名一并拒收(复检 M5:'新'字入表后预扫全文命中率爆炸,纯噪声)。"""
al = (al or "").strip()
if not al or len(al) < 2 or len(al) > 12:
return None
if any(c in al for c in "()()。,,:"):
return None
return al
def _is_garbage(text):
"""结构垃圾检测(抽检 H2 根治):窗75 实测模型把原始变更包字符串化塞进字段值,
7 张卡被 {'draft_id':...} 类程序结构污染。含结构特征的文本一律拒收留审计。"""
t = str(text)
return ("draft_id" in t or "变更字段" in t or "别名新增" in t
or t.lstrip().startswith(("{'", '{"', "[{")))
def _entry_win(x):
"""追加条目的窗号(无 [窗N] 前缀的初卡条目记 0,排最前)。"""
m = re.match(r"^\[窗(\d+)\]", str(x))
return int(m.group(1)) if m else 0
# ── 里程碑对象(升格卡改造 P0):{章,台阶,周期} 结构化条目的清洗/合并 ──
# 章级排序不用运行时窗号,而用真实章号(设计稿 §6.1「真实章号索引」的落点)。
# 内嵌真实章号抽取:正文/台阶里原样出现的「第X章 / X章 / Ch.X」——迁移与降级共用
# 只认"第X章"/"Ch.X"两种明确章号写法。刻意不收裸"N章"——它无法与"隔3章/花了3章篇幅"这类
# 数量词区分,误当章号会污染迁移(且被标成最高置信"内嵌"),违背人工精确化;裸写法一律落到待LLM重抽。
# 带"第"前缀时取首个数字,天然处理"第489-490章"→489(区间取首章、不取末章)。
INLINE_CHAP_RE = re.compile(r"第\s*(\d+)(?:\s*[-—~到]\s*\d+)?\s*章|(?:Ch|CH|ch)\.?\s*(\d+)")
def _extract_inline_chapter(text):
"""从台阶文本抽第一个内嵌真实章号(第X章/ChX/X章),抽不到返回 None。"""
m = INLINE_CHAP_RE.search(str(text))
if not m:
return None
return int(next(g for g in m.groups() if g))
def _chapter_sort_key(ch):
"""里程碑排序键:从「章」值(整数 / 区间字符串"420-423" / None)取起点章号;
缺失或非法排到最后(CHAP_BIG),保证"待人工"条目不插进正常时间线中间。"""
if isinstance(ch, int):
return ch
if isinstance(ch, str):
m = re.search(r"\d+", ch)
if m:
return int(m.group())
return CHAP_BIG
def _int_chaps(chaps):
"""出场章归一化为整数集合(窗113 实证 bug 修复):模型偶尔把章号输出成字符串(如 "508"),
与库内 int 章号一起 sorted 会炸('<' not supported between int and str,深空窗113 当场停书);
且 set 并集把 "508" 和 508 当两个值、会虚增跨章计数误判立卡门槛。这里把数字串强制转 int、
非数字(脏值/区间)丢弃——出场章只应是单章整数(区间只出现在里程碑「章」,不在出场章)。
bool 是 int 子类,显式排除以防 True/False 混入被当章号。"""
out = set()
for c in (chaps or []):
if isinstance(c, bool):
continue
if isinstance(c, int):
out.add(c)
elif isinstance(c, str) and c.strip().isdigit():
out.add(int(c.strip()))
return out
def _infer_lifecycle(text):
"""从台阶文本启发式推断生命周期枚举(模型未给或非法「周期」时的降级填充)。
诚实边界:这是关键词启发式、非精确判定;新抽取由提示词强制模型直接给枚举,此路仅兜底。"""
t = str(text)
if any(w in t for w in ("登场", "首次", "初次", "出场", "诞生", "创立", "问世", "面世")):
return "登场"
if any(w in t for w in ("退役", "封存", "陨落", "覆灭", "销毁", "谢幕", "退场", "解散", "湮灭")):
return "退场"
if any(w in t for w in ("结局", "终局", "最终", "决战", "了结", "落幕")):
return "结局"
if any(w in t for w in ("巅峰", "高光", "对决", "突破", "解放", "觉醒", "封神", "登顶", "碾压")):
return "高光"
return "成长"
def _milestone_chapters(value):
"""把单章或闭区间章号转为整数列表;含糊格式直接返回空列表。"""
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
return [value]
if not isinstance(value, str):
return []
match = re.fullmatch(r"\s*(\d+)\s*(?:[-~—–至]\s*(\d+)\s*)?", value)
if not match:
return []
start = int(match.group(1))
end = int(match.group(2) or start)
if start <= 0 or end < start or end - start > 50:
return []
return list(range(start, end + 1))
def _normalize_evidence_text(value):
"""证据比对忽略排版空白和宽窄差异,但保留正文字符与标点。"""
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(value or "")))
GENERIC_ADDRESS_ALIASES = frozenset({
"小姐", "少爷", "先生", "女士", "夫人", "大人", "老师", "师父", "师傅",
"老板", "医生",
# 军职:只禁裸职务词,带专名的完整名称仍可作为精确证据。
"队长", "舰长", "指挥官", "总指挥", "司令", "司令官", "舰队司令",
"统帅", "元帅", "将军", "统领", "军长", "师长", "旅长", "团长",
"营长", "连长", "排长", "班长", "参谋长",
# 组织与学校职务。
"首领", "会长", "副会长", "理事长", "董事长", "社长", "主任", "主管",
"经理", "部长", "局长", "处长", "科长", "厂长", "院长", "副院长",
"校长", "副校长", "系主任", "教授", "导师", "教官",
# 宗门、帮派与家族职务。
"宗主", "门主", "掌门", "掌门人", "长老", "大长老", "太上长老",
"护法", "教主", "帮主", "盟主", "峰主", "堂主", "宫主", "家主",
"族长", "少主", "圣子", "圣女",
# 领地与王室称谓。
"领主", "城主", "堡主", "庄主", "国王", "女王", "皇帝", "皇后",
"王后", "王爷", "王妃", "王子", "公主", "太子", "皇太子", "亲王",
"太后", "陛下", "殿下",
"父亲", "母亲", "爸爸", "妈妈", "哥哥", "姐姐", "弟弟", "妹妹",
"丈夫", "妻子", "夫君", "兄长", "师兄", "师姐", "师弟", "师妹",
"叔叔", "阿姨", "爷爷", "奶奶",
})
TOP_LEVEL_APPEARANCE_AUDIT_FIELD = "顶层:出场章"
def _filter_entity_chapters(name, aliases, chaps, chapter_texts):
"""只保留实体规范名或合法别名在对应章节正文真实出现的模型出场章。
规范名按原样参与机械匹配;别名先经过既有准入规则,避免单字、括号注释等噪声
被当作实体证据。章号与正文都做既有宽窄、空白归一化,但不做语义猜测。"""
if not isinstance(chapter_texts, dict):
return set()
names = []
canonical = str(name or "").strip()
# 与立卡名称清洗保持一致:括号前是规范名,括号内容只有通过别名准入后才能成为匹配证据。
parenthetical = re.match(r"^(.+?)[((](.+?)[))]\s*$", canonical)
if parenthetical:
canonical = parenthetical.group(1).strip()
aliases = list(aliases or []) + [parenthetical.group(2)]
# 单字规范名在中文正文中假阳率极高,不得单独作为机械章证据;常规二至四字专名仍按原子串规则匹配。
if len(canonical) >= 2 and canonical not in GENERIC_ADDRESS_ALIASES:
names.append((canonical, False))
names.extend(
(alias, True)
for alias in (_clean_alias(value) for value in (aliases or []))
# 通用称谓/关系称呼即使在正文独立出现也无法唯一指向该卡,保守地完全禁作出场章证据。
if alias and alias not in GENERIC_ADDRESS_ALIASES
)
normalized_names = {
(unicodedata.normalize("NFKC", value), is_alias)
for value, is_alias in names
if value
}
if not normalized_names:
return set()
verified = set()
for chapter in _int_chaps(chaps):
if chapter not in chapter_texts:
continue
text = unicodedata.normalize("NFKC", str(chapter_texts[chapter] or ""))
for entity_name, _ in normalized_names:
if entity_name in text:
verified.add(chapter)
break
return verified
def _append_verified_appearance_chapters(
conn,
draft_id,
win_no,
payload,
appearance_chapters,
chapter_texts,
*,
known_aliases=None,
):
"""过滤并追加顶层出场章,同时审计完整旧值,供同窗撤销精确还原。"""
verified = _filter_entity_chapters(
payload.get("名称"),
list(payload.get("别名") or []) + list(known_aliases or []),
appearance_chapters,
chapter_texts,
)
if not verified:
return False
merged = sorted(_int_chaps(payload.get("出场章", [])) | verified)
if payload.get("出场章") == merged:
return False
old_value = json.dumps(payload.get("出场章"), ensure_ascii=False) \
if "出场章" in payload else None
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s)""",
(draft_id, win_no, TOP_LEVEL_APPEARANCE_AUDIT_FIELD, old_value,
json.dumps(merged, ensure_ascii=False), TENANT),
)
payload["出场章"] = merged
return True
def _milestone_evidence_matches(item, chapter_texts):
"""验证短原文证据确实存在于里程碑声明的章节正文。"""
if not isinstance(item, dict) or not isinstance(chapter_texts, dict):
return False
evidence = _normalize_evidence_text(item.get("证据"))
if not 8 <= len(evidence) <= 30:
return False
chapters = _milestone_chapters(item.get("章"))
if not chapters or any(chapter not in chapter_texts for chapter in chapters):
return False
return any(
evidence in _normalize_evidence_text(chapter_texts[chapter])
for chapter in chapters
)
def _walk_milestone_items(value, path="$"):
"""稳定遍历模型输出中的里程碑对象,并生成可回填的 JSON 路径。"""
if isinstance(value, dict):
if "章" in value and ("台阶" in value or "阶" in value):
yield path, value
return
for key, child in value.items():
yield from _walk_milestone_items(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
yield from _walk_milestone_items(child, f"{path}[{index}]")
def repair_missing_milestone_evidence(
model_output,
*,
title,
a,
b,
text,
chapter_texts,
call,
):
"""为本窗缺证据里程碑补原文短引;窗外重抄项不进入修复调用。"""
candidates = []
by_ref = {}
for ref, item in _walk_milestone_items(model_output):
if _milestone_evidence_matches(item, chapter_texts):
continue
chapters = _milestone_chapters(item.get("章"))
if not chapters or any(chapter not in chapter_texts for chapter in chapters):
continue
candidates.append(
{
"ref": ref,
"章": item.get("章"),
"台阶": item.get("台阶") or item.get("阶"),
}
)
by_ref[ref] = item
if not candidates:
return 0
prompt = f"""【功能指令(parse-book 里程碑证据修复)】
下列候选里程碑缺少可机械验证的证据。只为确实被正文支持的候选返回一条所标章节中的原文连续短句(8–30字);不得改章号、不得改台阶、不得概括或改写原文。找不到逐字证据的候选不要返回。
【输出规则(只输出一个 JSON 对象)】
{{"证据修复":[{{"ref":"候选ref原样回传","章":489,"证据":"正文原样连续短句"}}]}}
━━━ 候选里程碑 ━━━
{json.dumps(candidates, ensure_ascii=False)}
━━━ 《{title}》第 {a}–{b} 章正文 ━━━
{text}
"""
repaired, _ = call(prompt, ("证据修复",))
count = 0
for row in repaired.get("证据修复") or []:
if not isinstance(row, dict):
continue
ref = str(row.get("ref") or "")
item = by_ref.get(ref)
if item is None or _milestone_chapters(row.get("章")) != _milestone_chapters(item.get("章")):
continue
item["证据"] = row.get("证据")
if _milestone_evidence_matches(item, chapter_texts):
count += 1
else:
item.pop("证据", None)
return count
def _clean_milestone(item, win_no, *, chapter_texts=None, require_evidence=False):
"""规范化单个里程碑为 {章,台阶,周期,_win};垃圾/空台阶返回 None。
- dict 入参:取 章/台阶/周期;台阶剥前缀+剥尾残;缺章从台阶抽内嵌章号兜底;缺/非法周期启发式推断。
- 字符串入参(模型降级输出或存量迁移):整串当台阶,抽内嵌章号当章,推断周期。
- _win 盖当前窗号,仅作撤销溯源(undo_window 按它删本窗新增),不参与展示与排序。
降级保底(设计稿 §九 风险2 + 拍板#1):抽不出完整对象也退成"章号+一句话"最小对象,绝不整条丢。"""
if require_evidence and not _milestone_evidence_matches(item, chapter_texts):
return None
if isinstance(item, dict):
step = _strip_tail(_strip_prefix(str(item.get("台阶") or item.get("阶") or "")))
ch = item.get("章")
cycle = item.get("周期")
else:
step = _strip_tail(_strip_prefix(str(item)))
ch, cycle = None, None
if not step or _is_garbage(step):
return None
# 章号:对象已给(整数或含数字的区间字符串)就用;否则从台阶文本抽内嵌章号;再无则 None(待人工)
if not (isinstance(ch, int) or (isinstance(ch, str) and re.search(r"\d", ch))):
ch = _extract_inline_chapter(step)
if cycle not in LIFECYCLE:
cycle = _infer_lifecycle(step)
# 台阶机械守卫(洞③):超 STEP_MAX 字直接截断——提示词只是软目标(≤40 字),这里是硬闸(≤80 字)
# 防模型把整段流水写进一条台阶跑飞长文。截断必须放在章号抽取与周期推断**之后**:跑飞长文正是
# 依赖降级兜底的场景,藏在 80 字外的内嵌章号/周期关键词若先被截掉,兜底就瞎了(用全文抽、抽完再截)。
# 兜底登场台阶(_debut_milestone)同用 STEP_MAX,两处一致。
if len(step) > STEP_MAX:
step = step[:STEP_MAX]
return {"章": ch, "台阶": step, "周期": cycle, "_win": win_no}
def _merge_milestones(old, items, win_no, *, chapter_texts=None, require_evidence=False):
"""合并里程碑数组:去重按台阶内容、排序按真实章号。返回 (merged, rejected)。
old 中已有对象保留其原 _win(不被本窗覆盖);新对象由 _clean_milestone 盖当前 win_no。
rejected 为被判垃圾的原始条目,交调用方留审计(对齐字符串路径的垃圾拦截)。"""
kept = [m for m in (old or []) if isinstance(m, dict) and m.get("台阶")]
seen = {str(m.get("台阶", "")).strip() for m in kept}
rejected = []
for it in (items if isinstance(items, list) else [items]):
m = _clean_milestone(
it,
win_no,
chapter_texts=chapter_texts,
require_evidence=require_evidence,
)
if not m:
rejected.append(it)
continue
key = m["台阶"].strip()
if key and key not in seen:
kept.append(m)
seen.add(key)
return sorted(kept, key=lambda m: _chapter_sort_key(m.get("章"))), rejected
def _merge_material(ent, rest_limit=600):
"""归并材料构造(洞②:登场里程碑不丢,纯函数便于离线自测)。四条归并路径共用的「初卡材料」文本:
型/名称/一句话摘要 + 「演变历程」条目**完整保留不截断** + 其余字段 json 截断(其余部分上限 rest_limit)。
根治病象:原四路都传 json.dumps(ent)[:400](观察漏看路更只传摘要),会把里程碑尤其登场条目截掉——
并入既有卡后登场/进化台阶信息静默丢失。这里把演变历程整段拎出不截,只截其余字段防超长。"""
fields = ent.get("字段") or {}
ms = fields.get("演变历程")
milestones = ms if isinstance(ms, list) else ([ms] if ms else [])
rest = {k: v for k, v in fields.items() if k not in MILESTONE_FIELDS}
seg = [f"型={ent.get('型', '')}|名称={ent.get('名称', '')}|摘要:{ent.get('一句话摘要', '')}"]
if milestones: # 演变历程完整保留(登场/进化台阶是归并关键,绝不截断)
seg.append("演变历程:" + json.dumps(milestones, ensure_ascii=False))
if rest: # 其余字段可容忍截断(防超长撑爆更新 prompt)
seg.append("其余字段:" + json.dumps(rest, ensure_ascii=False)[:rest_limit])
return ";".join(seg)
def _debut_milestone(milestones, brief, chaps, win_no):
"""登场兜底(洞② 机械那一保险,纯函数便于离线自测):里程碑列表里若没有任何 周期=登场 的条目,
在头部补一条兜底登场里程碑;已有登场则原样返回(提示词软约束 + 此机械兜底=双保险)。
- 章:min(正文实证出场章)(仅取整数章号);无实证章则不补,真实性优先;
- 台阶:"登场:"+一句话摘要截 40 字,再过 STEP_MAX 守卫(与 _clean_milestone 同上限);
- _win:盖当前窗号——带 _win 才能被同窗撤销(undo_window 按 _win 删本窗新增)识别,防重跑 double-append。
根治病象:实体首现走新名字路径时模型倾向只填当前态、漏建登场里程碑,机械补一条保成长线起点不缺。"""
if any(isinstance(m, dict) and m.get("周期") == "登场" for m in (milestones or [])):
return list(milestones or [])
ch_ints = sorted(_int_chaps(chaps))
if not ch_ints:
return list(milestones or [])
debut = {"台阶": ("登场:" + (brief or "")[:40])[:STEP_MAX], "周期": "登场", "_win": win_no}
debut["章"] = ch_ints[0]
return [debut] + list(milestones or [])
def merge_card(
conn,
draft_id,
win_no,
changes,
alias_new,
valid_keys=None,
*,
chapter_texts=None,
appearance_chapters=None,
known_aliases=None,
):
"""按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号),
标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。
valid_keys:该型合同的合法字段 key 集——越合同 key 裁剪留审计(窗29实测模型把
整段条目文本误当字段 key 写入,无校验会把卡体字段区打烂)。"""
payload = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(draft_id,)).fetchone()[0]
fields = payload.setdefault("字段", {})
# 卡水位(该卡最后一次被更新的窗号):补跑迟到窗(如窗41在窗80后补跑)的覆写类字段
# 若直接落卡,会把书末态倒写回中期态(时间倒流污染,2026-07-15 补跑实测坐实)。
# 闸门:win_no < 水位 ⇒ 覆写只留审计不动卡体;追加类带窗号标签乱序无害,不拦。
wm_row = conn.execute("SELECT watermark_window FROM example_upgrade_card_state WHERE draft_id=%s",
(draft_id,)).fetchone()
watermark = wm_row[0] if wm_row else 0
changes = dict(changes or {})
# 模型偶把「别名新增」混进变更字段(窗2实测):摘出来并入别名流程,不落卡体字段
alias_new = list(alias_new or []) + \
[a for a in (changes.pop("别名新增", None) or []) if isinstance(a, str)]
# alias 表可能有尚未回填 payload 的合法别名;与本窗新别名一起参与过滤。统一 helper 同时写入可撤销审计。
_append_verified_appearance_chapters(
conn,
draft_id,
win_no,
payload,
appearance_chapters,
chapter_texts,
known_aliases=list(known_aliases or []) + list(alias_new),
)
for k, v in changes.items():
if valid_keys is not None and k not in valid_keys:
# 越合同字段:裁剪留审计(方案守卫条款),畸形长 key(条目误当 key)一并挡下
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, ("越合同:" + str(k))[:100],
json.dumps(v, ensure_ascii=False)[:2000], TENANT))
continue
if k == "一句话摘要":
# 摘要住卡顶层而非"字段"子对象——此前写进子对象成"影子摘要"且顶层摘要
# 从不更新(抽检 M4:600 章前的旧摘要一直挂着)。特判写顶层,同守水位闸。
if isinstance(v, str) and v.strip() and not _is_garbage(v):
if win_no < watermark:
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, "迟到覆写弃用:一句话摘要",
json.dumps(v, ensure_ascii=False)[:2000], TENANT))
else:
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s)""",
(draft_id, win_no, "一句话摘要",
json.dumps(payload.get("一句话摘要"), ensure_ascii=False),
json.dumps(v.strip(), ensure_ascii=False), TENANT))
payload["一句话摘要"] = v.strip()
continue
if k in MILESTONE_FIELDS:
# 里程碑对象数组(演变历程):走对象合并路径——去重按台阶内容、排序按真实章号,
# 不走下面处理字符串条目([窗N] 前缀)的老路径。迟到窗追加无害:对象自带章号,
# 乱序由 _chapter_sort_key 排序纠正,故不设水位闸(与字符串追加同策略)。
base = fields.get(k) if isinstance(fields.get(k), list) else ([fields[k]] if fields.get(k) else [])
merged, rejected = _merge_milestones(
base,
v,
win_no,
chapter_texts=chapter_texts,
require_evidence=True,
)
for rj in rejected:
# 垃圾/空台阶里程碑拒收留审计(对齐字符串路径的垃圾拦截守卫)
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, "垃圾拦截:演变历程",
json.dumps(rj, ensure_ascii=False)[:2000], TENANT))
fields[k] = merged
continue
is_append = k in APPEND_FIELDS or isinstance(v, list) or isinstance(fields.get(k), list)
# 覆写值以增量口吻开头=模型把增量当全量(抽检#4:新值会抹掉旧基线),转追加不覆写
if not is_append and isinstance(v, str) and \
any(v.lstrip().startswith(w) for w in ("新增", "本窗", "另外", "此外")):
is_append = True
if is_append:
items = v if isinstance(v, list) else [v]
old = fields.get(k) if isinstance(fields.get(k), list) else ([fields[k]] if fields.get(k) else [])
seen = {_strip_prefix(x) for x in old} # 同文去重(抽检#1 重复病)
for it in items:
# 巨型粘连拆分(唐灵窗51 实测:模型把整个历史弧线连成一条"…;[窗2]…;[窗3]…"
# 输出成单条目)——按";[窗N]"边界拆段,带原窗号的段保留原窗号,其余记本窗
for seg in re.split(r";\s*(?=\[[窗本])", str(it)):
m0 = re.match(r"^\[窗(\d+)\]\s*(.*)", seg.strip(), flags=re.S)
seg_win, body = (int(m0.group(1)), m0.group(2)) if m0 else (win_no, seg)
core = _strip_tail(_strip_prefix(body)) # 剥模型自带前缀+尾部拼接残渣
if core and _is_garbage(core):
# 结构垃圾条目拒收留审计(窗75 事故根治:变更包字符串化混进字段值)
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, ("垃圾拦截:" + str(k))[:100],
str(core)[:2000], TENANT))
continue
if core and core not in seen:
old.append(f"[窗{seg_win}] {core}")
seen.add(core)
# 窗序归位(抽检 M2:补跑/重试窗条目尾插致时间线倒流)——稳定排序,同窗保持原序
fields[k] = sorted(old, key=_entry_win)
elif isinstance(v, str) and _is_garbage(v):
# 结构垃圾覆写值拒收留审计(同窗75 事故根治)
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, ("垃圾拦截:" + str(k))[:100], v[:2000], TENANT))
elif win_no < watermark:
# 迟到覆写弃用:本窗时序早于卡已生长到的窗位,覆写会让卡态倒流——
# 只留审计(标记可查),卡体保持高窗态不动
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(draft_id, win_no, ("迟到覆写弃用:" + str(k))[:100],
json.dumps(v, ensure_ascii=False)[:2000], TENANT))
else:
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s)""",
(draft_id, win_no, k, json.dumps(fields.get(k), ensure_ascii=False)
if fields.get(k) is not None else None,
json.dumps(v, ensure_ascii=False), TENANT))
# 覆写值同样剥尾部拼接残渣(批9 样张走查同款病灶,覆写路一并守住)
fields[k] = _strip_tail(v) if isinstance(v, str) else v
for al0 in alias_new or []:
al = _clean_alias(al0)
if al and al != payload.get("名称"):
payload.setdefault("别名", [])
if al not in payload["别名"]:
payload["别名"].append(al)
conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,'ai',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(payload.get("_work_id") or 0, payload.get("名称"), al, win_no, TENANT))
conn.execute(
"""UPDATE muse_knowledge_draft SET draft_payload=%s, revision=revision+1,
updater='upgrade' WHERE id=%s""",
(json.dumps(payload, ensure_ascii=False), draft_id))
conn.execute(
"""INSERT INTO example_upgrade_card_state (draft_id, work_id, watermark_window, tenant_id)
VALUES (%s,%s,%s,%s)
ON CONFLICT (draft_id) DO UPDATE
SET watermark_window=GREATEST(example_upgrade_card_state.watermark_window,
EXCLUDED.watermark_window),
update_time=now()""",
(draft_id, payload.get("_work_id") or 0, win_no, TENANT))
def new_card(
conn,
work_id,
win_no,
ent,
milestone_types=None,
*,
chapter_texts=None,
known_chapters=None,
):
"""立初卡:payload 全字段以旧值=NULL 入审计(G7,错认拆回可还原初始态)。
milestone_types(洞②):含「演变历程」字段的型集合(据库内合同动态判定)——该型立卡时若抽取结果
无登场里程碑,机械兜底补一条登场(保成长型实体登场即有生命周期起点);None/空集则不触发兜底。
名称剥括号注(抽检#7 根因):「白色游魂(无名侦察兵)」这类名称使预扫精确匹配失明
——主名之外的括号内容若像名字则转别名,否则丢弃。"""
import re as _re
raw = ent["名称"].strip()
m = _re.match(r"^(.+?)[((](.+?)[))]\s*$", raw)
extra_alias = []
if m:
raw = m.group(1).strip()
note = _clean_alias(m.group(2))
if note:
extra_alias.append(note)
aliases = [
alias
for alias in ([_clean_alias(value) for value in ent.get("别名", [])] + extra_alias)
if alias
]
# 初卡字段过守卫(深空 4917 实测:立卡路不走 merge_card,粘连/自造前缀/垃圾
# 原样入库——更新路守了、立卡路漏了):列表值逐条拆分、剥前缀、垃圾拦截、带窗号
fields0 = {}
for k, v in (ent.get("字段", {}) or {}).items():
if k in MILESTONE_FIELDS:
# 里程碑字段(演变历程):初卡即走对象合并(登场/首个进化台阶),去重排序;
# 初卡尚无 draft_id 无法留审计,垃圾条目直接过滤(与下方字符串路径初卡同策略)。
merged, _ = _merge_milestones(
[],
v,
win_no,
chapter_texts=chapter_texts,
require_evidence=True,
)
fields0[k] = merged
continue
if not isinstance(v, list):
fields0[k] = v
continue
out, seen = [], set()
for it in v:
for seg in re.split(r";\s*(?=\[[窗本])", str(it)):
core = _strip_tail(_strip_prefix(seg))
if not core or _is_garbage(core) or core in seen:
continue
out.append(f"[窗{win_no}] {core}")
seen.add(core)
fields0[k] = out
# 当前窗模型章必须逐章命中实体名;历史留档章来自既有机械留档,单独并入,不能因本窗正文不含历史章而误删。
chaps_int = sorted(
_filter_entity_chapters(raw, aliases, ent.get("出场章", []), chapter_texts)
| _int_chaps(known_chapters)
)
# 洞② 登场兜底:该型合同含「演变历程」但抽取结果无登场里程碑时,机械补一条登场(出场章 min + 摘要)——
# 模型倾向只填当前态、漏建登场,此为「提示词硬约束 + 机械兜底」双保险里的机械那一保险。
if milestone_types and ent.get("型") in milestone_types:
fields0["演变历程"] = _debut_milestone(
fields0.get("演变历程") or [], ent.get("一句话摘要", ""), chaps_int, win_no)
payload = {"type": ent["型"], "名称": raw,
"别名": aliases,
"一句话摘要": ent.get("一句话摘要", ""),
"字段": fields0,
"出场章": chaps_int,
"来源": f"升格@窗{win_no}", "状态": "草稿",
"目标库": "本书作品库", "可见范围": "本书私有",
"_work_id": work_id}
did = conn.execute(
"""INSERT INTO muse_knowledge_draft
(work_id, draft_type, draft_payload, status, source_type, source_id,
creator, updater, tenant_id)
VALUES (%s,'entity',%s,'pending',%s,%s,'upgrade','upgrade',%s) RETURNING id""",
(work_id, json.dumps(payload, ensure_ascii=False), SOURCE_TYPE, work_id,
TENANT)).fetchone()[0]
for k, v in payload["字段"].items():
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(did, win_no, k, json.dumps(v, ensure_ascii=False), TENANT))
# 初始别名同步入 alias 表(抽检 L3 根源:立卡别名只写卡内不入表,
# 别名表覆盖率仅 26%,"别名精确判重"层大面积空转导致同人两卡漏并)
for al in payload["别名"]:
conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,'init',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, raw, al, win_no, TENANT))
conn.execute(
"""INSERT INTO example_upgrade_card_state (draft_id, work_id, watermark_window, tenant_id)
VALUES (%s,%s,%s,%s) ON CONFLICT (draft_id) DO NOTHING""",
(did, work_id, win_no, TENANT))
return did
def _snapshot_redo_window(conn, work_id, win_no):
"""抓取显式 redo 前的完整可恢复状态,不让数据库连接跨 LLM 调用存活。"""
snapshot = {
"window_no": win_no,
"drafts": conn.execute(
"""SELECT id, draft_payload, revision, updater, deleted
FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s""",
(TENANT, work_id, SOURCE_TYPE),
).fetchall(),
"aliases": conn.execute(
"""SELECT id, canonical_name, alias, evidence_window, verdict_by,
creator, create_time, updater, update_time, deleted
FROM example_upgrade_alias
WHERE tenant_id=%s AND work_id=%s""",
(TENANT, work_id),
).fetchall(),
"presence": conn.execute(
"""SELECT id, window_no, chapter_no, entity_type, name, observation,
creator, create_time, deleted
FROM example_upgrade_presence
WHERE tenant_id=%s AND work_id=%s""",
(TENANT, work_id),
).fetchall(),
"card_states": conn.execute(
"""SELECT draft_id, watermark_window, update_time
FROM example_upgrade_card_state
WHERE tenant_id=%s AND work_id=%s""",
(TENANT, work_id),
).fetchall(),
"audits": conn.execute(
"""SELECT a.id, a.draft_id, a.window_no, a.field_name,
a.old_value, a.new_value, a.create_time
FROM example_upgrade_audit a
JOIN muse_knowledge_draft d ON d.id=a.draft_id
WHERE a.tenant_id=%s AND d.work_id=%s AND d.source_type=%s""",
(TENANT, work_id, SOURCE_TYPE),
).fetchall(),
"window": conn.execute(
"""SELECT status, error_message, updater FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",
(TENANT, work_id, win_no),
).fetchone(),
}
return deepcopy(snapshot)
def _restore_redo_window(conn, work_id, snapshot):
"""原子恢复 redo 前状态;重试中新建卡只做软删,保留全局编号与引用安全。"""
old_draft_ids = {row[0] for row in snapshot["drafts"]}
current_draft_ids = {
row[0]
for row in conn.execute(
"""SELECT id FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s""",
(TENANT, work_id, SOURCE_TYPE),
).fetchall()
}
for draft_id in current_draft_ids - old_draft_ids:
conn.execute("UPDATE muse_knowledge_draft SET deleted=TRUE WHERE id=%s", (draft_id,))
for draft_id, payload, revision, updater, deleted in snapshot["drafts"]:
conn.execute(
"""UPDATE muse_knowledge_draft
SET draft_payload=%s, revision=%s, updater=%s, deleted=%s
WHERE id=%s AND tenant_id=%s AND work_id=%s AND source_type=%s""",
(json.dumps(payload, ensure_ascii=False), revision, updater, deleted,
draft_id, TENANT, work_id, SOURCE_TYPE),
)
conn.execute(
"DELETE FROM example_upgrade_alias WHERE tenant_id=%s AND work_id=%s",
(TENANT, work_id),
)
for row in snapshot["aliases"]:
conn.execute(
"""INSERT INTO example_upgrade_alias
(id, work_id, canonical_name, alias, evidence_window, verdict_by,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(row[0], work_id, *row[1:], TENANT),
)
conn.execute(
"DELETE FROM example_upgrade_presence WHERE tenant_id=%s AND work_id=%s",
(TENANT, work_id),
)
for row in snapshot["presence"]:
conn.execute(
"""INSERT INTO example_upgrade_presence
(id, work_id, window_no, chapter_no, entity_type, name, observation,
creator, create_time, deleted, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(row[0], work_id, *row[1:], TENANT),
)
conn.execute(
"DELETE FROM example_upgrade_card_state WHERE tenant_id=%s AND work_id=%s",
(TENANT, work_id),
)
for draft_id, watermark, update_time in snapshot["card_states"]:
conn.execute(
"""INSERT INTO example_upgrade_card_state
(draft_id, work_id, watermark_window, update_time, tenant_id)
VALUES (%s,%s,%s,%s,%s)""",
(draft_id, work_id, watermark, update_time, TENANT),
)
conn.execute(
"""DELETE FROM example_upgrade_audit
WHERE tenant_id=%s AND draft_id IN
(SELECT id FROM muse_knowledge_draft WHERE work_id=%s AND source_type=%s)""",
(TENANT, work_id, SOURCE_TYPE),
)
for row in snapshot["audits"]:
conn.execute(
"""INSERT INTO example_upgrade_audit
(id, draft_id, window_no, field_name, old_value, new_value,
create_time, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(*row, TENANT),
)
if snapshot["window"]:
status, error_message, updater = snapshot["window"]
conn.execute(
"""UPDATE example_upgrade_window SET status=%s, error_message=%s, updater=%s
WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",
(status, error_message, updater, TENANT, work_id, snapshot.get("window_no")),
)
def undo_window(conn, work_id, win_no, *, from_chapter=None, to_chapter=None):
"""同窗重跑先撤销;显式 redo 可附带章域,迁移清理历史无顶层审计的出场章。"""
# 与向量的互动(洞①,不改本函数逻辑,仅说明边界):软删卡的向量靠召回 SQL 的 JOIN d.deleted=FALSE
# 天然排除,无需在此动嵌入行;被回滚的更新卡向量暂时偏新(对应已撤销的内容),重跑后嵌段
# (embed_touched_cards)按当前 payload 重算哈希、软删旧活行 + upsert 复活,自愈到正确态。
# 还原覆写字段(倒序还原,先写的最后还原到最初旧值)
rows = conn.execute(
"""SELECT a.draft_id, a.field_name, a.old_value FROM example_upgrade_audit a
JOIN muse_knowledge_draft d ON d.id=a.draft_id
WHERE a.tenant_id=%s AND d.work_id=%s AND a.window_no=%s ORDER BY a.id DESC""",
(TENANT, work_id, win_no)).fetchall()
for did, fname, old in rows:
payload = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0]
if fname == TOP_LEVEL_APPEARANCE_AUDIT_FIELD:
# 顶层出场章不在「字段」对象内:旧值为空表示原键不存在,否则按审计原 JSON 精确恢复。
if old is None:
payload.pop("出场章", None)
else:
payload["出场章"] = json.loads(old) if isinstance(old, str) else old
elif old is None:
payload.get("字段", {}).pop(fname, None)
else:
payload.setdefault("字段", {})[fname] = json.loads(old)
conn.execute("UPDATE muse_knowledge_draft SET draft_payload=%s WHERE id=%s",
(json.dumps(payload, ensure_ascii=False), did))
conn.execute("""DELETE FROM example_upgrade_audit WHERE tenant_id=%s AND window_no=%s
AND draft_id IN (SELECT id FROM muse_knowledge_draft WHERE work_id=%s)""",
(TENANT, win_no, work_id))
# 删本窗追加条目([窗N] 前缀)与初立于本窗的卡(audit 已删,靠 card_state 水位判初窗不可靠,
# 初卡以「来源=升格@窗N」标记识别)
for did, payload in conn.execute(
"""SELECT id, draft_payload FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s AND deleted=FALSE""",
(TENANT, work_id, SOURCE_TYPE)).fetchall():
if payload.get("来源") == f"升格@窗{win_no}":
conn.execute("UPDATE muse_knowledge_draft SET deleted=TRUE WHERE id=%s", (did,))
# 同步清卡水位行(fable 复验实证:深空回滚删 400+ 初卡后 card_state 僵尸行
# 残留,按 draft JOIN 不过滤 deleted 的查询会捞出僵尸卡)
conn.execute("DELETE FROM example_upgrade_card_state WHERE draft_id=%s", (did,))
continue
tag, changed = f"[窗{win_no}] ", False
if isinstance(from_chapter, int) and isinstance(to_chapter, int) \
and from_chapter <= to_chapter and isinstance(payload.get("出场章"), list):
old_chapters = payload["出场章"]
kept_chapters = [
value
for value in old_chapters
if not any(from_chapter <= chapter <= to_chapter
for chapter in _int_chaps([value]))
]
if kept_chapters != old_chapters:
# 修复前历史章没有审计:首次显式 redo 清理时补写完整旧值。
# 后续正文处理失败会走无章域 undo,按此审计恢复到清理前状态。
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s)""",
(did, win_no, TOP_LEVEL_APPEARANCE_AUDIT_FIELD,
json.dumps(old_chapters, ensure_ascii=False),
json.dumps(kept_chapters, ensure_ascii=False), TENANT),
)
payload["出场章"] = kept_chapters
changed = True
for k, v in list(payload.get("字段", {}).items()):
if isinstance(v, list):
# 字符串条目按 [窗N] 前缀删;里程碑对象(演变历程)按内部 _win 溯源键删——
# 对象无窗号前缀,撤销靠 _win 精确识别本窗新增,否则同窗重跑会 double-append。
nv = [x for x in v
if not (isinstance(x, str) and x.startswith(tag))
and not (isinstance(x, dict) and x.get("_win") == win_no)]
if len(nv) != len(v):
payload["字段"][k], changed = nv, True
if changed:
conn.execute("UPDATE muse_knowledge_draft SET draft_payload=%s WHERE id=%s",
(json.dumps(payload, ensure_ascii=False), did))
conn.execute("DELETE FROM example_upgrade_presence WHERE tenant_id=%s AND work_id=%s AND window_no=%s",
(TENANT, work_id, win_no))
conn.execute("""DELETE FROM example_upgrade_alias WHERE tenant_id=%s AND work_id=%s
AND evidence_window=%s""", (TENANT, work_id, win_no))
# ── 机械判重分类(洞①):预判段与写段共用的纯函数,把判据抽出防两处漂移 ──
def _classify_new_name(ent, name_map, presence):
"""机械判重分类(纯函数,无副作用;洞①):把一个「新名字」实体归到判重分支之一,返回 (kind, key):
('empty', None) 名称为空 → 跳过
('merge', nm) nm 已在底册(观察漏看在场清单)→ 直接归并到 name_map[nm]
('alias', hint) 疑似别名指向底册中实体 → 并入 name_map[hint]
('substr', 既有名) 同型名称互为子串 → 并入 name_map[该既有名]
('new', None) 跨章(含跨窗合计 ≥2 章)新实体 → 立卡(语义判重在此候选上做)
('presence', None) 单章龙套 → 留档
判据与写段 ②-立卡门槛**完全一致**(名非空 / 不在 name_map / 无疑似别名命中 / 无同型子串命中 /
跨章阈值 ≥2);预判段按此识别 'new' 候选做语义判重、写段按此走对应副作用分支,同一份判据防漂移。"""
nm = (ent.get("名称") or "").strip()
if not nm:
return "empty", None
if nm in name_map: # 观察漏看在场清单:直接归并
return "merge", nm
hint = (ent.get("疑似别名指向") or "").strip()
if hint and hint in name_map: # 疑似别名指向底册实体
return "alias", hint
t = ent.get("型", "")
sub_hit = next( # 同型名称互为子串(「果子」vs「开心果子」同人两卡)
(ex for ex, (d0, t0, _) in name_map.items()
if t0 == t and len(nm) >= 2 and len(ex) >= 2
and nm != ex and (nm in ex or ex in nm)), None)
if sub_hit:
return "substr", sub_hit
chaps = _int_chaps(ent.get("出场章", [])) # 归一化 int:避免 "508"/508 混算虚增跨章计数
hist = presence.get((t, nm), set())
if len(chaps | hist) >= 2: # 跨章(含跨窗合计)→ 立卡
return "new", None
return "presence", None # 单章龙套 → 留档
# ── 语义判重(P1,设计稿 §8.2):打开 v6 已设计、暂时关着的「嵌入近邻 + M3 终判」那级 ──
# 治病根 4:机械判重只比名字字符串,改名("影杀者"→"IV代纯机械机甲·影杀者")/跨型指代就漏并。
# 默认关(--semantic-dedup 开启):连接三段式(洞①)——嵌入/召回/终判在预判段(prejudge_semantic)无长
# 连接完成,写段只查预判 verdicts;边抽边嵌(嵌段 embed_touched_cards)保后窗能语义召回前窗刚长成的卡。
def _entity_embed_text(ent):
"""判重查询侧嵌入文本:【型】名称:摘要 + 关键字段摘选。
与存储侧 build_embed_text(embed skill)格式同构同源——查询向量与库内卡向量落同一语义空间才可比;
差异仅在此处吃候选新实体 ent(键:型/名称/一句话摘要/字段),故下划线内部键(如里程碑 _win)在此
显式排除不进文本(build_embed_text 侧里程碑值 str() 后带 _win 噪音是已知递延项,两侧对称、本次不修)。"""
t = ent.get("型") or ent.get("type") or ""
fields = ent.get("字段") or {}
body = "\n".join(f"{k}:{v}" for k, v in fields.items()
if v and k not in ("名称", "一句话摘要") and not str(k).startswith("_"))
return f"【{t}】{ent.get('名称', '')}:{ent.get('一句话摘要', '')}\n{body}"[:4000]
def recall_neighbors(conn, vec, work_id, top=6):
"""用**现成向量**在 example_knowledge_embedding 召回同书 ≥阈值 近邻卡。
返回 [(did, 型, 名称, 摘要, 字段摘选, 相似度)…] 按相似度降序、过滤 <阈值。
字段摘选(2026-07-18 小样校准):终判证据不能只有一句话摘要——实测 M3 在"改名候选 vs 厚卡"上
因近邻证据太薄保守判无关(境界阶梯/力量来源这类字段才是同一实体的强证据),故召回时带出
字段 JSON 截断 300 字随近邻进终判 prompt(与候选侧字段 600 字对称)。
三段式连接(洞①核心红线):嵌入已在预判段批量算好并挪出——本函数只跑向量召回 SQL、不再发嵌入 HTTP,
调用方用短连接查完即关(不再沿用窗内长连接跨 LLM/嵌入调用存活,这正是本次要治的连接红线)。
嵌入表无同书向量(试跑期未 embed)时返回 []——优雅降级,判重回退纯机械,绝不阻断主流程。"""
qvec = json.dumps(vec)
rows = conn.execute(
"""SELECT d.id, d.draft_payload->>'type', d.draft_payload->>'名称',
d.draft_payload->>'一句话摘要', 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=%s AND d.work_id=%s
ORDER BY score DESC LIMIT %s""",
(qvec, TENANT, SOURCE_TYPE, work_id, top)).fetchall()
return [(r[0], r[1], r[2], r[3], (r[4] or "")[:300], float(r[5])) for r in rows
if float(r[5]) >= DEDUP_SIM_THRESHOLD]
def dedup_judge_prompt(ent, neighbors):
"""M3 终判 prompt:候选新实体 vs 每个同书近邻,判 同一实体 / 前身 / 后继 / 无关。
近邻带字段摘选(小样校准):只给一句话摘要时模型证据不足会保守判无关,字段是强证据。"""
nb = "\n".join(f"{i + 1}. 卡号{did}|{t}|{nm}|{brief or ''}|字段摘选:{fs or '(无)'}"
for i, (did, t, nm, brief, fs, _s) in enumerate(neighbors))
return f"""【功能指令(parse-book 作品面升格·语义判重终判)】
下面是一个"候选新实体"和若干"同书既有卡"(向量召回的近邻)。逐一判断候选与每张近邻卡的关系,四选一:
- 同一实体:同一对象的改名/化名/不同侧面(如"影杀者"与"IV代纯机械机甲·影杀者")——**仅同型可判**。
- 前身:候选是该近邻卡的上一代/来源(同一条进化链的相邻代际,如"铁头(一代)"之于"铁卫(二代)")。
- 后继:候选是该近邻卡的下一代/继承者。
- 无关:只是题材相近,各自独立。
判据:看名称/摘要/字段是否指向同一对象或同一条演变链;**同型且名称互含(如"生物机甲"与"联邦生物机甲技术")通常是同一实体的简称/全称,除非字段证据明确指向不同对象**;名称不互含但摘要/字段描述同一套力量来源、同一条境界阶梯、同一批代表单位的,也应判同一实体(改名不改实质);**跨型(如具体机甲 item vs 整套体系 power_system)绝不判同一实体,最多判前身/后继**——一整套体系不等于其中一台机体。
【候选新实体】
型={ent.get("型", "")}|名称={ent.get("名称", "")}|摘要={ent.get("一句话摘要", "")}
字段:{json.dumps(ent.get("字段", {}), ensure_ascii=False)[:600]}
【同书近邻卡】
{nb}
【输出规则(只输出一个 JSON 对象)】
{{"判定": [{{"卡号": 数字, "关系": "同一实体|前身|后继|无关"}}]}}"""
def semantic_dedup(ent, neighbors, call):
"""语义判重终判(洞① 三段式:只吃**现成近邻** + M3,全程无连接)。返回 (verdict, data):
('merge', (did, 近邻名称)) 同型·同一实体 → 并卡(治改名漏并)
('chain', [(did, 关系, 名称)]) 前身后继 → 不并卡但记串链候选
('new', None) 无近邻或全判无关 → 各自立卡
近邻召回(recall_neighbors)已在预判段用短连接做完并挪出——本函数不碰连接、不发嵌入 HTTP,
只发 M3 终判且走 run 内 call() 包装(计入 calls 计数与 --max-calls 闸,与观察/更新同治理)。
同型才允许 merge;跨型即便 M3 判同一实体也降级为串链候选(设计稿 §8.2:跨型仅提示、不自动并)。
判重是增益非必需:无近邻 / 终判异常(敏感或 JSON 形状)一律保守返回 new(不并可后补,误并难回退)。"""
if not neighbors:
return "new", None
try:
data, _ = call(dedup_judge_prompt(ent, neighbors), ("判定",))
except (SensitiveHardStop, RuntimeError):
return "new", None
nb_type = {did: t for did, t, _, _, _, _ in neighbors}
nb_name = {did: nm for did, _, nm, _, _, _ in neighbors}
ent_type = ent.get("型", "")
chain = []
for j in [x for x in (data.get("判定") or []) if isinstance(x, dict)]:
did, rel = j.get("卡号"), j.get("关系")
if did not in nb_type:
continue
same_type = nb_type[did] == ent_type
if rel == "同一实体" and same_type:
return "merge", (did, nb_name[did]) # 近邻按相似度降序,第一个同型同一实体即采
if rel in ("前身", "后继"):
chain.append((did, rel, nb_name[did]))
elif rel == "同一实体": # 跨型判同一实体不可信 → 降级串链候选
chain.append((did, "前身后继待定", nb_name[did]))
return ("chain", chain) if chain else ("new", None)
def prejudge_semantic(obs, name_map, presence, embed_sess, work_id, call):
"""预判段(洞① 三段式连接):无长连接完成「粗筛将立卡候选 → 批量嵌入 → 短连接召回 → M3 终判」,
产出 verdicts 供写段查表替代原窗内 semantic_dedup(conn,…)。返回 (verdicts, merge_n, chain_n),
verdicts = {名称: (verdict, data)}。
连接纪律:嵌入 HTTP(b 段)与 M3 终判(d 段)全程无连接;仅召回 SQL(c 段)用短连接、查完即关。
近似超集:按 读1 时的 name_map 用 _classify_new_name 粗筛(与写段同判据),**不模拟**写段中途
new_card 对 name_map 的登记效应——name_map 只增不减,故本段候选恒 ⊇ 写段真正立卡集;多算的候选
写段自然走归并路径判定弃用(写段判据权威),此处宁多勿漏、不会漏判。"""
# a. 纯内存粗筛「将立卡」候选(与写段同判据,防两处漂移)
cands = [ent for ent in obs.get("新名字", [])
if _classify_new_name(ent, name_map, presence)[0] == "new"]
if not cands:
return {}, 0, 0
# b. 批量嵌入粗候选(HTTP,无连接)
vecs, bad = embed_texts(embed_sess, [_entity_embed_text(e) for e in cands])
# c. 短连接:逐候选用现成向量召回近邻(recall SQL 半),查完即关(三段式)
cand_nbrs = {}
with psycopg.connect(DSN) as conn:
for i, ent in enumerate(cands):
if i in bad or i >= len(vecs) or vecs[i] is None:
continue # 嵌入失败的候选优雅跳过(判重回退纯机械立卡)
nbrs = recall_neighbors(conn, vecs[i], work_id)
if nbrs:
cand_nbrs[i] = nbrs
# d. 逐「有近邻」候选发 M3 终判(无连接),走 call() 计数+闸;异常保守 new(semantic_dedup 内部兜)
verdicts, merge_n, chain_n = {}, 0, 0
for i, ent in enumerate(cands):
if i not in cand_nbrs:
continue # 无近邻→写段 verdicts.get 兜默认 new,不必发 M3
verdict, data = semantic_dedup(ent, cand_nbrs[i], call)
verdicts[(ent.get("名称") or "").strip()] = (verdict, data)
if verdict == "merge":
merge_n += 1
elif verdict == "chain":
chain_n += 1
return verdicts, merge_n, chain_n
def embed_touched_cards(sess, work_id, touched):
"""嵌段(洞① 三段式连接):窗事务 commit **之后**,把本窗新建/更新的卡增量嵌入落库,
让后窗语义判重能召回前窗刚长成的卡(治「起了对不上正文的名→机械预扫认不出→成长线静默断」)。
读(短连接):取 touched 卡最新 payload + 各卡现存活嵌入行的 content_hash 集;关连接。
算(无连接):build_embed_text 构文(与检索端同源同构)→ sha256(text|MODEL) 同 embed_drafts 公式
→ 同哈希已有活行的卡跳过(内容未变,幂等)→ 批量 embed_texts。
写(短连接单事务):该卡其他活行 UPDATE deleted=TRUE(软删红线,绝不物理 DELETE)→ 新行 upsert
(ON CONFLICT DO UPDATE deleted=FALSE:内容回退到旧版本时复活软删旧行)。
失败语义:本段任何异常只 stderr 告警、**不 fail 窗**(窗已 commit done)——判重是增益非必需,
向量缺口可由后续批量补嵌(embed skill)兜住。返回本窗实际新嵌条数。"""
try:
# 读段:短连接取 payload + 各卡现存活嵌入哈希,读完即关
payloads, live_hashes = {}, {}
with psycopg.connect(DSN) as conn:
for did in touched:
row = conn.execute(
"SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s AND deleted=FALSE",
(did,)).fetchone()
if not row:
continue # 卡已被撤销/软删(如同窗回滚)——跳过不嵌
payloads[did] = row[0]
live_hashes[did] = {h for (h,) in conn.execute(
"SELECT content_hash FROM example_knowledge_embedding "
"WHERE tenant_id=%s AND draft_id=%s AND deleted=FALSE", (TENANT, did)).fetchall()}
# 算段:构文 + 哈希 + 跳过内容未变者 + 批量嵌入(全程无连接)
todo = [] # [(did, text, hash)…]
for did, payload in payloads.items():
text = build_embed_text(payload or {})
h = hashlib.sha256(f"{text}|{EMBED_MODEL}".encode()).hexdigest()
if h not in live_hashes.get(did, set()):
todo.append((did, text, h))
if not todo:
return 0
vecs, bad = embed_texts(sess, [t for _, t, _ in todo])
# 写段:短连接单事务——淘汰旧活行(软删)+ upsert 新行
done = 0
with psycopg.connect(DSN) as conn:
for j, (did, text, h) in enumerate(todo):
if j in bad or j >= len(vecs) or vecs[j] is None:
continue # 嵌入失败的卡跳过(向量缺口由后续批量补嵌兜住)
# 该卡其他活行软删(内容已变、旧向量过期)——软删红线:绝不物理 DELETE
conn.execute(
"""UPDATE example_knowledge_embedding SET deleted=TRUE, updater=%s
WHERE tenant_id=%s AND draft_id=%s AND deleted=FALSE AND content_hash!=%s""",
(EMBED_ACTOR, TENANT, did, h))
# 新行 upsert:内容回退到旧版本(content_hash 命中软删旧行)时复活,列结构照抄 embed_drafts
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 UPDATE SET deleted=FALSE, updater=EXCLUDED.updater""",
(did, h, text, EMBED_MODEL, EMBED_DIM, json.dumps(vecs[j]),
EMBED_ACTOR, EMBED_ACTOR, TENANT))
done += 1
conn.commit()
return done
except Exception as e:
# 嵌段失败不牵连窗(窗已 commit done):判重是增益、向量缺口后续批量补嵌可兜,只告警可审计
print(f"[嵌段告警] work={work_id} 增量嵌入异常(不 fail 窗,向量缺口后续补嵌兜住): "
f"{type(e).__name__}: {str(e)[:180]}", file=sys.stderr)
return 0
# ── 命令 ──
@click.group()
def cli():
"""作品面升格(正文窗直抽 · 全实体统一生长)"""
@cli.command()
@click.option("--work-id", type=int, required=True)
def windows(work_id):
"""机械切正文窗(幂等)。"""
try:
with upgrade_work_lock(DSN, TENANT, work_id):
with psycopg.connect(DSN) as conn:
total, new = cut_windows(conn, work_id)
click.echo(f"work={work_id} 切窗完成:全书 {total} 窗(本次新建 {new} 行)")
except UpgradeWorkLockUnavailable as exc:
raise click.ClickException(str(exc)) from exc
def _run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
"""按窗顺序跑升格;redo 仅限当前末窗,历史窗须全书前滚重建。"""
calls = {"n": 0} # 调用计数(含敏感失败换模型的次数由 m3_json 内部消化,此处计成功轮次)
# 语义判重嵌入会话(仅开启时建;禁系统代理,走内网直连)
embed_sess = _embed_session() if semantic_on else None
redo_snapshot = None
redo_material = None
def call(prompt, need_keys):
calls["n"] += 1
return m3_json(prompt, model, need_keys, system=IDENTITY)
with psycopg.connect(DSN) as conn:
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s",
(work_id,)).fetchone()[0]
contracts = load_entity_contracts(conn)
# 洞② 登场兜底所需:含「演变历程」字段的型集合(据库内合同动态判定,不硬编码型名——
# schema 未铺该字段的型自动不触发兜底);供 new_card 判定是否补登场里程碑。
milestone_types = {t for t in ENTITY_TYPES
if any(f.get("key") == "演变历程"
for f in contracts.get(t, {}).get("字段", []))}
if redo_window:
active_windows = conn.execute(
"""SELECT window_no, from_chapter, to_chapter FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE
ORDER BY window_no""", (TENANT, work_id)).fetchall()
target = next((row for row in active_windows if row[0] == redo_window), None)
if target is None:
raise click.ClickException(f"redo 窗{redo_window}不存在")
for index, row in enumerate(active_windows, 1):
win_no, from_chapter, to_chapter = row
if win_no != index:
raise click.ClickException(
f"active 窗口号不连续:期望窗{index},实际窗{win_no}")
if from_chapter is None or to_chapter is None \
or from_chapter <= 0 or to_chapter < from_chapter:
raise click.ClickException(
f"窗{win_no}章域非法:{from_chapter}-{to_chapter}")
if index > 1 and from_chapter != active_windows[index - 2][2] + 1:
raise click.ClickException(
f"active 窗章域不连续:窗{win_no - 1}止于{active_windows[index - 2][2]},"
f"窗{win_no}始于{from_chapter}")
final_window = active_windows[-1][0]
if redo_window != final_window:
raise click.ClickException(
f"仅允许重跑当前末窗{final_window};历史窗须全书前滚重建,"
"后缀级联重算属于 P1、当前不支持")
text, chapter_texts = load_window_material(conn, work_id, target[1], target[2])
expected_chapters = set(range(target[1], target[2] + 1))
missing = sorted(expected_chapters - set(chapter_texts))
empty = sorted(chapter for chapter in expected_chapters
if chapter in chapter_texts and not str(chapter_texts[chapter]).strip())
if missing or empty or not text.strip():
raise click.ClickException(
f"末窗{redo_window}正文不完整:缺章{missing or '无'},空正文章{empty or '无'}")
wins = [target]
redo_material = (text, chapter_texts)
redo_snapshot = _snapshot_redo_window(conn, work_id, redo_window)
click.echo(f"[撤销] 窗{redo_window} 旧写入回滚中…")
undo_window(
conn,
work_id,
redo_window,
from_chapter=target[1],
to_chapter=target[2],
)
else:
wins = conn.execute(
"""SELECT window_no, from_chapter, to_chapter, status FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND status!='done' AND deleted=FALSE
ORDER BY from_chapter""", (TENANT, work_id)).fetchall()
wins = [(r[0], r[1], r[2]) for r in wins]
# failed 窗重跑前必须先撤销旧写入(部分写入直接重跑会 double-append 追加字段)
for r0 in conn.execute(
"""SELECT window_no FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND status='failed' AND deleted=FALSE""",
(TENANT, work_id)).fetchall():
click.echo(f"[撤销] failed 窗{r0[0]} 旧写入回滚后重跑")
undo_window(conn, work_id, r0[0])
done_n = 0
# 串行铁律(创始人 2026-07-15 拍板):窗与窗是串行生长——后窗的预扫/判重/更新
# 全依赖前窗长成的卡。窗失败绝不跳窗(跳窗=知识断层+事后补跑有覆盖风险),
# 而是当场撤销半写入→整窗重试一次(挡偶发病);仍败→停书,断点就在本窗,
# 下次启动从本窗续跑(failed 先撤销机制),串行语义天然无损。
wi = 0
retried = False # 当前窗是否已当场重试过
while wi < len(wins):
win_no, a, b = wins[wi]
if max_windows and done_n >= max_windows:
break
if max_calls and calls["n"] >= max_calls and not retried:
click.echo(f"⏸ 调用闸 {max_calls} 已到,停在窗{win_no} 之前")
break
try:
# ── 读1(短连接):窗正文 + 判重底册,读完即关(三段式红线:连接不跨 LLM/嵌入调用存活)──
with psycopg.connect(DSN) as conn:
if redo_material is not None:
text, chapter_texts = redo_material
else:
text, chapter_texts = load_window_material(conn, work_id, a, b)
name_map, presence, aliases_by_draft = load_known(conn, work_id)
onstage = prescan(name_map, text) # ⓪ 机械预扫纯内存,无连接
# ── 算1(无连接):① 实体观察 M3 调用 + 输出清洗,全程不持连接 ──
obs, usage = call(observe_prompt(contracts, title, a, b, text, onstage),
("新名字", "已知实体新信息", "纯出场"))
repair_missing_milestone_evidence(
obs,
title=title,
a=a,
b=b,
text=text,
chapter_texts=chapter_texts,
call=call,
)
# 模型输出防御(深空窗5实测:列表元素偶为裸字符串,.get 直接炸)——统一只留 dict 元素
for k in ("新名字", "已知实体新信息", "纯出场"):
obs[k] = [x for x in (obs.get(k) or []) if isinstance(x, dict)]
# 新实体的出场章参与跨章立卡判定,必须在预判前先按规范名/合法别名逐章实证;
# 否则模型虚报两章会把单章实体误送入立卡路径,并污染后续语义判重候选。
for ent in obs["新名字"]:
ent["出场章"] = sorted(
_filter_entity_chapters(
ent.get("名称"),
ent.get("别名"),
ent.get("出场章"),
chapter_texts,
)
)
# ── 预判段(仅 semantic_on,无长连接):粗筛将立卡候选→批量嵌入→短连接召回→M3 终判,产出
# verdicts 供写段查表(洞①:把原窗内 semantic_dedup(conn,…) 从连接存活期整体挪走)──
verdicts, dedup_merge_n, dedup_chain_n = {}, 0, 0
if semantic_on:
verdicts, dedup_merge_n, dedup_chain_n = prejudge_semantic(
obs, name_map, presence, embed_sess, work_id, call)
# ── 写(窗事务,保持现单事务原子性):②-⑥ 照旧,唯一改动=判重换查预判 verdicts;update/relation
# 的 M3 仍在窗事务内——既有已接受模式(keepalives 兜底),本次明确不动其连接边界 ──
new_ids = [] # 本窗新立卡 id(洞①:与 to_update 键并成 touched 供嵌段增量嵌入)
with psycopg.connect(DSN) as conn:
# ② 判重+立卡门槛(机械;分类走 _classify_new_name 纯函数,与预判段同判据防两处漂移)
to_update = {} # draft_id -> [观察点…]
for ent in obs.get("新名字", []):
nm = (ent.get("名称") or "").strip()
kind, key = _classify_new_name(ent, name_map, presence)
if kind == "empty":
continue
if kind == "merge": # 观察漏看在场清单:直接归并(洞②:材料带全里程碑)
did = name_map[key][0]
to_update.setdefault(did, []).append(f"(新名字归并){_merge_material(ent)}")
continue
if kind == "alias": # 疑似别名:初卡转观察材料(G3;洞②材料不截里程碑)
did = name_map[key][0]
to_update.setdefault(did, []).append(
f"(别名「{nm}」并入)初卡材料:{_merge_material(ent)}")
conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,'ai',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, key, nm, win_no, TENANT))
legal_alias = _clean_alias(nm)
if legal_alias:
aliases_by_draft.setdefault(did, set()).add(legal_alias)
continue
if kind == "substr": # 同型名称互为子串(「果子」vs「开心果子」):
# 不直接立卡,转观察材料并入既有卡由更新步 AI 甄别(软防护,留复核标记;洞②材料不截里程碑)
did = name_map[key][0]
to_update.setdefault(did, []).append(
f"(名称疑似同一实体「{nm}」≈「{key}」,请甄别后再并入)"
f"初卡材料:{_merge_material(ent)}")
conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,'substr',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, key, nm, win_no, TENANT))
legal_alias = _clean_alias(nm)
if legal_alias:
aliases_by_draft.setdefault(did, set()).add(legal_alias)
continue
# kind in ("new","presence"):跨章立卡 / 单章龙套留档
chaps = _int_chaps(ent.get("出场章", [])) # 归一化 int(窗113 修复)
hist = presence.get((ent.get("型", ""), nm), set())
if kind == "new": # 跨章(含跨窗合计)→ 立卡
# 语义判重(洞①):判据已在预判段(三段式)算好,此处只查表——预判按 读1 时 name_map
# 近似超集粗筛、未模拟本段中途登记,多算的候选此处走归并/留档自然弃用(写段判据权威)。
if semantic_on:
verdict, vd = verdicts.get(nm, ("new", None))
if verdict == "merge": # 同型同一实体:并入既有卡,不另立
did0, canon = vd
to_update.setdefault(did0, []).append(
f"(语义判重·「{nm}」并入同一实体)初卡材料:{_merge_material(ent)}")
conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,'semantic',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, canon, nm, win_no, TENANT))
legal_alias = _clean_alias(nm)
if legal_alias:
aliases_by_draft.setdefault(did0, set()).add(legal_alias)
continue
if verdict == "chain": # 前身后继:仍立卡,串链关系记候选审计
did = new_card(
conn,
work_id,
win_no,
ent,
milestone_types,
chapter_texts=chapter_texts,
known_chapters=hist,
)
new_ids.append(did)
name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
# 只记候选提示、不自动写「前身/后继」字段——避免误串,链接由人工/后续确认落字段
for did2, rel, nm2 in vd:
conn.execute(
"""INSERT INTO example_upgrade_audit
(draft_id, window_no, field_name, old_value, new_value, tenant_id)
VALUES (%s,%s,%s,NULL,%s,%s)""",
(did, win_no, ("串链候选:" + str(rel))[:100],
json.dumps({"对方卡号": did2, "对方名称": nm2},
ensure_ascii=False), TENANT))
continue
did = new_card(
conn,
work_id,
win_no,
ent,
milestone_types,
chapter_texts=chapter_texts,
known_chapters=hist,
)
new_ids.append(did)
name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
else: # 单章龙套 → 留档(G4);无正文实证章则不造留档
for ch in chaps:
conn.execute(
"""INSERT INTO example_upgrade_presence
(work_id, window_no, chapter_no, entity_type, name,
observation, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
(work_id, win_no, ch, ent.get("型", ""), nm,
ent.get("一句话摘要", ""), TENANT))
pres_add = {} # did -> set(出场章)——被更新的卡也要记出场章(抽检#2:只走纯出场路径整窗丢章)
for it in obs.get("已知实体新信息", []):
nm = (it.get("名称") or "").strip()
if nm in name_map and it.get("观察点"):
did = name_map[nm][0]
to_update.setdefault(did, []).append(it["观察点"])
pres_add.setdefault(did, set()).update(_int_chaps(it.get("出场章")))
# ④ 卡更新(分批≤6)
items = sorted(to_update.items())
for i in range(0, len(items), UPDATE_BATCH):
batch = items[i:i + UPDATE_BATCH]
cards = []
for did, obs_pts in batch:
p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0]
cards.append((did, p, obs_pts))
# 缺"更新"键宽容为空批:prompt 教"无变化的卡不输出",某批恰好全无变化时
# 模型会顺势连键一起省(批7实测 6 窗全死于此)。语义上缺键≈空批,按空批放行
# 并留警告日志可审计;其余格式错误(乱码/解析失败)仍原样抛、窗照 fail。
try:
upd, _ = call(update_prompt(contracts, title, a, b, text, cards), ("更新",))
except RuntimeError as e:
if "缺少必需键" not in str(e):
raise
print(f"[宽容] 窗{win_no} 更新批缺键按空批放行: {str(e)[:80]}", file=sys.stderr)
upd = {"更新": []}
repair_missing_milestone_evidence(
upd,
title=title,
a=a,
b=b,
text=text,
chapter_texts=chapter_texts,
call=call,
)
valid = {d for d, _, _ in cards}
# 每卡按其型的合同 key 集校验(+一句话摘要),越合同 key 裁剪留审计
did2keys = {d: {f["key"] for f in contracts.get(p.get("type"), {}).get("字段", [])}
| {"一句话摘要"} for d, p, _ in cards}
for u in [x for x in (upd.get("更新") or []) if isinstance(x, dict)]:
if u.get("draft_id") in valid and (u.get("变更字段") or u.get("别名新增")):
merge_card(conn, u["draft_id"], win_no,
u.get("变更字段"), u.get("别名新增"),
valid_keys=did2keys.get(u["draft_id"]),
chapter_texts=chapter_texts,
appearance_chapters=pres_add.pop(u["draft_id"], set()),
known_aliases=aliases_by_draft.get(u["draft_id"], set()))
# ⑤ 关系增量(核心角色=本窗有更新的 character + 在场 character,≤8)
char_cards = []
seen = set()
for did in list(to_update.keys()):
p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0]
if p.get("type") == "character" and did not in seen:
char_cards.append((did, p))
seen.add(did)
for nm, (did, t, _) in onstage.items():
if t == "character" and did and did not in seen and len(char_cards) < 8:
p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0]
char_cards.append((did, p))
seen.add(did)
char_cards = char_cards[:8]
if len(char_cards) >= 2:
rels = conn.execute(
"""SELECT id, draft_payload FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s AND deleted=FALSE
AND draft_payload->>'type'=%s""",
(TENANT, work_id, SOURCE_TYPE, RELATION_TYPE)).fetchall()
rel_out, _ = call(relation_prompt(contracts, title, a, b, text,
char_cards, rels), ("关系",))
id2name = {d: p.get("名称") for d, p in char_cards}
exist = {tuple(sorted((r.get("甲方draft"), r.get("乙方draft")))): (rid, r)
for rid, r in rels
if r.get("甲方draft") and r.get("乙方draft")}
for r in [x for x in (rel_out.get("关系") or []) if isinstance(x, dict)]:
ja, yi = r.get("甲方"), r.get("乙方")
if ja not in id2name or yi not in id2name or ja == yi:
continue
key = tuple(sorted((ja, yi)))
evo_core = _strip_prefix(r.get("本窗演变", ""))
if key in exist:
# 落点统一(抽检#3):其他字段与演变轨迹全并进 rp["字段"],
# 顶层遗留的演变轨迹迁移进字段后删除,避免双份分裂
rid, rp = exist[key]
f2 = rp.setdefault("字段", {})
if "演变轨迹" in rp: # 存量顶层迁移
legacy = rp.pop("演变轨迹")
base = f2.get("演变轨迹") or []
f2["演变轨迹"] = (base if isinstance(base, list) else [base]) + \
(legacy if isinstance(legacy, list) else [legacy])
for k2, v2 in (r.get("其他字段") or {}).items():
if k2 == "演变轨迹":
continue # 演变只走本窗演变通道
f2[k2] = v2 # 覆写(当前状态等保最新)
if evo_core:
lst = f2.setdefault("演变轨迹", [])
if not isinstance(lst, list):
lst = [lst]
f2["演变轨迹"] = lst
if evo_core not in {_strip_prefix(x) for x in lst}:
lst.append(f"[窗{win_no}] {evo_core}")
if r.get("关系类型"):
rp["关系类型"] = r["关系类型"]
conn.execute(
"""UPDATE muse_knowledge_draft SET draft_payload=%s,
revision=revision+1 WHERE id=%s""",
(json.dumps(rp, ensure_ascii=False), rid))
else:
f2 = dict(r.get("其他字段") or {})
f2["演变轨迹"] = [f"[窗{win_no}] {evo_core}"] if evo_core else []
rp = {"type": RELATION_TYPE,
"名称": f"{id2name[ja]}×{id2name[yi]}",
"甲方draft": ja, "乙方draft": yi,
"甲方名称": id2name[ja], "乙方名称": id2name[yi],
"关系类型": r.get("关系类型", ""),
"字段": f2,
"来源": f"升格@窗{win_no}", "状态": "草稿",
"目标库": "本书作品库", "可见范围": "本书私有",
"_work_id": work_id}
conn.execute(
"""INSERT INTO muse_knowledge_draft
(work_id, draft_type, draft_payload, status, source_type,
source_id, creator, updater, tenant_id)
VALUES (%s,'entity',%s,'pending',%s,%s,'upgrade','upgrade',%s)""",
(work_id, json.dumps(rp, ensure_ascii=False), SOURCE_TYPE,
work_id, TENANT))
# ⑥ 机械收尾:出场章统一并入(纯出场 + 被更新卡,抽检#2)+ 窗置 done
for it in obs.get("纯出场", []):
nm = (it.get("名称") or "").strip()
if nm in name_map:
pres_add.setdefault(name_map[nm][0], set()).update(
_int_chaps(it.get("出场章"))
)
for did, chs in pres_add.items():
if not chs:
continue
p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0]
# 纯出场与无字段变化卡复用同一「过滤+审计追加」路径,确保 redo 能撤销上一轮顶层章号。
changed = _append_verified_appearance_chapters(
conn,
did,
win_no,
p,
chs,
chapter_texts,
known_aliases=aliases_by_draft.get(did, set()),
)
if not changed:
continue
conn.execute("UPDATE muse_knowledge_draft SET draft_payload=%s WHERE id=%s",
(json.dumps(p, ensure_ascii=False), did))
conn.execute(
"""UPDATE example_upgrade_window SET status='done', error_message=NULL,
updater='upgrade' WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",
(TENANT, work_id, win_no))
conn.commit()
# 写段成功:收集本窗触达卡(洞①:new_card 新 id + to_update 键)供嵌段增量嵌入;关系卡走
# 独立 INSERT/UPDATE 不经这两处、天然不嵌,由后续批量补嵌(embed skill)覆盖。
touched = set(new_ids) | set(to_update.keys())
except SensitiveHardStop as e:
with psycopg.connect(DSN) as conn:
if redo_snapshot is not None:
_restore_redo_window(conn, work_id, redo_snapshot)
else:
conn.execute(
"""UPDATE example_upgrade_window SET status='failed', error_message=%s
WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",
(str(e)[:500], TENANT, work_id, win_no))
conn.commit()
click.echo(f" ⛔ 窗{win_no} 敏感降级链全失败,本书升格硬停:{e}")
return
except Exception as e:
with psycopg.connect(DSN) as conn:
conn.execute(
"""UPDATE example_upgrade_window SET status='failed', error_message=%s
WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",
(str(e)[:500], TENANT, work_id, win_no))
conn.commit()
if not retried:
# 串行铁律第一层:当场撤销半写入→同窗立即重试(挡网络瞬断/偶发格式病)
with psycopg.connect(DSN) as conn:
if redo_window:
# 显式 redo 首次失败:先恢复清理前历史值,再在同一事务重新清理本窗章域供第二次尝试。
undo_window(
conn,
work_id,
win_no,
from_chapter=a,
to_chapter=b,
)
else:
undo_window(conn, work_id, win_no)
conn.commit()
retried = True
click.echo(f" ↻ 窗{win_no} 失败,撤销后当场重试(串行铁律不跳窗): {str(e)[:150]}")
continue
if redo_snapshot is not None:
# 第二次仍失败:写事务已回滚,按 redo 前完整恢复点原子恢复字段、别名、留档、初建卡与窗状态。
with psycopg.connect(DSN) as conn:
_restore_redo_window(conn, work_id, redo_snapshot)
conn.commit()
stop_state = "redo 前旧窗状态已完整恢复" if redo_snapshot is not None \
else "断点=本窗,续跑从此接续"
click.echo(
f" ⛔ 窗{win_no} 当场重试仍失败,本书升格停({stop_state}): {str(e)[:200]}"
)
return
# ── 嵌段(仅 semantic_on 且 touched 非空;窗 commit 之后;三段式)+ 窗完成汇报 ──
# 成功路径放在 try 之外:绝不触发上面的 failed/undo(窗已 commit done);嵌段内部吞异常、
# 只告警,不会把已 done 的窗拖回 failed(判重是增益、向量缺口后续批量补嵌可兜)。
embed_n = 0
if semantic_on and touched:
embed_n = embed_touched_cards(embed_sess, work_id, touched)
new_n = len(obs.get('新名字', []))
extra = f" 判重:并{dedup_merge_n}/链{dedup_chain_n} 嵌入:{embed_n}" if semantic_on else ""
click.echo(f" 窗{win_no}✓ ({a}-{b}章) 在场{len(onstage)} 新名字{new_n} "
f"更新卡{len(to_update)} 调用累计{calls['n']}{extra}")
done_n += 1
wi += 1
retried = False
click.echo(f"《{title}》本次完成 {done_n} 窗,LLM 调用 {calls['n']} 次")
@cli.command("run")
@click.option("--work-id", type=int, required=True)
@click.option("--max-windows", type=click.IntRange(min=0), default=0,
help="本次最多跑几个窗(0=不限)")
@click.option("--max-calls", type=click.IntRange(min=0), default=0,
help="本次 LLM 调用上限(0=不限,含敏感失败)")
@click.option("--model", default="MiniMax-M3", show_default=True)
@click.option("--redo-window", type=int, default=0,
help="仅允许当前末窗强制重跑;历史窗须全书前滚重建,后缀级联重算(P1)暂不支持")
@click.option("--semantic-dedup", "semantic_on", is_flag=True,
help="开启语义判重(P1):立卡前召回同书近邻+M3终判治改名/跨型漏并;"
"边抽边嵌(本窗新建/更新卡窗事务后增量嵌入落库,后窗即可召回前窗的卡);默认关")
def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
"""持有同书会话锁后执行升格,锁连接可安全跨越模型调用。"""
try:
with upgrade_work_lock(DSN, TENANT, work_id):
return _run(work_id, max_windows, max_calls, model, redo_window, semantic_on)
except UpgradeWorkLockUnavailable as exc:
raise click.ClickException(str(exc)) from exc
@cli.command()
@click.option("--work-id", type=int, required=True)
def status(work_id):
"""升格进度:窗状态/卡数/留档数。"""
with psycopg.connect(DSN) as conn:
title = conn.execute("SELECT title FROM muse_content_work WHERE id=%s",
(work_id,)).fetchone()[0]
w = conn.execute(
"""SELECT count(*) FILTER (WHERE status='done'), count(*) FILTER (WHERE status='failed'),
count(*) FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE""",
(TENANT, work_id)).fetchone()
cards = conn.execute(
"""SELECT draft_payload->>'type', count(*) FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s AND deleted=FALSE
GROUP BY 1 ORDER BY 2 DESC""", (TENANT, work_id, SOURCE_TYPE)).fetchall()
pres = conn.execute(
"SELECT count(*) FROM example_upgrade_presence WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchone()[0]
ali = conn.execute(
"SELECT count(*) FROM example_upgrade_alias WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchone()[0]
click.echo(f"《{title}》窗 {w[0]}done/{w[1]}failed/{w[2]}total | "
f"卡 {', '.join(f'{t}:{n}' for t, n in cards) or '0'} | 留档{pres} 别名{ali}")
if __name__ == "__main__":
cli()