修复: 阻断历史单窗重跑破坏成长链

以正文证据校验实体出场章,补齐失败恢复和参数边界;历史窗口重跑在写入前失败关闭,仅保留末窗安全重试。记录 work8 全书前滚重建的 P0/P1 边界。
This commit is contained in:
zizi 2026-07-21 16:08:15 +08:00
parent f5965577d6
commit b9ff4d0b40
4 changed files with 1388 additions and 59 deletions

View File

@ -58,7 +58,7 @@ disable-model-invocation: true
**窗行陷阱(放量首日实测)**:`--window` 参数变化后重切,旧窗行会按 from_order 占位,新的大窗被「已有大纲跳过」→ 中间章域永远漏出卡(验收期 1–3 章小窗占住 from_order=1,放量 1–34 章大窗被跳过)。**换窗参数重切前必须先删该书全部窗行**(窗行是可再生中间产物;卡挂「窗起」,cards 重出时按窗软删重出)。 **窗行陷阱(放量首日实测)**:`--window` 参数变化后重切,旧窗行会按 from_order 占位,新的大窗被「已有大纲跳过」→ 中间章域永远漏出卡(验收期 1–3 章小窗占住 from_order=1,放量 1–34 章大窗被跳过)。**换窗参数重切前必须先删该书全部窗行**(窗行是可再生中间产物;卡挂「窗起」,cards 重出时按窗软删重出)。
**作品面升格执行器 `scripts/parse_upgrade.py`(与上面范式拆书管线并行的另一条线,命令 `windows`/`run`/`status`)**:把参考书正文按窗抽成「会随剧情长大的实体卡」(升格卡,`source_type=upgrade_book`),设计见 `docs/2026-07-16-升格卡改造设计.md`。里程碑除真实章号外,模型输出必须临时携带所标章节正文短引;系统机械核验后删除证据,错章、缺证据或改写证据均拒收入库并留审计。`run` 默认**不发嵌入**;加 `--semantic-dedup` 开语义判重(治改名/跨型漏并)时,每窗按**读→算→写三段式短连接**跑——观察/近邻召回/M3 终判都在**无长连接**段发 LLM 与嵌入 HTTP(不再持窗级连接跨调用存活),写段只查预判结果落库;并**边抽边嵌**:本窗新建/更新卡在窗事务提交后增量嵌入落库(软删旧向量+upsert 新行),**后窗即可语义召回前窗刚长成的卡**,不再依赖"同书须预先全量 embed"。嵌段失败只告警、不牵连已 done 的窗。 **作品面升格执行器 `scripts/parse_upgrade.py`(与上面范式拆书管线并行的另一条线,命令 `windows`/`run`/`status`)**:把参考书正文按窗抽成「会随剧情长大的实体卡」(升格卡,`source_type=upgrade_book`),设计见 `docs/2026-07-16-升格卡改造设计.md`。里程碑除真实章号外,模型输出必须临时携带所标章节正文短引;系统机械核验后删除证据,错章、缺证据或改写证据均拒收入库并留审计。模型给出的顶层出场章也必须由实体规范名或合法别名在对应章节正文中的实际出现机械证明;合法别名集合同时取卡内 payload 与独立 alias 表,单字规范名禁作章证据,规范名和别名均禁止通用称谓/关系称呼(如队长、舰长)作证据,常规二至四字专名保持精确子串命中。未命中章不得参与立卡、登场兜底或既有卡追加,无实证章时不补登场里程碑;既有卡每窗新增出场章必须审计完整旧值,redo/undo 精确恢复后再按新正文重算。显式 redo **仅允许当前 active 末窗**:执行前机械校验窗口号连续、章域合法且首尾相接,并校验目标窗逐章正文齐全且非空;任一失败必须在快照、undo 与任何写入前非零退出。历史窗修正必须全书前滚重建;后缀级联重算属于 P1,当前不支持。合法末窗 redo 仍须在撤销事务中清除所有 active `upgrade_book` 卡的本窗章域,保留窗外章并为历史无审计数据补旧值审计;redo 前建立完整恢复点(卡 payload/revision/deleted、别名、presence、水位、审计与窗状态),首次失败重新清理再试,最终失败完整恢复且重试初建卡软删。`--max-calls` 不得截断已经开始的即时重试,非显式 redo 不做该全局清理。`run` 默认**不发嵌入**;加 `--semantic-dedup` 开语义判重(治改名/跨型漏并)时,每窗按**读→算→写三段式短连接**跑——观察/近邻召回/M3 终判都在**无长连接**段发 LLM 与嵌入 HTTP(不再持窗级连接跨调用存活),写段只查预判结果落库;并**边抽边嵌**:本窗新建/更新卡在窗事务提交后增量嵌入落库(软删旧向量+upsert 新行),**后窗即可语义召回前窗刚长成的卡**,不再依赖"同书须预先全量 embed"。嵌段失败只告警、不牵连已 done 的窗。
**范式线失败关闭(2026-07-21 假绿复盘后现行)**:`parse_outline.py window`、`parse_llm.py cards`、`parse_outline.py check` 是同一本作品的顺序流水线。普通网络/429/5xx 由 `chat_governed` 在单模型内短重试后沿 `MiniMax-M3 → MiniMax-M2.7 → glm-5.2 → deepseek-v4-flash` 降级;整条链耗尽,或功能层结构输出有限重试仍失败,必须**立即停止当前作品并保留断点**,禁止跳窗/跳型继续。`cards` 和 `check` 开始前机械验证大纲窗对全书章域无头部、中部、尾部缝隙;任一缝隙非零退出。补插窗后所有终检按 `from_order/to_order` 绝对章号排序并重新编号阶段,`window_no` 只作运行展示号,不得参与时序判断。 **范式线失败关闭(2026-07-21 假绿复盘后现行)**:`parse_outline.py window`、`parse_llm.py cards`、`parse_outline.py check` 是同一本作品的顺序流水线。普通网络/429/5xx 由 `chat_governed` 在单模型内短重试后沿 `MiniMax-M3 → MiniMax-M2.7 → glm-5.2 → deepseek-v4-flash` 降级;整条链耗尽,或功能层结构输出有限重试仍失败,必须**立即停止当前作品并保留断点**,禁止跳窗/跳型继续。`cards` 和 `check` 开始前机械验证大纲窗对全书章域无头部、中部、尾部缝隙;任一缝隙非零退出。补插窗后所有终检按 `from_order/to_order` 绝对章号排序并重新编号阶段,`window_no` 只作运行展示号,不得参与时序判断。

View File

