muse-agent-example/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py

2972 lines
136 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_upgrade 升格拆卡执行器·纯逻辑离线自测(升格卡改造 3 洞补齐)。
红线:**不连库、不发任何网络/嵌入/LLM 调用**——只覆盖纯函数与提示词构造器:
· 机械判重分类 _classify_new_name(各分支)
· 登场兜底 _debut_milestone(有/无登场、无出场章、跨章事实隔离)
· 里程碑台阶守卫 _clean_milestone(超 80 字截断)
· 归并材料 _merge_material(演变历程完整不截、其余字段截断)
· embed_drafts.build_embed_text 对 type 键的型修正
· observe/update/relation 三个提示词含关键纪律语句
连库/联网路径(prejudge_semantic / recall_neighbors / semantic_dedup / embed_touched_cards)
本测试**不触碰**——它们需真连接,属放量前零落库小样阶段的活体验收,不在离线自测范围。
跑法:仓根 `.venv/bin/python .claude/skills/parse-book/scripts/test_parse_upgrade_offline.py`
"""
import json
import hashlib
import pathlib
import sys
from contextlib import nullcontext
from copy import deepcopy
from unittest.mock import patch
from click.testing import CliRunner
# 与 parse_upgrade 同目录:直接 import 触发其 sys.path 装配(含 embed/llm scripts),随后可导 embed_drafts
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import parse_upgrade as pu # noqa: E402
import embed_drafts # noqa: E402 (型修正验证在 embed skill 本体)
_passed = 0
def check(name, cond, detail=""):
"""单项断言:通过打 [PASS],失败抛 AssertionError(带上下文,令 CI/人工一眼定位)。"""
global _passed
assert cond, f"[FAIL] {name} :: {detail}"
_passed += 1
print(f"[PASS] {name}")
# ── ① 机械判重分类 _classify_new_name(洞①:预判段与写段共用判据)──
def test_classify_new_name():
name_map = {
"张三": (10, "character", "主角"),
"生物机甲": (20, "power_system", "机甲体系"),
"开心果子": (30, "item", "道具"),
}
presence = {("character", "李四"): {5, 12}} # 李四靠留档跨章
check("classify-empty",
pu._classify_new_name({"名称": " ", "型": "character"}, name_map, presence) == ("empty", None))
check("classify-merge(在册直接归并)",
pu._classify_new_name({"名称": "张三", "型": "character"}, name_map, presence) == ("merge", "张三"))
check("classify-alias(疑似别名指向在册)",
pu._classify_new_name({"名称": "阿三", "型": "character", "疑似别名指向": "张三"},
name_map, presence) == ("alias", "张三"))
check("classify-substr(同型互为子串)",
pu._classify_new_name({"名称": "果子", "型": "item", "出场章": [1]},
name_map, presence) == ("substr", "开心果子"))
# 子串判据须同型:果子(character) 不应命中 开心果子(item),单章 → presence
check("classify-substr跨型不命中",
pu._classify_new_name({"名称": "果子", "型": "character", "出场章": [1]},
name_map, presence) == ("presence", None))
check("classify-new(本窗跨章立卡)",
pu._classify_new_name({"名称": "王五", "型": "character", "出场章": [3, 8]},
name_map, presence) == ("new", None))
check("classify-new(留档补足跨章)",
pu._classify_new_name({"名称": "李四", "型": "character", "出场章": [20]},
name_map, presence) == ("new", None))
check("classify-presence(单章龙套留档)",
pu._classify_new_name({"名称": "路人甲", "型": "character", "出场章": [7]},
name_map, presence) == ("presence", None))
def test_resolve_canonical_name():
"""name_map 的 alias 键命中时,落库正名仍必须取同 draft 的真实活跃卡名称。"""
target = (701, "item", "一代机械外骨骼")
name_map = {
"一代机械外骨骼·铁壳": target,
"铁壳": target,
"机械铁壳": target,
}
aliases_by_draft = {701: {"铁壳", "机械铁壳"}}
check(
"canonical-alias-token解析为真实卡名",
pu._resolve_canonical_name(name_map, aliases_by_draft, "铁壳") == "一代机械外骨骼·铁壳",
)
check(
"canonical-substring命中alias仍解析为真实卡名",
pu._resolve_canonical_name(name_map, aliases_by_draft, "机械铁壳") == "一代机械外骨骼·铁壳",
)
try:
pu._resolve_canonical_name({"铁壳": target}, {701: {"铁壳"}}, "铁壳")
except RuntimeError as exc:
check("canonical-找不到真实卡名失败关闭", "真实规范名" in str(exc))
else:
check("canonical-找不到真实卡名失败关闭", False, detail="未抛 RuntimeError")
# ── ② 登场兜底 _debut_milestone(洞②机械那一保险)──
def test_debut_milestone():
# 已有登场 → 原样返回,不重复补
has = [{"章": 10, "台阶": "训练机登场", "周期": "登场"}, {"章": 50, "台阶": "进化", "周期": "成长"}]
out = pu._debut_milestone(has, "训练机", "item", [], [8], 3, {8: "训练机进入机库。"})
check("debut-已有登场不补", len(out) == 2 and out[0]["台阶"] == "训练机登场")
# 无登场 + 有正文实证出场章 → 只补中性名称/型描述,章=min(出场章),_win 盖窗号
no_debut = [{"章": 50, "台阶": "V代编队", "周期": "成长"}]
out = pu._debut_milestone(
no_debut,
"4级训练机",
"item",
[],
[15, 3, 8],
7,
{3: "4级训练机被推入测试场。", 8: "4级训练机完成返航。", 15: "4级训练机封存。"},
)
check("debut-无登场则补一条",
len(out) == 2 and out[0]["周期"] == "登场" and out[0]["章"] == 3 and out[0]["_win"] == 7)
check("debut-台阶仅用中性名称与型", out[0]["台阶"] == "物件「4级训练机」登场")
check("debut-进化台阶仍在尾部", out[1]["台阶"] == "V代编队")
# 无正文实证出场章 → 不补登场里程碑(真实性优先,不能生成无证据台阶)
out = pu._debut_milestone([], "无章实体", "item", [], [], 5, {})
check("debut-无真实章不补", out == [])
# milestones=None 也当空处理
out = pu._debut_milestone(None, "空列表实体", "item", [], [2], 5, {2: "空列表实体出现。"})
check("debut-None当空补登场", len(out) == 1 and out[0]["周期"] == "登场" and out[0]["章"] == 2)
# 反例:实体第57章首现,但当前窗后续章材料含第59/60章事实;兜底绝不能把未来事实倒灌到57章。
future_fact = "第59章升级二代装甲,第60章击毁母舰"
out = pu._debut_milestone(
[],
"一代机械外骨骼·铁壳",
"item",
["铁壳"],
[57, 59, 60],
8,
{
57: "一代机械外骨骼·铁壳第一次走出维修架。",
59: "铁壳升级成二代装甲。",
60: "铁壳击毁母舰。",
},
)
check("debut-57章台阶不含59与60章事实",
out[0]["章"] == 57 and future_fact not in out[0]["台阶"]
and "升级" not in out[0]["台阶"] and "母舰" not in out[0]["台阶"])
alias_only = pu._debut_milestone(
[],
"二代机械外骨骼·铁壳",
"item",
["铁壳"],
[57],
8,
{57: "铁壳第一次走出维修架。"},
)
check("debut-规范名未出现时只用首章真实别名", alias_only[0]["台阶"] == "物件「铁壳」登场")
ambiguous = pu._debut_milestone(
[],
"二代机械外骨骼·铁壳",
"item",
["铁壳", "老铁"],
[57],
8,
{57: "铁壳又被队员叫作老铁。"},
)
check("debut-多名称命中退化为仅类型", ambiguous[0]["台阶"] == "物件登场")
generic_only = pu._debut_milestone(
[], "未来规范名", "character", ["队长"], [57], 8, {57: "队长首次出现。"}
)
check("debut-通用称谓不得进入台阶", generic_only[0]["台阶"] == "人物登场")
check("debut-缺少首章正文宁可不补",
pu._debut_milestone([], "铁壳", "item", [], [57], 8, {59: "铁壳升级。"}) == [])
# ── ③ 里程碑台阶守卫 _clean_milestone(洞③:超 80 字截断)──
def test_clean_milestone_guard():
# 对象入参:台阶 90 字 → 截到 80
m = pu._clean_milestone({"章": 100, "台阶": "阶" * 90, "周期": "成长"}, 1)
check("clean-对象台阶截至80", m is not None and len(m["台阶"]) == 80 and m["台阶"] == "阶" * 80)
check("clean-对象保真章周期", m["章"] == 100 and m["周期"] == "成长" and m["_win"] == 1)
# 字符串入参:整串当台阶,同样受守卫
m = pu._clean_milestone("步" * 120, 2)
check("clean-字符串台阶截至80", m is not None and len(m["台阶"]) == 80)
# 正常短台阶不动
m = pu._clean_milestone({"章": 5, "台阶": "短台阶", "周期": "高光"}, 3)
check("clean-短台阶不截", m["台阶"] == "短台阶" and len(m["台阶"]) == 3)
# 截断顺序回归(主代理核验修复):跑飞长文缺章缺周期时,藏在 80 字外的内嵌章号/周期关键词
# 必须先用**全文**抽取/推断、抽完再截——若先截后抽,兜底线索被截丢(章=None、周期误判成长)。
m = pu._clean_milestone({"台阶": "阶" * 85 + "突破至V代(见第432章)"}, 4)
check("clean-截断前先抽全文章号", m is not None and m["章"] == 432)
check("clean-截断前先推全文周期", m["周期"] == "高光")
check("clean-抽后仍截至80", len(m["台阶"]) == 80)
# 新抽取里程碑必须用短原文证据把章号绑定到对应正文;证据只用于校验,不写入卡体。
chapter_texts = {
488: "安若雪率领亲卫继续突进,环星防御火力全面展开。",
489: "安若雪与加特朗激战,同步率飙至100%,击碎能量屏障。",
}
verified = pu._clean_milestone(
{
"章": 489,
"台阶": "同步率飙至100%击碎加特朗屏障",
"周期": "高光",
"证据": "同步率飙至100%,击碎能量屏障",
},
84,
chapter_texts=chapter_texts,
require_evidence=True,
)
check(
"clean-证据绑定正确章",
verified is not None and verified["章"] == 489 and "证据" not in verified,
)
wrong_chapter = pu._clean_milestone(
{
"章": 488,
"台阶": "同步率飙至100%击碎加特朗屏障",
"周期": "高光",
"证据": "同步率飙至100%,击碎能量屏障",
},
84,
chapter_texts=chapter_texts,
require_evidence=True,
)
check("clean-错章证据拒收", wrong_chapter is None)
missing_evidence = pu._clean_milestone(
{"章": 489, "台阶": "同步率飙至100%", "周期": "高光"},
84,
chapter_texts=chapter_texts,
require_evidence=True,
)
check("clean-缺证据拒收", missing_evidence is None)
short_evidence = {"章": 488, "台阶": "继续突进", "周期": "成长", "证据": "安若雪率领亲卫"}
check(
"clean-不足8字证据拒收",
not pu._milestone_evidence_matches(short_evidence, chapter_texts),
)
long_quote = "证" * 31
long_evidence = {"章": 489, "台阶": "长证据", "周期": "成长", "证据": long_quote}
check(
"clean-超过30字证据拒收",
not pu._milestone_evidence_matches(long_evidence, {489: f"前缀{long_quote}后缀"}),
)
# ── ④ 归并材料 _merge_material(洞②:演变历程完整不截、其余字段截断)──
def test_merge_material():
milestones = [{"章": i, "台阶": f"台阶第{i}条", "周期": "成长"} for i in range(1, 21)]
ent = {"型": "item", "名称": "影杀者", "一句话摘要": "IV代机甲",
"字段": {"演变历程": milestones, "能力与限制": "填充" * 500}} # 其余字段 1000 字
mat = pu._merge_material(ent, rest_limit=600)
# 演变历程每条台阶完整保留(尤其末条不被截断丢失)
check("material-演变历程首条在", "台阶第1条" in mat)
check("material-演变历程末条在", "台阶第20条" in mat)
check("material-摘要在", "IV代机甲" in mat and "影杀者" in mat)
# 其余字段被截断(1000 字巨型字段不应整段进材料)
check("material-其余字段被截断", ("填充" * 500) not in mat)
# 无字段时不崩
check("material-空字段不崩", pu._merge_material({"型": "character", "名称": "甲", "一句话摘要": "s"}))
# ── ⑤ build_embed_text 型键修正(洞①配套;改在 embed skill 本体)──
def test_build_embed_text_type_fix():
# 升格卡用 type 键存型:修正后应认出(修正前会产「【】名称…」丢型)
txt = embed_drafts.build_embed_text({"type": "power_system", "名称": "生物机甲",
"一句话摘要": "体系", "字段": {"境界阶梯": "一代<二代"}})
check("embed-type键认出型", txt.startswith("【power_system】生物机甲"))
check("embed-非空型不再丢", not txt.startswith("【】"))
# 型 键仍优先(既有行为不动)
check("embed-型键优先",
embed_drafts.build_embed_text({"型": "item", "名称": "剑"}).startswith("【item】"))
# target_type 回退仍在
check("embed-target_type回退",
embed_drafts.build_embed_text({"target_type": "faction", "名称": "军团"}).startswith("【faction】"))
# 三者优先级:型 > type > target_type
check("embed-型优先于type与target_type",
embed_drafts.build_embed_text({"型": "A", "type": "B", "target_type": "C",
"名称": "x"}).startswith("【A】"))
def _fake_contracts():
"""离线假合同(覆盖六实体型 + 关系型),供三个提示词构造器渲染,不连库。"""
c = {}
for t in pu.ENTITY_TYPES + (pu.RELATION_TYPE,):
c[t] = {"中文名": t, "判据": f"{t}判据",
"字段": [{"key": "演变历程", "说明": "里程碑数组"},
{"key": "一句话摘要", "说明": "摘要"}]}
return c
# ── ⑦ 出场章归一化 _int_chaps(窗113 实证 bug:int/str 混排炸 + 并集虚增计数)──
def test_int_chaps():
# 核心复现:模型给字符串章号 "508" 与库内 int 507 混合,旧代码 sorted 直接炸
mixed = [507, "508", 509]
got = pu._int_chaps(mixed)
check("intchaps-混类型归一为int", got == {507, 508, 509})
check("intchaps-归一后可安全排序", sorted(got) == [507, 508, 509])
# 并集不再虚增:{"508"} 与 {508} 旧代码算 2 个(误判跨章立卡),归一后算 1 个
union = pu._int_chaps(["508"]) | {508}
check("intchaps-并集不虚增计数", union == {508} and len(union) == 1)
# 脏值/区间/bool 丢弃(出场章只应是单章整数)
check("intchaps-丢非数字与区间", pu._int_chaps(["420-423", "abc", None, 12]) == {12})
check("intchaps-排除bool", pu._int_chaps([True, False, 5]) == {5})
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 = []
self.revision = 0
def execute(self, query, params):
"""按 SQL 用途返回最小结果,或捕获立卡、更新后的 payload。"""
normalized = " ".join(query.split())
if normalized.startswith(
"SELECT draft_payload, status, revision, source_type FROM muse_knowledge_draft"):
return _CardResult((self.payload, "pending", self.revision, pu.SOURCE_TYPE))
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 normalized.startswith("INSERT INTO example_upgrade_alias"):
return _CardResult((params[1],))
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])
if "revision=revision+1" in normalized:
self.revision += 1
return _CardResult((self.revision,))
return _CardResult()
def commit(self):
"""模拟事务提交;离线测试中的状态已在内存立即生效。"""
class _RelationConn:
"""关系卡持久化与 undo 的内存数据库,覆盖 payload、审计、水位和软删。"""
def __init__(self, drafts=None, states=None):
self.drafts = {
did: {
"payload": deepcopy(payload),
"deleted": False,
"status": "pending",
"source_type": pu.SOURCE_TYPE,
"revision": 0,
"updater": "before",
}
for did, payload in (drafts or {}).items()
}
self.states = dict(states or {})
self.audits = []
self.next_draft_id = max(self.drafts, default=700) + 1
self.before_lock = None
def execute(self, query, params=()):
"""按生产 SQL 语义更新内存状态,测试不建立任何真实连接。"""
normalized = " ".join(query.split())
if normalized.startswith(
"SELECT draft_payload, status, revision, source_type FROM muse_knowledge_draft"):
if "FOR UPDATE" in normalized and self.before_lock:
self.before_lock(params[0])
row = self.drafts.get(params[0])
if not row or row["deleted"]:
return _CardResult(None)
return _CardResult(
(row["payload"], row["status"], row["revision"], row["source_type"])
)
if normalized.startswith(
"SELECT work_id, draft_payload, status, revision, source_type, deleted"):
row = self.drafts.get(params[0])
if not row:
return _CardResult(None)
return _CardResult((
8, row["payload"], row["status"], row["revision"],
row["source_type"], row["deleted"],
))
if normalized.startswith("SELECT watermark_window FROM example_upgrade_card_state"):
watermark = self.states.get(params[0])
return _CardResult((watermark,) if watermark is not None else None)
if normalized.startswith("SELECT a.draft_id, a.field_name, a.old_value"):
window_no = params[1]
rows = [
(item["draft_id"], item["field_name"], item["old_value"])
for item in reversed(self.audits)
if item["window_no"] == window_no
]
return _CardResult(rows=rows)
if normalized.startswith("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s"):
return _CardResult((self.drafts[params[0]]["payload"],))
if normalized.startswith("SELECT id, draft_payload FROM muse_knowledge_draft"):
return _CardResult(rows=[
(did, row["payload"])
for did, row in self.drafts.items()
if not row["deleted"]
])
if normalized.startswith("INSERT INTO example_upgrade_audit"):
self.audits.append({
"draft_id": params[0],
"window_no": params[1],
"field_name": params[2],
"old_value": params[3],
"new_value": params[4],
})
return _CardResult()
if normalized.startswith("INSERT INTO muse_knowledge_draft"):
did = self.next_draft_id
self.next_draft_id += 1
self.drafts[did] = {
"payload": json.loads(params[1]),
"deleted": False,
"status": "pending",
"source_type": pu.SOURCE_TYPE,
"revision": 0,
"updater": "upgrade",
}
return _CardResult((did,))
if normalized.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s"):
row = self.drafts[params[1]]
row["payload"] = json.loads(params[0])
if "revision=revision+1" in normalized:
row["revision"] += 1
if "updater='upgrade-undo'" in normalized:
row["updater"] = "upgrade-undo"
return _CardResult((row["revision"],))
return _CardResult()
if normalized.startswith("UPDATE muse_knowledge_draft SET deleted=TRUE"):
row = self.drafts[params[0]]
row["deleted"] = True
if "revision=revision+1" in normalized:
row["revision"] += 1
if "updater='upgrade-undo'" in normalized:
row["updater"] = "upgrade-undo"
return _CardResult()
if normalized.startswith("INSERT INTO example_upgrade_card_state"):
did, _, watermark = params[:3]
if "SET watermark_window=EXCLUDED.watermark_window" in normalized:
self.states[did] = watermark
else:
self.states[did] = max(self.states.get(did, watermark), watermark)
return _CardResult()
if normalized.startswith("UPDATE example_upgrade_card_state SET watermark_window=%s"):
self.states[params[1]] = params[0]
return _CardResult()
if normalized.startswith("DELETE FROM example_upgrade_card_state"):
self.states.pop(params[0], None)
return _CardResult()
if normalized.startswith("DELETE FROM example_upgrade_audit"):
window_no = params[1]
self.audits = [item for item in self.audits if item["window_no"] != window_no]
return _CardResult()
return _CardResult(rows=[])
def test_relation_update_and_new_card_undo_boundaries():
"""关系更新按真实落点审计并可完整 undo;本窗新关系软删且清除 state。"""
old_payload = {
"type": pu.RELATION_TYPE,
"名称": "甲×乙",
"甲方draft": 11,
"乙方draft": 12,
"关系类型": "盟友",
"字段": {"当前状态": "合作", "演变轨迹": ["[窗3] 初次合作"]},
"来源": "升格@窗3",
"_work_id": 8,
}
conn = _RelationConn({701: old_payload}, {701: 3})
pu._update_relation_card(
conn,
8,
8,
701,
0,
{
"关系类型": "对手",
"本窗演变": "公开决裂",
"其他字段": {"当前状态": "敌对", "信任基础": "破裂"},
},
)
updated = conn.drafts[701]["payload"]
check("relation-update-顶层关系类型落正确位置", updated["关系类型"] == "对手")
check("relation-update-其他字段逐字段落库",
updated["字段"]["当前状态"] == "敌对" and updated["字段"]["信任基础"] == "破裂")
check("relation-update-演变轨迹带窗号", updated["字段"]["演变轨迹"][-1] == "[窗8] 公开决裂")
audit_by_name = {item["field_name"]: item for item in conn.audits}
check("relation-update-关系类型使用顶层sentinel",
pu.TOP_LEVEL_RELATION_TYPE_AUDIT_FIELD in audit_by_name)
check("relation-update-其他字段完整旧新审计",
json.loads(audit_by_name["当前状态"]["old_value"]) == "合作"
and json.loads(audit_by_name["当前状态"]["new_value"]) == "敌对"
and audit_by_name["信任基础"]["old_value"] is None)
check("relation-update-演变轨迹完整旧新审计",
json.loads(audit_by_name["演变轨迹"]["old_value"]) == ["[窗3] 初次合作"]
and json.loads(audit_by_name["演变轨迹"]["new_value"])[-1] == "[窗8] 公开决裂")
check("relation-update-state升到本窗且留旧水位审计",
conn.states[701] == 8
and json.loads(audit_by_name[pu.CARD_STATE_WATERMARK_AUDIT_FIELD]["old_value"]) == 3)
pu.undo_window(conn, 8, 8)
check("relation-update-undo完整恢复payload", conn.drafts[701]["payload"] == old_payload)
check("relation-update-undo恢复旧水位", conn.states[701] == 3)
new_payload = {
"type": pu.RELATION_TYPE,
"名称": "甲×丙",
"甲方draft": 11,
"乙方draft": 13,
"关系类型": "师徒",
"字段": {"当前状态": "建立", "演变轨迹": ["[窗8] 首次指点"]},
"来源": "升格@窗8",
"_work_id": 8,
}
new_id = pu._insert_relation_card(conn, 8, 8, new_payload)
new_audits = [item for item in conn.audits if item["draft_id"] == new_id]
check("relation-new-取得RETURNING-id并建state", new_id in conn.drafts and conn.states[new_id] == 8)
check("relation-new-不是单条占位审计",
{item["field_name"] for item in new_audits}
>= {pu.TOP_LEVEL_RELATION_TYPE_AUDIT_FIELD, "当前状态", "演变轨迹",
pu.CARD_STATE_WATERMARK_AUDIT_FIELD})
pu.undo_window(conn, 8, 8)
check("relation-new-undo软删", conn.drafts[new_id]["deleted"] is True)
check("relation-new-undo清除state", new_id not in conn.states)
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
self.connection_id = self.state.setdefault("connection_seq", 0) + 1
self.state["connection_seq"] = self.connection_id
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, query, params=()):
normalized = " ".join(query.split())
if self.state.get("track_sql"):
self.state.setdefault("sql_order", []).append(normalized)
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, error_message FROM example_upgrade_window"):
return _CardResult(rows=self.state.get("failed_windows", []))
if normalized.startswith(
"SELECT draft_payload, status, revision, source_type FROM muse_knowledge_draft"):
draft_id = params[0]
payload = self.state.get("payloads", {}).get(draft_id, self.state.get("payload"))
revision = self.state.setdefault("revisions", {}).get(draft_id, 0)
status = self.state.get("draft_status", "pending")
source_type = self.state.get("draft_source_type", pu.SOURCE_TYPE)
if self.state.get("bump_revision_after_lock"):
self.state["revisions"][draft_id] = revision + 1
return _CardResult((payload, status, revision, source_type))
if normalized.startswith(
"SELECT work_id, draft_payload, status, revision, source_type, deleted"):
draft_id = params[0]
payload = self.state.get("payloads", {}).get(draft_id, self.state.get("payload"))
return _CardResult((
8,
payload,
self.state.get("draft_status", "pending"),
self.state.setdefault("revisions", {}).get(draft_id, 0),
self.state.get("draft_source_type", pu.SOURCE_TYPE),
False,
))
if normalized.startswith("SELECT draft_payload FROM muse_knowledge_draft"):
payload = self.state.get("payloads", {}).get(params[0], self.state.get("payload"))
return _CardResult((payload,))
if normalized.startswith("INSERT INTO muse_knowledge_draft"):
draft_id = self.state.setdefault("next_draft_id", 101)
self.state["next_draft_id"] = draft_id + 1
self.state.setdefault("payloads", {})[draft_id] = json.loads(params[1])
return _CardResult((draft_id,))
if normalized.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s"):
self.state.setdefault("draft_update_sql", []).append(normalized)
if "RETURNING revision" in normalized:
draft_id = params[1]
revisions = self.state.setdefault("revisions", {})
expected_revision = params[-1]
if self.state.get("draft_status", "pending") != "pending" \
or self.state.get("draft_source_type", pu.SOURCE_TYPE) != pu.SOURCE_TYPE \
or revisions.get(draft_id, 0) != expected_revision:
return _CardResult(None)
payload = json.loads(params[0])
if "payloads" in self.state:
self.state["payloads"][params[1]] = payload
else:
self.state["payload"] = payload
if "revision=revision+1" in normalized:
revisions = self.state.setdefault("revisions", {})
revisions[params[1]] = revisions.get(params[1], 0) + 1
return _CardResult((revisions[params[1]],))
if normalized.startswith("INSERT INTO example_upgrade_alias"):
self.state.setdefault("alias_rows", []).append(params)
return _CardResult((params[1],))
if normalized.startswith("UPDATE example_upgrade_window SET status='failed'"):
if "RETURNING status" in normalized and (
self.state.get("window_status") != "done"
or self.state.get("window_error") is not None
or self.state.get("window_updater", "upgrade") != "upgrade"):
return _CardResult(None)
self.state["window_status"] = "failed"
self.state["window_error"] = params[0]
self.state.setdefault("window_errors", []).append(params[0])
self.state["window_updater"] = "upgrade"
if self.state.get("track_events"):
self.state.setdefault("events", []).append(("failed", self.connection_id))
if "RETURNING status" in normalized:
return _CardResult(("failed",))
if normalized.startswith("UPDATE example_upgrade_window SET status='done'"):
self.state["window_status"] = "done"
self.state["window_error"] = None
self.state["window_updater"] = "upgrade"
if not normalized.startswith("SELECT"):
self.writes.append((normalized, params))
return _CardResult()
def commit(self):
"""内存状态即时生效。"""
self.commits += 1
if self.state.get("track_events"):
self.state.setdefault("events", []).append(("commit", self.connection_id))
def test_run_alias_paths_store_real_canonical_name():
"""疑似别名与 substring 命中 alias 键时,实际 INSERT 均写活跃卡真实名称。"""
canonical = "一代机械外骨骼·铁壳"
target = (701, "item", "一代机械外骨骼")
state = {
"payload": {
"type": "item",
"名称": canonical,
"别名": ["铁壳"],
"字段": {},
"_work_id": 8,
},
}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [
{"型": "item", "名称": "新铁壳", "疑似别名指向": "铁壳",
"出场章": [57], "字段": {}},
{"型": "item", "名称": "机械铁壳", "出场章": [58], "字段": {}},
],
"已知实体新信息": [],
"纯出场": [],
}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
name_map = {canonical: target, "铁壳": target}
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=(
"新铁壳出现,机械铁壳随后启动。",
{57: "新铁壳出现。", 58: "机械铁壳随后启动。"},
)), \
patch.object(pu, "load_known", return_value=(name_map, {}, {701: {"铁壳"}}, {701: 0})), \
patch.object(pu, "m3_json", side_effect=fake_m3_json):
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
)
check("alias-run-两条alias路径均执行", len(state.get("alias_rows", [])) == 2)
check("alias-run-canonical_name始终是真实卡名",
{params[1] for params in state["alias_rows"]} == {canonical}, detail=str(state["alias_rows"]))
def test_run_new_card_registers_normalized_name_and_aliases_same_window():
"""括号名立卡后,同窗后续 alias/substr 必须命中新 canonical 并写真实规范名。"""
state = {"payloads": {}}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [
{"型": "item", "名称": "白色游魂(无名侦察兵)", "出场章": [70, 71],
"字段": {}, "别名": []},
{"型": "item", "名称": "侦察兵甲", "疑似别名指向": "无名侦察兵",
"出场章": [70], "字段": {}},
{"型": "item", "名称": "无名侦察兵甲", "出场章": [71], "字段": {}},
],
"已知实体新信息": [],
"纯出场": [],
}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
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, "m3_json", side_effect=fake_m3_json):
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
)
candidate_rows = [
params for params in state.get("alias_rows", [])
if params[2] in {"侦察兵甲", "无名侦察兵甲"}
]
check("new-card-index-同窗alias与substr均命中", len(candidate_rows) == 2, detail=str(state))
check("new-card-index-canonical_name均为规范化名称",
{params[1] for params in candidate_rows} == {"白色游魂"}, detail=str(candidate_rows))
def _run_duplicate_entity_update_case(updates):
"""执行同一实体的重复更新输出,并返回写入口调用和窗失败证据。"""
draft_id = 24473
state = {
"window_status": "pending",
"revisions": {draft_id: 3},
"payload": {
"type": "character", "名称": "安捷", "别名": [], "字段": {}, "_work_id": 8,
},
}
contracts = _fake_contracts()
contracts["character"]["字段"].extend([
{"key": "阵营", "说明": "当前所属阵营"},
{"key": "当前状态", "说明": "人物当前状态"},
])
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安捷", "观察点": "安捷状态发生变化", "出场章": []},
],
"纯出场": [],
}, {})
if need_keys == ("更新",):
return ({"更新": deepcopy(updates)}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \
patch.object(pu, "load_entity_contracts", return_value=contracts), \
patch.object(pu, "load_window_material", return_value=("安捷保持警戒。", {70: "安捷保持警戒。"})), \
patch.object(pu, "load_known", return_value=(
{"安捷": (draft_id, "character", "")}, {}, {}, {draft_id: 3},
)), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "undo_window"), \
patch.object(pu, "merge_card", wraps=pu.merge_card) as merge_mock:
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
return state, merge_mock
def test_run_exact_and_disjoint_duplicate_entity_updates_merge_once():
"""相同项折叠、互补字段合并、别名稳定去重后,每个实体只写一次。"""
exact = {
"draft_id": 24473,
"变更字段": {"阵营": "探索队"},
"别名新增": ["小安", 7, "捷哥"],
}
disjoint = {
"draft_id": 24473,
"变更字段": {"当前状态": "保持警戒"},
"别名新增": ["捷哥", None, "安队"],
}
state, merge_mock = _run_duplicate_entity_update_case([exact, deepcopy(exact), disjoint])
check("entity-update-相同与互补重复只写一次", merge_mock.call_count == 1,
detail=str(merge_mock.call_args_list))
args, kwargs = merge_mock.call_args
check("entity-update-互补字段按key合并",
args[3] == {"阵营": "探索队", "当前状态": "保持警戒"}, detail=str(args[3]))
check("entity-update-别名仅字符串稳定去重并集",
args[4] == ["小安", "捷哥", "安队"], detail=str(args[4]))
check("entity-update-仍使用模型读取时revision",
kwargs.get("expected_revision") == 3 and state["revisions"][24473] == 4,
detail=f"kwargs={kwargs}, revisions={state['revisions']}")
check("entity-update-合并后整窗成功", state["window_status"] == "done")
def test_run_conflicting_duplicate_entity_updates_fail_before_any_write():
"""同一实体同一字段值冲突时,整批预检必须在 merge_card 与 alias 写入前稳定失败。"""
state, merge_mock = _run_duplicate_entity_update_case([
{"draft_id": 24473, "变更字段": {"阵营": "探索队"}, "别名新增": ["小安"]},
{"draft_id": 24473, "变更字段": {"阵营": "敌方"}, "别名新增": ["安队"]},
])
check("entity-update-冲突重复零merge写入", merge_mock.call_count == 0,
detail=str(merge_mock.call_args_list))
check("entity-update-冲突重复零alias写入", not state.get("alias_rows"),
detail=str(state.get("alias_rows")))
check("entity-update-冲突重复两次尝试错误稳定",
len(state.get("window_errors", [])) == 2
and len(set(state["window_errors"])) == 1,
detail=str(state.get("window_errors")))
check("entity-update-冲突重复整窗失败", state["window_status"] == "failed")
class _RelationRunConn:
"""复用关系内存库执行一个完整窗,验证关系 draft id 真进入提交后 touched。"""
def __init__(self, db):
self.db = db
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, status"):
return _CardResult(rows=[(8, 57, 60, "pending")])
if normalized.startswith("SELECT window_no, error_message FROM example_upgrade_window"):
return _CardResult(rows=[])
if normalized.startswith("UPDATE example_upgrade_window SET status='failed'"):
self.db.window_status = "failed"
if not hasattr(self.db, "window_errors"):
self.db.window_errors = []
self.db.window_errors.append(params[0])
return _CardResult()
if normalized.startswith("UPDATE example_upgrade_window SET status='done'"):
self.db.window_status = "done"
return _CardResult()
if normalized.startswith("SELECT id, draft_payload, revision FROM muse_knowledge_draft") \
and "draft_payload->>'type'=%s" in normalized:
return _CardResult(rows=[
(did, row["payload"], row["revision"])
for did, row in self.db.drafts.items()
if not row["deleted"] and row["payload"].get("type") == pu.RELATION_TYPE
])
return self.db.execute(query, params)
def commit(self):
"""内存写即时生效,无需额外动作。"""
def test_run_relation_cards_enter_touched_embedding():
"""关系更新与新建都必须进入窗提交后的 touched,复用唯一活向量刷新链路。"""
character_payloads = {
11: {"type": "character", "名称": "甲", "字段": {}, "_work_id": 8},
12: {"type": "character", "名称": "乙", "字段": {}, "_work_id": 8},
13: {"type": "character", "名称": "丙", "字段": {}, "_work_id": 8},
}
relation_payload = {
"type": pu.RELATION_TYPE,
"名称": "甲×乙",
"甲方draft": 11,
"乙方draft": 12,
"关系类型": "盟友",
"字段": {"演变轨迹": ["[窗3] 初次合作"]},
"来源": "升格@窗3",
"_work_id": 8,
}
db = _RelationConn({**character_payloads, 701: relation_payload}, {701: 3})
embedded = []
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {})
if need_keys == ("关系",):
return ({
"关系": [
{"甲方": 11, "乙方": 12, "关系类型": "对手", "本窗演变": "公开决裂",
"其他字段": {"当前状态": "敌对"}},
{"甲方": 11, "乙方": 13, "关系类型": "师徒", "本窗演变": "首次指点",
"其他字段": {"当前状态": "建立"}},
]
}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
name_map = {
"甲": (11, "character", ""),
"乙": (12, "character", ""),
"丙": (13, "character", ""),
}
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RelationRunConn(db)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=(
"甲与乙决裂,随后甲开始指点丙。",
{57: "甲与乙共同出现。", 60: "甲开始指点丙。"},
)), \
patch.object(pu, "load_known", return_value=(name_map, {}, {}, {11: 0, 12: 0, 13: 0})), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "prejudge_semantic", return_value=({}, 0, 0)), \
patch.object(
pu,
"embed_touched_cards",
side_effect=lambda sess, work_id, touched: embedded.append(set(touched)) or len(touched),
):
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=True,
)
check("relation-run-新关系取得id", 702 in db.drafts)
check("relation-run-更新与新建关系均进入touched", embedded == [{701, 702}], detail=str(embedded))
def _run_duplicate_relation_case(relations):
"""执行同一无序人物对的重复关系输出,并返回关系写调用与稳定失败证据。"""
characters = {
11: {"type": "character", "名称": "甲", "字段": {}, "_work_id": 8},
12: {"type": "character", "名称": "乙", "字段": {}, "_work_id": 8},
}
relation_payload = {
"type": pu.RELATION_TYPE,
"名称": "甲×乙",
"甲方draft": 11,
"乙方draft": 12,
"关系类型": "盟友",
"字段": {"演变轨迹": ["[窗3] 初次合作"]},
"来源": "升格@窗3",
"_work_id": 8,
}
db = _RelationConn({**characters, 701: relation_payload}, {701: 3})
db.window_status = "pending"
db.window_errors = []
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {})
if need_keys == ("关系",):
return ({"关系": deepcopy(relations)}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
name_map = {
"甲": (11, "character", ""),
"乙": (12, "character", ""),
}
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RelationRunConn(db)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=(
"甲与乙的关系发生变化。", {57: "甲与乙的关系发生变化。"},
)), \
patch.object(pu, "load_known", return_value=(name_map, {}, {}, {11: 0, 12: 0})), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "undo_window"), \
patch.object(pu, "_update_relation_card", wraps=pu._update_relation_card) as update_mock, \
patch.object(pu, "_insert_relation_card", wraps=pu._insert_relation_card) as insert_mock:
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
return db, update_mock.call_count, insert_mock.call_count
def test_run_conflicting_duplicate_relations_fail_before_any_relation_write():
"""同一无序实体对内容冲突时,整批预检必须在任何关系卡写入前稳定失败。"""
db, update_calls, insert_calls = _run_duplicate_relation_case([
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作",
"其他字段": {"当前状态": "互相信任"}},
{"甲方": 12, "乙方": 11, "关系类型": "对手", "本窗演变": "公开决裂",
"其他字段": {"当前状态": "彼此敌对"}},
])
check("relation-冲突重复零关系写入", update_calls == 0 and insert_calls == 0,
detail=f"update={update_calls}, insert={insert_calls}")
check("relation-冲突重复两次尝试错误稳定",
len(db.window_errors) == 2 and len(set(db.window_errors)) == 1,
detail=str(db.window_errors))
check("relation-冲突重复整窗失败", db.window_status == "failed")
def test_run_identical_duplicate_relations_write_existing_card_once():
"""完全相同的反向重复项规范成同一无序对,既有关系卡最多更新一次。"""
first = {"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作",
"其他字段": {"当前状态": "互相信任"}}
reversed_duplicate = {**deepcopy(first), "甲方": 12, "乙方": 11}
db, update_calls, insert_calls = _run_duplicate_relation_case([
{"甲方": 11, "乙方": 11, "关系类型": "自关系"},
{"甲方": 11, "乙方": 999, "关系类型": "窗外角色"},
first,
reversed_duplicate,
])
check("relation-相同重复既有卡只更新一次", update_calls == 1 and insert_calls == 0,
detail=f"update={update_calls}, insert={insert_calls}")
check("relation-相同重复整窗成功", db.window_status == "done",
detail=str(db.window_errors))
check("relation-相同重复只推进一次revision", db.drafts[701]["revision"] == 1,
detail=str(db.drafts[701]))
def test_run_prompt_revision_rejects_entity_and_relation_stale_results():
"""实体与关系模型调用期间 revision 变化时,旧模型结果都不得覆盖外部新值。"""
entity_state = {
"window_status": "pending",
"revisions": {701: 0},
"payload": {
"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8,
},
}
def entity_m3(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({"新名字": [], "已知实体新信息": [
{"名称": "安若雪", "观察点": "模型观察", "出场章": []}
], "纯出场": []}, {})
if need_keys == ("更新",):
entity_state["payload"]["字段"]["外部改版"] = "保留"
entity_state["revisions"][701] += 1
return ({"更新": [{
"draft_id": 701, "变更字段": {"阵营": "模型旧结果"}, "别名新增": []
}]}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(entity_state)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=("安若雪出现。", {70: "正文"})), \
patch.object(pu, "load_known", return_value=(
{"安若雪": (701, "character", "")}, {}, {}, {701: 0},
)), \
patch.object(pu, "m3_json", side_effect=entity_m3), \
patch.object(pu, "undo_window"):
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
check("prompt-revision-实体外部改版保留",
entity_state["payload"]["字段"] == {"外部改版": "保留"})
check("prompt-revision-实体旧模型结果令窗失败", entity_state["window_status"] == "failed")
chars = {
11: {"type": "character", "名称": "甲", "字段": {}, "_work_id": 8},
12: {"type": "character", "名称": "乙", "字段": {}, "_work_id": 8},
}
relation_payload = {
"type": pu.RELATION_TYPE, "名称": "甲×乙", "甲方draft": 11, "乙方draft": 12,
"关系类型": "盟友", "字段": {"当前状态": "旧值"}, "_work_id": 8,
}
relation_db = _RelationConn({**chars, 701: relation_payload}, {701: 3})
def relation_m3(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {})
if need_keys == ("关系",):
row = relation_db.drafts[701]
row["payload"]["字段"]["外部改版"] = "保留"
row["revision"] += 1
return ({"关系": [{
"甲方": 11, "乙方": 12, "关系类型": "对手",
"本窗演变": "模型旧结果", "其他字段": {"当前状态": "模型覆盖"},
}]}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RelationRunConn(relation_db)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=("甲与乙出现。", {57: "甲与乙出现。"})), \
patch.object(pu, "load_known", return_value=(
{"甲": (11, "character", ""), "乙": (12, "character", "")},
{}, {}, {11: 0, 12: 0},
)), \
patch.object(pu, "m3_json", side_effect=relation_m3), \
patch.object(pu, "undo_window"):
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
relation_fields = relation_db.drafts[701]["payload"]["字段"]
check("prompt-revision-关系外部改版保留",
relation_fields == {"当前状态": "旧值", "外部改版": "保留"})
check("prompt-revision-关系零审计副作用", not relation_db.audits)
def test_relation_participants_lock_before_prompt_and_reject_external_drift():
"""关系参与角色在 prompt 前逐卡锁定;确认、删除或改版后不得调用关系模型。"""
characters = {
11: {"type": "character", "名称": "甲", "字段": {}, "_work_id": 8},
12: {"type": "character", "名称": "乙", "字段": {}, "_work_id": 8},
}
for name in ("revision改版", "外部确认", "外部删除"):
db = _RelationConn(characters)
relation_calls = []
def drift_before_lock(draft_id):
if draft_id != 12:
return
row = db.drafts[12]
if name == "revision改版":
row["payload"]["字段"]["外部改版"] = "保留"
row["revision"] += 1
elif name == "外部确认":
row["status"] = "confirmed"
else:
row["deleted"] = True
db.before_lock = drift_before_lock
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {})
if need_keys == ("关系",):
relation_calls.append(prompt)
return ({"关系": [{"甲方": 11, "乙方": 12, "关系类型": "盟友"}]}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RelationRunConn(db)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=("甲与乙出现。", {57: "甲与乙出现。"})), \
patch.object(pu, "load_known", return_value=(
{"甲": (11, "character", ""), "乙": (12, "character", "")},
{}, {}, {11: 0, 12: 0},
)), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "undo_window"):
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
check(f"relation-participant-{name}-prompt前失败", not relation_calls)
check(f"relation-participant-{name}-不新建关系",
set(db.drafts) == {11, 12} and not db.audits)
def test_run_alias_gate_precedes_empty_update_and_deleted_alias_fails_closed():
"""alias 分类先校验目标版本;唯一 tombstone 即使同 canonical 也不可视为成功。"""
state = {
"window_status": "pending",
"revisions": {701: 0},
"payload": {"type": "item", "名称": "铁壳", "字段": {}, "_work_id": 8},
}
def fake_m3(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
state["revisions"][701] += 1
return ({"新名字": [{
"型": "item", "名称": "新铁壳", "疑似别名指向": "铁壳",
"出场章": [70], "字段": {},
}], "已知实体新信息": [], "纯出场": []}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
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: "新铁壳出现。"})), \
patch.object(pu, "load_known", return_value=(
{"铁壳": (701, "item", "")}, {}, {}, {701: 0},
)), \
patch.object(pu, "m3_json", side_effect=fake_m3), \
patch.object(pu, "undo_window"):
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
check("alias-gate-空更新前版本冲突不写alias", not state.get("alias_rows"))
check("alias-gate-版本冲突窗失败", state["window_status"] == "failed")
tombstone = _IntegrityConn(
{"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8},
aliases={"雪姐": ("安若雪", 2)},
)
tombstone.aliases["雪姐"]["deleted"] = True
try:
pu.merge_card(tombstone, 701, 8, {}, ["雪姐"], expected_revision=4)
except pu.AliasOwnershipConflict as exc:
check("alias-tombstone-同canonical仍失败关闭", "deleted" in str(exc))
else:
check("alias-tombstone-同canonical仍失败关闭", False, detail="tombstone 被当作幂等成功")
check("alias-tombstone-不复活不写payload",
tombstone.aliases["雪姐"]["deleted"] is True
and "别名" not in tombstone.drafts[701]["payload"])
def test_run_embedding_owner_conflict_marks_window_failed():
"""当前新 hash 无 owner 时,同 draft 旧活向量的 entity owner 仍必须阻断且保持活跃。"""
state = {
"window_status": "pending",
"track_events": True,
"payload": {
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"_work_id": 8,
},
"artifacts": {
"drafts": [701],
"states": [701],
"audits": [701],
"aliases": ["雪姐"],
"presence": ["窗7出场"],
},
}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [],
"已知实体新信息": [{"名称": "安若雪", "观察点": "状态变化", "出场章": [70]}],
"纯出场": [],
}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
embed_text = "当前新向量文本"
old_embed_text = "同 draft 旧向量文本"
old_content_hash = hashlib.sha256(f"{old_embed_text}|{pu.EMBED_MODEL}".encode()).hexdigest()
embedding_state = {
"drafts": {
701: {"payload": deepcopy(state["payload"]), "deleted": False},
},
"embeddings": [{
"draft_id": 701,
"entity_id": 9001,
"content_hash": old_content_hash,
"embed_text": old_embed_text,
"model": pu.EMBED_MODEL,
"dimensions": pu.EMBED_DIM,
"embedding": "[0.3]",
"deleted": False,
}],
}
embed_calls = []
real_embed_touched_cards = pu.embed_touched_cards
def compensate_undo(conn, work_id, win_no):
"""模拟真实 undo 的五域清理,并记录必须发生在 failed 更新之前的连接身份。"""
state.setdefault("events", []).append(("undo", conn.connection_id))
for values in state["artifacts"].values():
values.clear()
def run_real_embed(sess, work_id, touched):
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _EmbeddingConn(embedding_state)), \
patch.object(pu, "build_embed_text", return_value=embed_text), \
patch.object(
pu,
"embed_texts",
side_effect=lambda embed_sess, texts: embed_calls.append(list(texts)) or ([[0.9]], set()),
):
return real_embed_touched_cards(sess, work_id, touched)
with patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
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", "旧摘要")}, {}, {}, {701: 0}),
), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "prejudge_semantic", return_value=({}, 0, 0)), \
patch.object(pu, "undo_window", side_effect=compensate_undo), \
patch.object(
pu,
"embed_touched_cards",
side_effect=run_real_embed,
):
result = CliRunner().invoke(pu.cli, ["run", "--work-id", "8", "--semantic-dedup"])
check("embed-conflict-run-命令非零", result.exit_code != 0, detail=result.output)
check("embed-conflict-run-status补偿为failed", state["window_status"] == "failed")
compensation = state.get("events", [])[-3:]
check(
"embed-conflict-run-同事务先undo再failed后commit",
[event for event, _ in compensation] == ["undo", "failed", "commit"]
and len({conn_id for _, conn_id in compensation}) == 1,
detail=str(state.get("events")),
)
check(
"embed-conflict-run-五域无已提交窗残留",
all(not values for values in state["artifacts"].values()),
detail=str(state["artifacts"]),
)
check("embed-conflict-run-不打印窗完成", "窗7✓" not in result.output, detail=result.output)
check("embed-old-entity-owner-新向量计算已发生", embed_calls == [[embed_text]])
check("embed-old-entity-owner-保持活跃与绑定",
embedding_state["embeddings"][0]["draft_id"] == 701
and embedding_state["embeddings"][0]["entity_id"] == 9001
and embedding_state["embeddings"][0]["deleted"] is False)
def _run_owner_conflict_case(state, *, redo_window=0, snapshot=None,
postcommit_snapshot=None, current_snapshot=None,
undo_effect=None, restore_effect=None):
"""执行一个完整窗直到嵌入 owner 冲突,供补偿边界反例复用。"""
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "观察点": "状态变化", "出场章": []}
],
"纯出场": [],
}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
def owner_conflict(sess, work_id, touched):
callback = state.get("before_owner_conflict")
if callback:
callback()
raise pu.EmbeddingOwnershipConflict("离线 owner 冲突")
cli_args = ["run", "--work-id", "8", "--semantic-dedup"]
if redo_window:
cli_args.extend(["--redo-window", str(redo_window)])
snapshot_calls = {"n": 0}
def fake_snapshot(conn, work_id, win_no):
snapshot_calls["n"] += 1
if snapshot_calls["n"] == 1:
return deepcopy(snapshot)
if snapshot_calls["n"] == 2:
return deepcopy(postcommit_snapshot if postcommit_snapshot is not None else snapshot)
current = deepcopy(
current_snapshot if current_snapshot is not None
else postcommit_snapshot if postcommit_snapshot is not None
else snapshot
)
# marker-first 会在补偿围栏重读前先改变窗口;fake 同步反映该受控变化。
if isinstance(current, dict) and "window" in current:
current["window"] = (
state.get("window_status"), state.get("window_error"), "upgrade"
)
return current
with patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
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", "旧摘要")}, {}, {}, {701: 0},
)), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "prejudge_semantic", return_value=({}, 0, 0)), \
patch.object(pu, "embed_touched_cards", side_effect=owner_conflict), \
patch.object(pu, "_snapshot_redo_window", side_effect=fake_snapshot), \
patch.object(pu, "undo_window", side_effect=undo_effect) as undo, \
patch.object(pu, "_restore_redo_window", side_effect=restore_effect) as restore:
result = CliRunner().invoke(pu.cli, cli_args)
return result, undo, restore
def test_owner_conflict_external_confirmation_preserves_draft_and_persists_breakpoint():
"""嵌入冲突前被外部确认/改版的 touched 卡绝不 undo,并持久化补偿失败断点。"""
original_payload = {
"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8,
}
state = {
"window_status": "pending",
"payload": deepcopy(original_payload),
"revisions": {701: 0},
}
def confirm_externally():
state["draft_status"] = "confirmed"
state["revisions"][701] = 1
state["payload"]["字段"]["人工确认"] = "必须保留"
state["before_owner_conflict"] = confirm_externally
result, undo, _ = _run_owner_conflict_case(state)
check("compensation-fence-外部确认后命令非零", result.exit_code != 0, detail=result.output)
check("compensation-fence-绝不undo或软删外部卡", not undo.called
and state["draft_status"] == "confirmed"
and state["payload"]["字段"] == {"人工确认": "必须保留"})
check("compensation-fence-窗口持久failed断点",
state["window_status"] == "failed"
and str(state.get("window_error", "")).startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=str(state))
def test_redo_owner_conflict_restores_snapshot_without_normal_undo():
"""显式 redo 的嵌入冲突恢复 redo 前五域和窗状态,不进入普通 undo+failed。"""
old_domains = {
"fields": {"阵营": "旧阵营"},
"aliases": ["旧别名"],
"presence": ["旧出场"],
"states": {701: 6},
"audits": ["旧审计"],
}
snapshot = {
"window_no": 1,
"domains": deepcopy(old_domains),
"window": ("done", None, "old"),
}
state = {
"windows": [(1, 70, 71)],
"window_status": "done",
"payload": {"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8},
"revisions": {701: 0},
**deepcopy(old_domains),
}
undo_calls = []
restore_calls = []
def initial_redo_undo(conn, work_id, win_no, *, from_chapter=None, to_chapter=None):
undo_calls.append((win_no, from_chapter, to_chapter))
for key in old_domains:
state[key] = {} if isinstance(old_domains[key], dict) else []
def restore_snapshot(conn, work_id, redo_snapshot):
restore_calls.append(redo_snapshot)
state.update(deepcopy(redo_snapshot["domains"]))
state["window_status"] = redo_snapshot["window"][0]
state["window_error"] = redo_snapshot["window"][1]
result, _, _ = _run_owner_conflict_case(
state,
redo_window=1,
snapshot=snapshot,
undo_effect=initial_redo_undo,
restore_effect=restore_snapshot,
)
check("redo-owner-conflict-命令非零且调用restore", result.exit_code != 0
and restore_calls == [snapshot], detail=result.output)
check("redo-owner-conflict-仅初始章域undo不走普通补偿undo",
undo_calls == [(1, 70, 71)], detail=str(undo_calls))
check("redo-owner-conflict-恢复原五域和窗口状态",
all(state[key] == value for key, value in old_domains.items())
and state["window_status"] == "done" and state["window_error"] is None,
detail=str(state))
def test_redo_full_fence_rejects_untouched_and_auxiliary_domain_drift():
"""redo 补偿覆盖的任一整书域漂移都必须阻断 restore,并保留 durable failed。"""
postcommit = {
"window_no": 1,
"drafts": [
(701, {"名称": "安若雪", "字段": {}}, "pending", 0, "upgrade", False),
(702, {"名称": "未参与角色", "字段": {"状态": "原值"}},
"pending", 4, "old", False),
],
"aliases": [(801, "安若雪", "雪姐", 1, "ai", "", "t1", "", "t2", False)],
"presence": [(901, 1, 70, "character", "路人", "出现", "", "t3", False)],
"card_states": [(701, 1, "t4"), (702, 0, "t4")],
"audits": [(1001, 701, 1, "阵营", None, '"联邦"', "t5")],
"window": ("done", None, "upgrade"),
}
cases = {}
for domain in ("drafts", "aliases", "presence", "card_states", "audits"):
drifted = deepcopy(postcommit)
if domain == "drafts":
# 702 未进入 touched;payload 与 revision 同时模拟外部 CAS 改版。
drifted[domain][1] = (
702, {"名称": "未参与角色", "字段": {"状态": "外部改版"}},
"pending", 5, "external", False,
)
else:
drifted[domain].append(tuple([999] + ["external"] * (len(drifted[domain][0]) - 1)))
cases[domain] = drifted
status_drift = deepcopy(postcommit)
untouched = status_drift["drafts"][1]
status_drift["drafts"][1] = (
untouched[0], untouched[1], "confirmed", *untouched[3:]
)
cases["draft_status"] = status_drift
for domain, current in cases.items():
state = {
"windows": [(1, 70, 71)],
"window_status": "done",
"track_sql": True,
"payload": {"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8},
"revisions": {701: 0},
}
result, undo, restore = _run_owner_conflict_case(
state,
redo_window=1,
snapshot=deepcopy(postcommit),
postcommit_snapshot=deepcopy(postcommit),
current_snapshot=current,
undo_effect=lambda *args, **kwargs: None,
)
check(f"redo-full-fence-{domain}-阻断restore",
result.exit_code != 0 and not restore.called and undo.call_count == 1,
detail=result.output)
check(f"redo-full-fence-{domain}-durable-failed",
state["window_status"] == "failed"
and str(state.get("window_error", "")).startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=str(state))
lock_sql = next((sql for sql in state.get("sql_order", [])
if sql.startswith("LOCK TABLE muse_knowledge_draft")), "")
lock_tables = (
"muse_knowledge_draft", "example_upgrade_alias", "example_upgrade_presence",
"example_upgrade_card_state", "example_upgrade_audit", "example_upgrade_window",
)
check(f"redo-full-fence-{domain}-固定六域阻写顺序",
all(name in lock_sql for name in lock_tables)
and [lock_sql.index(name) for name in lock_tables]
== sorted(lock_sql.index(name) for name in lock_tables),
detail=lock_sql)
def test_compensation_exception_persists_durable_failed_and_next_run_stops():
"""undo/restore 异常后另事务落 failed 断点,下一次 run 必须显式阻断而非跳过。"""
for name, redo_window in (("undo异常", 0), ("restore异常", 1)):
state = {
"window_status": "pending",
"track_events": True,
"payload": {"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8},
"revisions": {701: 0},
}
if redo_window:
state["windows"] = [(1, 70, 71)]
def fail_compensation(*args, **kwargs):
raise RuntimeError(f"{name}离线故障")
result, undo, restore = _run_owner_conflict_case(
state,
redo_window=redo_window,
snapshot={"window_no": 1, "window": ("done", None, "old")},
undo_effect=None if redo_window else fail_compensation,
restore_effect=fail_compensation if redo_window else None,
)
error_message = str(state.get("window_error", ""))
check(f"compensation-durable-{name}-另事务持久failed",
result.exit_code != 0 and state["window_status"] == "failed"
and error_message.startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=f"output={result.output!r}, state={state!r}")
check(f"compensation-durable-{name}-确实经过目标失败点",
restore.called if redo_window else undo.called)
compensation_conn = (restore if redo_window else undo).call_args.args[0]
durable_events = state.get("events", [])[-2:]
check(f"compensation-durable-{name}-failed另事务提交",
[event for event, _ in durable_events] == ["failed", "commit"]
and durable_events[0][1] == durable_events[1][1]
and durable_events[0][1] != compensation_conn.connection_id,
detail=str(state.get("events")))
state["failed_windows"] = [(1 if redo_window else 7, error_message)]
with patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "undo_window") as next_undo:
next_result = CliRunner().invoke(pu.cli, ["run", "--work-id", "8"])
check(f"compensation-durable-{name}-下次run不跳过",
next_result.exit_code != 0 and pu.COMPENSATION_FAILED_PREFIX in next_result.output
and not next_undo.called,
detail=next_result.output)
def test_run_embedding_payload_drift_marks_window_failed_without_vector_writes():
"""HTTP 期间 payload 漂移时,写段必须锁行复验并在任何向量 UPDATE/UPSERT 前阻断。"""
state = {
"window_status": "pending",
"payload": {
"type": "character", "名称": "安若雪", "别名": [], "字段": {}, "_work_id": 8,
},
}
embedding_state = {
"drafts": {701: {
"payload": deepcopy(state["payload"]), "deleted": False,
"tenant_id": pu.TENANT, "status": "pending",
}},
"embeddings": [],
}
real_embed_touched_cards = pu.embed_touched_cards
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({"新名字": [], "已知实体新信息": [
{"名称": "安若雪", "观察点": "状态变化", "出场章": [70]}
], "纯出场": []}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
def run_real_embed(sess, work_id, touched):
def drift_during_http(embed_sess, texts):
embedding_state["drafts"][701]["payload"]["字段"]["HTTP后漂移"] = "新值"
return ([[0.7]], set())
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _EmbeddingConn(embedding_state)), \
patch.object(pu, "embed_texts", side_effect=drift_during_http):
return real_embed_touched_cards(sess, work_id, touched)
with patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
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", "旧摘要")}, {}, {}, {701: 0},
)), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "prejudge_semantic", return_value=({}, 0, 0)), \
patch.object(pu, "embed_touched_cards", side_effect=run_real_embed):
result = CliRunner().invoke(pu.cli, ["run", "--work-id", "8", "--semantic-dedup"])
vector_writes = [sql for sql in embedding_state.get("sql_order", [])
if sql.startswith("UPDATE example_knowledge_embedding")
or sql.startswith("INSERT INTO example_knowledge_embedding")]
check("embed-payload-drift-命令非零且窗failed",
result.exit_code != 0 and state["window_status"] == "failed", detail=result.output)
check("embed-payload-drift-无窗完成", "窗7✓" not in result.output, detail=result.output)
check("embed-payload-drift-零向量写入", not vector_writes, detail=str(vector_writes))
class _EmbeddingConn:
"""增量嵌入 fake DB:复现旧软删 draft 占用同 hash 唯一键的冲突行。"""
def __init__(self, state):
self.state = state
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, query, params=()):
normalized = " ".join(query.split())
self.state.setdefault("sql_order", []).append(normalized)
if normalized.startswith("SELECT tenant_id, deleted, status, draft_payload"):
draft = self.state["drafts"].get(params[0])
if not draft:
return _CardResult(None)
return _CardResult((
draft.get("tenant_id", pu.TENANT),
draft["deleted"],
draft.get("status", "pending"),
draft["payload"],
))
self.state.setdefault("sql_order", []).append(normalized)
if normalized.startswith("SELECT draft_payload FROM muse_knowledge_draft"):
draft = self.state["drafts"].get(params[0])
if not draft or draft["deleted"]:
return _CardResult(None)
return _CardResult((draft["payload"],))
if normalized.startswith("SELECT content_hash FROM example_knowledge_embedding"):
rows = [
(row["content_hash"],)
for row in self.state["embeddings"]
if row["draft_id"] == params[1] and not row["deleted"]
]
return _CardResult(rows=rows)
if normalized.startswith("SELECT id, content_hash, entity_id FROM example_knowledge_embedding"):
rows = [
(index + 1, row["content_hash"], row.get("entity_id"))
for index, row in enumerate(self.state["embeddings"])
if row["draft_id"] == params[1] and not row["deleted"]
]
return _CardResult(rows=rows)
if normalized.startswith("SELECT e.draft_id"):
content_hash = params[1]
row = next(
(item for item in self.state["embeddings"] if item["content_hash"] == content_hash),
None,
)
if row is None:
return _CardResult(None)
owner = self.state["drafts"].get(row["draft_id"])
owner_values = (
row["draft_id"],
row["deleted"],
owner["deleted"] if owner else True,
owner["payload"] if owner else None,
)
if "e.entity_id" in normalized:
owner_values = (owner_values[0], row.get("entity_id"), *owner_values[1:])
return _CardResult(owner_values)
if normalized.startswith("UPDATE example_knowledge_embedding SET deleted=TRUE"):
for row in self.state["embeddings"]:
if row["draft_id"] == params[2] and not row["deleted"] \
and row["content_hash"] != params[3] \
and ("entity_id IS NULL" not in normalized or row.get("entity_id") is None):
row["deleted"] = True
return _CardResult()
if normalized.startswith("INSERT INTO example_knowledge_embedding"):
draft_id, content_hash, embed_text, model, dimensions, embedding = params[:6]
row = next(
(item for item in self.state["embeddings"] if item["content_hash"] == content_hash),
None,
)
is_new = row is None
if is_new:
row = {"entity_id": None}
self.state["embeddings"].append(row)
previous_owner = row.get("draft_id")
previous_draft = self.state["drafts"].get(previous_owner)
guarded = "WHERE example_knowledge_embedding.draft_id=EXCLUDED.draft_id" in normalized
can_update = is_new or previous_owner == draft_id or row.get("deleted") \
or not previous_draft or previous_draft["deleted"]
if "example_knowledge_embedding.entity_id IS NULL" in normalized:
can_update = can_update and row.get("entity_id") is None
if guarded and not can_update:
return _CardResult(None)
# 按 SQL 文本模拟真实 ON CONFLICT:旧实现没有迁移 draft_id,测试应先 RED。
if is_new or "draft_id=EXCLUDED.draft_id" in normalized:
row["draft_id"] = draft_id
row.update({
"content_hash": content_hash,
"embed_text": embed_text if "embed_text=EXCLUDED.embed_text" in normalized or is_new else row.get("embed_text"),
"model": model,
"dimensions": dimensions if "dimensions=EXCLUDED.dimensions" in normalized or is_new else row.get("dimensions"),
"embedding": embedding if "embedding=EXCLUDED.embedding" in normalized or is_new else row.get("embedding"),
"deleted": False,
})
return _CardResult((row["draft_id"],))
return _CardResult()
def commit(self):
"""fake DB 写入即时生效。"""
def test_embed_touched_migrates_soft_deleted_hash_owner():
"""全重抽新 draft 命中旧软删同 hash 时,唯一活向量必须迁到新 draft 并刷新当前列。"""
payload = {"type": pu.RELATION_TYPE, "名称": "甲×乙", "字段": {"演变轨迹": ["首次合作"]}}
embed_text = "当前关系卡嵌入文本"
content_hash = hashlib.sha256(f"{embed_text}|{pu.EMBED_MODEL}".encode()).hexdigest()
state = {
"drafts": {
701: {"payload": deepcopy(payload), "deleted": True},
902: {"payload": deepcopy(payload), "deleted": False},
},
"embeddings": [{
"draft_id": 701,
"content_hash": content_hash,
"embed_text": "旧文本",
"model": pu.EMBED_MODEL,
"dimensions": pu.EMBED_DIM,
"embedding": "[0.1]",
"deleted": True,
}],
}
current_vector = [0.9, 0.8]
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _EmbeddingConn(state)), \
patch.object(pu, "build_embed_text", return_value=embed_text), \
patch.object(pu, "embed_texts", return_value=([current_vector], set())):
done = pu.embed_touched_cards(object(), 8, {902})
active = [row for row in state["embeddings"] if not row["deleted"]]
check("embed-conflict-只保留唯一活向量", done == 1 and len(active) == 1)
check("embed-conflict-同hash迁到新draft", active[0]["draft_id"] == 902, detail=str(active))
check("embed-conflict-同步当前嵌入列",
active[0]["embed_text"] == embed_text
and active[0]["dimensions"] == pu.EMBED_DIM
and json.loads(active[0]["embedding"]) == current_vector)
sql_order = state.get("sql_order", [])
table_lock = next((i for i, sql in enumerate(sql_order)
if sql.startswith("LOCK TABLE muse_knowledge_draft, example_knowledge_embedding")), None)
row_locks = [i for i, sql in enumerate(sql_order) if "FOR UPDATE" in sql]
check("embed-write-固定表锁早于draft与hash行锁",
table_lock is not None and row_locks and table_lock < min(row_locks), detail=str(sql_order))
def test_embed_touched_rejects_two_active_drafts_with_same_hash():
"""两个活跃 touched draft 当前文本同 hash 时必须失败关闭,禁止按循环顺序抢占唯一行。"""
state = {
"drafts": {
801: {"payload": {"名称": "重复卡"}, "deleted": False},
802: {"payload": {"名称": "重复卡"}, "deleted": False},
},
"embeddings": [],
}
embed_calls = []
try:
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _EmbeddingConn(state)), \
patch.object(pu, "build_embed_text", return_value="完全相同文本"), \
patch.object(
pu,
"embed_texts",
side_effect=lambda sess, texts: embed_calls.append(list(texts)) or ([[0.1], [0.1]], set()),
):
pu.embed_touched_cards(object(), 8, {801, 802})
except pu.EmbeddingOwnershipConflict:
check("embed-duplicate-active-失败关闭", True)
else:
check("embed-duplicate-active-失败关闭", False, detail="同 hash 活跃 owner 冲突未抛硬错误")
check("embed-duplicate-active-不调用嵌入不落错误owner", not embed_calls and not state["embeddings"])
def test_embed_touched_rejects_existing_active_hash_owner():
"""单个新候选撞到另一活跃 draft 的当前 hash 时,也不得迁移或复活成错误 owner。"""
embed_text = "跨窗完全相同文本"
content_hash = hashlib.sha256(f"{embed_text}|{pu.EMBED_MODEL}".encode()).hexdigest()
state = {
"drafts": {
811: {"payload": {"名称": "重复卡"}, "deleted": False},
812: {"payload": {"名称": "重复卡"}, "deleted": False},
},
"embeddings": [{
"draft_id": 811,
"content_hash": content_hash,
"embed_text": embed_text,
"model": pu.EMBED_MODEL,
"dimensions": pu.EMBED_DIM,
"embedding": "[0.2]",
"deleted": False,
}],
}
try:
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _EmbeddingConn(state)), \
patch.object(pu, "build_embed_text", return_value=embed_text), \
patch.object(pu, "embed_texts", return_value=([[0.8]], set())):
pu.embed_touched_cards(object(), 8, {812})
except pu.EmbeddingOwnershipConflict:
check("embed-existing-active-失败关闭", True)
else:
check("embed-existing-active-失败关闭", False, detail="已有活跃 owner 未阻断")
check("embed-existing-active-owner保持不变",
len(state["embeddings"]) == 1
and state["embeddings"][0]["draft_id"] == 811
and state["embeddings"][0]["deleted"] is False)
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, "upgrade_work_lock", return_value=nullcontext()), \
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(
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, status, revision, updater, deleted"):
return _CardResult(rows=[
(701, {"字段": {"阵营": "旧阵营"}}, "confirmed", 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-捕获旧卡status", snapshot["drafts"][0][2] == "confirmed")
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-恢复旧卡字段status与revision",
any(sql.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s, status=%s, revision=%s")
and params[:4] == (
json.dumps({"字段": {"阵营": "旧阵营"}}, ensure_ascii=False),
"confirmed", 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_pure_presence_rejects_external_state_and_revision_conflicts():
"""窗尾纯出场必须锁当前 draft;外部终态或 revision 漂移都不得覆盖 payload。"""
original_payload = {
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"出场章": [],
"_work_id": 8,
}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [],
"已知实体新信息": [],
"纯出场": [{"名称": "安若雪", "出场章": [70]}],
}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
for name, extra_state in (
("外部状态终态", {"draft_status": "confirmed"}),
("外部revision漂移", {"revisions": {701: 0}})):
state = {
"window_status": "pending",
"payload": deepcopy(original_payload),
**extra_state,
}
def drift_during_observe(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
if name == "外部revision漂移":
state["revisions"][701] += 1
return fake_m3_json(prompt, model, need_keys, system)
return fake_m3_json(prompt, model, need_keys, system)
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", "旧摘要")}, {}, {}, {701: 0}),
), \
patch.object(pu, "m3_json", side_effect=drift_during_observe), \
patch.object(pu, "undo_window"):
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
)
check(f"pure-presence-{name}-payload零覆盖", state["payload"] == original_payload)
check(f"pure-presence-{name}-窗失败关闭", state["window_status"] == "failed")
if name == "外部状态终态":
check("pure-presence-终态在UPDATE前阻断", not state.get("draft_update_sql"))
else:
check(
"pure-presence-模型期间revision变化在UPDATE前阻断",
not state.get("draft_update_sql"),
detail=str(state.get("draft_update_sql")),
)
def test_run_presence_paths_accept_numeric_strings():
"""同卡横跨新信息与纯出场时,有无模型更新都只走一次实体写入并落全章节。"""
def run_case(updates):
state = {
"window_status": "pending",
"revisions": {701: 0},
"payload": {
"type": "character",
"名称": "安若雪",
"别名": [],
"字段": {},
"出场章": [],
"_work_id": 8,
},
}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == ("新名字", "已知实体新信息", "纯出场"):
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "观察点": "出现新变化", "出场章": ["70"]}
],
"纯出场": [{"名称": "安若雪", "出场章": ["71"]}],
}, {})
return ({"更新": deepcopy(updates)}, {})
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", "旧摘要")}, {}, {}, {701: 0}),
), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "undo_window"), \
patch.object(pu, "merge_card", wraps=pu.merge_card) as merge_mock, \
patch.object(
pu, "_write_locked_upgrade_draft", wraps=pu._write_locked_upgrade_draft
) as write_mock:
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
return state, merge_mock.call_count, write_mock.call_count
updated, merge_calls, write_calls = run_case([
{"draft_id": 701, "变更字段": {"一句话摘要": "新摘要"}, "别名新增": []},
])
check("run-presence-有更新时只写一次",
merge_calls == 1 and write_calls == 1 and updated["revisions"][701] == 1,
detail=f"merge={merge_calls}, write={write_calls}, state={updated}")
check("run-presence-有更新时一次消费两数组章节",
updated["window_status"] == "done" and updated["payload"]["出场章"] == [70, 71],
detail=str(updated))
unchanged, merge_calls, write_calls = run_case([])
check("run-presence-无更新时由机械收尾写一次",
merge_calls == 0 and write_calls == 1 and unchanged["revisions"][701] == 1,
detail=f"merge={merge_calls}, write={write_calls}, state={unchanged}")
check("run-presence-无更新时两数组章节仍落库",
unchanged["window_status"] == "done" and unchanged["payload"]["出场章"] == [70, 71],
detail=str(unchanged))
def test_load_known_projection():
"""判重底册只投影索引字段,禁止跨网搬运每张卡的完整 payload。"""
class Result:
def __init__(self, rows):
self.rows = rows
def fetchall(self):
return self.rows
class Conn:
def __init__(self):
self.queries = []
def execute(self, query, params):
self.queries.append(query)
if "muse_knowledge_draft" in query:
return Result([(1, "character", "安若雪", "联邦战士", [], 6)])
if "example_upgrade_alias" in query:
return Result([("安若雪", "雪姐")])
return Result([("character", "李四", 7)])
conn = Conn()
name_map, presence, aliases_by_draft, revisions = pu.load_known(conn, 8)
check("known-正名投影", name_map["安若雪"] == (1, "character", "联邦战士"))
check("known-别名表投影", name_map["雪姐"] == name_map["安若雪"])
check("known-按卡携带alias表独有别名", aliases_by_draft[1] == {"雪姐"})
check("known-presence保留", presence[("character", "李四")] == {7})
check("known-revision与模型底册同步捕获", revisions == {1: 6})
first_query = conn.queries[0]
check(
"known-禁搬完整payload",
"SELECT id, draft_payload FROM" not 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():
"""只修本窗缺证据里程碑;未来重抄项不能借修复调用混回卡体。"""
output = {
"更新": [
{
"draft_id": 1,
"变更字段": {
"演变历程": [
{"章": 489, "台阶": "同步率飙至100%", "周期": "高光"},
{"章": 493, "台阶": "未来治疗事件", "周期": "高光"},
]
},
}
]
}
chapter_texts = {488: "防线开启。", 489: "安若雪同步率飙至100%,击碎能量屏障。"}
seen = {}
def fake_call(prompt, required_keys):
seen["prompt"] = prompt
seen["required_keys"] = required_keys
return (
{
"证据修复": [
{
"ref": "$.更新[0].变更字段.演变历程[0]",
"章": 489,
"证据": "同步率飙至100%,击碎能量屏障",
}
]
},
{},
)
repaired = pu.repair_missing_milestone_evidence(
output,
title="深空之影",
a=488,
b=489,
text="## 第488章\n防线开启。\n## 第489章\n安若雪同步率飙至100%,击碎能量屏障。",
chapter_texts=chapter_texts,
call=fake_call,
)
milestones = output["更新"][0]["变更字段"]["演变历程"]
check("repair-本窗证据补齐", repaired == 1 and "证据" in milestones[0])
check("repair-未来重抄不送修复", "证据" not in milestones[1] and "未来治疗事件" not in seen["prompt"])
check("repair-固定输出键", seen["required_keys"] == ("证据修复",))
class _IntegrityConn:
"""升格写入完整性内存库:模拟 draft 行锁、alias 唯一键、审计与水位撤销。"""
def __init__(self, payload, *, status="pending", source_type=None, revision=4,
watermark=3, aliases=None):
self.drafts = {
701: {
"payload": deepcopy(payload),
"status": status,
"source_type": source_type or pu.SOURCE_TYPE,
"revision": revision,
"deleted": False,
"updater": "before",
}
}
self.states = {} if watermark is None else {701: watermark}
self.aliases = {
alias: {
"canonical_name": canonical,
"evidence_window": evidence_window,
"deleted": False,
}
for alias, (canonical, evidence_window) in (aliases or {}).items()
}
self.audits = []
self.sql_order = []
def execute(self, query, params=()):
"""按生产 SQL 的关键语义更新内存状态,任何未模拟读查询默认返回空集。"""
normalized = " ".join(query.split())
self.sql_order.append(normalized)
if normalized.startswith(
"SELECT draft_payload, status, revision, source_type FROM muse_knowledge_draft"):
row = self.drafts.get(params[0])
if not row or row["deleted"]:
return _CardResult(None)
return _CardResult((row["payload"], row["status"], row["revision"], row["source_type"]))
if normalized.startswith("SELECT draft_payload FROM muse_knowledge_draft WHERE id=%s"):
return _CardResult((self.drafts[params[0]]["payload"],))
if normalized.startswith("SELECT watermark_window FROM example_upgrade_card_state"):
watermark = self.states.get(params[0])
return _CardResult((watermark,) if watermark is not None else None)
if normalized.startswith("INSERT INTO example_upgrade_audit"):
self.audits.append({
"draft_id": params[0],
"window_no": params[1],
"field_name": params[2],
"old_value": params[3],
"new_value": params[4],
})
return _CardResult()
if normalized.startswith("INSERT INTO example_upgrade_alias"):
work_id, canonical_name, alias, evidence_window = params[:4]
existing = self.aliases.get(alias)
if existing is None:
self.aliases[alias] = {
"canonical_name": canonical_name,
"evidence_window": evidence_window,
"deleted": False,
}
return _CardResult((canonical_name,))
if existing["canonical_name"] == canonical_name and not existing.get("deleted", False):
return _CardResult((canonical_name,))
return _CardResult(None)
if normalized.startswith("SELECT canonical_name, deleted FROM example_upgrade_alias"):
existing = self.aliases.get(params[2])
return _CardResult(
(existing["canonical_name"], existing.get("deleted", False)) if existing else None
)
if normalized.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s"):
row = self.drafts[params[1]]
if "RETURNING revision" in normalized:
expected_revision = params[-1]
if row["status"] != "pending" or row["source_type"] != pu.SOURCE_TYPE \
or row["revision"] != expected_revision or row["deleted"]:
return _CardResult(None)
row["payload"] = json.loads(params[0])
row["revision"] += 1
if "updater='upgrade-undo'" in normalized:
row["updater"] = "upgrade-undo"
return _CardResult((row["revision"],))
row["payload"] = json.loads(params[0])
if "revision=revision+1" in normalized:
row["revision"] += 1
if "updater='upgrade-undo'" in normalized:
row["updater"] = "upgrade-undo"
return _CardResult()
if normalized.startswith("INSERT INTO example_upgrade_card_state"):
draft_id, _, watermark = params[:3]
if "SET watermark_window=EXCLUDED.watermark_window" in normalized:
self.states[draft_id] = watermark
else:
self.states[draft_id] = max(self.states.get(draft_id, watermark), watermark)
return _CardResult()
if normalized.startswith("SELECT a.draft_id, a.field_name, a.old_value"):
window_no = params[1]
rows = [
(audit["draft_id"], audit["field_name"], audit["old_value"])
for audit in reversed(self.audits)
if audit["window_no"] == window_no
]
return _CardResult(rows=rows)
if normalized.startswith("DELETE FROM example_upgrade_card_state"):
self.states.pop(params[0], None)
return _CardResult()
if normalized.startswith("DELETE FROM example_upgrade_audit"):
window_no = params[1]
self.audits = [audit for audit in self.audits if audit["window_no"] != window_no]
return _CardResult()
if normalized.startswith("SELECT id, draft_payload FROM muse_knowledge_draft"):
return _CardResult(rows=[
(draft_id, row["payload"])
for draft_id, row in self.drafts.items()
if not row["deleted"]
])
if normalized.startswith("DELETE FROM example_upgrade_alias"):
evidence_window = params[2]
self.aliases = {
alias: row for alias, row in self.aliases.items()
if row["evidence_window"] != evidence_window
}
return _CardResult()
return _CardResult(rows=[])
def test_upgrade_writes_lock_current_draft_and_reject_invalid_state():
"""实体与关系写入必须锁后取当前值,并拒绝非 pending 或非 upgrade_book。"""
current_payload = {
"type": pu.RELATION_TYPE,
"名称": "甲×乙",
"关系类型": "盟友",
"字段": {"当前状态": "数据库新值", "并发新增": "必须保留"},
"_work_id": 8,
}
stale_payload = deepcopy(current_payload)
stale_payload["字段"] = {"当前状态": "模型调用前旧值"}
conn = _IntegrityConn(current_payload, revision=9)
pu._update_relation_card(
conn,
8,
8,
701,
9,
{"关系类型": "盟友", "本窗演变": "继续合作", "其他字段": {"信任基础": "共同目标"}},
)
updated = conn.drafts[701]
first_write = next(index for index, sql in enumerate(conn.sql_order)
if sql.startswith(("INSERT", "UPDATE", "DELETE")))
lock_index = next(index for index, sql in enumerate(conn.sql_order)
if "FROM muse_knowledge_draft" in sql and "FOR UPDATE" in sql)
check("write-lock-关系基于锁后当前payload", updated["payload"]["字段"]["并发新增"] == "必须保留")
check("write-lock-先锁行后写入", lock_index < first_write, detail=str(conn.sql_order))
check("write-lock-revision受保护只增一次", updated["revision"] == 10)
guarded = _IntegrityConn(current_payload, revision=5)
try:
pu._write_locked_upgrade_draft(guarded, 701, current_payload, 4)
except pu.UpgradeDraftWriteConflict:
check("write-lock-过期revision失败关闭", True)
else:
check("write-lock-过期revision失败关闭", False, detail="过期 revision 被写入")
check("write-lock-过期revision零覆盖", guarded.drafts[701]["revision"] == 5)
for name, status, source_type in (
("非pending", "confirmed", pu.SOURCE_TYPE),
("非upgrade来源", "pending", "parse_book")):
invalid = _IntegrityConn(
{"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8},
status=status,
source_type=source_type,
)
try:
pu.merge_card(invalid, 701, 8, {"阵营": "联邦"}, [])
except pu.UpgradeDraftWriteConflict:
check(f"write-lock-{name}失败关闭", True)
else:
check(f"write-lock-{name}失败关闭", False, detail="非法 draft 被写入")
check(f"write-lock-{name}零写入", not invalid.audits and invalid.states == {701: 3})
def test_alias_claim_payload_undo_and_entity_watermark():
"""alias 不同 owner 必须拒绝;同 owner 幂等后 payload 与实体水位都可精确撤销。"""
old_payload = {
"type": "character",
"名称": "安若雪",
"一句话摘要": "旧摘要",
"字段": {"阵营": "联邦"},
"_work_id": 8,
}
conflict = _IntegrityConn(old_payload, aliases={"雪姐": ("另一人物", 2)})
try:
pu.merge_card(conflict, 701, 8, {}, ["雪姐"])
except pu.AliasOwnershipConflict as exc:
check("alias-不同canonical确定性失败", "雪姐" in str(exc))
else:
check("alias-不同canonical确定性失败", False, detail="唯一冲突被静默吞掉")
check("alias-冲突不写payload", conflict.drafts[701]["payload"] == old_payload)
conn = _IntegrityConn(old_payload, revision=6, watermark=3,
aliases={"雪姐": ("安若雪", 2)})
pu.merge_card(conn, 701, 8, {"一句话摘要": "新摘要"}, ["雪姐"])
check("alias-同canonical幂等后才写payload", conn.drafts[701]["payload"]["别名"] == ["雪姐"])
check("alias-同canonical保留原证据窗", conn.aliases["雪姐"]["evidence_window"] == 2)
watermark_audit = next(
audit for audit in conn.audits
if audit["field_name"] == pu.CARD_STATE_WATERMARK_AUDIT_FIELD
)
check("entity-watermark-审计完整旧值", json.loads(watermark_audit["old_value"]) == 3)
check("entity-watermark-推进到本窗", conn.states[701] == 8)
pu.undo_window(conn, 8, 8)
check("alias-undo恢复顶层键缺失", conn.drafts[701]["payload"] == old_payload)
check("entity-watermark-undo恢复旧值", conn.states[701] == 3)
check("alias-undo不删旧窗同canonical映射", "雪姐" in conn.aliases)
payload_with_empty_aliases = {**deepcopy(old_payload), "别名": []}
fresh = _IntegrityConn(payload_with_empty_aliases, watermark=None)
pu.merge_card(fresh, 701, 8, {}, ["若雪"])
check("alias-新映射成功后写payload", fresh.drafts[701]["payload"]["别名"] == ["若雪"])
pu.undo_window(fresh, 8, 8)
check("alias-undo恢复顶层空列表", fresh.drafts[701]["payload"] == payload_with_empty_aliases)
check("alias-undo删除本窗新映射", "若雪" not in fresh.aliases)
check("entity-watermark-undo删除本窗新state", 701 not in fresh.states)
def test_undo_advances_revision_and_invalidates_precompensation_cas():
"""撤销恢复与新卡软删都推进 revision,使基于撤销前版本的外部 CAS 必然失败。"""
old_payload = {
"type": "character", "名称": "安若雪", "一句话摘要": "旧摘要",
"字段": {}, "_work_id": 8,
}
conn = _IntegrityConn(old_payload, revision=4)
pu.merge_card(
conn, 701, 8, {"一句话摘要": "窗内摘要"}, [], expected_revision=4
)
stale_revision = conn.drafts[701]["revision"]
stale_payload = deepcopy(conn.drafts[701]["payload"])
pu.undo_window(conn, 8, 8)
check("undo-revision-既有卡恢复后单调推进且标记updater",
conn.drafts[701]["revision"] > stale_revision
and conn.drafts[701]["updater"] == "upgrade-undo")
try:
pu._write_locked_upgrade_draft(conn, 701, stale_payload, stale_revision)
except pu.UpgradeDraftWriteConflict:
check("undo-revision-撤销前旧CAS失败", True)
else:
check("undo-revision-撤销前旧CAS失败", False, detail="旧 revision 仍写入成功")
check("undo-revision-旧CAS不覆盖恢复payload", conn.drafts[701]["payload"] == old_payload)
relation_conn = _RelationConn()
new_payload = {
"type": pu.RELATION_TYPE, "名称": "甲×乙", "甲方draft": 11, "乙方draft": 12,
"关系类型": "盟友", "字段": {"演变轨迹": ["[窗8] 建立"]},
"来源": "升格@窗8", "_work_id": 8,
}
new_id = pu._insert_relation_card(relation_conn, 8, 8, new_payload)
before_delete_revision = relation_conn.drafts[new_id]["revision"]
pu.undo_window(relation_conn, 8, 8)
deleted = relation_conn.drafts[new_id]
check("undo-revision-新卡软删revision单调且标记updater",
deleted["deleted"] is True and deleted["revision"] > before_delete_revision
and deleted["updater"] == "upgrade-undo")
# ── ⑥ 三个提示词含关键纪律语句(洞②登场必须 / 洞③≤40字 + 体系级 / 洞②归并去重)──
def test_prompts_disciplines():
c = _fake_contracts()
obs = pu.observe_prompt(c, "测试书", 1, 12, "正文占位", {"张三": (10, "character", "主角")})
check("observe-登场必须硬约束", "必须给「演变历程」的首条登场里程碑" in obs)
check("observe-台阶≤40字", "≤40 字" in obs)
check("observe-体系级纪律", "体系级纪律" in obs)
check("observe-里程碑要求原文证据", '"证据"' in obs and "正文原样连续短句" in obs)
upd = pu.update_prompt(c, "测试书", 1, 12, "正文占位",
[(10, {"type": "power_system", "名称": "生物机甲"}, ["观察点"])])
check("update-台阶≤40字", "≤40 字" in upd)
check("update-体系级纪律", "体系级纪律" in upd)
check("update-登场归并去重", "若本卡演变历程已有登场里程碑则不再重复追加" in upd)
check("update-里程碑要求原文证据", '"证据"' in upd and "正文原样连续短句" in upd)
rel = pu.relation_prompt(c, "测试书", 1, 12, "正文占位",
[(10, {"名称": "张三"}), (11, {"名称": "李四"})], [])
check("relation-构造器可渲染", "人物关系增量" in rel)
# 判重终判 prompt(小样校准后):近邻六元组带字段摘选进证据 + 名称互含判据
nbs = [(6223, "power_system", "生物机甲", "机甲体系", '{"境界阶梯":"IV代→V代"}', 0.66)]
jp = pu.dedup_judge_prompt({"型": "power_system", "名称": "联邦生物机甲技术",
"一句话摘要": "s", "字段": {}}, nbs)
check("judge-近邻字段摘选入证据", "字段摘选:" in jp and "境界阶梯" in jp)
check("judge-名称互含判据", "同型且名称互含" in jp)
check("judge-跨型禁判同一实体仍在", "绝不判同一实体" in jp)
if __name__ == "__main__":
for fn in (test_classify_new_name, test_resolve_canonical_name,
test_debut_milestone, test_clean_milestone_guard,
test_merge_material, test_build_embed_text_type_fix, test_int_chaps,
test_relation_update_and_new_card_undo_boundaries,
test_upgrade_writes_lock_current_draft_and_reject_invalid_state,
test_alias_claim_payload_undo_and_entity_watermark,
test_undo_advances_revision_and_invalidates_precompensation_cas,
test_entity_chapter_evidence_filter, test_new_card_chapter_evidence_chain,
test_merge_card_chapter_evidence, test_redo_cleans_legacy_appearance_chapters,
test_run_alias_paths_store_real_canonical_name,
test_run_new_card_registers_normalized_name_and_aliases_same_window,
test_run_exact_and_disjoint_duplicate_entity_updates_merge_once,
test_run_conflicting_duplicate_entity_updates_fail_before_any_write,
test_run_relation_cards_enter_touched_embedding,
test_run_conflicting_duplicate_relations_fail_before_any_relation_write,
test_run_identical_duplicate_relations_write_existing_card_once,
test_run_prompt_revision_rejects_entity_and_relation_stale_results,
test_relation_participants_lock_before_prompt_and_reject_external_drift,
test_run_alias_gate_precedes_empty_update_and_deleted_alias_fails_closed,
test_run_embedding_owner_conflict_marks_window_failed,
test_owner_conflict_external_confirmation_preserves_draft_and_persists_breakpoint,
test_redo_owner_conflict_restores_snapshot_without_normal_undo,
test_redo_full_fence_rejects_untouched_and_auxiliary_domain_drift,
test_compensation_exception_persists_durable_failed_and_next_run_stops,
test_run_embedding_payload_drift_marks_window_failed_without_vector_writes,
test_embed_touched_migrates_soft_deleted_hash_owner,
test_embed_touched_rejects_two_active_drafts_with_same_hash,
test_embed_touched_rejects_existing_active_hash_owner,
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_pure_presence_rejects_external_state_and_revision_conflicts,
test_run_presence_paths_accept_numeric_strings,
test_load_known_projection, test_repair_milestone_evidence,
test_prompts_disciplines):
fn()
print(f"\n全部离线自测通过:{_passed} 项(未连库、未发任何网络/嵌入/LLM 调用)")