2619 lines
143 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,双层防跑飞长文
# 登场机械兜底只允许使用名称与型的中性描述,禁止把跨章摘要事实绑定到首章。
DEBUT_TYPE_LABELS = {
"character": "人物",
"location": "地点",
"item": "物件",
"faction": "组织",
"power_system": "能力体系",
"event": "事件",
}
class EmbeddingOwnershipConflict(RuntimeError):
"""同一嵌入唯一键被两个活跃 draft 的当前 payload 同时声明。"""
class UpgradeDraftWriteConflict(RuntimeError):
"""升格卡已离开可写状态,或锁定后的 revision 写入失败。"""
class AliasOwnershipConflict(RuntimeError):
"""同书同别名已唯一映射到另一张 canonical 卡。"""
class CompensationFenceConflict(RuntimeError):
"""窗提交后的 draft 已被外部改动,禁止补偿覆盖。"""
COMPENSATION_FAILED_PREFIX = "compensation-failed:"
# ── 库内合同(元数据驱动公理: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 = {}
revisions = {}
for did, entity_type, name, brief, aliases, revision in conn.execute(
"""SELECT id,
draft_payload->>'type',
draft_payload->>'名称',
COALESCE(draft_payload->>'一句话摘要',''),
COALESCE(draft_payload->'别名','[]'::jsonb),
revision
FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s
AND status='pending' AND deleted=FALSE""",
(TENANT, work_id, SOURCE_TYPE)).fetchall():
revisions[did] = revision
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, revisions
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 _claim_alias(conn, work_id, canonical_name, alias, win_no, verdict_by):
"""原子登记别名:仅活跃同 canonical 幂等,冲突或 tombstone 必须失败关闭。"""
alias = _clean_alias(alias)
if not alias or alias == canonical_name:
return None
row = conn.execute(
"""INSERT INTO example_upgrade_alias
(work_id, canonical_name, alias, evidence_window, verdict_by, tenant_id)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT (tenant_id, work_id, alias) DO UPDATE
SET canonical_name=EXCLUDED.canonical_name
WHERE example_upgrade_alias.canonical_name=EXCLUDED.canonical_name
AND example_upgrade_alias.deleted=FALSE
RETURNING canonical_name""",
(work_id, canonical_name, alias, win_no, verdict_by, TENANT),
).fetchone()
if row and row[0] == canonical_name:
return alias
owner = conn.execute(
"""SELECT canonical_name, deleted FROM example_upgrade_alias
WHERE tenant_id=%s AND work_id=%s AND alias=%s FOR UPDATE""",
(TENANT, work_id, alias),
).fetchone()
owner_name = owner[0] if owner else "<唯一行缺失>"
owner_state = "deleted" if owner and owner[1] else "active"
raise AliasOwnershipConflict(
f"别名唯一映射冲突:work={work_id},alias={alias},"
f"现有canonical={owner_name},现有状态={owner_state},请求canonical={canonical_name}"
)
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 = "顶层:出场章"
# 关系卡顶层字段与卡水位不在 payload["字段"] 内,必须用明确 sentinel 让 undo 精确落回原位置。
TOP_LEVEL_ALIASES_AUDIT_FIELD = "顶层:别名"
TOP_LEVEL_SUMMARY_AUDIT_FIELD = "顶层:一句话摘要"
TOP_LEVEL_RELATION_TYPE_AUDIT_FIELD = "顶层:关系类型"
TOP_LEVEL_RELATION_EVOLUTION_AUDIT_FIELD = "顶层:演变轨迹"
CARD_STATE_WATERMARK_AUDIT_FIELD = "状态:watermark_window"
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 _read_upgrade_draft_snapshot(conn, draft_id):
"""读取送入模型的可信 payload/revision;不持锁跨模型调用。"""
row = conn.execute(
"""SELECT draft_payload, status, revision, source_type
FROM muse_knowledge_draft
WHERE id=%s AND tenant_id=%s AND deleted=FALSE""",
(draft_id, TENANT),
).fetchone()
if not row:
raise UpgradeDraftWriteConflict(f"升格 draft 不存在或已删除:draft={draft_id}")
payload, status, revision, source_type = row
if status != "pending" or source_type != SOURCE_TYPE:
raise UpgradeDraftWriteConflict(
f"升格 draft 不可写:draft={draft_id},status={status},source_type={source_type}"
)
return deepcopy(payload), revision
def _lock_upgrade_draft(conn, draft_id, expected_revision=None):
"""锁定当前 draft;状态、来源或模型所见 revision 漂移都失败关闭。"""
row = conn.execute(
"""SELECT draft_payload, status, revision, source_type
FROM muse_knowledge_draft
WHERE id=%s AND tenant_id=%s AND deleted=FALSE
FOR UPDATE""",
(draft_id, TENANT),
).fetchone()
if not row:
raise UpgradeDraftWriteConflict(f"升格 draft 不存在或已删除:draft={draft_id}")
payload, status, revision, source_type = row
if status != "pending" or source_type != SOURCE_TYPE:
raise UpgradeDraftWriteConflict(
f"升格 draft 不可写:draft={draft_id},status={status},source_type={source_type}"
)
if expected_revision is not None and revision != expected_revision:
raise UpgradeDraftWriteConflict(
f"升格 draft 模型版本冲突:draft={draft_id},"
f"expected_revision={expected_revision},current_revision={revision}"
)
return deepcopy(payload), revision
def _write_locked_upgrade_draft(conn, draft_id, payload, revision):
"""用锁后 revision 回写;任何状态或版本漂移都按确定性冲突失败关闭。"""
row = conn.execute(
"""UPDATE muse_knowledge_draft SET draft_payload=%s,
revision=revision+1, updater='upgrade'
WHERE id=%s AND tenant_id=%s AND deleted=FALSE
AND status='pending' AND source_type=%s AND revision=%s
RETURNING revision""",
(json.dumps(payload, ensure_ascii=False), draft_id, TENANT, SOURCE_TYPE, revision),
).fetchone()
if not row:
raise UpgradeDraftWriteConflict(
f"升格 draft 锁后写入冲突:draft={draft_id},expected_revision={revision}"
)
return row[0]
def _audit_relation_change(conn, draft_id, win_no, field_name, old_value, new_value):
"""记录关系卡一个真实落点的完整旧/新 JSON;值未变化时不制造空审计。"""
if old_value == new_value:
return False
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,
field_name,
json.dumps(old_value, ensure_ascii=False) if old_value is not None else None,
json.dumps(new_value, ensure_ascii=False) if new_value is not None else None,
TENANT,
),
)
return True
def _update_card_state(conn, draft_id, work_id, win_no):
"""推进实体或关系卡水位并审计旧值;undo 可精确恢复或删除新 state。"""
row = conn.execute(
"SELECT watermark_window FROM example_upgrade_card_state WHERE draft_id=%s",
(draft_id,),
).fetchone()
old_watermark = row[0] if row else None
_audit_relation_change(
conn,
draft_id,
win_no,
CARD_STATE_WATERMARK_AUDIT_FIELD,
old_watermark,
max(old_watermark or 0, win_no),
)
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, work_id, win_no, TENANT),
)
def _update_relation_card(conn, work_id, win_no, draft_id, expected_revision, relation):
"""锁后按当前 payload 更新关系卡,并校验模型读取时的 revision。"""
payload, revision = _lock_upgrade_draft(conn, draft_id, expected_revision)
fields = payload.setdefault("字段", {})
# 历史数据可能把演变轨迹放在顶层;迁移本身也必须可撤销,不能只恢复字段侧。
old_field_evolution = deepcopy(fields.get("演变轨迹")) if "演变轨迹" in fields else None
legacy_present = "演变轨迹" in payload
legacy_evolution = deepcopy(payload.get("演变轨迹")) if legacy_present else None
if legacy_present:
_audit_relation_change(
conn,
draft_id,
win_no,
TOP_LEVEL_RELATION_EVOLUTION_AUDIT_FIELD,
legacy_evolution,
None,
)
payload.pop("演变轨迹", None)
evolution = fields.get("演变轨迹")
evolution = list(evolution) if isinstance(evolution, list) else ([evolution] if evolution else [])
if legacy_present:
evolution.extend(legacy_evolution if isinstance(legacy_evolution, list) else [legacy_evolution])
evolution_core = _strip_prefix(relation.get("本窗演变", ""))
if evolution_core and evolution_core not in {_strip_prefix(item) for item in evolution}:
evolution.append(f"[窗{win_no}] {evolution_core}")
if (legacy_present or evolution_core or "演变轨迹" in fields) \
and old_field_evolution != evolution:
_audit_relation_change(
conn, draft_id, win_no, "演变轨迹", old_field_evolution, evolution
)
fields["演变轨迹"] = evolution
# 关系合同的其他字段逐字段覆写,每个字段都记录完整旧/新值,供即时重试精确还原。
for field_name, new_value in (relation.get("其他字段") or {}).items():
if field_name == "演变轨迹":
continue
old_value = deepcopy(fields.get(field_name)) if field_name in fields else None
if _audit_relation_change(
conn, draft_id, win_no, field_name, old_value, new_value):
fields[field_name] = new_value
new_relation_type = relation.get("关系类型")
if new_relation_type:
old_relation_type = payload.get("关系类型") if "关系类型" in payload else None
if _audit_relation_change(
conn,
draft_id,
win_no,
TOP_LEVEL_RELATION_TYPE_AUDIT_FIELD,
old_relation_type,
new_relation_type):
payload["关系类型"] = new_relation_type
_write_locked_upgrade_draft(conn, draft_id, payload, revision)
_update_card_state(conn, draft_id, work_id, win_no)
return draft_id
def _insert_relation_card(conn, work_id, win_no, payload):
"""新建关系卡并返回 draft id;逐字段审计和 state 任一步失败都会让窗事务回滚。"""
draft_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)
RETURNING id""",
(work_id, json.dumps(payload, ensure_ascii=False), SOURCE_TYPE, work_id, TENANT),
).fetchone()[0]
_audit_relation_change(
conn,
draft_id,
win_no,
TOP_LEVEL_RELATION_TYPE_AUDIT_FIELD,
None,
payload.get("关系类型"),
)
for field_name, value in (payload.get("字段") or {}).items():
_audit_relation_change(conn, draft_id, win_no, field_name, None, value)
_update_card_state(conn, draft_id, work_id, win_no)
return draft_id
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_evidence_name(entity_name, aliases, chapter_text):
"""从 debut 正文选择唯一实际命中的合法名称;歧义或无命中时返回 None。"""
canonical = str(entity_name or "").strip()
candidates = []
if len(canonical) >= 2 and canonical not in GENERIC_ADDRESS_ALIASES:
candidates.append(canonical)
for raw_alias in aliases or []:
alias = _clean_alias(raw_alias)
if alias and alias not in GENERIC_ADDRESS_ALIASES and alias not in candidates:
candidates.append(alias)
normalized_text = unicodedata.normalize("NFKC", str(chapter_text or ""))
matched = [
name for name in candidates
if unicodedata.normalize("NFKC", name) in normalized_text
]
return matched[0] if len(matched) == 1 else None
def _debut_milestone(milestones, entity_name, entity_type, aliases, chaps, win_no, chapter_texts):
"""登场兜底(洞② 机械那一保险,纯函数便于离线自测):里程碑列表里若没有任何 周期=登场 的条目,
在头部补一条兜底登场里程碑;已有登场则原样返回(提示词软约束 + 此机械兜底=双保险)。
- 章:min(正文实证出场章)(仅取整数章号);无实证章则不补,真实性优先;
- 台阶:名称只可取 debut 正文唯一命中的合法规范名/别名;无唯一命中则退化为仅类型;
- 证据:debut 章必须存在非空正文;缺正文时不补,禁止把后续章事实倒灌到最早章;
- _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_chapter = ch_ints[0]
if not isinstance(chapter_texts, dict) or not str(chapter_texts.get(debut_chapter) or "").strip():
return list(milestones or [])
name = str(entity_name or "").strip()
label = DEBUT_TYPE_LABELS.get(entity_type)
if not name or not label:
return list(milestones or [])
evidence_name = _debut_evidence_name(name, aliases, chapter_texts[debut_chapter])
debut = {
"章": debut_chapter,
"台阶": (f"{label}「{evidence_name}」登场" if evidence_name else f"{label}登场")[:STEP_MAX],
"周期": "登场",
"_win": win_no,
}
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,
expected_revision=None,
):
"""按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号),
标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。
valid_keys:该型合同的合法字段 key 集——越合同 key 裁剪留审计(窗29实测模型把
整段条目文本误当字段 key 写入,无校验会把卡体字段区打烂)。"""
payload, revision = _lock_upgrade_draft(conn, draft_id, expected_revision)
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:
old_summary = deepcopy(payload.get("一句话摘要")) \
if "一句话摘要" in payload else None
if _audit_relation_change(
conn,
draft_id,
win_no,
TOP_LEVEL_SUMMARY_AUDIT_FIELD,
old_summary,
v.strip()):
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
old_aliases = deepcopy(payload.get("别名")) if "别名" in payload else None
merged_aliases = list(old_aliases) if isinstance(old_aliases, list) else []
work_id = payload.get("_work_id") or 0
canonical_name = payload.get("名称")
alias_added = False
for raw_alias in alias_new or []:
alias = _claim_alias(conn, work_id, canonical_name, raw_alias, win_no, "ai")
if alias and alias not in merged_aliases:
merged_aliases.append(alias)
alias_added = True
if alias_added and _audit_relation_change(
conn,
draft_id,
win_no,
TOP_LEVEL_ALIASES_AUDIT_FIELD,
old_aliases,
merged_aliases):
payload["别名"] = merged_aliases
_write_locked_upgrade_draft(conn, draft_id, payload, revision)
_update_card_state(conn, draft_id, work_id, win_no)
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 [], raw, ent.get("型"), aliases,
chaps_int, win_no, chapter_texts)
payload = {"type": ent["型"], "名称": raw,
"别名": aliases,
"一句话摘要": ent.get("一句话摘要", ""),
"字段": fields0,
"出场章": chaps_int,
"来源": f"升格@窗{win_no}", "状态": "草稿",
"目标库": "本书作品库", "可见范围": "本书私有",
"_work_id": work_id}
# 先原子占用全部 alias;任一不同 canonical 冲突都会让窗事务在写 payload 前失败关闭。
for alias in payload["别名"]:
_claim_alias(conn, work_id, raw, alias, win_no, "init")
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))
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, raw, tuple(payload["别名"])
def _snapshot_redo_window(conn, work_id, win_no):
"""抓取显式 redo 前的完整可恢复状态,不让数据库连接跨 LLM 调用存活。"""
snapshot = {
"window_no": win_no,
"drafts": conn.execute(
"""SELECT id, draft_payload, status, revision, updater, deleted
FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s
ORDER BY id""",
(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
ORDER BY id""",
(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
ORDER BY id""",
(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
ORDER BY draft_id""",
(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
ORDER BY a.id""",
(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 _assert_redo_postcommit_fence(conn, work_id, expected_snapshot):
"""阻写 redo 恢复覆盖域并 exact 复验提交态;任一域漂移都禁止整书恢复。"""
if expected_snapshot is None:
raise CompensationFenceConflict("redo 补偿缺少 postcommit 全域围栏")
# 固定表序一次取得阻写锁;等待中的外部写事务提交后,READ COMMITTED 重读必能看到并拒绝漂移。
conn.execute(
"""LOCK TABLE muse_knowledge_draft, example_upgrade_alias,
example_upgrade_presence, example_upgrade_card_state,
example_upgrade_audit, example_upgrade_window
IN SHARE ROW EXCLUSIVE MODE"""
)
current_snapshot = _snapshot_redo_window(
conn, work_id, expected_snapshot["window_no"]
)
for domain in ("drafts", "aliases", "presence", "card_states", "audits", "window"):
if current_snapshot.get(domain) != expected_snapshot.get(domain):
raise CompensationFenceConflict(f"redo 补偿全域围栏冲突:domain={domain}")
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, status, revision, updater, deleted in snapshot["drafts"]:
conn.execute(
"""UPDATE muse_knowledge_draft
SET draft_payload=%s, status=%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), status, 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:
if fname == CARD_STATE_WATERMARK_AUDIT_FIELD:
# 水位是独立表状态,不得误还原进 payload["字段"];新关系旧值为空时直接删 state。
if old is None:
conn.execute("DELETE FROM example_upgrade_card_state WHERE draft_id=%s", (did,))
else:
watermark = json.loads(old) if isinstance(old, str) else old
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=EXCLUDED.watermark_window, update_time=now()""",
(did, work_id, watermark, TENANT),
)
continue
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 fname == TOP_LEVEL_ALIASES_AUDIT_FIELD:
# 顶层别名与唯一表必须一起撤销;旧值为空表示原 payload 连该键都不存在。
if old is None:
payload.pop("别名", None)
else:
payload["别名"] = json.loads(old) if isinstance(old, str) else old
elif fname == TOP_LEVEL_SUMMARY_AUDIT_FIELD:
# 一句话摘要住顶层,禁止按普通字段恢复成 payload["字段"] 内的影子摘要。
if old is None:
payload.pop("一句话摘要", None)
else:
payload["一句话摘要"] = json.loads(old) if isinstance(old, str) else old
elif fname == TOP_LEVEL_RELATION_TYPE_AUDIT_FIELD:
# 关系类型住 payload 顶层;普通字段恢复会错误写到 payload["字段"]。
if old is None:
payload.pop("关系类型", None)
else:
payload["关系类型"] = json.loads(old) if isinstance(old, str) else old
elif fname == TOP_LEVEL_RELATION_EVOLUTION_AUDIT_FIELD:
# 兼容历史关系卡顶层演变轨迹迁移,undo 时恢复到原顶层位置。
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, revision=revision+1, updater='upgrade-undo'
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, revision=revision+1, updater='upgrade-undo'
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, revision=revision+1, updater='upgrade-undo'
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 # 单章龙套 → 留档
def _resolve_canonical_name(name_map, aliases_by_draft, matched_name):
"""把命中的规范名或别名解析为该 draft 当前真实规范名。
``name_map`` 同时以规范名和别名建索引,不能把命中的键直接当 ``canonical_name`` 落库。
真实规范名必须是同 draft 下不属于合法别名集合的活跃卡名称;若底册不完整到无法唯一解析,
直接失败关闭当前窗,避免写入 alias token 后仍把窗口标成完成。"""
target = name_map.get(matched_name)
if not target:
raise RuntimeError(f"判重命中项不在底册:{matched_name}")
draft_id = target[0]
aliases = set(aliases_by_draft.get(draft_id, set()))
canonical_names = [
name
for name, value in name_map.items()
if value[0] == draft_id and name not in aliases
]
if len(canonical_names) != 1:
raise RuntimeError(
f"draft={draft_id} 无法唯一解析真实规范名:候选={canonical_names},命中={matched_name}"
)
return canonical_names[0]
# ── 语义判重(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 _assert_embedding_owner_available(conn, draft_id, content_hash, *, lock=False):
"""校验同 hash 唯一行可安全归当前 draft;写段可加行锁防预检后的并发竞态。"""
lock_clause = " FOR UPDATE OF e" if lock else ""
conflict = conn.execute(
"""SELECT e.draft_id, e.entity_id, e.deleted,
COALESCE(d.deleted, TRUE), d.draft_payload
FROM example_knowledge_embedding e
LEFT JOIN muse_knowledge_draft d ON d.id=e.draft_id
WHERE e.tenant_id=%s AND e.content_hash=%s AND e.model=%s""" + lock_clause,
(TENANT, content_hash, EMBED_MODEL),
).fetchone()
if not conflict:
return
owner_draft_id, owner_entity_id, _, owner_deleted, owner_payload = conflict
# 已确认实体拥有的向量不可重新降级归 draft;无论 draft 是否软删都必须失败关闭。
if owner_entity_id is not None:
raise EmbeddingOwnershipConflict(
f"同 hash 唯一行已归 entity:hash={content_hash},entity={owner_entity_id},"
f"candidate={draft_id}"
)
if owner_draft_id in (None, draft_id) or owner_deleted:
return
owner_text = build_embed_text(owner_payload or {})
owner_hash = hashlib.sha256(f"{owner_text}|{EMBED_MODEL}".encode()).hexdigest()
if owner_hash == content_hash:
raise EmbeddingOwnershipConflict(
f"活跃 draft 同 hash 争用:hash={content_hash},"
f"owner={owner_draft_id},candidate={draft_id}"
)
def _assert_draft_live_embeddings_mutable(conn, draft_id):
"""写前锁定同 draft 全部活向量;任一已归 entity 都禁止被 draft 更新链软删。"""
rows = conn.execute(
"""SELECT id, content_hash, entity_id FROM example_knowledge_embedding
WHERE tenant_id=%s AND draft_id=%s AND deleted=FALSE
FOR UPDATE""",
(TENANT, draft_id),
).fetchall()
entity_rows = [(row_id, content_hash, entity_id) for row_id, content_hash, entity_id in rows
if entity_id is not None]
if entity_rows:
raise EmbeddingOwnershipConflict(
f"draft={draft_id} 的旧活向量已归 entity,禁止软删:{entity_rows}"
)
def _assert_ready_draft_current(conn, draft_id, expected_hash):
"""锁定待写 draft,复验可信状态与当前 payload hash,阻断嵌入 HTTP 期间漂移。"""
row = conn.execute(
"""SELECT tenant_id, deleted, status, draft_payload
FROM muse_knowledge_draft WHERE id=%s FOR UPDATE""",
(draft_id,),
).fetchone()
if not row:
raise EmbeddingOwnershipConflict(f"待写 draft 不存在:draft={draft_id}")
tenant_id, deleted, status, payload = row
if tenant_id != TENANT or deleted or status != "pending":
raise EmbeddingOwnershipConflict(
f"待写 draft 状态非法:draft={draft_id},tenant={tenant_id},"
f"deleted={deleted},status={status}"
)
current_hash = _payload_embed_hash(payload)
if current_hash != expected_hash:
raise EmbeddingOwnershipConflict(
f"HTTP 期间 draft payload 已漂移:draft={draft_id},"
f"expected_hash={expected_hash},current_hash={current_hash}"
)
def _payload_embed_hash(payload):
"""按嵌入唯一键同源公式计算当前 payload hash。"""
text = build_embed_text(payload or {})
return hashlib.sha256(f"{text}|{EMBED_MODEL}".encode()).hexdigest()
def embed_touched_cards(sess, work_id, touched):
"""嵌段(洞① 三段式连接):窗事务 commit **之后**,把本窗新建/更新的卡增量嵌入落库,
让后窗语义判重能召回前窗刚长成的卡(治「起了对不上正文的名→机械预扫认不出→成长线静默断」)。
读(短连接):取 touched 卡最新 payload + 各卡现存活嵌入行的 content_hash 集;关连接。
算(无连接):build_embed_text 构文(与检索端同源同构)→ sha256(text|MODEL) 同 embed_drafts 公式
→ 同哈希已有活行的卡跳过(内容未变,幂等)→ 批量 embed_texts。
写(短连接单事务):固定顺序锁表后复验 draft 状态与当前 payload hash,再锁定同 draft
全部活向量与同 hash 唯一行,拒绝任何 entity owner;
再软删该卡其他仍属 draft 的旧活行;
upsert 会把旧软删卡占用的 hash 迁到当前 draft,并刷新当前嵌入全部语义列。
失败语义:普通网络/服务异常只 stderr 告警、不 fail 已提交窗;确定性的活跃 owner 冲突向 caller
重抛,由 caller 把窗改为 failed 并非零退出,确保下次 run 会 undo 后重跑。返回本窗实际新嵌条数。"""
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 = _payload_embed_hash(payload)
if h not in live_hashes.get(did, set()):
todo.append((did, text, h))
if not todo:
return 0
# owner 预检必须覆盖全部 todo,放在 embed_texts 之前;即使服务随后把候选标 bad,
# entity/活跃 draft 冲突也已经硬停,不能因 ready 为空而返回假成功。
with psycopg.connect(DSN) as conn:
for did, _, content_hash in todo:
_assert_embedding_owner_available(conn, did, content_hash)
# 唯一键是 tenant+content_hash+model;同批两个活跃 draft 文本完全相同时,数据库无法同时表达
# 两个 owner。必须在发嵌入和写库前失败关闭,禁止后处理者按循环顺序抢走前者的唯一行。
hash_owners = {}
for did, _, content_hash in todo:
hash_owners.setdefault(content_hash, []).append(did)
duplicate_hashes = {
content_hash: draft_ids
for content_hash, draft_ids in hash_owners.items()
if len(draft_ids) > 1
}
if duplicate_hashes:
raise EmbeddingOwnershipConflict(f"活跃 touched 卡出现同 hash 争用:{duplicate_hashes}")
vecs, bad = embed_texts(sess, [t for _, t, _ in todo])
ready = [
(did, text, content_hash, vecs[index])
for index, (did, text, content_hash) in enumerate(todo)
if index not in bad and index < len(vecs) and vecs[index] is not None
]
if not ready:
return 0
# 写段:短连接单事务——先锁定并校验唯一 owner,再淘汰旧活行(软删)+ upsert 新行。
done = 0
with psycopg.connect(DSN) as conn:
# 写事务先按固定表顺序取得 ROW EXCLUSIVE 锁,再执行任何行锁或写入。
conn.execute(
"LOCK TABLE muse_knowledge_draft, example_knowledge_embedding IN ROW EXCLUSIVE MODE"
)
for did, _, content_hash, _ in sorted(ready, key=lambda item: item[0]):
_assert_ready_draft_current(conn, did, content_hash)
for did in sorted({draft_id for draft_id, _, _, _ in ready}):
_assert_draft_live_embeddings_mutable(conn, did)
for did, _, content_hash, _ in sorted(ready, key=lambda item: (item[2], item[0])):
_assert_embedding_owner_available(conn, did, content_hash, lock=True)
for did, text, h, vector in ready:
# 该卡其他活行软删(内容已变、旧向量过期)——软删红线:绝不物理 DELETE
conn.execute(
"""UPDATE example_knowledge_embedding SET deleted=TRUE, updater=%s
WHERE tenant_id=%s AND draft_id=%s AND deleted=FALSE
AND entity_id IS NULL AND content_hash!=%s""",
(EMBED_ACTOR, TENANT, did, h))
# 同 hash 旧行可能仍绑定全重抽前的软删 draft;冲突时必须迁移 owner 并刷新全部当前语义列。
upserted = 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 draft_id=EXCLUDED.draft_id,
embed_text=EXCLUDED.embed_text,
model=EXCLUDED.model,
dimensions=EXCLUDED.dimensions,
embedding=EXCLUDED.embedding,
deleted=FALSE,
updater=EXCLUDED.updater
WHERE example_knowledge_embedding.entity_id IS NULL
AND (example_knowledge_embedding.draft_id=EXCLUDED.draft_id
OR example_knowledge_embedding.deleted=TRUE
OR NOT EXISTS (
SELECT 1 FROM muse_knowledge_draft owner
WHERE owner.id=example_knowledge_embedding.draft_id
AND owner.deleted=FALSE))
RETURNING draft_id""",
(did, h, text, EMBED_MODEL, EMBED_DIM, json.dumps(vector),
EMBED_ACTOR, EMBED_ACTOR, TENANT)).fetchone()
if not upserted or upserted[0] != did:
raise EmbeddingOwnershipConflict(
f"同 hash 唯一行未绑定当前 draft:hash={h},candidate={did}"
)
done += 1
conn.commit()
return done
except EmbeddingOwnershipConflict as e:
# 这是确定性数据所有权冲突,不是可降级的网络抖动;必须令命令非零且禁止打印窗完成假绿。
print(f"[嵌段阻断] work={work_id} {e}", file=sys.stderr)
raise
except Exception as e:
# 嵌段失败不牵连窗(窗已 commit done):判重是增益、向量缺口后续批量补嵌可兜,只告警可审计
print(f"[嵌段告警] work={work_id} 增量嵌入异常(不 fail 窗,向量缺口后续补嵌兜住): "
f"{type(e).__name__}: {str(e)[:180]}", file=sys.stderr)
return 0
def _capture_touched_fence(conn, touched):
"""捕获窗提交后 touched 卡的 revision/hash,作为冲突补偿唯一许可。"""
fence = {}
for draft_id in sorted(touched):
payload, revision = _read_upgrade_draft_snapshot(conn, draft_id)
fence[draft_id] = (revision, _payload_embed_hash(payload))
return fence
def _assert_touched_fence(conn, work_id, fence):
"""锁定并复验提交后围栏;任何外部确认、改版、删除都禁止 undo。"""
for draft_id in sorted(fence):
row = conn.execute(
"""SELECT work_id, draft_payload, status, revision, source_type, deleted
FROM muse_knowledge_draft
WHERE id=%s AND tenant_id=%s FOR UPDATE""",
(draft_id, TENANT),
).fetchone()
expected_revision, expected_hash = fence[draft_id]
if not row:
raise CompensationFenceConflict(f"补偿围栏 draft 缺失:draft={draft_id}")
current_work, payload, status, revision, source_type, deleted = row
current_hash = _payload_embed_hash(payload)
if current_work != work_id or deleted or status != "pending" \
or source_type != SOURCE_TYPE or revision != expected_revision \
or current_hash != expected_hash:
raise CompensationFenceConflict(
f"补偿围栏冲突:draft={draft_id},work={current_work},status={status},"
f"source_type={source_type},deleted={deleted},revision={revision},"
f"expected_revision={expected_revision},hash_match={current_hash == expected_hash}"
)
def _persist_compensation_failed(work_id, win_no, owner_error, compensation_error):
"""补偿事务失败后另开事务落永久断点,避免旧 done 被后续运行跳过。"""
message = (
f"{COMPENSATION_FAILED_PREFIX} owner={owner_error}; compensation={compensation_error}"
)[:500]
with psycopg.connect(DSN) as conn:
row = conn.execute(
"""UPDATE example_upgrade_window SET status='failed', error_message=%s,
updater='upgrade'
WHERE tenant_id=%s AND work_id=%s AND window_no=%s
AND status='done' AND error_message IS NULL AND updater='upgrade'
RETURNING status""",
(message, TENANT, work_id, win_no),
).fetchone()
if not row:
raise CompensationFenceConflict(
f"补偿 marker 窗口 CAS 冲突:work={work_id},window={win_no}"
)
conn.commit()
return message
# ── 命令 ──
@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
redo_postcommit_fence = 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, error_message FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND status='failed' AND deleted=FALSE""",
(TENANT, work_id)).fetchall():
if str(r0[1] or "").startswith(COMPENSATION_FAILED_PREFIX):
raise click.ClickException(
f"failed 窗{r0[0]} 存在未完成补偿断点,禁止自动 undo:{r0[1]}"
)
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, expected_revisions = 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 供嵌段增量嵌入)
touched_fence = {}
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]
canonical_name = _resolve_canonical_name(name_map, aliases_by_draft, key)
# alias 是分类副作用,也必须先通过模型所见目标卡的 pending/source/revision 门禁。
_lock_upgrade_draft(conn, did, expected_revisions[did])
to_update.setdefault(did, []).append(
f"(别名「{nm}」并入)初卡材料:{_merge_material(ent)}")
legal_alias = _claim_alias(
conn, work_id, canonical_name, nm, win_no, "ai"
)
if legal_alias:
aliases_by_draft.setdefault(did, set()).add(legal_alias)
continue
if kind == "substr": # 同型名称互为子串(「果子」vs「开心果子」):
# 不直接立卡,转观察材料并入既有卡由更新步 AI 甄别(软防护,留复核标记;洞②材料不截里程碑)
did = name_map[key][0]
canonical_name = _resolve_canonical_name(name_map, aliases_by_draft, key)
_lock_upgrade_draft(conn, did, expected_revisions[did])
to_update.setdefault(did, []).append(
f"(名称疑似同一实体「{nm}」≈「{key}」,请甄别后再并入)"
f"初卡材料:{_merge_material(ent)}")
legal_alias = _claim_alias(
conn, work_id, canonical_name, nm, win_no, "substr"
)
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
_lock_upgrade_draft(conn, did0, expected_revisions[did0])
to_update.setdefault(did0, []).append(
f"(语义判重·「{nm}」并入同一实体)初卡材料:{_merge_material(ent)}")
legal_alias = _claim_alias(
conn, work_id, canon, nm, win_no, "semantic"
)
if legal_alias:
aliases_by_draft.setdefault(did0, set()).add(legal_alias)
continue
if verdict == "chain": # 前身后继:仍立卡,串链关系记候选审计
did, canonical_name, card_aliases = new_card(
conn,
work_id,
win_no,
ent,
milestone_types,
chapter_texts=chapter_texts,
known_chapters=hist,
)
new_ids.append(did)
_, expected_revisions[did] = _read_upgrade_draft_snapshot(conn, did)
target = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
name_map[canonical_name] = target
for card_alias in card_aliases:
name_map[card_alias] = target
aliases_by_draft.setdefault(did, set()).add(card_alias)
# 只记候选提示、不自动写「前身/后继」字段——避免误串,链接由人工/后续确认落字段
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, canonical_name, card_aliases = new_card(
conn,
work_id,
win_no,
ent,
milestone_types,
chapter_texts=chapter_texts,
known_chapters=hist,
)
new_ids.append(did)
_, expected_revisions[did] = _read_upgrade_draft_snapshot(conn, did)
target = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
name_map[canonical_name] = target
for card_alias in card_aliases:
name_map[card_alias] = target
aliases_by_draft.setdefault(did, set()).add(card_alias)
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 = []
batch_revisions = {}
for did, obs_pts in batch:
p, prompt_revision = _read_upgrade_draft_snapshot(conn, did)
batch_revisions[did] = prompt_revision
expected_revisions[did] = prompt_revision
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()),
expected_revision=batch_revisions[u["draft_id"]])
# ⑤ 关系增量(核心角色=本窗有更新的 character + 在场 character,≤8)
relation_touched = set()
char_cards = []
seen = set()
for did in list(to_update.keys()):
candidate, participant_revision = _read_upgrade_draft_snapshot(conn, did)
if candidate.get("type") == "character" and did not in seen:
p, _ = _lock_upgrade_draft(conn, did, participant_revision)
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:
_, participant_revision = _read_upgrade_draft_snapshot(conn, did)
p, _ = _lock_upgrade_draft(conn, did, participant_revision)
char_cards.append((did, p))
seen.add(did)
char_cards = char_cards[:8]
if len(char_cards) >= 2:
rel_rows = conn.execute(
"""SELECT id, draft_payload, revision FROM muse_knowledge_draft
WHERE tenant_id=%s AND work_id=%s AND source_type=%s AND deleted=FALSE
AND status='pending' AND draft_payload->>'type'=%s""",
(TENANT, work_id, SOURCE_TYPE, RELATION_TYPE)).fetchall()
rels = [(rid, payload) for rid, payload, _ in rel_rows]
relation_revisions = {rid: revision for rid, _, revision in rel_rows}
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:
rid, rp = exist[key]
relation_touched.add(
_update_relation_card(
conn, work_id, win_no, rid, relation_revisions[rid], r
)
)
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}
relation_touched.add(
_insert_relation_card(conn, work_id, win_no, rp)
)
# ⑥ 机械收尾:出场章统一并入(纯出场 + 被更新卡,抽检#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("出场章"))
)
presence_touched = set()
for did, chs in pres_add.items():
if not chs:
continue
payload, revision = _lock_upgrade_draft(
conn, did, expected_revisions[did]
)
# 纯出场与无字段变化卡复用同一「过滤+审计追加」路径,确保 redo 能撤销上一轮顶层章号。
changed = _append_verified_appearance_chapters(
conn,
did,
win_no,
payload,
chs,
chapter_texts,
known_aliases=aliases_by_draft.get(did, set()),
)
if not changed:
continue
_write_locked_upgrade_draft(conn, did, payload, revision)
presence_touched.add(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))
touched = set(new_ids) | set(to_update.keys()) | relation_touched | presence_touched
if semantic_on and touched:
# 在提交事务内捕获即将提交的精确版本,消除 commit 后被外部改版再采样的竞态。
touched_fence = _capture_touched_fence(conn, touched)
if redo_snapshot is not None:
# restore 会覆盖整书六域;在提交前捕获其完整提交态,供冲突补偿 exact 复验。
redo_postcommit_fence = _snapshot_redo_window(conn, work_id, win_no)
conn.commit()
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 之后;三段式)+ 窗完成汇报 ──
# 普通服务异常仍由嵌段告警降级;确定性的唯一 owner 冲突必须把已提交窗补偿标为 failed,
# 让下次 run 先 undo 后重跑,并在打印窗完成勾号前非零退出。
embed_n = 0
if semantic_on and touched:
try:
embed_n = embed_touched_cards(embed_sess, work_id, touched)
except EmbeddingOwnershipConflict as exc:
error_message = f"嵌入唯一 owner 冲突:{exc}"[:500]
# 先把 done 改成 durable 失败断点;补偿成功后普通窗会覆盖成最终 failed,
# redo 会恢复原窗状态。若补偿异常,此 marker 天然保留,不依赖第二次数据库写入。
try:
durable_message = _persist_compensation_failed(
work_id, win_no, exc, "pending"
)
except Exception as marker_exc:
raise click.ClickException(
f"嵌入冲突后无法持久化补偿断点:{marker_exc}"
) from marker_exc
try:
with psycopg.connect(DSN) as conn:
if redo_snapshot is not None:
# redo restore 覆盖整书六域,必须阻写并 exact 复验完整提交态。
expected_redo_fence = deepcopy(redo_postcommit_fence)
expected_redo_fence["window"] = (
"failed", durable_message, "upgrade"
)
_assert_redo_postcommit_fence(
conn, work_id, expected_redo_fence
)
_restore_redo_window(conn, work_id, redo_snapshot)
else:
# 普通前滚只会撤销本窗写入,维持 touched draft 围栏边界。
_assert_touched_fence(conn, work_id, touched_fence)
# 普通窗必须在同一事务先撤销五域,再标 failed,禁止留下已提交半窗。
undo_window(conn, work_id, win_no)
conn.execute(
"""UPDATE example_upgrade_window SET status='failed', error_message=%s,
updater='upgrade'
WHERE tenant_id=%s AND work_id=%s AND window_no=%s""",
(error_message, TENANT, work_id, win_no),
)
conn.commit()
except Exception as compensation_exc:
# 补偿事务回滚,先前独立提交的 marker 保留;不依赖数据库仍可写的二次更新。
raise click.ClickException(
f"{durable_message}; compensation={compensation_exc}"[:500]
) from compensation_exc
raise click.ClickException(error_message) from exc
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()