@ -28,6 +28,7 @@ import pathlib
import re import re
import sys import sys
import unicodedata import unicodedata
from copy import deepcopy
import click import click
import psycopg import psycopg
@ -156,10 +157,11 @@ def load_window_text(conn, work_id, a, b):
def load_known(conn, work_id): def load_known(conn, work_id):
"""加载判重底册:name_map(名字/别名→卡)+ presence 留档出场章。 """加载判重底册:名字索引、按卡完整合法别名集合与 presence 留档出场章。
返回 (name_map: {名字: (draft_id, 型, 摘要)}, presence: {(型,名字): set(章)})""" aliases_by_draft 同时汇总 payload 与独立别名表,供后续正文实体命中过滤使用。"""
name_map = {} name_map = {}
aliases_by_draft = {}
for did, entity_type, name, brief, aliases in conn.execute( for did, entity_type, name, brief, aliases in conn.execute(
"""SELECT id, """SELECT id,
draft_payload->>'type', draft_payload->>'type',
@ -172,22 +174,27 @@ def load_known(conn, work_id):
t, nm = entity_type or "", (name or "").strip() t, nm = entity_type or "", (name or "").strip()
if nm: if nm:
name_map[nm] = (did, t, brief) name_map[nm] = (did, t, brief)
for al in aliases or []: for raw_alias in aliases or []:
if isinstance(al, str) and al.strip(): alias = _clean_alias(raw_alias)
name_map[al.strip()] = (did, t, brief) if alias:
name_map[alias] = (did, t, brief)
aliases_by_draft.setdefault(did, set()).add(alias)
for cn, al in conn.execute( for cn, al in conn.execute(
"SELECT canonical_name, alias FROM example_upgrade_alias " "SELECT canonical_name, alias FROM example_upgrade_alias "
"WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE", "WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchall(): (TENANT, work_id)).fetchall():
if cn in name_map: alias = _clean_alias(al)
name_map[al] = name_map[cn] 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 = {} presence = {}
for t, nm, ch in conn.execute( for t, nm, ch in conn.execute(
"SELECT entity_type, name, chapter_no FROM example_upgrade_presence " "SELECT entity_type, name, chapter_no FROM example_upgrade_presence "
"WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE", "WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE",
(TENANT, work_id)).fetchall(): (TENANT, work_id)).fetchall():
presence.setdefault((t, nm), set()).add(ch) presence.setdefault((t, nm), set()).add(ch)
return name_map, presence return name_map, presence, aliases_by_draft
def prescan(name_map, text): def prescan(name_map, text):
@ -428,6 +435,111 @@ def _normalize_evidence_text(value):
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(value or ""))) 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): def _milestone_evidence_matches(item, chapter_texts):
"""验证短原文证据确实存在于里程碑声明的章节正文。""" """验证短原文证据确实存在于里程碑声明的章节正文。"""
@ -594,16 +706,17 @@ def _merge_material(ent, rest_limit=600):
def _debut_milestone(milestones, brief, chaps, win_no): def _debut_milestone(milestones, brief, chaps, win_no):
"""登场兜底(洞② 机械那一保险,纯函数便于离线自测):里程碑列表里若没有任何 周期=登场 的条目, """登场兜底(洞② 机械那一保险,纯函数便于离线自测):里程碑列表里若没有任何 周期=登场 的条目,
在头部补一条兜底登场里程碑;已有登场则原样返回(提示词软约束 + 此机械兜底=双保险)。 在头部补一条兜底登场里程碑;已有登场则原样返回(提示词软约束 + 此机械兜底=双保险)。
- 章:min(出场章)(仅取整数章号);无出场章则**省略「章」键**(待人工/后续补,诚实边界不编造章号); - 章:min(正文实证出场章)(仅取整数章号);无实证章则不补,真实性优先;
- 台阶:"登场:"+一句话摘要截 40 字,再过 STEP_MAX 守卫(与 _clean_milestone 同上限); - 台阶:"登场:"+一句话摘要截 40 字,再过 STEP_MAX 守卫(与 _clean_milestone 同上限);
- _win:盖当前窗号——带 _win 才能被同窗撤销(undo_window 按 _win 删本窗新增)识别,防重跑 double-append。 - _win:盖当前窗号——带 _win 才能被同窗撤销(undo_window 按 _win 删本窗新增)识别,防重跑 double-append。
根治病象:实体首现走新名字路径时模型倾向只填当前态、漏建登场里程碑,机械补一条保成长线起点不缺。""" 根治病象:实体首现走新名字路径时模型倾向只填当前态、漏建登场里程碑,机械补一条保成长线起点不缺。"""
if any(isinstance(m, dict) and m.get("周期") == "登场" for m in (milestones or [])): if any(isinstance(m, dict) and m.get("周期") == "登场" for m in (milestones or [])):
return list(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 = {"台阶": ("登场:" + (brief or "")[:40])[:STEP_MAX], "周期": "登场", "_win": win_no}
ch_ints = sorted(c for c in (chaps or []) if isinstance(c, int)) debut["章"] = ch_ints[0]
if ch_ints: # 有出场章才落「章」;无则省键(不硬编造)
debut["章"] = ch_ints[0]
return [debut] + list(milestones or []) return [debut] + list(milestones or [])
@ -616,6 +729,8 @@ def merge_card(
valid_keys=None, valid_keys=None,
*, *,
chapter_texts=None, chapter_texts=None,
appearance_chapters=None,
known_aliases=None,
): ):
"""按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号), """按 5.1 三类规则合并变更字段:数组/白名单=追加(剥模型自带前缀+同文去重后带窗号),
标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。 标量=覆写留审计(增量式假全量拦截转追加——抽检#4 信息回退病)。
@ -634,6 +749,16 @@ def merge_card(
# 模型偶把「别名新增」混进变更字段(窗2实测):摘出来并入别名流程,不落卡体字段 # 模型偶把「别名新增」混进变更字段(窗2实测):摘出来并入别名流程,不落卡体字段
alias_new = list(alias_new or []) + \ alias_new = list(alias_new or []) + \
[a for a in (changes.pop("别名新增", None) or []) if isinstance(a, str)] [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(): for k, v in changes.items():
if valid_keys is not None and k not in valid_keys: if valid_keys is not None and k not in valid_keys:
# 越合同字段:裁剪留审计(方案守卫条款),畸形长 key(条目误当 key)一并挡下 # 越合同字段:裁剪留审计(方案守卫条款),畸形长 key(条目误当 key)一并挡下
@ -769,7 +894,16 @@ def merge_card(
(draft_id, payload.get("_work_id") or 0, win_no, TENANT)) (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): def new_card(
conn,
work_id,
win_no,
ent,
milestone_types=None,
*,
chapter_texts=None,
known_chapters=None,
):
"""立初卡:payload 全字段以旧值=NULL 入审计(G7,错认拆回可还原初始态)。 """立初卡:payload 全字段以旧值=NULL 入审计(G7,错认拆回可还原初始态)。
milestone_types(洞②):含「演变历程」字段的型集合(据库内合同动态判定)——该型立卡时若抽取结果 milestone_types(洞②):含「演变历程」字段的型集合(据库内合同动态判定)——该型立卡时若抽取结果
无登场里程碑,机械兜底补一条登场(保成长型实体登场即有生命周期起点);None/空集则不触发兜底。 无登场里程碑,机械兜底补一条登场(保成长型实体登场即有生命周期起点);None/空集则不触发兜底。
@ -785,6 +919,11 @@ def new_card(conn, work_id, win_no, ent, milestone_types=None, *, chapter_texts=
note = _clean_alias(m.group(2)) note = _clean_alias(m.group(2))
if note: if note:
extra_alias.append(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,粘连/自造前缀/垃圾 # 初卡字段过守卫(深空 4917 实测:立卡路不走 merge_card,粘连/自造前缀/垃圾
# 原样入库——更新路守了、立卡路漏了):列表值逐条拆分、剥前缀、垃圾拦截、带窗号 # 原样入库——更新路守了、立卡路漏了):列表值逐条拆分、剥前缀、垃圾拦截、带窗号
fields0 = {} fields0 = {}
@ -813,14 +952,18 @@ def new_card(conn, work_id, win_no, ent, milestone_types=None, *, chapter_texts=
out.append(f"[窗{win_no}] {core}") out.append(f"[窗{win_no}] {core}")
seen.add(core) seen.add(core)
fields0[k] = out fields0[k] = out
chaps_int = sorted(_int_chaps(ent.get("出场章", []))) # 归一化 int(窗113 修复):登场兜底与落库共用 # 当前窗模型章必须逐章命中实体名;历史留档章来自既有机械留档,单独并入,不能因本窗正文不含历史章而误删。
chaps_int = sorted(
_filter_entity_chapters(raw, aliases, ent.get("出场章", []), chapter_texts)
| _int_chaps(known_chapters)
)
# 洞② 登场兜底:该型合同含「演变历程」但抽取结果无登场里程碑时,机械补一条登场(出场章 min + 摘要)—— # 洞② 登场兜底:该型合同含「演变历程」但抽取结果无登场里程碑时,机械补一条登场(出场章 min + 摘要)——
# 模型倾向只填当前态、漏建登场,此为「提示词硬约束 + 机械兜底」双保险里的机械那一保险。 # 模型倾向只填当前态、漏建登场,此为「提示词硬约束 + 机械兜底」双保险里的机械那一保险。
if milestone_types and ent.get("型") in milestone_types: if milestone_types and ent.get("型") in milestone_types:
fields0["演变历程"] = _debut_milestone( fields0["演变历程"] = _debut_milestone(
fields0.get("演变历程") or [], ent.get("一句话摘要", ""), chaps_int, win_no) fields0.get("演变历程") or [], ent.get("一句话摘要", ""), chaps_int, win_no)
payload = {"type": ent["型"], "名称": raw, payload = {"type": ent["型"], "名称": raw,
"别名": [x for x in ([_clean_alias(a) for a in ent.get("别名", [])] + extra_alias) if x], "别名": aliases,
"一句话摘要": ent.get("一句话摘要", ""), "一句话摘要": ent.get("一句话摘要", ""),
"字段": fields0, "字段": fields0,
"出场章": chaps_int, "出场章": chaps_int,
@ -856,8 +999,141 @@ def new_card(conn, work_id, win_no, ent, milestone_types=None, *, chapter_texts=
return did return did
def undo_window(conn, work_id, win_no): 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 # 与向量的互动(洞①,不改本函数逻辑,仅说明边界):软删卡的向量靠召回 SQL 的 JOIN d.deleted=FALSE
# 天然排除,无需在此动嵌入行;被回滚的更新卡向量暂时偏新(对应已撤销的内容),重跑后嵌段 # 天然排除,无需在此动嵌入行;被回滚的更新卡向量暂时偏新(对应已撤销的内容),重跑后嵌段
# (embed_touched_cards)按当前 payload 重算哈希、软删旧活行 + upsert 复活,自愈到正确态。 # (embed_touched_cards)按当前 payload 重算哈希、软删旧活行 + upsert 复活,自愈到正确态。
@ -870,7 +1146,13 @@ def undo_window(conn, work_id, win_no):
for did, fname, old in rows: for did, fname, old in rows:
payload = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s", payload = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0] (did,)).fetchone()[0]
if old is None: 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) payload.get("字段", {}).pop(fname, None)
else: else:
payload.setdefault("字段", {})[fname] = json.loads(old) payload.setdefault("字段", {})[fname] = json.loads(old)
@ -892,6 +1174,28 @@ def undo_window(conn, work_id, win_no):
conn.execute("DELETE FROM example_upgrade_card_state WHERE draft_id=%s", (did,)) conn.execute("DELETE FROM example_upgrade_card_state WHERE draft_id=%s", (did,))
continue continue
tag, changed = f"[窗{win_no}] ", False 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()): for k, v in list(payload.get("字段", {}).items()):
if isinstance(v, list): if isinstance(v, list):
# 字符串条目按 [窗N] 前缀删;里程碑对象(演变历程)按内部 _win 溯源键删—— # 字符串条目按 [窗N] 前缀删;里程碑对象(演变历程)按内部 _win 溯源键删——
@ -908,7 +1212,6 @@ def undo_window(conn, work_id, win_no):
(TENANT, work_id, win_no)) (TENANT, work_id, win_no))
conn.execute("""DELETE FROM example_upgrade_alias WHERE tenant_id=%s AND work_id=%s conn.execute("""DELETE FROM example_upgrade_alias WHERE tenant_id=%s AND work_id=%s
AND evidence_window=%s""", (TENANT, work_id, win_no)) AND evidence_window=%s""", (TENANT, work_id, win_no))
conn.commit()
# ── 机械判重分类(洞①):预判段与写段共用的纯函数,把判据抽出防两处漂移 ── # ── 机械判重分类(洞①):预判段与写段共用的纯函数,把判据抽出防两处漂移 ──
@ -1164,18 +1467,23 @@ def windows(work_id):
@cli.command() @cli.command()
@click.option("--work-id", type=int, required=True) @click.option("--work-id", type=int, required=True)
@click.option("--max-windows", type=int, default=0, help="本次最多跑几个窗(0=不限)") @click.option("--max-windows", type=click.IntRange(min=0), default=0,
@click.option("--max-calls", type=int, default=0, help="本次 LLM 调用上限(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("--model", default="MiniMax-M3", show_default=True)
@click.option("--redo-window", type=int, default=0, help="指定窗号强制重跑(先撤销后重写)") @click.option("--redo-window", type=int, default=0,
help="仅允许当前末窗强制重跑;历史窗须全书前滚重建,后缀级联重算(P1)暂不支持")
@click.option("--semantic-dedup", "semantic_on", is_flag=True, @click.option("--semantic-dedup", "semantic_on", is_flag=True,
help="开启语义判重(P1):立卡前召回同书近邻+M3终判治改名/跨型漏并;" help="开启语义判重(P1):立卡前召回同书近邻+M3终判治改名/跨型漏并;"
"边抽边嵌(本窗新建/更新卡窗事务后增量嵌入落库,后窗即可召回前窗的卡);默认关") "边抽边嵌(本窗新建/更新卡窗事务后增量嵌入落库,后窗即可召回前窗的卡);默认关")
def run(work_id, max_windows, max_calls, model, redo_window, semantic_on): def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
"""按窗顺序跑升格:断点续跑跳过 done 窗;敏感硬停=窗 failed+书停。""" """按窗顺序跑升格;redo 仅限当前末窗,历史窗须全书前滚重建。"""
calls = {"n": 0} # 调用计数(含敏感失败换模型的次数由 m3_json 内部消化,此处计成功轮次) calls = {"n": 0} # 调用计数(含敏感失败换模型的次数由 m3_json 内部消化,此处计成功轮次)
# 语义判重嵌入会话(仅开启时建;禁系统代理,走内网直连) # 语义判重嵌入会话(仅开启时建;禁系统代理,走内网直连)
embed_sess = _embed_session() if semantic_on else None embed_sess = _embed_session() if semantic_on else None
redo_snapshot = None
redo_material = None
def call(prompt, need_keys): def call(prompt, need_keys):
calls["n"] += 1 calls["n"] += 1
@ -1191,14 +1499,50 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
if any(f.get("key") == "演变历程" if any(f.get("key") == "演变历程"
for f in contracts.get(t, {}).get("字段", []))} for f in contracts.get(t, {}).get("字段", []))}
if redo_window: if redo_window:
row = conn.execute( active_windows = conn.execute(
"""SELECT window_no, from_chapter, to_chapter FROM example_upgrade_window """SELECT window_no, from_chapter, to_chapter FROM example_upgrade_window
WHERE tenant_id=%s AND work_id=%s AND window_no=%s AND deleted=FALSE""", WHERE tenant_id=%s AND work_id=%s AND deleted=FALSE
(TENANT, work_id, redo_window)).fetchone() ORDER BY window_no""", (TENANT, work_id)).fetchall()
wins = [row] if row else [] target = next((row for row in active_windows if row[0] == redo_window), None)
if wins: if target is None:
click.echo(f"[撤销] 窗{redo_window} 旧写入回滚中…") raise click.ClickException(f"redo 窗{redo_window}不存在")
undo_window(conn, work_id, 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: else:
wins = conn.execute( wins = conn.execute(
"""SELECT window_no, from_chapter, to_chapter, status FROM example_upgrade_window """SELECT window_no, from_chapter, to_chapter, status FROM example_upgrade_window
@ -1223,14 +1567,17 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
win_no, a, b = wins[wi] win_no, a, b = wins[wi]
if max_windows and done_n >= max_windows: if max_windows and done_n >= max_windows:
break break
if max_calls and calls["n"] >= max_calls: if max_calls and calls["n"] >= max_calls and not retried:
click.echo(f"⏸ 调用闸 {max_calls} 已到,停在窗{win_no} 之前") click.echo(f"⏸ 调用闸 {max_calls} 已到,停在窗{win_no} 之前")
break break
try: try:
# ── 读1(短连接):窗正文 + 判重底册,读完即关(三段式红线:连接不跨 LLM/嵌入调用存活)── # ── 读1(短连接):窗正文 + 判重底册,读完即关(三段式红线:连接不跨 LLM/嵌入调用存活)──
with psycopg.connect(DSN) as conn: with psycopg.connect(DSN) as conn:
text, chapter_texts = load_window_material(conn, work_id, a, b) if redo_material is not None:
name_map, presence = load_known(conn, work_id) 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) # ⓪ 机械预扫纯内存,无连接 onstage = prescan(name_map, text) # ⓪ 机械预扫纯内存,无连接
# ── 算1(无连接):① 实体观察 M3 调用 + 输出清洗,全程不持连接 ── # ── 算1(无连接):① 实体观察 M3 调用 + 输出清洗,全程不持连接 ──
obs, usage = call(observe_prompt(contracts, title, a, b, text, onstage), obs, usage = call(observe_prompt(contracts, title, a, b, text, onstage),
@ -1247,6 +1594,17 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
# 模型输出防御(深空窗5实测:列表元素偶为裸字符串,.get 直接炸)——统一只留 dict 元素 # 模型输出防御(深空窗5实测:列表元素偶为裸字符串,.get 直接炸)——统一只留 dict 元素
for k in ("新名字", "已知实体新信息", "纯出场"): for k in ("新名字", "已知实体新信息", "纯出场"):
obs[k] = [x for x in (obs.get(k) or []) if isinstance(x, dict)] 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 终判,产出 # ── 预判段(仅 semantic_on,无长连接):粗筛将立卡候选→批量嵌入→短连接召回→M3 终判,产出
# verdicts 供写段查表(洞①:把原窗内 semantic_dedup(conn,…) 从连接存活期整体挪走)── # verdicts 供写段查表(洞①:把原窗内 semantic_dedup(conn,…) 从连接存活期整体挪走)──
verdicts, dedup_merge_n, dedup_chain_n = {}, 0, 0 verdicts, dedup_merge_n, dedup_chain_n = {}, 0, 0
@ -1278,6 +1636,9 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
VALUES (%s,%s,%s,%s,'ai',%s) VALUES (%s,%s,%s,%s,'ai',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""", ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, key, nm, win_no, TENANT)) (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 continue
if kind == "substr": # 同型名称互为子串(「果子」vs「开心果子」): if kind == "substr": # 同型名称互为子串(「果子」vs「开心果子」):
# 不直接立卡,转观察材料并入既有卡由更新步 AI 甄别(软防护,留复核标记;洞②材料不截里程碑) # 不直接立卡,转观察材料并入既有卡由更新步 AI 甄别(软防护,留复核标记;洞②材料不截里程碑)
@ -1291,13 +1652,14 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
VALUES (%s,%s,%s,%s,'substr',%s) VALUES (%s,%s,%s,%s,'substr',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""", ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, key, nm, win_no, TENANT)) (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 continue
# kind in ("new","presence"):跨章立卡 / 单章龙套留档 # kind in ("new","presence"):跨章立卡 / 单章龙套留档
chaps = _int_chaps(ent.get("出场章", [])) # 归一化 int(窗113 修复) chaps = _int_chaps(ent.get("出场章", [])) # 归一化 int(窗113 修复)
hist = presence.get((ent.get("型", ""), nm), set()) hist = presence.get((ent.get("型", ""), nm), set())
if kind == "new": # 跨章(含跨窗合计)→ 立卡 if kind == "new": # 跨章(含跨窗合计)→ 立卡
if hist: # 用留档补足初卡出场章
ent["出场章"] = sorted(chaps | hist)
# 语义判重(洞①):判据已在预判段(三段式)算好,此处只查表——预判按 读1 时 name_map # 语义判重(洞①):判据已在预判段(三段式)算好,此处只查表——预判按 读1 时 name_map
# 近似超集粗筛、未模拟本段中途登记,多算的候选此处走归并/留档自然弃用(写段判据权威)。 # 近似超集粗筛、未模拟本段中途登记,多算的候选此处走归并/留档自然弃用(写段判据权威)。
if semantic_on: if semantic_on:
@ -1312,6 +1674,9 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
VALUES (%s,%s,%s,%s,'semantic',%s) VALUES (%s,%s,%s,%s,'semantic',%s)
ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""", ON CONFLICT (tenant_id, work_id, alias) DO NOTHING""",
(work_id, canon, nm, win_no, TENANT)) (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 continue
if verdict == "chain": # 前身后继:仍立卡,串链关系记候选审计 if verdict == "chain": # 前身后继:仍立卡,串链关系记候选审计
did = new_card( did = new_card(
@ -1321,6 +1686,7 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
ent, ent,
milestone_types, milestone_types,
chapter_texts=chapter_texts, chapter_texts=chapter_texts,
known_chapters=hist,
) )
new_ids.append(did) new_ids.append(did)
name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", "")) name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
@ -1341,11 +1707,12 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
ent, ent,
milestone_types, milestone_types,
chapter_texts=chapter_texts, chapter_texts=chapter_texts,
known_chapters=hist,
) )
new_ids.append(did) new_ids.append(did)
name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", "")) name_map[nm] = (did, ent.get("型", ""), ent.get("一句话摘要", ""))
else: # 单章龙套 → 留档(G4) else: # 单章龙套 → 留档(G4);无正文实证章则不造留档
for ch in (chaps or {a}): for ch in chaps:
conn.execute( conn.execute(
"""INSERT INTO example_upgrade_presence """INSERT INTO example_upgrade_presence
(work_id, window_no, chapter_no, entity_type, name, (work_id, window_no, chapter_no, entity_type, name,
@ -1359,8 +1726,7 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
if nm in name_map and it.get("观察点"): if nm in name_map and it.get("观察点"):
did = name_map[nm][0] did = name_map[nm][0]
to_update.setdefault(did, []).append(it["观察点"]) to_update.setdefault(did, []).append(it["观察点"])
pres_add.setdefault(did, set()).update( pres_add.setdefault(did, set()).update(_int_chaps(it.get("出场章")))
c for c in (it.get("出场章") or []) if isinstance(c, int))
# ④ 卡更新(分批≤6) # ④ 卡更新(分批≤6)
items = sorted(to_update.items()) items = sorted(to_update.items())
for i in range(0, len(items), UPDATE_BATCH): for i in range(0, len(items), UPDATE_BATCH):
@ -1398,7 +1764,9 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
merge_card(conn, u["draft_id"], win_no, merge_card(conn, u["draft_id"], win_no,
u.get("变更字段"), u.get("别名新增"), u.get("变更字段"), u.get("别名新增"),
valid_keys=did2keys.get(u["draft_id"]), valid_keys=did2keys.get(u["draft_id"]),
chapter_texts=chapter_texts) 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) # ⑤ 关系增量(核心角色=本窗有更新的 character + 在场 character,≤8)
char_cards = [] char_cards = []
seen = set() seen = set()
@ -1484,14 +1852,25 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
nm = (it.get("名称") or "").strip() nm = (it.get("名称") or "").strip()
if nm in name_map: if nm in name_map:
pres_add.setdefault(name_map[nm][0], set()).update( pres_add.setdefault(name_map[nm][0], set()).update(
c for c in (it.get("出场章") or []) if isinstance(c, int)) _int_chaps(it.get("出场章"))
)
for did, chs in pres_add.items(): for did, chs in pres_add.items():
if not chs: if not chs:
continue continue
p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s", p = conn.execute("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s",
(did,)).fetchone()[0] (did,)).fetchone()[0]
# 归一化 int(窗113 修复):库内旧 payload 可能残留字符串章号,与 chs(已 int)混排会炸 # 纯出场与无字段变化卡复用同一「过滤+审计追加」路径,确保 redo 能撤销上一轮顶层章号。
p["出场章"] = sorted(_int_chaps(p.get("出场章", [])) | chs) 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", conn.execute("UPDATE muse_knowledge_draft SET draft_payload=%s WHERE id=%s",
(json.dumps(p, ensure_ascii=False), did)) (json.dumps(p, ensure_ascii=False), did))
conn.execute( conn.execute(
@ -1504,10 +1883,13 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
touched = set(new_ids) | set(to_update.keys()) touched = set(new_ids) | set(to_update.keys())
except SensitiveHardStop as e: except SensitiveHardStop as e:
with psycopg.connect(DSN) as conn: with psycopg.connect(DSN) as conn:
conn.execute( if redo_snapshot is not None:
"""UPDATE example_upgrade_window SET status='failed', error_message=%s _restore_redo_window(conn, work_id, redo_snapshot)
WHERE tenant_id=%s AND work_id=%s AND window_no=%s""", else:
(str(e)[:500], TENANT, work_id, win_no)) 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() conn.commit()
click.echo(f" ⛔ 窗{win_no} 敏感降级链全失败,本书升格硬停:{e}") click.echo(f" ⛔ 窗{win_no} 敏感降级链全失败,本书升格硬停:{e}")
return return
@ -1521,13 +1903,31 @@ def run(work_id, max_windows, max_calls, model, redo_window, semantic_on):
if not retried: if not retried:
# 串行铁律第一层:当场撤销半写入→同窗立即重试(挡网络瞬断/偶发格式病) # 串行铁律第一层:当场撤销半写入→同窗立即重试(挡网络瞬断/偶发格式病)
with psycopg.connect(DSN) as conn: with psycopg.connect(DSN) as conn:
undo_window(conn, work_id, win_no) 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() conn.commit()
retried = True retried = True
click.echo(f" ↻ 窗{win_no} 失败,撤销后当场重试(串行铁律不跳窗): {str(e)[:150]}") click.echo(f" ↻ 窗{win_no} 失败,撤销后当场重试(串行铁律不跳窗): {str(e)[:150]}")
continue continue
# 第二层重试仍败:停书,断点=本窗;下次启动 failed 先撤销、从本窗续跑 if redo_snapshot is not None:
click.echo(f" ⛔ 窗{win_no} 当场重试仍失败,本书升格停(断点=本窗,续跑从此接续): {str(e)[:200]}") # 第二次仍失败:写事务已回滚,按 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 return
# ── 嵌段(仅 semantic_on 且 touched 非空;窗 commit 之后;三段式)+ 窗完成汇报 ── # ── 嵌段(仅 semantic_on 且 touched 非空;窗 commit 之后;三段式)+ 窗完成汇报 ──
# 成功路径放在 try 之外:绝不触发上面的 failed/undo(窗已 commit done);嵌段内部吞异常、 # 成功路径放在 try 之外:绝不触发上面的 failed/undo(窗已 commit done);嵌段内部吞异常、

View File

@ -13,8 +13,13 @@
跑法:仓根 `.venv/bin/python .claude/skills/parse-book/scripts/test_parse_upgrade_offline.py` 跑法:仓根 `.venv/bin/python .claude/skills/parse-book/scripts/test_parse_upgrade_offline.py`
""" """
import json
import pathlib import pathlib
import sys import sys
from copy import deepcopy
from unittest.mock import patch
from click.testing import CliRunner
# 与 parse_upgrade 同目录:直接 import 触发其 sys.path 装配(含 embed/llm scripts),随后可导 embed_drafts # 与 parse_upgrade 同目录:直接 import 触发其 sys.path 装配(含 embed/llm scripts),随后可导 embed_drafts
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
@ -81,9 +86,9 @@ def test_debut_milestone():
check("debut-台阶取摘要", out[0]["台阶"].startswith("登场:4级训练机")) check("debut-台阶取摘要", out[0]["台阶"].startswith("登场:4级训练机"))
check("debut-进化台阶仍在尾部", out[1]["台阶"] == "V代编队") check("debut-进化台阶仍在尾部", out[1]["台阶"] == "V代编队")
# 无出场章 → 省略「章」键(诚实边界:不编造章号) # 无正文实证出场章 → 不补登场里程碑(真实性优先,不能生成无证据台阶)
out = pu._debut_milestone([], "无章摘要", [], 5) out = pu._debut_milestone([], "无章摘要", [], 5)
check("debut-无出场章省略章键", len(out) == 1 and "章" not in out[0] and out[0]["周期"] == "登场") check("debut-无真实章不补", out == [])
# milestones=None 也当空处理 # milestones=None 也当空处理
out = pu._debut_milestone(None, "空列表摘要", [2], 5) out = pu._debut_milestone(None, "空列表摘要", [2], 5)
@ -228,6 +233,634 @@ def test_int_chaps():
check("intchaps-空输入", pu._int_chaps([]) == set() and pu._int_chaps(None) == set()) check("intchaps-空输入", pu._int_chaps([]) == set() and pu._int_chaps(None) == set())
class _CardResult:
"""为立卡与归并离线测试提供最小查询结果对象。"""
def __init__(self, row=None, rows=None):
self.row = row
self.rows = rows or []
def fetchone(self):
"""返回预置的单行结果。"""
return self.row
def fetchall(self):
"""返回预置的多行结果。"""
return self.rows
class _CardConn:
"""只模拟卡片写入所需 SQL,并保留最终 payload 供机械断言。"""
def __init__(self, payload=None):
self.payload = payload
self.audits = []
def execute(self, query, params):
"""按 SQL 用途返回最小结果,或捕获立卡、更新后的 payload。"""
normalized = " ".join(query.split())
if normalized.startswith("SELECT draft_payload"):
return _CardResult((self.payload,))
if normalized.startswith("SELECT watermark_window"):
return _CardResult((0,))
if normalized.startswith("SELECT a.draft_id, a.field_name, a.old_value"):
rows = [(did, field_name, old_value)
for did, _, field_name, old_value in reversed(self.audits)]
return _CardResult(rows=rows)
if normalized.startswith("SELECT id, draft_payload FROM muse_knowledge_draft"):
return _CardResult(rows=[(101, self.payload)])
if "INSERT INTO muse_knowledge_draft" in query:
self.payload = json.loads(params[1])
return _CardResult((101,))
if "INSERT INTO example_upgrade_audit" in query and len(params) >= 4 \
and params[2] == "顶层:出场章":
self.audits.append((params[0], params[1], params[2], params[3]))
if normalized.startswith("DELETE FROM example_upgrade_audit"):
self.audits = []
if "UPDATE muse_knowledge_draft SET draft_payload" in query:
self.payload = json.loads(params[0])
return _CardResult()
def commit(self):
"""模拟事务提交;离线测试中的状态已在内存立即生效。"""
def test_entity_chapter_evidence_filter():
"""模型出场章只能保留规范名或合法别名在对应正文真实出现的章节。"""
chapter_texts = {
488: "环星防线开启,亲卫继续突进。",
489: "安若雪同步率飙至100%,击碎能量屏障。",
490: "众人称雪姐已经抵达核心区。",
491: "备忘录提到雪(指挥官)这个带注释称呼。",
}
got = pu._filter_entity_chapters(
"安若雪",
["雪姐", "雪(指挥官)", "新"],
[488, "489", 490, 491],
chapter_texts,
)
check("chapter-evidence-错488过滤为489与合法别名章", got == {489, 490})
parenthetical = pu._filter_entity_chapters(
"白色游魂(无名侦察兵)",
[],
[492],
{492: "白色游魂从破损舱门后现身。"},
)
check("chapter-evidence-括号注名称按规范名命中", parenthetical == {492})
false_positive_texts = {
493: "大小姐转身离开大厅。",
494: "小姐姐转身离开大厅。",
495: "雪姐已经抵达核心区。",
496: "安若雪已经抵达核心区。",
497: "小姐转身离开大厅。",
}
check(
"chapter-evidence-单字规范名禁作证据",
pu._filter_entity_chapters("雪", [], [495, 496], false_positive_texts) == set(),
)
check(
"chapter-evidence-通用称谓别名完全禁作证据",
pu._filter_entity_chapters("安若雪", ["小姐"], [493, 494, 497], false_positive_texts) == set(),
)
generic_titles = (
"队长", "舰长", "指挥官", "司令", "统领", "院长", "校长",
"会长", "团长", "主任", "长老", "领主", "城主", "陛下", "殿下",
)
titles = {500 + index: f"{title}下令立刻行动。"
for index, title in enumerate(generic_titles)}
check(
"chapter-evidence-规范名常见职务称谓全部禁作证据",
all(
pu._filter_entity_chapters(title, [], [500 + index], titles) == set()
for index, title in enumerate(generic_titles)
),
)
check(
"chapter-evidence-别名常见职务称谓全部禁作证据",
all(
pu._filter_entity_chapters("安若雪", [title], [500 + index], titles) == set()
for index, title in enumerate(generic_titles)
),
)
proper_names = {
530: "银河指挥官越过了环星防线。",
531: "青云院长打开密室入口。",
532: "玄天宗主唤醒护山大阵。",
533: "司令塔发出低沉警报。",
}
check(
"chapter-evidence-完整专名不因职务片段误杀",
pu._filter_entity_chapters(
"银河指挥官",
["青云院长", "玄天宗主", "司令塔"],
["530", 531, 532, 533],
proper_names,
) == {530, 531, 532, 533},
)
check(
"chapter-evidence-常规二至四字专名不受损",
pu._filter_entity_chapters("安若雪", ["雪姐"], [495, 496], false_positive_texts) == {495, 496},
)
def test_new_card_chapter_evidence_chain():
"""串联复现:错章里程碑被拒后,初卡只能用正文实证章补登场。"""
conn = _CardConn()
pu.new_card(
conn,
8,
84,
{
"型": "character",
"名称": "安若雪",
"别名": ["雪姐"],
"一句话摘要": "联邦战士",
"字段": {
"演变历程": [
{
"章": 488,
"台阶": "同步率飙至100%",
"周期": "高光",
"证据": "同步率飙至100%,击碎能量屏障",
}
]
},
"出场章": [488, 489],
},
{"character"},
chapter_texts={
488: "环星防线开启,亲卫继续突进。",
489: "安若雪同步率飙至100%,击碎能量屏障。",
},
)
milestones = conn.payload["字段"]["演变历程"]
check("new-card-错488过滤为489", conn.payload["出场章"] == [489])
check(
"new-card-拒错里程碑后仅补实证登场",
len(milestones) == 1
and milestones[0]["章"] == 489
and milestones[0]["周期"] == "登场"
and milestones[0]["_win"] == 84,
)
no_evidence_conn = _CardConn()
pu.new_card(
no_evidence_conn,
8,
85,
{
"型": "character",
"名称": "安若雪",
"别名": [],
"一句话摘要": "联邦战士",
"字段": {"演变历程": []},
"出场章": [488],
},
{"character"},
chapter_texts={488: "环星防线开启,亲卫继续突进。"},
)
check("new-card-无真实章不落出场章", no_evidence_conn.payload["出场章"] == [])
check("new-card-无真实章不补里程碑", no_evidence_conn.payload["字段"]["演变历程"] == [])
history_conn = _CardConn()
pu.new_card(
history_conn,
8,
86,
{
"型": "character",
"名称": "安若雪",
"别名": [],
"一句话摘要": "联邦战士",
"字段": {"演变历程": []},
"出场章": [489],
},
{"character"},
chapter_texts={489: "安若雪抵达核心区。"},
known_chapters=[480],
)
check("new-card-历史留档章不被当前窗过滤", history_conn.payload["出场章"] == [480, 489])
def test_merge_card_chapter_evidence():
"""既有卡顶层出场章追加必须复用正文实体命中过滤。"""
conn = _CardConn(
{
"type": "character",
"名称": "安若雪",
"别名": ["雪姐"],
"一句话摘要": "联邦战士",
"字段": {},
"出场章": [487],
"_work_id": 8,
}
)
pu.merge_card(
conn,
101,
84,
{},
[],
chapter_texts={
488: "环星防线开启,亲卫继续突进。",
489: "雪姐同步率飙至100%,击碎能量屏障。",
},
appearance_chapters=[488, 489],
)
check("merge-card-顶层错488过滤为489", conn.payload["出场章"] == [487, 489])
# 顶层出场章必须随窗可撤销;正文变化后重跑不得残留上一轮已失效的章。
pu.undo_window(conn, 8, 84)
check("merge-card-undo精确恢复旧出场章", conn.payload["出场章"] == [487])
pu.merge_card(
conn,
101,
84,
{},
[],
chapter_texts={
489: "同步率飙至100%,击碎能量屏障。",
490: "安若雪已经抵达核心区。",
},
appearance_chapters=[489, 490],
)
check("merge-card-正文变化重跑删除旧错章", conn.payload["出场章"] == [487, 490])
def test_redo_cleans_legacy_appearance_chapters():
"""显式 redo 清理历史无审计章域,并可由失败撤销或本窗全文重新建立。"""
# 修复前历史 payload 没有顶层审计:首次 redo 必须先清空指定窗域,再只重建正文真实出现章。
conn = _CardConn(
{
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"出场章": [487, 489],
"_work_id": 8,
}
)
pu.undo_window(conn, 8, 84, from_chapter=487, to_chapter=489)
check("redo-历史无审计窗内章清空", conn.payload["出场章"] == [])
# 首次失败后的即时重试走“恢复旧值后重新清理”,第二次尝试前窗内仍必须为空。
pu.undo_window(conn, 8, 84, from_chapter=487, to_chapter=489)
check("redo-首次失败重试前重新清理", conn.payload["出场章"] == [])
# 清理本身带旧值审计:模拟后续处理失败时的既有失败撤销,必须恢复清理前历史值。
pu.undo_window(conn, 8, 84)
check("redo-后续失败可恢复历史章", conn.payload["出场章"] == [487, 489])
pu.undo_window(conn, 8, 84, from_chapter=487, to_chapter=489)
pu.merge_card(
conn,
101,
84,
{},
[],
chapter_texts={
487: "环星防线开启,亲卫继续突进。",
488: "同步率继续上升。",
489: "安若雪击碎能量屏障。",
},
appearance_chapters=[487, 488, 489],
)
check("redo-全文重建只落真实489", conn.payload["出场章"] == [489])
outside_conn = _CardConn(
{
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"出场章": [486, 487, 489, 490],
"_work_id": 8,
}
)
pu.undo_window(outside_conn, 8, 84, from_chapter=487, to_chapter=489)
check("redo-窗外章完整保留", outside_conn.payload["出场章"] == [486, 490])
normal_conn = _CardConn(
{
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"出场章": [487, 489],
"_work_id": 8,
}
)
pu.undo_window(normal_conn, 8, 84)
check("redo-非显式redo不做章域清理", normal_conn.payload["出场章"] == [487, 489])
class _RunConn:
"""run 级离线夹具:只模拟控制流所需 SQL,绝不建立真实连接。"""
def __init__(self, state):
self.state = state
self.writes = []
self.commits = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, query, params=()):
normalized = " ".join(query.split())
if normalized.startswith("SELECT title FROM muse_content_work"):
return _CardResult(("离线书",))
if normalized.startswith("SELECT window_no, from_chapter, to_chapter FROM example_upgrade_window"):
return _CardResult(rows=self.state.get("windows", [(7, 70, 71)]))
if normalized.startswith("SELECT window_no, from_chapter, to_chapter, status FROM example_upgrade_window"):
return _CardResult(rows=[(7, 70, 71, "pending")])
if normalized.startswith("SELECT window_no FROM example_upgrade_window"):
return _CardResult(rows=[])
if normalized.startswith("SELECT draft_payload FROM muse_knowledge_draft"):
return _CardResult((self.state["payload"],))
if normalized.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s"):
self.state["payload"] = json.loads(params[0])
if normalized.startswith("UPDATE example_upgrade_window SET status='failed'"):
self.state["window_status"] = "failed"
if normalized.startswith("UPDATE example_upgrade_window SET status='done'"):
self.state["window_status"] = "done"
if not normalized.startswith("SELECT"):
self.writes.append((normalized, params))
return _CardResult()
def commit(self):
"""内存状态即时生效。"""
self.commits += 1
def test_run_redo_preflight_rejects_unsafe_targets():
"""所有 redo 非法输入都必须在快照、撤销与写入前失败。"""
cases = [
("历史窗", [(1, 1, 2), (2, 3, 4)], 1, {1: "甲", 2: "乙"}, "仅允许重跑当前末窗"),
("不存在", [(1, 1, 2)], 2, {}, "不存在"),
("窗口号断裂", [(1, 1, 2), (3, 3, 4)], 3, {}, "窗口号不连续"),
("章域断裂", [(1, 1, 2), (2, 4, 5)], 2, {}, "窗章域不连续"),
("正文缺章", [(1, 70, 71)], 1, {70: "正文"}, "正文不完整"),
("正文为空", [(1, 70, 71)], 1, {70: "正文", 71: " "}, "正文不完整"),
]
for name, windows, target, chapter_texts, expected_error in cases:
state = {"windows": windows}
snapshot_calls, undo_calls, connections = [], [], []
def fake_connect(*_):
conn = _RunConn(state)
connections.append(conn)
return conn
def fake_material(conn, work_id, a, b):
text = "\n".join(str(chapter_texts.get(chapter, "")) for chapter in range(a, b + 1))
return text, chapter_texts
with patch.object(pu.psycopg, "connect", side_effect=fake_connect), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", side_effect=fake_material), \
patch.object(pu, "_snapshot_redo_window", side_effect=lambda *args: snapshot_calls.append(args)), \
patch.object(pu, "undo_window", side_effect=lambda *args, **kwargs: undo_calls.append((args, kwargs))):
result = CliRunner().invoke(
pu.cli,
["run", "--work-id", "8", "--redo-window", str(target)],
)
check(
f"redo-preflight-{name}-非零拒绝",
result.exit_code != 0 and expected_error in result.output,
detail=f"exit={result.exit_code}, output={result.output!r}",
)
check(f"redo-preflight-{name}-无副作用",
not snapshot_calls and not undo_calls
and not any(conn.writes or conn.commits for conn in connections))
check(f"redo-preflight-{name}-无假完成", "完成 0 窗" not in result.output)
def test_run_rejects_negative_limits_before_redo_side_effects():
"""负调用闸必须由 Click 在函数入口拒绝,即使同时请求合法末窗 redo 也不得接触数据库。"""
for option in ("--max-windows", "--max-calls"):
with patch.object(pu.psycopg, "connect") as connect, \
patch.object(pu, "_snapshot_redo_window") as snapshot, \
patch.object(pu, "undo_window") as undo:
result = CliRunner().invoke(
pu.cli,
["run", "--work-id", "8", "--redo-window", "2", option, "-1"],
)
check(
f"negative-limit-{option}-入口非零拒绝",
result.exit_code != 0,
detail=f"exit={result.exit_code}, output={result.output!r}",
)
check(
f"negative-limit-{option}-无snapshot与undo",
not snapshot.called and not undo.called,
)
check(f"negative-limit-{option}-无DB调用", not connect.called)
def _run_with_failures(*, max_calls, failures):
"""执行单个显式 redo 窗;前 failures 次观察调用失败,返回状态与调用数。"""
old_state = {
"windows": [(1, 1, 69), (2, 70, 71)],
"window_status": "done",
"fields": {"阵营": "旧阵营", "经历": ["[窗2] 旧经历"]},
"aliases": ["旧别名"],
"presence": [(2, 70, "旧龙套")],
"initial_cards": [701],
}
state = deepcopy(old_state)
calls = {"n": 0}
undo_calls = []
def fake_undo(conn, work_id, win_no, *, from_chapter=None, to_chapter=None):
undo_calls.append((from_chapter, to_chapter))
# 复现旧实现边界:章域 undo 会清完整旧窗;无章域 undo 只能恢复顶层章,救不回其余旧窗状态。
state["fields"] = {}
state["aliases"] = []
state["presence"] = []
state["initial_cards"] = []
def fake_m3_json(prompt, model, need_keys, system=None):
calls["n"] += 1
if calls["n"] <= failures:
raise RuntimeError(f"离线失败{calls['n']}")
return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {})
snapshot = deepcopy(old_state)
def fake_restore(conn, work_id, redo_snapshot):
state.clear()
state.update(deepcopy(redo_snapshot))
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=("正文", {70: "正文", 71: "正文"})), \
patch.object(pu, "load_known", return_value=({}, {}, {})), \
patch.object(pu, "undo_window", side_effect=fake_undo), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "_snapshot_redo_window", return_value=snapshot), \
patch.object(pu, "_restore_redo_window", side_effect=fake_restore):
pu.run.callback(
work_id=8,
max_windows=0,
max_calls=max_calls,
model="MiniMax-M3",
redo_window=2,
semantic_on=False,
)
return old_state, state, calls["n"], undo_calls
def test_run_redo_max_calls_finishes_active_retry():
"""调用闸不能在首次失败后的已清理中间态退出。"""
_, state, call_count, undo_calls = _run_with_failures(max_calls=1, failures=1)
check("run-max-calls-已清理窗仍完成即时重试", call_count == 2)
check("run-max-calls-重试成功落done", state["window_status"] == "done")
check("run-max-calls-首次失败前后均执行章域清理", undo_calls == [(70, 71), (70, 71)])
def test_run_redo_final_failure_restores_full_snapshot():
"""显式 redo 两次都失败时,完整恢复字段、别名、presence 与初建卡。"""
old_state, state, call_count, _ = _run_with_failures(max_calls=0, failures=2)
check("run-redo-final-failure-确实尝试两次", call_count == 2)
check("run-redo-final-failure-完整恢复旧窗", state == old_state, detail=f"state={state!r}")
class _SnapshotConn:
"""恢复点 SQL 的离线记录器:提供旧窗快照行,并记录所有恢复写入。"""
def __init__(self):
self.writes = []
def execute(self, query, params=()):
normalized = " ".join(query.split())
if normalized.startswith("SELECT id, draft_payload, revision, updater, deleted"):
return _CardResult(rows=[(701, {"字段": {"阵营": "旧阵营"}}, 9, "old", False)])
if normalized.startswith("SELECT id, canonical_name, alias, evidence_window"):
return _CardResult(rows=[(801, "旧主角", "旧别名", 7, "ai", "", "t1", "", "t2", False)])
if normalized.startswith("SELECT id, window_no, chapter_no, entity_type"):
return _CardResult(rows=[(901, 7, 70, "character", "旧龙套", "旧观察", "", "t3", False)])
if normalized.startswith("SELECT draft_id, watermark_window, update_time"):
return _CardResult(rows=[(701, 7, "t4")])
if normalized.startswith("SELECT a.id, a.draft_id, a.window_no"):
return _CardResult(rows=[(1001, 701, 7, "阵营", '"更旧阵营"', '"旧阵营"', "t5")])
if normalized.startswith("SELECT status, error_message, updater FROM example_upgrade_window"):
return _CardResult(("done", None, "old"))
if normalized.startswith("SELECT id FROM muse_knowledge_draft"):
return _CardResult(rows=[(701,), (702,)])
self.writes.append((normalized, params))
return _CardResult()
def test_redo_snapshot_restore_sql_boundaries():
"""恢复点覆盖旧字段、别名、presence、初建卡、水位、审计与窗状态。"""
conn = _SnapshotConn()
snapshot = pu._snapshot_redo_window(conn, 8, 7)
check("redo-snapshot-捕获完整旧卡", snapshot["drafts"][0][1]["字段"]["阵营"] == "旧阵营")
check("redo-snapshot-捕获别名presence", snapshot["aliases"] and snapshot["presence"])
check("redo-snapshot-捕获初建卡水位", snapshot["card_states"] == [(701, 7, "t4")])
check("redo-snapshot-捕获旧窗状态", snapshot["window"] == ("done", None, "old"))
pu._restore_redo_window(conn, 8, snapshot)
writes = conn.writes
check(
"redo-restore-恢复旧卡字段与revision",
any(sql.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s, revision=%s")
and params[:3] == (json.dumps({"字段": {"阵营": "旧阵营"}}, ensure_ascii=False), 9, "old")
for sql, params in writes),
)
check(
"redo-restore-软删重试新建卡",
any(sql.startswith("UPDATE muse_knowledge_draft SET deleted=TRUE") and params == (702,)
for sql, params in writes),
)
check(
"redo-restore-恢复别名与presence",
any("INSERT INTO example_upgrade_alias" in sql and "旧别名" in params for sql, params in writes)
and any("INSERT INTO example_upgrade_presence" in sql and "旧龙套" in params for sql, params in writes),
)
check(
"redo-restore-恢复初建卡水位与旧窗状态",
any("INSERT INTO example_upgrade_card_state" in sql and params[0] == 701 for sql, params in writes)
and any(sql.startswith("UPDATE example_upgrade_window SET status=%s")
and params[:3] == ("done", None, "old") for sql, params in writes),
)
def test_presence_chapter_normalization_boundaries():
"""归并卡与纯出场章号都必须接受数字字符串,并丢弃非数字值。"""
check("presence-归并卡数字字符串", pu._int_chaps(["489", 490, "bad"]) == {489, 490})
check("presence-纯出场数字字符串", pu._int_chaps(["491", " 492 ", None]) == {491, 492})
def test_run_presence_paths_accept_numeric_strings():
"""run 的已知更新归并卡与纯出场都把数字字符串章号落成整数。"""
state = {
"window_status": "pending",
"payload": {
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"出场章": [],
"_work_id": 8,
},
}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "观察点": "出现新变化", "出场章": ["70"]}
],
"纯出场": [{"名称": "安若雪", "出场章": ["71"]}],
}, {})
return ({"更新": []}, {})
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(
pu,
"load_window_material",
return_value=("安若雪连续出现", {70: "安若雪出现新变化。", 71: "安若雪继续前进。"}),
), \
patch.object(
pu,
"load_known",
return_value=({"安若雪": (701, "character", "旧摘要")}, {}, {}),
), \
patch.object(pu, "m3_json", side_effect=fake_m3_json):
pu.run.callback(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
)
check("run-presence-归并与纯出场数字字符串均落库", state["payload"]["出场章"] == [70, 71])
def test_load_known_projection(): def test_load_known_projection():
"""判重底册只投影索引字段,禁止跨网搬运每张卡的完整 payload。""" """判重底册只投影索引字段,禁止跨网搬运每张卡的完整 payload。"""
@ -245,16 +878,16 @@ def test_load_known_projection():
def execute(self, query, params): def execute(self, query, params):
self.queries.append(query) self.queries.append(query)
if "muse_knowledge_draft" in query: if "muse_knowledge_draft" in query:
return Result([(1, "character", "张三", "主角", ["阿三"])]) return Result([(1, "character", "安若雪", "联邦战士", [])])
if "example_upgrade_alias" in query: if "example_upgrade_alias" in query:
return Result([("张三", "老张")]) return Result([("安若雪", "雪姐")])
return Result([("character", "李四", 7)]) return Result([("character", "李四", 7)])
conn = Conn() conn = Conn()
name_map, presence = pu.load_known(conn, 8) name_map, presence, aliases_by_draft = pu.load_known(conn, 8)
check("known-正名投影", name_map["张三"] == (1, "character", "主角")) check("known-正名投影", name_map["安若雪"] == (1, "character", "联邦战士"))
check("known-payload别名投影", name_map["阿三"] == name_map["张三"]) check("known-别名表投影", name_map["雪姐"] == name_map["安若雪"])
check("known-别名表投影", name_map["老张"] == name_map["张三"]) check("known-按卡携带alias表独有别名", aliases_by_draft[1] == {"雪姐"})
check("known-presence保留", presence[("character", "李四")] == {7}) check("known-presence保留", presence[("character", "李四")] == {7})
first_query = conn.queries[0] first_query = conn.queries[0]
check( check(
@ -263,6 +896,49 @@ def test_load_known_projection():
and "draft_payload->>'名称'" in first_query, and "draft_payload->>'名称'" in first_query,
) )
merge_conn = _CardConn(
{
"type": "character",
"名称": "安若雪",
"别名": [],
"一句话摘要": "联邦战士",
"字段": {},
"出场章": [488],
"_work_id": 8,
}
)
pu.merge_card(
merge_conn,
1,
84,
{},
[],
chapter_texts={489: "雪姐已经抵达核心区。"},
appearance_chapters=[489],
known_aliases=aliases_by_draft[1],
)
check("known-alias表独有别名可供merge过滤", merge_conn.payload["出场章"] == [488, 489])
pure_payload = {
"名称": "安若雪",
"别名": [],
"出场章": [488],
}
pure_conn = _CardConn(pure_payload)
changed = pu._append_verified_appearance_chapters(
pure_conn,
1,
84,
pure_payload,
[489],
{489: "雪姐已经抵达核心区。"},
known_aliases=aliases_by_draft[1],
)
check(
"known-alias表独有别名可供纯出场过滤",
changed and pure_payload["出场章"] == [488, 489],
)
def test_repair_milestone_evidence(): def test_repair_milestone_evidence():
"""只修本窗缺证据里程碑;未来重抄项不能借修复调用混回卡体。""" """只修本窗缺证据里程碑;未来重抄项不能借修复调用混回卡体。"""
@ -346,6 +1022,15 @@ def test_prompts_disciplines():
if __name__ == "__main__": if __name__ == "__main__":
for fn in (test_classify_new_name, test_debut_milestone, test_clean_milestone_guard, for fn in (test_classify_new_name, test_debut_milestone, test_clean_milestone_guard,
test_merge_material, test_build_embed_text_type_fix, test_int_chaps, test_merge_material, test_build_embed_text_type_fix, test_int_chaps,
test_entity_chapter_evidence_filter, test_new_card_chapter_evidence_chain,
test_merge_card_chapter_evidence, test_redo_cleans_legacy_appearance_chapters,
test_run_redo_preflight_rejects_unsafe_targets,
test_run_rejects_negative_limits_before_redo_side_effects,
test_run_redo_max_calls_finishes_active_retry,
test_run_redo_final_failure_restores_full_snapshot,
test_redo_snapshot_restore_sql_boundaries,
test_presence_chapter_normalization_boundaries,
test_run_presence_paths_accept_numeric_strings,
test_load_known_projection, test_repair_milestone_evidence, test_load_known_projection, test_repair_milestone_evidence,
test_prompts_disciplines): test_prompts_disciplines):
fn() fn()

View File

@ -0,0 +1,244 @@
# 升格级联重跑设计
> 日期:2026-07-21
> 状态:已完成独立评审,P0/P1 边界已冻结
> 适用范围:`parse-book` 作品面升格管线
> 执行状态:P0 历史 redo 安全门已完成并验证;一次性备份、恢复演练、reset 和 `1..116` 重建尚未执行,执行前仍需用户绑定确认
## 一、评审结论
当前 `--redo-window K` 只撤销并重写第 K 窗。在串行生长的升格管线中,后窗已经消费了旧的卡、别名、出场记录、水位和向量;只重写历史单窗会制造前后代际不一致。因此,本次修复分成两个互不阻塞的阶段:
| 阶段 | 目标 | 本阶段必须交付 | 明确不做 |
|---|---|---|---|
| P0 | 立即止损并恢复 work 8 | 封死历史 redo;停止 work 8 评测读取;建立一次性可校验备份;经用户确认后执行 `reset_upgrade_work`;全书 `1..116` 正序重建;完成验收 | 不建设通用持久任务、恢复点、围栏接管和真正级联 |
| P1 | 建立可长期使用的历史重跑能力 | 持久任务、恢复点、写入围栏、强杀恢复、真正的 `K..F` 级联重跑 | 不作为 P0 的前置条件,不借 P0 成功宣称 P1 完成 |
**P0 是本次 work 8 恢复的唯一方案。** 不再根据审计是否充分选择 `84..F` 撤销,也不在本次恢复中实现或调用真正级联。
## 二、事实与边界
### 2.1 已确认事实
- work 8 当前设计基线末窗为 `F=116`。
- 已知失活触发点是 **window 101**;101 不是末窗,也不是 P0 的重建终点。
- 升格按窗口串行生长,后窗依赖前窗形成的卡、别名、出场记录、水位、审计和向量。
- 当前 `--redo-window` 只处理指定单窗,不能重建该窗之后已经形成的派生判断。
- 现有 `reset_upgrade_work.py` 会软删本书活跃升格卡,将全部窗口重置为 `pending`,并清理本书别名、出场留档、卡水位和升格审计;旧向量行保留,但随所属旧卡软删而退出召回。
- 正常 `parse_upgrade.py run` 会按章节顺序处理未完成窗口,可用于全书正序前滚重建。
### 2.2 执行前必须重新核验
`F=116` 是本设计的确认基线,不是允许执行时静默变化的动态参数。任何破坏性动作前必须只读核验:
1. work 8 恰有 116 个有效窗口,窗号和章节范围连续,正文可读。
2. 当前没有 work 8 升格写入进程,也没有评测任务正在读取 work 8。
3. 正文版本、窗口边界、字段合同和必要模型配置已生成不可变摘要。
4. `reset_upgrade_work --work-id 8` 的预览影响范围与备份清单一致。
任一项不满足,立即停止。特别是实际末窗不等于 116 时,不得自动扩大或缩小范围,必须重新生成设计基线并取得新的用户确认。
### 2.3 本文不授予执行权限
全书 reset 会软删和硬删现有派生数据,是破坏性动作。**本文评审通过不等于授权执行。** 只有一次性备份完成并校验通过后,向用户展示本次执行摘要并取得针对该摘要的明确确认,才能执行数据库写入或模型调用。
## 三、P0:立即止损与 work 8 全书重建
### 3.1 P0 总流程
```mermaid
flowchart TD
A[封死历史 redo] --> B[停止 work 8 升格写入与全部评测读取]
B --> C[只读预检并冻结 F=116 与输入摘要]
C --> D[生成一次性备份]
D --> E{读回与恢复校验通过?}
E -- 否 --> X[失败关闭,不改业务数据]
E -- 是 --> F[展示 work 8 / 1..116 / 备份 ID / 破坏影响]
F --> G{用户明确确认本次 reset?}
G -- 否 --> X
G -- 是 --> H[执行 reset_upgrade_work]
H --> I{reset 后空态验收通过?}
I -- 否 --> Y[停止并保持评测关闭,人工决定是否恢复备份]
I -- 是 --> J[按 1 到 116 严格正序前滚重建]
J --> K{P0 全链验收通过?}
K -- 否 --> Y
K -- 是 --> L[用户确认解除 work 8 评测读取禁令]
```
### 3.2 封死历史 redo
P0 第一项是让历史单窗重跑失败关闭,避免 P1 完成前再次制造代际错位:
- `--redo-window K` 指向已完成历史窗时,必须在任何业务写入前拒绝。
- 不提供 `--force`、配置开关或“已知风险继续”等绕过路径。
- 正常运行中对**当前失败窗**的撤销后同窗重试不属于历史 redo,可以保留;它不能越过失败窗,也不能回写更早的已完成窗。
- 只有 P1 真正级联通过全部验收后,历史 redo 才能以新的 `K..F` 语义重新开放。
### 3.3 停止 work 8 评测读取
冻结区间从生成备份前开始,到 `1..116` 重建和 P0 验收全部通过、用户明确同意开放为止。在此期间:
- 停止 work 8 的在线评测、离线回放、批处理评测、评测快照装配、评测导出和缓存预热。
- 停止普通升格写入、历史 redo 和其他会修改 work 8 升格派生状态的任务。
- 只允许本次预检、备份、重建和验收使用受控入口;验收查询不产生评测结果。
- 任一失败或中断都保持关闭,不能因为进程退出、窗口暂停或备份恢复成功而自动开放。
P0 必须留下冻结开始、命中拒绝、验收结束和解除冻结的记录。最终验收要求冻结区间内 work 8 的成功评测读取数为 0。
### 3.4 一次性可校验备份
P0 备份是本次 work 8 reset 前的唯一恢复基线,不是 P1 的通用恢复点系统。备份必须在同一个一致性读视图中覆盖:
| 状态域 | 备份范围 |
|---|---|
| drafts | work 8 全部 `upgrade_book` 卡,包含活跃、软删、版本和归属字段 |
| windows | work 8 全部 116 个窗口及状态、错误、边界和软删字段 |
| aliases | work 8 全部别名行 |
| presence | work 8 全部出场留档 |
| card_state | work 8 全部卡水位 |
| audits | 所有指向 work 8 升格卡的审计行 |
| embeddings | 所有指向 work 8 升格卡的向量行、内容哈希、模型和软删状态 |
备份工件必须满足:
1. 生成唯一 `backup_id`,记录数据库标识、生成时间、代码版本、`F=116` 和输入摘要。
2. 每个状态域记录行数、主键集合摘要和按稳定顺序计算的内容校验值;备份文件另记 SHA-256。
3. 备份存放在不会被 `reset_upgrade_work` 影响的位置,只允许授权操作者读取或恢复,不得被后续尝试覆盖。
4. 生成后重新读回,核对文件校验值、七域行数和内容摘要。
5. 在隔离库或隔离 schema 做一次恢复演练;恢复后的七域摘要必须与源数据一致。
任一校验或恢复演练失败,P0 停止,不得执行 reset。
### 3.5 用户确认硬门
备份校验通过后必须停止,并向用户展示以下固定摘要:
- 作品:`work_id=8`,范围:`1..116`。
- `backup_id`、备份 SHA-256、七域行数与校验结果。
- 将执行的破坏性入口:`reset_upgrade_work --work-id 8 --execute`。
- reset 的影响:软删活跃升格卡、全部窗口置为 `pending`、硬删别名/出场留档/水位/审计;随后会调用模型从窗 1 重建到窗 116。
- 失败边界:P0 没有 P1 的强杀自动恢复;失败后 work 8 保持关闭,由用户决定续跑还是恢复唯一备份。
确认必须绑定本次 `work_id`、范围、输入摘要和 `backup_id`。旧确认、默认值、通用 `--yes` 或范围变化后的确认均无效。**没有本次明确确认,流程只能停在这里。**
### 3.6 reset 与正序前滚
获得确认后,P0 只允许以下路径:
1. 执行现有 `reset_upgrade_work.py --work-id 8 --execute`,不得另写临时 SQL 替代,不得改走 `--redo-window`。
2. reset 后立即验证:活跃升格卡为 0;116 个有效窗口全部为 `pending`;别名、出场留档、卡水位和指向旧升格卡的审计均为空;旧向量只挂在软删旧卡上,不参与召回。
3. 使用正常升格入口从窗 1 开始,严格按 `1,2,...,116` 正序前滚;不得跳窗、并行同书窗口或从 84/101 起跑。
4. 每个窗口只有在完整提交并通过窗后不变量后才能进入下一窗。全局 `$24/6000` 治理或局部上限可以让运行在窗边界暂停,但 work 8 仍保持关闭。
5. 普通失败按现有“当前失败窗撤销后重试/续跑”处理。发生强杀、状态不可解释或输入摘要漂移时立即停止,保持评测关闭;P0 不声称能自动接管恢复。
### 3.7 window 101 回归基线
window 101 是已知失活触发点,必须作为状态转移回归检查,不得只看窗 116 的最终活跃卡数:
- reset 前记录目标逻辑实体在 window 100 结束、window 101 处理后以及 102..116 的卡身份、活跃状态、归并关系、水位和审计来源。
- 重建时记录同一逻辑实体在 window 100/101/116 的对应状态。
- 验收必须证明 window 101 按当前业务证据产生了正确状态转移,且 102..116 消费的是重建后的新前态。
- 不能用“最终存在一张同名活跃卡”代替验证;新建重复卡、错误归并后复活或旧新代际叠加均不通过。
## 四、P0 验收与完成口径
P0 只有同时通过以下门槛才算完成:
| 验收门 | 必须证据 |
|---|---|
| 历史 redo 已封死 | 历史 `--redo-window` 在写入前被机械拒绝;当前失败窗重试仍正常 |
| 冻结有效 | 从备份前到开放前,work 8 成功评测读取数为 0,且无第二升格写入者 |
| 备份可恢复 | 唯一 `backup_id`、SHA-256、七域摘要和隔离恢复演练全部通过 |
| 确认有效 | 确认记录绑定 work 8、`1..116`、输入摘要和 `backup_id`,且发生在 reset 前 |
| reset 正确 | 执行入口和影响行数可追踪;reset 后空态不变量全部通过 |
| 顺序完整 | 窗口完成事件严格为 `1..116`,无跳窗、倒序、并行或旧状态复用 |
| window 101 回归 | 100→101 的状态转移符合业务证据;102..116 持续消费新前态;无重复逻辑实体或代际混合 |
| 七域一致 | 卡、窗口、别名、presence、水位、审计和向量互相一致;活跃向量哈希匹配活跃卡正文 |
| 开放受控 | 116 窗全部完成且全链验收通过后,仍需用户明确同意才解除评测读取禁令 |
以下情况只能报告局部进度,不能说 P0 完成:备份只生成未恢复演练;reset 成功但未跑满 116;window 101 最终态看似正常但过程未核验;任务暂停或回滚;只验证一张卡;评测读取未证明全程为 0。
## 五、P1:持久任务与真正级联
P1 在 P0 独立完成后实施。它把历史重跑从一次性运维动作升级为可恢复、可并发约束、可审计的产品能力。
### 5.1 持久任务与恢复点
每次历史重跑创建持久任务,至少记录:`task_id`、`work_id`、起点 `K`、冻结末窗 `F`、输入版本、当前阶段、逆序/正序游标、最近完成窗口、恢复点、围栏令牌、暂停原因、错误和终态。
- 任务创建时冻结 `K..F` 和输入版本;范围或输入漂移时失败关闭。
- 任何撤销前创建可恢复到任务开始前的全书恢复点,并机械验证完整性。
- 每个窗口边界形成持久检查点。模型计算期间不持有数据库长事务。
- 暂停必须续接同一任务;不得另起普通升格任务越过未完成级联。
### 5.2 写入围栏与读取关闭
同一本书同一时刻只允许一个升格写入任务。数据库可信边界为每次任务发放递增围栏令牌;所有升格写事务都必须验证令牌。
- 租约或心跳过期只表示任务可被接管,不表示普通写入者可以进入。
- 接管者取得新令牌后,旧进程即使复活也无法提交。
- 任务从撤销开始到验收发布前,业务消费和评测读取只能看到“重跑中/待恢复”,不能读取半成品。
### 5.3 真正级联语义
P1 重新开放后的 `--redo-window K` 固定表示:冻结当前末窗 `F`,先按 `F..K` 逆序撤销,再按 `K..F` 正序重放。撤销和重放都不得跳窗;后窗只能读取前窗已经重放完成的新状态。
```mermaid
flowchart LR
A[创建持久级联任务 K..F] --> B[取得新围栏令牌]
B --> C[冻结输入并校验全书恢复点]
C --> D[关闭本书消费与评测读取]
D --> E[逆序撤销 F..K]
E --> F[校验已回到 K-1]
F --> G[正序重放 K..F]
G --> H{全链验收通过?}
H -- 是 --> I[发布新代际并开放读取]
H -- 否 --> J[恢复任务前恢复点并保持关闭]
```
### 5.4 强杀与恢复
强杀后不得猜测进度。下一执行者读取持久任务、恢复点、最近完成检查点和当前围栏令牌:
- 未开始当前窗口写入时,从该窗口继续。
- 当前窗口事务未提交时,从该窗口重新计算。
- 阶段或数据无法由检查点证明时,恢复任务前恢复点并将任务置为失败关闭。
- 恢复、接管或回滚完成前,旧任务令牌、普通写入和所有消费读取均不得重新开放。
```mermaid
stateDiagram-v2
[*] --> preflight
preflight --> snapshotting: 预检通过
snapshotting --> undoing: 恢复点校验通过
undoing --> replaying: 已回到 K-1
replaying --> paused: 达到治理或局部上限
paused --> replaying: 同一任务续接
undoing --> recovery_required: 强杀或状态不明
replaying --> recovery_required: 强杀或状态不明
recovery_required --> restoring: 新围栏持有者接管
restoring --> failed_closed: 已恢复任务前状态
replaying --> validating: 已重放到 F
validating --> restoring: 验收失败
validating --> succeeded: 验收通过并发布
```
### 5.5 P1 验收
P1 完成至少要求:
1. 给定任意 `K..F`,事件顺序严格为撤销 `F..K`、重放 `K..F`。
2. 在恢复点生成、逆序撤销、正序重放、暂停等待和最终验收阶段分别强杀,重启后都能由持久证据决定续接或恢复。
3. 两个同书进程并发时只有新围栏持有者能写;旧进程复活提交被拒绝;不同作品可并行。
4. 任一中间态的业务消费和评测读取均被拒绝,只有完整新代际发布后开放。
5. 恢复点往返后七域逐行一致;窗口检查点不会留下半窗卡、孤儿别名、超前水位或旧向量。
6. P0 的历史 redo 禁令只有在上述自动化、数据库集成和强杀演练全部通过后才能解除。
## 六、实施顺序
1. **P0 代码止损(已完成)**:历史 redo 安全门仅允许末窗重跑;历史窗、不存在窗、不连续窗口、正文为空和负数参数均在业务写入前拒绝。验证证据:parse upgrade 128 项、parse LLM 9 项、outline 11 项全部通过,独立复审 PASS,真实 work 8 的 redo 101 已在写入前拒绝。
2. **P0 执行准备(未执行)**:只读预检、一次性备份与隔离恢复演练尚未执行。
3. **人工确认门(待执行)**:备份与恢复演练通过后,仍须提交绑定 `work_id=8`、`1..116`、输入摘要和 `backup_id` 的破坏性执行摘要,取得用户明确确认。
4. **P0 数据恢复(未执行)**:尚未执行 reset,也未开始 `1..116` 正序重建和验收;只有取得第 3 步绑定确认后才能执行。
5. **P1 长期能力(未执行)**:持久任务、恢复点、围栏、强杀恢复和真正级联仍属后续范围。
当前已完成第 1 步。第 2 至第 5 步均未执行;全书破坏性 reset 仍无执行授权。