muse-agent-example/tests/skills/extract-work-knowledge/test_parse_upgrade_offline.py
zizi c9f69d9d6d 治理: Skill 测试治理第一阶段——harness 控制平面 + 实现测试迁出运行时目录
范围(不含 design-story-foundation、docs/、humanization/README.md 等进行中改动):

1. 新增 harness/ 控制平面
   - skill_harness.py 静态审计:32 个运行时 Skill 的 frontmatter/manifest/文档污染,当前 0 问题
   - run_selected.py 选择性执行器:manifest 与磁盘一一对账、依赖阻断、
     空跑与 skip-only 失败关闭、AST 测试形状门
   - manifests/skills.json:32 个 Skill 的合同责任方与协作领域登记
   - manifests/test-inventory.json:81 个测试资产登记
   - specs/skill-testing.md 与 README.md:测试分层、证据边界与 harness 职责

2. 实现测试从 .claude/skills/*/scripts/ 迁至 tests/skills/<skill>/
   - 71 个测试文件迁移并修复项目根与临时目录运行导入
   - 数据库触发器测试宽泛异常收窄为 psycopg.errors.RaiseException
   - 抽取离线大测试拆出真实 PG smoke(默认阻断,不计入离线通过)
   - 抽取 presence 去重边界拆出独立测试:493 + 78 = 571 项检查不变

3. 运行时文档清理
   - 13 个 SKILL.md 移除自测/离线验证段落、测试命令与测试文件事实源表述,
     只保留运行时合同;业务运行合同、额度、授权与离线模式均保留

4. SoT 同步
   - AGENTS.md:新增 Skill 领域索引(7 个合同责任方分组,覆盖 32 个运行时 Skill)
   - 领域 07:测试入口改由 harness/manifests/ 登记,SKILL.md 不承载测试命令
   - humanization 覆盖矩阵:活动测试路径同步迁移

验证证据: harness 自测 15 项 + runner 自测 13 项通过;静态审计 32 Skill / 0 问题;
73 个非数据库测试通过;8 个集成条目中 6 个 PostgreSQL 项被依赖门明确阻断;
py_compile 与 git diff --check 通过。未连接 PostgreSQL、网络、真实模型或额度。

已知边界: 真正 skill_behavior_eval 仍为 0,尚未验证任何 Skill 自然语言行为;
evaluate-frozen-replay 的 raw 存储边界冲突留待单独治理。
2026-08-19 01:50:20 +08:00

5925 lines
261 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
"""upgrade 升格拆卡执行器·纯逻辑离线自测(升格卡改造 3 洞补齐)。
红线:**不连真实库、不发任何网络/嵌入/LLM 调用**——只覆盖纯函数、提示词构造器和
使用内存 fake DB 的事务/CAS 合同:
· 机械判重分类 _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)
本测试**不触碰真实服务**——质量修复命令只通过 fake DB 验证 preview、CAS 和原子写入,
不在离线自测中执行真实 work/window。
跑法:仓库根目录 `.venv/bin/python tests/skills/extract-work-knowledge/test_parse_upgrade_offline.py`
"""
import json
import hashlib
import inspect
from io import StringIO
import pathlib
import sys
from contextlib import nullcontext
from contextlib import redirect_stderr
from copy import deepcopy
from unittest.mock import patch
from click.testing import CliRunner
# 从生产 scripts 导入 upgrade;upgrade 自己装配 parse-book/embed/llm 的生产模块路径。
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
SCRIPT_DIR = PROJECT_ROOT / ".claude" / "skills" / "extract-work-knowledge" / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
import 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_classify_candidate_alias_same_type_merges():
"""候选自身别名命中同型既有卡时,允许机械 alias 归并。"""
name_map = {"伊琳娅丝": (24403, "item", "既有物件别名")}
candidate = {
"型": "item",
"名称": "VI代生物机甲·伊琳娅丝",
"别名": [" 伊琳娅丝 "],
"出场章": [527, 528],
}
check(
"classify-own-alias-同型命中既有draft",
pu._classify_new_name(candidate, name_map, {}) == ("alias", "伊琳娅丝"),
)
def test_classify_candidate_alias_cross_type_does_not_merge():
"""候选自身别名命中跨型既有卡时,纯分类阶段不得直接归并。"""
name_map = {"伊琳娅丝": (24403, "character", "既有角色别名")}
candidate = {
"型": "item",
"名称": "VI代生物机甲·伊琳娅丝",
"别名": [" 伊琳娅丝 "],
"出场章": [527, 528],
}
try:
pu._classify_new_name(candidate, name_map, {})
except RuntimeError as exc:
check("classify-own-alias-跨型未裁决失败关闭", "跨型" in str(exc))
else:
check("classify-own-alias-跨型未裁决失败关闭", False, detail="未抛稳定冲突")
def test_classify_cross_type_hits_without_normalization_fail_closed():
"""规范名、alias/hint 跨型未被上游规范化时均稳定失败,伪造旧标记也无效。"""
canonical = "既有角色"
target = (24403, "character", "既有角色摘要")
name_map = {canonical: target, "伊琳娅丝": target}
cases = [
("规范名", {"型": "item", "名称": canonical, "别名": [], "出场章": [527, 528]}),
("alias", _cross_type_candidate()),
(
"伪造旧标记",
{**_cross_type_candidate(), "_cross_type_alias_arbitrated": True},
),
]
for label, candidate in cases:
try:
pu._classify_new_name(candidate, name_map, {})
except RuntimeError as exc:
check(f"classify-cross-type-{label}-失败关闭", "跨型" in str(exc))
else:
check(f"classify-cross-type-{label}-失败关闭", False, detail="未抛稳定冲突")
def test_classify_multiple_candidate_aliases_same_draft_merges():
"""候选多个清洗后别名命中同一 draft 时仍按一个实体归并。"""
target = (24403, "item", "既有物件别名")
name_map = {"伊琳娅丝": target, "琳娅丝": target}
candidate = {
"型": "item",
"名称": "VI代生物机甲·伊琳娅丝",
"别名": ["伊琳娅丝", " 琳娅丝 "],
"出场章": [527, 528],
}
kind, key = pu._classify_new_name(candidate, name_map, {})
check(
"classify-own-alias-多个别名同draft仍归并",
kind == "alias" and key in {"伊琳娅丝", "琳娅丝"}
and name_map[key][0] == 24403,
)
def test_classify_multiple_candidate_aliases_different_drafts_fails_closed():
"""候选多个别名命中不同 draft 时必须在判重阶段失败关闭。"""
name_map = {
"伊琳娅丝": (24403, "character", "既有角色别名"),
"机甲伊琳": (24404, "item", "既有机甲别名"),
}
candidate = {
"型": "item",
"名称": "VI代生物机甲·伊琳娅丝",
"别名": ["伊琳娅丝", "机甲伊琳"],
"出场章": [527, 528],
}
try:
pu._classify_new_name(candidate, name_map, {})
except RuntimeError as exc:
check("classify-own-alias-多个draft失败关闭", "多个 draft" in str(exc))
else:
check("classify-own-alias-多个draft失败关闭", False, detail="未抛出冲突")
def _cross_type_candidate(aliases=None):
"""构造窗口97使用的跨型别名候选。"""
return {
"型": "item",
"名称": "VI代生物机甲·伊琳娅丝",
"别名": ["伊琳娅丝"] if aliases is None else aliases,
"一句话摘要": "候选机甲摘要",
"字段": {"当前状态": "在机库活动"},
"出场章": [527, 528],
}
def _cross_type_known():
"""构造规范名与别名均指向 character draft=24403 的底册。"""
canonical = "既有角色"
target = (24403, "character", "既有角色摘要")
return canonical, {
canonical: target,
"伊琳娅丝": target,
}, {24403: {"伊琳娅丝"}}
def _cross_type_decision(candidate, canonical, verdict, aliases=None):
"""构造严格绑定候选与 owner 的裁决行。"""
return {
"候选名称": candidate["名称"],
"候选类型": candidate["型"],
"已有规范名": canonical,
"已有类型": "character",
"冲突别名": ["伊琳娅丝"] if aliases is None else aliases,
"候选出场章": list(candidate.get("出场章") or []),
"裁决": verdict,
}
def _cross_type_arbitration_call(decisions, calls):
"""构造只响应跨型别名裁决的离线 call fake。"""
def call(prompt, need_keys):
calls.append((prompt, need_keys))
if need_keys != ("跨型别名裁决",):
raise AssertionError(f"不应出现的裁决外模型调用:{need_keys}")
return {"跨型别名裁决": deepcopy(decisions)}, {}
return call
def test_cross_type_alias_arbitration_same_entity_updates_existing_plan():
"""same_entity 后设置疑似别名指向,planner 更新旧卡并登记完整称谓。"""
canonical, name_map, aliases_by_draft = _cross_type_known()
candidate = _cross_type_candidate()
obs = {"新名字": [candidate], "已知实体新信息": [], "纯出场": []}
calls = []
pu._arbitrate_cross_type_aliases(
obs, name_map, aliases_by_draft,
"第527章原文:VI代生物机甲·伊琳娅丝现身。\n第528章原文:伊琳娅丝继续行动。",
_cross_type_arbitration_call(
[_cross_type_decision(candidate, canonical, "same_entity")], calls
),
)
check("cross-type-same-设置疑似别名指向", candidate["疑似别名指向"] == canonical)
check("cross-type-same-规范化为owner型", candidate["型"] == "character")
check(
"cross-type-same-裁决绑定出场章",
'"候选出场章"' in calls[0][0]
and '527' in calls[0][0]
and '528' in calls[0][0],
detail=calls[0][0],
)
plan = pu._plan_window_entities(
8, 97, obs, name_map, {}, aliases_by_draft, {}, False, set(),
{527: "VI代生物机甲·伊琳娅丝现身。", 528: "伊琳娅丝继续行动。"},
)
check(
"cross-type-same-update旧draft登记完整称谓",
set(plan["to_update"]) == {24403}
and any(
action[0] == "alias" and action[1] == 24403
and action[2] == canonical and action[3] == candidate["名称"]
for action in plan["actions"]
),
detail=str(plan),
)
check(
"cross-type-same-不创建new-payload",
plan["payloads"] == {} and not any(action[0] == "new" for action in plan["actions"]),
detail=str(plan),
)
check("cross-type-same-单次批量裁决", len(calls) == 1)
def test_same_type_multi_owner_exact_name_skips_arbitration_and_merges():
"""精确候选名命中同型 owner 时,附加别名命中另一同型 owner 仍走精确 merge。"""
primary = (24403, "character", "加布雷斯大将卡")
secondary = (24404, "character", "加布雷斯大将之父卡")
name_map = {
"加布雷斯大将": primary,
"加布雷斯大将之父": secondary,
}
aliases_by_draft = {24403: set(), 24404: {"加布雷斯大将之父"}}
candidate = {
"型": "character",
"名称": "加布雷斯大将",
"别名": ["加布雷斯大将之父"],
"一句话摘要": "同型精确名称候选",
"字段": {"当前状态": "出现"},
"出场章": [569, 570],
}
calls = []
def must_not_arbitrate(*args):
calls.append(args)
raise AssertionError("同型精确名称命中不得调用跨型裁决")
pu._arbitrate_cross_type_aliases(
{"新名字": [candidate], "已知实体新信息": [], "纯出场": []},
name_map,
aliases_by_draft,
"第569章加布雷斯大将出现。第570章加布雷斯大将之父提及。",
must_not_arbitrate,
)
check("same-type-multi-owner-裁决零调用", calls == [])
check(
"same-type-multi-owner-精确分类merge",
pu._classify_new_name(candidate, name_map, {}) == ("merge", "加布雷斯大将"),
)
plan = pu._plan_window_entities(
8,
110,
{"新名字": [candidate], "已知实体新信息": [], "纯出场": []},
name_map,
{},
aliases_by_draft,
{},
False,
set(),
{569: "第569章加布雷斯大将出现。", 570: "第570章加布雷斯大将之父提及。"},
)
check(
"same-type-multi-owner-planner只更新精确owner",
set(plan["to_update"]) == {24403}
and plan["payloads"] == {}
and not any(action[0] == "new" for action in plan["actions"]),
detail=str(plan),
)
def test_cross_type_alias_arbitration_different_entity_keeps_new_card():
"""different_entity 只移除冲突 alias,候选继续走新卡计划。"""
canonical, name_map, aliases_by_draft = _cross_type_known()
candidate = _cross_type_candidate()
obs = {"新名字": [candidate], "已知实体新信息": [], "纯出场": []}
calls = []
pu._arbitrate_cross_type_aliases(
obs, name_map, aliases_by_draft,
"第527章原文:VI代生物机甲·伊琳娅丝现身。\n第528章原文:VI代生物机甲·伊琳娅丝继续行动。",
_cross_type_arbitration_call(
[_cross_type_decision(candidate, canonical, "different_entity")], calls
),
)
check(
"cross-type-different-只移除冲突alias",
candidate["别名"] == [] and "疑似别名指向" not in candidate,
)
plan = pu._plan_window_entities(
8, 97, obs, name_map, {}, aliases_by_draft, {}, False, set(),
{
527: "VI代生物机甲·伊琳娅丝现身。",
528: "VI代生物机甲·伊琳娅丝继续行动。",
},
)
check(
"cross-type-different-保持new卡",
any(action[0] == "new" for action in plan["actions"])
and len(plan["payloads"]) == 1
and next(iter(plan["payloads"].values()))["别名"] == [],
detail=str(plan),
)
check("cross-type-different-单次批量裁决", len(calls) == 1)
exact_name = {
"型": "item", "名称": "伊琳娅丝", "别名": [],
"一句话摘要": "同名物件", "字段": {}, "出场章": [527, 528],
"疑似别名指向": "伊琳娅丝",
}
exact_calls = []
try:
pu._arbitrate_cross_type_aliases(
{"新名字": [exact_name], "已知实体新信息": [], "纯出场": []},
name_map,
aliases_by_draft,
"第527章伊琳娅丝出现。",
_cross_type_arbitration_call(
[_cross_type_decision(exact_name, canonical, "different_entity")], exact_calls
),
)
except RuntimeError as exc:
check(
"cross-type-different-精确候选名称被占用失败关闭",
"候选名称" in str(exc)
and exact_calls
and '"候选名称": "伊琳娅丝"' in exact_calls[0][0]
and exact_name["疑似别名指向"] == "伊琳娅丝",
detail=str(exact_calls),
)
else:
check("cross-type-different-精确候选名称被占用失败关闭", False, detail="精确名称冲突被删除后继续")
def test_cross_type_different_entity_new_card_claims_only_remaining_aliases():
"""different_entity 应用阶段不再 claim 旧 alias,剩余 alias 仍经过唯一门。"""
canonical, name_map, aliases_by_draft = _cross_type_known()
candidate = _cross_type_candidate(["伊琳娅丝", "机甲独有"])
obs = {"新名字": [candidate], "已知实体新信息": [], "纯出场": []}
pu._arbitrate_cross_type_aliases(
obs, name_map, aliases_by_draft,
"第527章原文:VI代生物机甲·伊琳娅丝现身。\n第528章原文:VI代生物机甲·伊琳娅丝继续行动。",
_cross_type_arbitration_call(
[_cross_type_decision(candidate, canonical, "different_entity")], []
),
)
plan = pu._plan_window_entities(
8, 97, obs, name_map, {}, aliases_by_draft, {}, False, set(),
{
527: "VI代生物机甲·伊琳娅丝现身。",
528: "VI代生物机甲·伊琳娅丝继续行动。",
},
)
claimed = []
real_claim_alias = pu._claim_alias
def guarded_claim_alias(conn, work_id, canonical_name, alias, win_no, verdict_by):
claimed.append(alias)
if alias == "伊琳娅丝":
raise AssertionError("different_entity 不得再次 claim 冲突 alias")
return real_claim_alias(conn, work_id, canonical_name, alias, win_no, verdict_by)
with patch.object(pu, "_claim_alias", side_effect=guarded_claim_alias):
pu.new_card(
_RunConn({"payloads": {}}),
8,
97,
plan["actions"][0][2],
set(),
chapter_texts={
527: "VI代生物机甲·伊琳娅丝现身。",
528: "VI代生物机甲·伊琳娅丝继续行动。",
},
)
check("cross-type-different-应用不claim旧alias", claimed == ["机甲独有"], detail=str(claimed))
def test_cross_type_alias_arbitration_rejects_invalid_cases():
"""多 owner、非法 alias、非法裁决和缺项均必须失败关闭。"""
canonical, name_map, aliases_by_draft = _cross_type_known()
multi_owner_map = dict(name_map)
multi_owner_map["机甲伊琳"] = (24404, "item", "另一张卡摘要")
multi_owner_aliases = {**aliases_by_draft, 24404: {"机甲伊琳"}}
calls = []
try:
pu._arbitrate_cross_type_aliases(
{"新名字": [_cross_type_candidate(["伊琳娅丝", "机甲伊琳"])]},
multi_owner_map, multi_owner_aliases, "当前窗原文",
_cross_type_arbitration_call([], calls),
)
except RuntimeError as exc:
check("cross-type-invalid-多个owner失败关闭", "多个 owner" in str(exc))
else:
check("cross-type-invalid-多个owner失败关闭", False, detail="未抛冲突")
check("cross-type-invalid-多个owner不调用模型", calls == [])
invalid_alias = _cross_type_candidate()
invalid_alias["别名"] = "伊琳娅丝"
try:
pu._arbitrate_cross_type_aliases(
{"新名字": [invalid_alias]}, name_map, aliases_by_draft,
"当前窗原文", _cross_type_arbitration_call([], []),
)
except RuntimeError as exc:
check("cross-type-invalid-alias失败关闭", "字符串列表" in str(exc))
else:
check("cross-type-invalid-alias失败关闭", False, detail="未抛冲突")
candidate = _cross_type_candidate()
calls = []
try:
pu._arbitrate_cross_type_aliases(
{"新名字": [candidate]}, name_map, aliases_by_draft,
"当前窗原文",
_cross_type_arbitration_call(
[_cross_type_decision(candidate, canonical, "uncertain")], calls
),
)
except RuntimeError as exc:
check("cross-type-invalid-非法裁决失败关闭", "裁决" in str(exc))
else:
check("cross-type-invalid-非法裁决失败关闭", False, detail="未抛冲突")
candidate = _cross_type_candidate()
try:
pu._arbitrate_cross_type_aliases(
{"新名字": [candidate]}, name_map, aliases_by_draft,
"当前窗原文", _cross_type_arbitration_call([], []),
)
except RuntimeError as exc:
check("cross-type-invalid-裁决缺项失败关闭", "缺失" in str(exc))
else:
check("cross-type-invalid-裁决缺项失败关闭", False, detail="未抛冲突")
def test_cross_type_alias_arbitration_uncertain_fails_closed_without_planner():
"""uncertain 是合法保留裁决,但解析后必须稳定失败且不触碰 observation/planner。"""
canonical, name_map, aliases_by_draft = _cross_type_known()
candidate = _cross_type_candidate()
obs = {"新名字": [candidate], "已知实体新信息": [], "纯出场": []}
original_obs = deepcopy(obs)
calls = []
def uncertain_call(prompt, need_keys):
calls.append((prompt, need_keys))
if "same_entity|different_entity|uncertain" not in prompt:
raise AssertionError("输出合同未允许 uncertain")
return {
"跨型别名裁决": [_cross_type_decision(candidate, canonical, "uncertain")],
}, {}
with patch.object(
pu, "_plan_window_entities", side_effect=AssertionError("uncertain 不得进入 planner")
) as planner_mock:
try:
pu._arbitrate_cross_type_aliases(
obs, name_map, aliases_by_draft, "当前窗原文", uncertain_call,
)
except RuntimeError as exc:
check("cross-type-uncertain-稳定失败关闭", str(exc) == "跨型别名裁决不确定,拒绝继续")
else:
check("cross-type-uncertain-稳定失败关闭", False, detail="未抛稳定 RuntimeError")
check("cross-type-uncertain-observation不变", obs == original_obs, detail=str(obs))
check("cross-type-uncertain-不进入planner", planner_mock.call_count == 0)
check("cross-type-uncertain-只调用一次裁决", len(calls) == 1)
def test_observe_prompt_known_entity_info_requires_type():
"""observe 输出合同必须要求已知实体新信息携带型。"""
prompt = pu.observe_prompt(
_fake_contracts(), "测试书", 527, 528, "当前窗原文", {}
)
check(
"observe-known-info-型必填",
'"已知实体新信息": [{"名称": "", "型": "", "观察点": ""}]' in prompt
and '"已知实体新信息": [{"名称": "", "型": "", "观察点": "", "出场章"' not in prompt,
detail=prompt,
)
check(
"observe-known-info-观察点允许600字",
"机械上限≤600字,超出按前600字截断" in prompt,
detail=prompt,
)
def test_observe_prompt_is_incremental_two_array_contract():
"""模型 observation 只承担新增/变化语义,不再生成纯出场数组。"""
prompt = pu.observe_prompt(
_fake_contracts(), "测试书", 527, 528, "当前窗原文", {
"铁壳": (701, "item", "既有实体"),
},
)
check("observe-incremental-模型keys只有两组",
pu.OBSERVATION_MODEL_KEYS == ("新名字", "已知实体新信息"))
check("observe-incremental-prompt声明两类变化",
"只产出两类需要语义判断的变化结果" in prompt)
check("observe-incremental-prompt不要求纯出场数组",
'"纯出场": [' not in prompt and "不要输出「纯出场」数组" in prompt,
detail=prompt)
check("observe-incremental-prompt输出两数组",
'"已知实体新信息": [{"名称": "", "型": "", "观察点": ""}]}'
in prompt,
detail=prompt)
check(
"observe-incremental-新名字要求正文原名",
'"正文原名": ""' in prompt and "逐字出现" in prompt,
detail=prompt,
)
def test_model_observation_chapters_are_mechanical_and_verified():
"""模型不负责出场章;正文索引覆盖缺失/伪造值,并拒绝无正文命中。"""
name_map = {
"安若雪": (701, "character", "既有角色"),
"雪姐": (701, "character", "既有角色"),
}
aliases_by_draft = {701: {"雪姐"}}
chapter_texts = {
527: "安若雪带着新物件抵达。",
528: "雪姐检查新物件。",
}
obs = {
"新名字": [{
"型": "item", "名称": "新物件", "正文原名": "新物件", "别名": [],
"一句话摘要": "刚出现", "字段": {}, "出场章": [999],
}],
"已知实体新信息": [{
"名称": "安若雪", "型": "character", "观察点": "身份变化",
}],
}
pu.populate_model_observation_chapters(
obs, name_map, aliases_by_draft, chapter_texts,
)
check(
"observation-chapters-新名字索引覆盖伪造章",
obs["新名字"][0]["出场章"] == [527, 528],
detail=str(obs),
)
check(
"observation-chapters-known合并canonical与alias",
obs["已知实体新信息"][0]["出场章"] == [527, 528],
detail=str(obs),
)
paraphrased = {
"新名字": [{
"型": "item", "名称": "巨型四方金属魔方",
"正文原名": "巨大的四方型金属魔方", "别名": [],
"一句话摘要": "运输兵器", "字段": {},
}],
"已知实体新信息": [],
}
pu.populate_model_observation_chapters(
paraphrased, {}, {}, {572: "三艘战列舰连接着一个巨大的四方型金属魔方。"},
)
check(
"observation-chapters-规范名可由正文原名索引",
paraphrased["新名字"][0]["出场章"] == [572],
detail=str(paraphrased),
)
missing = {
"新名字": [{
"型": "item", "名称": "不存在的物件", "别名": [],
"一句话摘要": "无证据", "字段": {},
}],
"已知实体新信息": [],
}
pu.populate_model_observation_chapters(
missing, name_map, aliases_by_draft, chapter_texts,
)
check(
"observation-chapters-无正文命中过滤候选",
missing["新名字"] == [],
detail=str(missing),
)
def test_model_observation_type_alias_is_deterministically_normalized():
"""模型偶发输出英文 type 时只做结构键归一,不放宽其他额外键。"""
obs = {
"新名字": [],
"已知实体新信息": [{
"名称": "安若雪", "type": "character", "观察点": "身份变化",
}],
}
pu.normalize_model_observation_aliases(obs)
check(
"observation-type别名归一",
obs["已知实体新信息"][0] == {
"名称": "安若雪", "型": "character", "观察点": "身份变化",
},
detail=str(obs),
)
invalid = {
"新名字": [],
"已知实体新信息": [{
"名称": "安若雪", "type": "not-a-type", "观察点": "身份变化",
}],
}
pu.normalize_model_observation_aliases(invalid)
check(
"observation-type非法值不静默放宽",
"type" in invalid["已知实体新信息"][0]
and "型" not in invalid["已知实体新信息"][0],
detail=str(invalid),
)
misplaced = {
"新名字": [{
"型": "item", "名称": "新物件", "正文原名": "新物件",
"字段": {}, "演变历程": [],
}],
"已知实体新信息": [],
}
pu.normalize_model_observation_aliases(misplaced)
check(
"observation-合同字段从顶层移入字段",
misplaced["新名字"][0].get("字段") == {"演变历程": []}
and "演变历程" not in misplaced["新名字"][0],
detail=str(misplaced),
)
def test_mechanical_presence_scans_canonical_and_alias_for_changed_draft():
"""presence 覆盖正名/合法 alias 的多章命中,并覆盖同时有 known-info 的变化卡。"""
target = (701, "character", "既有角色")
other = (702, "location", "既有地点")
name_map = {
"安若雪": target,
"雪姐": target,
"北港": other,
"港口": other,
}
aliases_by_draft = {701: {"雪姐"}, 702: {"港口"}}
chapter_texts = {
527: "安若雪在北港停留。",
528: "雪姐转身离开港口。",
529: "北港恢复安静。",
}
onstage = pu.prescan(name_map, "\n".join(chapter_texts.values()))
entries = pu.mechanical_presence(onstage, name_map, aliases_by_draft, chapter_texts)
by_name = {item["名称"]: item["出场章"] for item in entries}
check("mechanical-presence-正名与alias合并",
by_name == {"安若雪": [527, 528], "北港": [527, 528, 529]},
detail=str(entries))
obs = {
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "型": "character", "观察点": "身份变化", "出场章": [527]},
],
}
pu.populate_mechanical_presence(obs, onstage, name_map, aliases_by_draft, chapter_texts)
changed_presence = {item["名称"]: item["出场章"] for item in obs["纯出场"]}
check("mechanical-presence-变化卡仍机械登记完整章节",
changed_presence["安若雪"] == [527, 528], detail=str(obs))
plan = pu._plan_window_entities(
8, 97, obs, name_map, {}, aliases_by_draft, {}, False, set(), chapter_texts,
)
check("mechanical-presence-planner继续消费内部三数组",
plan["appearances"][701] == {527, 528}, detail=str(plan))
def test_model_observation_pure_presence_is_rejected_before_repair():
"""生产两数组合同拒收模型自带纯出场,避免静默接收或进入 planner。"""
calls = []
invalid = {
"新名字": [],
"已知实体新信息": [],
"纯出场": [{"名称": "安若雪", "出场章": [527]}],
}
try:
pu._repair_entity_observation_structure(
invalid,
title="测试书",
a=527,
b=528,
text="正文",
onstage={},
name_map={},
call=lambda *args: calls.append(args),
keys=pu.OBSERVATION_MODEL_KEYS,
)
except RuntimeError as exc:
check("model-presence-额外数组失败关闭", "禁止包含纯出场" in str(exc))
else:
check("model-presence-额外数组失败关闭", False, detail="额外纯出场被放行")
check("model-presence-额外数组不触发repair", calls == [], detail=str(calls))
def test_normalize_known_entity_observations():
"""known-info 先按 owner 型归一,未知名称仍要求合法模型型。"""
name_map = {
"安若雪": (24403, "character", "人物摘要"),
"伊琳娅丝": (24404, "character", "角色旧摘要"),
"风穹号": (24405, "item", "风穹号旧摘要"),
"龙基之枪": (24406, "item", "龙基之枪旧摘要"),
}
long_observation = " 苏铭观察点:" + "长" * 700 + " "
obs = {
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": [527]},
{"名称": "伊琳娅丝", "型": "item", "观察点": "机甲化形", "出场章": [527, 528]},
{"名称": "风穹号", "观察点": "航向改变", "出场章": [527]},
{"名称": "龙基之枪", "型": "非法型", "观察点": "枪身展开", "出场章": [527, 528]},
{"名称": "传奇级歼星舰·神隐号", "型": "item", "观察点": "跨区跃迁", "出场章": [527, 528, 529]},
{"名称": "苏铭", "型": "character", "观察点": long_observation, "出场章": [527, 528]},
],
"纯出场": [],
}
pu._validate_known_entity_observations(obs, name_map)
check(
"known-info-同型精确命中保留",
obs["已知实体新信息"] == [
{"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": [527]},
{"名称": "伊琳娅丝", "型": "character", "观察点": "机甲化形", "出场章": [527, 528]},
{"名称": "风穹号", "观察点": "航向改变", "出场章": [527], "型": "item"},
{"名称": "龙基之枪", "型": "item", "观察点": "枪身展开", "出场章": [527, 528]},
],
detail=str(obs),
)
candidates = {item["名称"]: item for item in obs["新名字"]}
check(
"known-info-item命中character-owner直接规范化保留",
"伊琳娅丝" not in candidates
and obs["已知实体新信息"][1]["型"] == "character",
detail=str(obs),
)
check(
"known-info-已知缺型按owner归一保留",
"风穹号" not in candidates
and obs["已知实体新信息"][2]["型"] == "item",
detail=str(obs),
)
check(
"known-info-已知非法型按owner归一保留",
"龙基之枪" not in candidates
and obs["已知实体新信息"][3]["型"] == "item",
detail=str(obs),
)
check(
"known-info-未知移入新名字且观察不丢",
candidates["传奇级歼星舰·神隐号"] == {
"型": "item", "名称": "传奇级歼星舰·神隐号", "别名": [],
"一句话摘要": "跨区跃迁", "字段": {}, "出场章": [527, 528, 529],
},
detail=str(candidates.get("传奇级歼星舰·神隐号")),
)
check(
"known-info-长苏铭观察点截断不失败",
candidates["苏铭"]["一句话摘要"] == long_observation.strip()[:600]
and len(candidates["苏铭"]["一句话摘要"]) == 600,
detail=str(candidates.get("苏铭")),
)
single_obs = {
"新名字": [],
"已知实体新信息": [{"名称": "单章未知", "型": "item", "观察点": "只出现一次", "出场章": [527]}],
"纯出场": [],
}
pu._validate_known_entity_observations(single_obs, name_map)
check(
"known-info-未知单章走presence",
pu._classify_new_name(single_obs["新名字"][0], name_map, {}) == ("presence", None),
detail=str(single_obs),
)
invalid_cases = [
("缺名称", {"型": "item", "观察点": "状态变化", "出场章": [527]}),
("未知缺型", {"名称": "未知缺型", "观察点": "状态变化", "出场章": [527]}),
("缺观察点", {"名称": "安若雪", "型": "character", "出场章": [527]}),
("出场章非列表", {"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": 527}),
("出场章含非法值", {"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": ["bad"]}),
]
for label, item in invalid_cases:
try:
pu._validate_known_entity_observations(
{"新名字": [], "已知实体新信息": [item], "纯出场": []}, name_map
)
except RuntimeError as exc:
check(f"known-info-{label}-结构失败关闭", True, detail=str(exc))
else:
check(f"known-info-{label}-结构失败关闭", False, detail="未抛结构冲突")
def _work8_observation_fixture():
"""构造 work8 窗110 已确认的两类观察结构错误。"""
name_map = {
"那样东西": (701, "item", "神秘物质"),
"那东西": (701, "item", "神秘物质"),
}
obs = {
"新名字": [],
"已知实体新信息": [
{"名称": "那东西", "观察点": "第570章明确指向该神秘物质", "出场章": 570},
{"名称": "阿曼芬高级议员", "观察点": "以议员身份出现", "出场章": [570]},
],
"纯出场": [],
}
return name_map, obs
def test_observation_structure_repair_work8_regressions():
"""先固定 scalar 出场章和未知 known-info 缺型的严格失败,再覆盖一次 repair 三路径。"""
name_map, broken = _work8_observation_fixture()
try:
pu._normalize_known_entity_observations(deepcopy(broken), name_map)
except RuntimeError as exc:
check("work8-原始scalar出场章仍由严格列表校验拒绝", "必须是列表" in str(exc))
else:
check("work8-原始scalar出场章仍由严格列表校验拒绝", False, detail="未失败")
missing_type = deepcopy(broken)
missing_type["已知实体新信息"][0]["出场章"] = [570]
try:
pu._normalize_known_entity_observations(missing_type, name_map)
except RuntimeError as exc:
check("work8-未知known-info缺型仍失败关闭", "缺少有效型" in str(exc))
else:
check("work8-未知known-info缺型仍失败关闭", False, detail="未失败")
repair_calls = []
def successful_repair(prompt, need_keys):
repair_calls.append((prompt, need_keys))
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "那东西", "型": "character", "观察点": "第570章明确指向该神秘物质", "出场章": [570]},
{"名称": "阿曼芬高级议员", "型": "character", "观察点": "以议员身份出现", "出场章": [570]},
],
"纯出场": [],
}, {})
repaired = pu._repair_entity_observation_structure(
deepcopy(broken),
title="测试书",
a=569,
b=572,
text="第570章那东西指向神秘物质;阿曼芬高级议员出现。",
onstage={"那东西": (701, "item", "神秘物质")},
name_map=name_map,
call=successful_repair,
)
pu._normalize_known_entity_observations(repaired, name_map)
check("work8-repair成功只调用一次", len(repair_calls) == 1)
check(
"work8-repair提示词只要求三数组结构修复",
repair_calls[0][1] == pu.OBSERVATION_KEYS
and "仅做结构修复" in repair_calls[0][0]
and "那样东西" in repair_calls[0][0]
and "character/location/item/faction/power_system/event" in repair_calls[0][0],
detail=repair_calls[0][0],
)
repaired_known = {item["名称"]: item for item in repaired["已知实体新信息"]}
check(
"work8-repair-owner型仍由name_map归一",
repaired_known["那东西"]["型"] == "item",
detail=str(repaired_known["那东西"]),
)
check(
"work8-repair未知known-info保留合法型与观察点",
repaired["新名字"][-1]["名称"] == "阿曼芬高级议员"
and repaired["新名字"][-1]["型"] == "character"
and repaired["新名字"][-1]["一句话摘要"] == "以议员身份出现",
detail=str(repaired),
)
illegal_calls = []
def illegal_repair(prompt, need_keys):
illegal_calls.append((prompt, need_keys))
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "阿曼芬高级议员", "型": "alien", "观察点": "以议员身份出现", "出场章": 570},
],
"纯出场": [],
}, {})
try:
pu._repair_entity_observation_structure(
deepcopy(broken),
title="测试书",
a=569,
b=572,
text="第570章正文",
onstage={},
name_map=name_map,
call=illegal_repair,
)
except RuntimeError as exc:
check("work8-repair仍非法失败关闭", "出场章必须是列表" in str(exc) or "缺少有效型" in str(exc))
else:
check("work8-repair仍非法失败关闭", False, detail="非法结果被放行")
check("work8-repair非法结果不追加第二次结构调用", len(illegal_calls) == 1)
no_repair_calls = []
valid = {
"新名字": [],
"已知实体新信息": [
{"名称": "那东西", "型": "item", "观察点": "第570章明确指向该神秘物质", "出场章": [570]},
],
"纯出场": [],
}
unchanged = pu._repair_entity_observation_structure(
valid,
title="测试书",
a=569,
b=572,
text="第570章正文",
onstage={"那东西": (701, "item", "神秘物质")},
name_map=name_map,
call=lambda *args: no_repair_calls.append(args),
)
check("work8-无需repair不额外调用", unchanged is valid and no_repair_calls == [])
def test_observation_structure_repair_conservation_gates():
"""repair 只能修结构,三数组对象身份和首轮事实必须机械守恒。"""
name_map = {"那东西": (701, "item", "神秘物质")}
base = {
"新名字": [
{"型": "character", "名称": "阿曼芬高级议员", "别名": ["阿曼芬议员"],
"一句话摘要": "以议员身份出现", "字段": {"阵营": "议会", "身份": "高级议员"},
"出场章": [570], "疑似别名指向": "议会高级议员"},
],
"已知实体新信息": [
{"名称": "那东西", "观察点": "第570章明确指向该神秘物质", "出场章": 570},
],
"纯出场": [
{"名称": "路过的门", "出场章": [571]},
],
}
extra = deepcopy(base)
extra["额外"] = []
check(
"work8-结构校验拒绝额外顶层键",
any("额外" in error for error in pu._observation_structure_errors(
extra, name_map, 569, 572
)),
)
repair_calls = []
def return_extra(prompt, need_keys):
repair_calls.append((prompt, need_keys))
repaired = deepcopy(base)
repaired["已知实体新信息"][0]["出场章"] = [570]
repaired["额外"] = []
return repaired, {}
try:
pu._repair_entity_observation_structure(
extra, title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map, call=return_extra,
)
except RuntimeError as exc:
check("work8-repair输出额外顶层键失败关闭", "仍非法" in str(exc))
else:
check("work8-repair输出额外顶层键失败关闭", False, detail="额外键被放行")
check("work8-repair额外顶层键只调用一次", len(repair_calls) == 1)
def repair_without_name(prompt, need_keys):
repaired = deepcopy(base)
repaired["已知实体新信息"][0]["出场章"] = [570]
repaired["已知实体新信息"] = []
return repaired, {}
try:
pu._repair_entity_observation_structure(
deepcopy(base), title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map, call=repair_without_name,
)
except RuntimeError as exc:
check("work8-repair删除对象失败关闭", "守恒" in str(exc))
else:
check("work8-repair删除对象失败关闭", False, detail="删除对象后仍通过")
def repair_with_renamed_name(prompt, need_keys):
repaired = deepcopy(base)
repaired["已知实体新信息"][0]["出场章"] = [570]
repaired["纯出场"][0]["名称"] = "换了名字的门"
return repaired, {}
try:
pu._repair_entity_observation_structure(
deepcopy(base), title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_with_renamed_name,
)
except RuntimeError as exc:
check("work8-repair改名对象失败关闭", "名称多重集合" in str(exc))
else:
check("work8-repair改名对象失败关闭", False, detail="改名后仍通过")
def repair_with_rewritten_fact(prompt, need_keys):
repaired = deepcopy(base)
repaired["已知实体新信息"][0]["出场章"] = [570]
repaired["新名字"][0]["一句话摘要"] = "被改写的摘要"
repaired["已知实体新信息"][0]["观察点"] = "被改写的观察点"
return repaired, {}
try:
pu._repair_entity_observation_structure(
deepcopy(base), title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_with_rewritten_fact,
)
except RuntimeError as exc:
check("work8-repair改写观察事实失败关闭", "原样保留" in str(exc))
else:
check("work8-repair改写观察事实失败关闭", False, detail="事实改写后仍通过")
def repair_with_deleted_chapter(prompt, need_keys):
repaired = deepcopy(base)
repaired["纯出场"][0]["出场章"] = []
return repaired, {}
try:
pu._repair_entity_observation_structure(
deepcopy(base), title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_with_deleted_chapter,
)
except RuntimeError as exc:
check("work8-repair删除合法出场章失败关闭", "出场章" in str(exc))
else:
check("work8-repair删除合法出场章失败关闭", False, detail="删除章号后仍通过")
def repair_with_numeric_normalization(prompt, need_keys):
repaired = deepcopy(base)
repaired["已知实体新信息"][0]["出场章"] = ["570"]
return repaired, {}
normalized = pu._repair_entity_observation_structure(
deepcopy(base), title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_with_numeric_normalization,
)
check(
"work8-repair数字字符串章号归一化允许",
normalized["已知实体新信息"][0]["出场章"] == ["570"],
)
moved = deepcopy(base)
moved["已知实体新信息"][0]["出场章"] = 570
def repair_known_to_new_name(prompt, need_keys):
repaired = deepcopy(moved)
repaired["已知实体新信息"] = []
repaired["新名字"].append({
"型": "item", "名称": "那东西", "别名": [],
"一句话摘要": "第570章明确指向该神秘物质", "字段": {}, "出场章": [570],
})
return repaired, {}
moved_result = pu._repair_entity_observation_structure(
moved, title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_known_to_new_name,
)
check(
"work8-repair-known-info迁移到新名字保留原观察点",
moved_result["新名字"][-1]["一句话摘要"] == "第570章明确指向该神秘物质",
)
def assert_repair_fact_drift_rejected(label, mutate, expected_text="守恒"):
def drifted_repair(prompt, need_keys):
repaired = deepcopy(base)
repaired["已知实体新信息"][0]["出场章"] = [570]
mutate(repaired)
return repaired, {}
try:
pu._repair_entity_observation_structure(
deepcopy(base), title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=drifted_repair,
)
except RuntimeError as exc:
check(f"work8-repair-{label}-失败关闭", expected_text in str(exc), detail=str(exc))
else:
check(f"work8-repair-{label}-失败关闭", False, detail="事实漂移后仍通过")
assert_repair_fact_drift_rejected(
"改已有型",
lambda repaired: repaired["新名字"][0].update({"型": "item"}),
)
assert_repair_fact_drift_rejected(
"改已有别名",
lambda repaired: repaired["新名字"][0].update({"别名": ["被改写的别名"]}),
)
assert_repair_fact_drift_rejected(
"改已有字段",
lambda repaired: repaired["新名字"][0]["字段"].update({"阵营": "被改写的阵营"}),
)
assert_repair_fact_drift_rejected(
"改已有疑似别名指向",
lambda repaired: repaired["新名字"][0].update({"疑似别名指向": "被改写的指向"}),
)
assert_repair_fact_drift_rejected(
"注入额外事实键",
lambda repaired: repaired["新名字"][0].update({"新增事实": "repair编造"}),
expected_text="额外",
)
assert_repair_fact_drift_rejected(
"删除已有事实键",
lambda repaired: repaired["新名字"][0].pop("字段"),
)
missing_type = deepcopy(base)
missing_type["新名字"][0].pop("型")
def repair_adds_legal_type(prompt, need_keys):
repaired = deepcopy(missing_type)
repaired["新名字"][0]["型"] = "character"
repaired["已知实体新信息"][0]["出场章"] = [570]
return repaired, {}
missing_type_result = pu._repair_entity_observation_structure(
missing_type, title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_adds_legal_type,
)
check(
"work8-repair缺型补合法型允许",
missing_type_result["新名字"][0]["型"] == "character",
)
moved_presence = deepcopy(base)
def repair_move_presence_to_new(prompt, need_keys):
repaired = deepcopy(moved_presence)
repaired["已知实体新信息"][0]["出场章"] = [570]
presence = repaired["纯出场"].pop()
repaired["新名字"].append({
"型": "item", "名称": presence["名称"], "出场章": presence["出场章"],
})
return repaired, {}
moved_presence_result = pu._repair_entity_observation_structure(
moved_presence, title="测试书", a=569, b=572, text="第570章正文",
onstage={"那东西": name_map["那东西"]}, name_map=name_map,
call=repair_move_presence_to_new,
)
check(
"work8-repair纯出场跨数组移动并补缺型允许",
moved_presence_result["新名字"][-1]["名称"] == "路过的门"
and moved_presence_result["新名字"][-1]["型"] == "item"
and moved_presence_result["纯出场"] == [],
)
no_call = []
non_dict = deepcopy(base)
non_dict["新名字"].append("不是对象")
try:
pu._repair_entity_observation_structure(
non_dict, title="测试书", a=569, b=572, text="第570章正文",
onstage={}, name_map=name_map,
call=lambda *args: no_call.append(args),
)
except RuntimeError as exc:
check("work8-repair首轮非字典项先失败关闭", "身份" in str(exc))
else:
check("work8-repair首轮非字典项先失败关闭", False, detail="非字典项送入repair")
check("work8-repair首轮非字典项不调用模型", no_call == [])
invalid_name = deepcopy(base)
invalid_name["纯出场"][0]["名称"] = " "
try:
pu._repair_entity_observation_structure(
invalid_name, title="测试书", a=569, b=572, text="第570章正文",
onstage={}, name_map=name_map,
call=lambda *args: no_call.append(args),
)
except RuntimeError as exc:
check("work8-repair首轮无有效名称先失败关闭", "身份" in str(exc))
else:
check("work8-repair首轮无有效名称先失败关闭", False, detail="无名称项送入repair")
check("work8-repair首轮无有效名称不调用模型", no_call == [])
def test_run_repair_then_cross_type_arbitration_then_planner():
"""repair 修改候选型后,后置跨型裁决必须 same 并继续进入 planner。"""
canonical = "蚀日要塞"
target = (24403, "item", "既有要塞摘要")
name_map = {canonical: target}
aliases_by_draft = {24403: set()}
candidate = {
"型": "item", "名称": canonical, "别名": [],
"一句话摘要": "要塞现身", "字段": {}, "出场章": [527, 528],
}
state = {"windows": [(97, 527, 528)], "window_status": "pending"}
events = []
observed = {}
model_calls = []
observation_calls = 0
real_normalize = pu._normalize_known_entity_observations
real_filter = pu._filter_entity_chapters
real_arbitrate = pu._arbitrate_cross_type_aliases
def fake_m3_json(prompt, model, need_keys, system=None):
nonlocal observation_calls
model_calls.append(need_keys)
if need_keys == pu.OBSERVATION_MODEL_KEYS:
observation_calls += 1
if observation_calls == 1:
return ({
"新名字": [deepcopy(candidate), "需要结构修复的非字典项"],
"已知实体新信息": [],
}, {})
events.append("structure-repair")
return ({"新名字": [deepcopy(candidate)], "已知实体新信息": []}, {})
if need_keys == ("跨型别名裁决",):
return ({"跨型别名裁决": [{
"候选名称": canonical,
"候选类型": "location",
"已有规范名": canonical,
"已有类型": "item",
"冲突别名": [canonical],
"候选出场章": [527, 528],
"裁决": "same_entity",
}]}, {})
raise AssertionError(f"不应出现的完整链路模型调用:{need_keys}")
def normalize_and_record(obs, current_name_map):
events.append("normalize")
observed["normalize_types"] = [type(item).__name__ for item in obs["新名字"]]
return real_normalize(obs, current_name_map)
def filter_and_record(*args, **kwargs):
events.append("chapter-filter")
return real_filter(*args, **kwargs)
def fake_repair(obs, **kwargs):
events.append("repair")
obs["新名字"][0]["型"] = "location"
return 1
def arbitrate_and_record(*args, **kwargs):
events.append("arbitrate")
return real_arbitrate(*args, **kwargs)
def planner_and_record(work_id, win_no, obs, *args, **kwargs):
events.append("planner")
observed["planner_type"] = obs["新名字"][0]["型"]
observed["planner_hint"] = obs["新名字"][0].get("疑似别名指向")
return {"actions": [], "payloads": {}, "to_update": {}, "appearances": {},
"aliases": {}, "names": {}}
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \
patch.object(pu, "_recover_processing_windows", return_value=None), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \
patch.object(pu, "load_window_material", return_value=(
"第527章蚀日要塞出现。第528章蚀日要塞继续运行。",
{527: "蚀日要塞出现。", 528: "蚀日要塞继续运行。"},
)), \
patch.object(pu, "load_known", return_value=(
name_map, {}, aliases_by_draft, {24403: 0},
)), \
patch.object(pu, "m3_json", side_effect=fake_m3_json), \
patch.object(pu, "_normalize_known_entity_observations", side_effect=normalize_and_record), \
patch.object(pu, "_filter_entity_chapters", side_effect=filter_and_record), \
patch.object(pu, "repair_missing_milestone_evidence", side_effect=fake_repair), \
patch.object(pu, "_arbitrate_cross_type_aliases", side_effect=arbitrate_and_record), \
patch.object(pu, "_plan_window_entities", side_effect=planner_and_record), \
patch.object(pu, "_compute_entity_updates", return_value=([], {})), \
patch.object(pu, "_apply_entity_stage", return_value=({}, {}, {}, set(), "entity-state")), \
patch.object(pu, "_read_relation_snapshot", return_value=([], [], {})), \
patch.object(pu, "_apply_relation_stage", return_value=(set(), "relation-state")), \
patch.object(pu, "_finalize_window", return_value=0):
pu._run(
work_id=8, max_windows=0, max_calls=0, model="MiniMax-M3",
redo_window=0, semantic_on=False, validate_window_input=False,
)
check(
"run-结构repair后再normalize",
observed["normalize_types"] == ["dict"],
detail=str(observed),
)
check(
"run-结构repair后置repair裁决再进planner",
events == [
"chapter-filter", "structure-repair", "chapter-filter", "chapter-filter",
"normalize", "chapter-filter", "chapter-filter", "repair", "arbitrate", "planner",
],
detail=str(events),
)
check(
"run-repair跨型same后planner看到owner型",
observed["planner_type"] == "item"
and observed["planner_hint"] == canonical,
detail=str(observed),
)
check(
"run-观察与后置裁决模型调用顺序",
model_calls == [
pu.OBSERVATION_MODEL_KEYS,
pu.OBSERVATION_MODEL_KEYS,
("跨型别名裁决",),
],
detail=str(model_calls),
)
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 = 1
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, self.revision)
if "RETURNING id, revision" in normalized else (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 _offline_window_input_rows(start, end, state=None):
"""共享 run fake 返回完整章/块输入,覆盖短写阶段的真实 fence 复验。"""
default_text = {
70: "安若雪状态变化。",
71: "正文。",
} if state and (state.get("payload") or {}).get("名称") == "安若雪" else {}
return [
(
10000 + order_no,
order_no,
f"第{order_no}章",
"published",
1,
20000 + order_no,
1,
"scene",
1,
default_text.get(order_no, f"离线正文{order_no}"),
)
for order_no in range(start, end + 1)
]
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, status, error_message"):
rows = self.state.get("windows", [(7, 70, 71)])
status = self.state.get("window_status", "pending")
error = self.state.get("window_error")
return _CardResult(rows=[(*row[:3], status, error) for row in rows])
if normalized.startswith("SELECT window_no FROM example_upgrade_window"):
return _CardResult(rows=[])
if normalized.startswith("SELECT id, window_no, from_chapter, to_chapter, deleted"):
row = self.state.get("windows", [(7, 70, 71)])[0]
return _CardResult((91, *row[:3], False))
if normalized.startswith("SELECT c.id, c.order_no, c.title, c.status, c.revision"):
row = self.state.get("windows", [(7, 70, 71)])[0]
return _CardResult(rows=_offline_window_input_rows(row[1], row[2], self.state))
if normalized.startswith("SELECT c.order_no, COALESCE(b.content_text,'')"):
return _CardResult(rows=[])
if normalized.startswith("SELECT s.schema_key, s.active_version_id"):
return _CardResult(rows=[])
if normalized.startswith("SELECT to_jsonb("):
return _CardResult(rows=[])
if normalized.startswith("SELECT id, status, error_message FROM example_upgrade_window"):
return _CardResult((91, self.state.get("window_status", "pending"),
self.state.get("window_error")))
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])
initial_revision = self.state.get("new_card_initial_revision", 1)
self.state.setdefault("revisions", {})[draft_id] = initial_revision
self.state.setdefault("inserted_draft_revisions", {})[draft_id] = initial_revision
return _CardResult(
(draft_id, initial_revision)
if "RETURNING id, revision" in normalized else (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 "AND status='done'" in normalized and "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='processing'"):
self.state["window_status"] = "processing"
self.state["window_error"] = None
if normalized.startswith("UPDATE example_upgrade_window SET error_message=%s"):
self.state["window_error"] = params[0]
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 "RETURNING status" in normalized:
return _CardResult(("done",))
if normalized.startswith("SELECT count(*)"):
return _CardResult((0,))
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 = {
"windows": [(7, 57, 58)],
"payload": {
"type": "item",
"名称": canonical,
"别名": ["铁壳"],
"字段": {},
"_work_id": 8,
},
}
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == pu.OBSERVATION_MODEL_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):
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
validate_window_input=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 == pu.OBSERVATION_MODEL_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):
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
validate_window_input=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 test_new_card_same_window_update_uses_database_initial_revision():
"""新卡同窗继续更新时,CAS 必须从 INSERT 返回的数据库初始 revision=1 起步。"""
state = {
"payloads": {},
"window_status": "pending",
"new_card_initial_revision": 1,
}
conn = _RunConn(state)
entity = {
"型": "item",
"名称": "星钥",
"别名": [],
"一句话摘要": "沉睡中的钥匙",
"字段": {},
"出场章": [70, 71],
}
plan = {
"actions": [("new", -1, entity, set(), [])],
"to_update": {-1: ["同窗补充状态"]},
"appearances": {},
"aliases": {},
}
updates = [{
"draft_id": -1,
"变更字段": {"一句话摘要": "星钥已激活"},
"别名新增": [],
"valid_keys": {"一句话摘要"},
}]
chapter_texts = {70: "星钥被发现。", 71: "星钥完成激活。"}
input_sha = pu._window_input_sha(*pu._capture_window_input(conn, 8, 7))
with patch.object(pu, "merge_card", wraps=pu.merge_card) as merge_mock:
ref_map, _, _, touched, _ = pu._apply_entity_stage(
conn,
8,
7,
plan,
updates,
{-1: (pu._build_new_card_payload(8, 7, entity), 0)},
{},
set(),
chapter_texts,
input_sha,
91,
)
draft_id = ref_map[-1]
check("new-card-revision-INSERT取得数据库初始值",
state["inserted_draft_revisions"][draft_id] == 1, detail=str(state))
check("new-card-revision-同窗merge使用真实初始revision",
merge_mock.call_args.kwargs["expected_revision"] == 1,
detail=str(merge_mock.call_args))
check("new-card-revision-CAS从1推进到2并完成实体阶段",
state["revisions"][draft_id] == 2
and state["payloads"][draft_id]["一句话摘要"] == "星钥已激活"
and touched == {draft_id}
and state["window_status"] == "processing",
detail=str(state))
def _run_duplicate_entity_update_case(updates, *, return_error=False):
"""执行同一实体的重复更新输出,并返回写入口调用和窗失败证据。"""
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 == pu.OBSERVATION_MODEL_KEYS:
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安捷", "型": "character", "观察点": "安捷状态发生变化", "出场章": []},
],
}, {})
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:
try:
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(8, 0, 0, "MiniMax-M3", 0, False, validate_window_input=False)
except pu.click.ClickException as exc:
if return_error:
return state, merge_mock, exc
raise
return (state, merge_mock, None) if return_error else (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_normalize_entity_updates_merges_compatible_milestones_only():
"""演变历程只合并可证明相同/互补的里程碑,不把冲突跃迁静默拼接。"""
updates = [
{
"draft_id": 24473,
"变更字段": {
"演变历程": [
{"章": 569, "台阶": "进入跃迁态", "周期": "成长"},
{"章": 570, "台阶": "完成跃迁", "周期": "高光"},
]
},
},
{
"draft_id": 24473,
"变更字段": {
"演变历程": [
{
"章": "569",
"台阶": "进入跃迁态",
"周期": "成长",
"证据": "正文中的跃迁证据",
},
{"章": 571, "台阶": "稳定新形态", "周期": "成长"},
]
},
},
]
normalized = pu._normalize_entity_updates(updates, {24473})
milestones = normalized[0]["变更字段"]["演变历程"]
check("entity-update-里程碑重复与互补安全合并", len(milestones) == 3,
detail=str(milestones))
check("entity-update-里程碑相同事件补齐证据",
milestones[0]["章"] == 569
and milestones[0]["证据"] == "正文中的跃迁证据",
detail=str(milestones[0]))
check("entity-update-里程碑互补事件保留", [item["章"] for item in milestones] == [569, 570, 571],
detail=str(milestones))
conflicting = [
{
"draft_id": 24473,
"变更字段": {"演变历程": [{"章": 569, "台阶": "进入跃迁态", "周期": "成长"}]},
},
{
"draft_id": 24473,
"变更字段": {"演变历程": [{"章": "569", "台阶": "退化失控", "周期": "成长"}]},
},
]
try:
pu._normalize_entity_updates(conflicting, {24473})
except pu.EntityUpdateOutputConflict as exc:
check("entity-update-同章同周期台阶冲突仍失败", "演变历程" in str(exc))
else:
check("entity-update-同章同周期台阶冲突仍失败", False, detail="未抛冲突")
def test_run_conflicting_duplicate_entity_updates_fail_before_any_write():
"""同一实体同一字段值冲突时,整批预检必须在 merge_card 与 alias 写入前稳定失败。"""
state, merge_mock, second_failure = _run_duplicate_entity_update_case(
[
{"draft_id": 24473, "变更字段": {"阵营": "探索队"}, "别名新增": ["小安"]},
{"draft_id": 24473, "变更字段": {"阵营": "敌方"}, "别名新增": ["安队"]},
],
return_error=True,
)
check("entity-update-冲突重复第二次失败CLI非零", second_failure is not None,
detail=str(second_failure))
check("entity-update-冲突重复零merge写入", merge_mock is None or merge_mock.call_count == 0,
detail=str(merge_mock.call_args_list if merge_mock else None))
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, error_message"):
return _CardResult(rows=[(
8, 57, 60, getattr(self.db, "window_status", "pending"),
getattr(self.db, "window_error", None),
)])
if normalized.startswith("SELECT window_no FROM example_upgrade_window"):
return _CardResult(rows=[])
if normalized.startswith("SELECT id, window_no, from_chapter, to_chapter, deleted"):
return _CardResult((92, 8, 57, 60, False))
if normalized.startswith("SELECT c.id, c.order_no, c.title, c.status, c.revision"):
return _CardResult(rows=_offline_window_input_rows(57, 60))
if normalized.startswith("SELECT c.order_no, COALESCE(b.content_text,'')"):
return _CardResult(rows=[])
if normalized.startswith("SELECT s.schema_key, s.active_version_id"):
return _CardResult(rows=[])
if normalized.startswith("SELECT to_jsonb("):
return _CardResult(rows=[])
if normalized.startswith("SELECT id, status, error_message FROM example_upgrade_window"):
return _CardResult((92, getattr(self.db, "window_status", "pending"),
getattr(self.db, "window_error", None)))
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])
self.db.window_error = params[0]
return _CardResult(("failed",) if "RETURNING status" in normalized else None)
if normalized.startswith("UPDATE example_upgrade_window SET status='processing'"):
self.db.window_status = "processing"
self.db.window_error = None
if normalized.startswith("UPDATE example_upgrade_window SET error_message=%s"):
self.db.window_error = params[0]
if normalized.startswith("UPDATE example_upgrade_window SET status='done'"):
self.db.window_status = "done"
self.db.window_error = None
return _CardResult(("done",) if "RETURNING status" in normalized else None)
if normalized.startswith("SELECT count(*)"):
return _CardResult((0,))
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 == pu.OBSERVATION_MODEL_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,
"prepare_touched_cards",
side_effect=lambda sess, touched: embedded.append(set(touched)) or [],
):
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=True,
validate_window_input=False,
)
check("relation-run-新关系取得id", 702 in db.drafts)
check("relation-run-更新与新建关系均进入touched", embedded == [{701, 702}], detail=str(embedded))
def _run_duplicate_relation_case(relations, *, return_error=False):
"""执行同一无序人物对的重复关系输出,并返回关系写调用与稳定失败证据。"""
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 == pu.OBSERVATION_MODEL_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:
try:
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(8, 0, 0, "MiniMax-M3", 0, False, validate_window_input=False)
except pu.click.ClickException as exc:
if return_error:
return db, update_mock.call_count, insert_mock.call_count, exc
raise
result = (db, update_mock.call_count, insert_mock.call_count)
return (*result, None) if return_error else result
def test_run_conflicting_duplicate_relations_fail_before_any_relation_write():
"""同一无序实体对内容冲突时,整批预检必须在任何关系卡写入前稳定失败。"""
db, update_calls, insert_calls, second_failure = _run_duplicate_relation_case([
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作",
"其他字段": {"当前状态": "互相信任"}},
{"甲方": 12, "乙方": 11, "关系类型": "对手", "本窗演变": "公开决裂",
"其他字段": {"当前状态": "彼此敌对"}},
], return_error=True)
check("relation-冲突重复第二次失败CLI非零", second_failure is not None,
detail=str(second_failure))
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 _run_relation_repair_case(first_relations, repaired_relations, *, return_error=False):
"""执行关系首轮冲突与一次专用重整,返回模型调用和关系写入证据。"""
characters = {
11: {"type": "character", "名称": "甲", "字段": {}, "_work_id": 8},
12: {"type": "character", "名称": "乙", "字段": {}, "_work_id": 8},
}
db = _RelationConn(characters)
db.window_status = "pending"
db.window_errors = []
relation_prompts = []
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == pu.OBSERVATION_MODEL_KEYS:
return ({"新名字": [], "已知实体新信息": []}, {})
if need_keys == ("关系",):
relation_prompts.append(prompt)
if len(relation_prompts) % 2 == 1:
return ({"关系": deepcopy(first_relations)}, {})
return ({"关系": deepcopy(repaired_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") as undo_mock, \
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:
try:
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(8, 0, 0, "MiniMax-M3", 0, False, validate_window_input=False)
except pu.click.ClickException as exc:
if return_error:
return (
db, relation_prompts, update_mock.call_count,
insert_mock.call_count, undo_mock.call_count, exc,
)
raise
result = (db, relation_prompts, update_mock.call_count, insert_mock.call_count, undo_mock.call_count)
return (*result, None) if return_error else result
def test_run_relation_conflict_repair_merges_to_one_card():
"""repair 仍拆成同一无序人物对的多维记录时,机械合并后只写一张卡。"""
first = [
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "并肩作战",
"其他字段": {"当前状态": "互信"}},
{"甲方": 12, "乙方": 11, "关系类型": "上下级", "本窗演变": "接受指挥",
"其他字段": {"权力结构": "乙指挥甲"}},
]
repaired = [
{
"甲方": 11, "乙方": 12, "关系类型": "盟友",
"本窗演变": "并肩作战",
"其他字段": {"当前状态": "互信"},
},
{
"甲方": 12, "乙方": 11, "关系类型": "上下级",
"本窗演变": "接受指挥",
"其他字段": {"权力结构": "乙指挥甲"},
},
]
with StringIO() as stderr, redirect_stderr(stderr):
db, prompts, update_calls, insert_calls, undo_calls = _run_relation_repair_case(
first, repaired
)
repair_log = stderr.getvalue()
relation_cards = [
row for row in db.drafts.values()
if row["payload"].get("type") == pu.RELATION_TYPE
]
check("relation-repair-首轮冲突后只额外调用一次", len(prompts) == 2,
detail=f"calls={len(prompts)}")
check("relation-repair-专用prompt包含首轮输出与唯一性纪律",
"关系专用重整" in prompts[1]
and "首次关系输出" in prompts[1]
and "同一无序人物对最多一条" in prompts[1],
detail=prompts[1])
check("relation-repair-合并后只写一张关系卡",
update_calls == 0 and insert_calls == 1 and len(relation_cards) == 1,
detail=f"update={update_calls}, insert={insert_calls}, cards={relation_cards}")
relation_payload = relation_cards[0]["payload"]
check("relation-repair-关系类型稳定合并",
relation_payload["关系类型"] == "上下级 / 盟友", detail=str(relation_payload))
check("relation-repair-本窗演变稳定合并",
relation_payload["字段"]["演变轨迹"] == ["[窗8] 并肩作战;接受指挥"],
detail=str(relation_payload))
check("relation-repair-其他字段并集完整",
relation_payload["字段"]["当前状态"] == "互信"
and relation_payload["字段"]["权力结构"] == "乙指挥甲",
detail=str(relation_payload))
check("relation-repair-二次机械合并stderr可追踪",
"pair=(11, 12)" in repair_log and "合并数=1" in repair_log,
detail=repair_log)
check("relation-repair-合并后整窗成功", db.window_status == "done",
detail=str(db.window_errors))
check("relation-repair-成功不触发整窗重试", undo_calls == 0,
detail=f"undo_calls={undo_calls}")
def test_repaired_relation_merge_order_and_reverse_are_stable():
"""二次机械合并对互补反向关系输出完全确定的 payload。"""
forward_rows = [
{"甲方": 20, "乙方": 21, "关系类型": "师徒", "本窗演变": "首次指点",
"其他字段": {"序列": "第二对"}},
{"甲方": 12, "乙方": 11, "关系类型": "上下级", "本窗演变": "接受指挥",
"其他字段": {"权力结构": "乙指挥甲"}},
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "并肩",
"其他字段": {"状态": "互信"}},
]
reverse_rows = [
{"甲方": 12, "乙方": 11, "关系类型": "盟友", "本窗演变": "并肩",
"其他字段": {"状态": "互信"}},
{"甲方": 21, "乙方": 20, "关系类型": "师徒", "本窗演变": "首次指点",
"其他字段": {"序列": "第二对"}},
{"甲方": 11, "乙方": 12, "关系类型": "上下级", "本窗演变": "接受指挥",
"其他字段": {"权力结构": "乙指挥甲"}},
]
first = pu._merge_repaired_relation_output(forward_rows, {11, 12, 20, 21})
second = pu._merge_repaired_relation_output(reverse_rows, {11, 12, 20, 21})
expected = [
{
"甲方": 11,
"乙方": 12,
"关系类型": "上下级 / 盟友",
"本窗演变": "并肩;接受指挥",
"其他字段": {"权力结构": "乙指挥甲", "状态": "互信"},
},
{
"甲方": 20,
"乙方": 21,
"关系类型": "师徒",
"本窗演变": "首次指点",
"其他字段": {"序列": "第二对"},
},
]
check("relation-repair-互补反向输出完全相等",
first == second, detail=f"first={first}, second={second}")
check("relation-repair-确定pair片段和字段键顺序",
first == expected and list(first[0]["其他字段"]) == ["权力结构", "状态"],
detail=f"first={first}")
def test_repaired_relation_merge_rejects_unknown_top_level_dimensions():
"""repair 的非空未知顶层维度必须失败关闭;空值未知键可忽略。"""
with_unknown = [{
"甲方": 11,
"乙方": 12,
"关系类型": "盟友",
"本窗演变": "并肩",
"其他字段": {},
"正文证据": "两人共同迎敌",
}]
try:
pu._merge_repaired_relation_output(with_unknown, {11, 12})
except pu.RelationOutputConflict as exc:
check("relation-repair-非空未知顶层键失败关闭",
"正文证据" in str(exc), detail=str(exc))
else:
check("relation-repair-非空未知顶层键失败关闭", False,
detail="未知顶层维度被静默丢弃")
empty_unknowns = [{
"甲方": 12,
"乙方": 11,
"关系类型": "盟友",
"本窗演变": "并肩",
"其他字段": {"状态": "互信"},
"空字符串": " ",
"空对象": {},
"空数组": [],
"空值": None,
}]
check("relation-repair-空值未知顶层键可忽略",
pu._merge_repaired_relation_output(empty_unknowns, {11, 12}) == [{
"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "并肩",
"其他字段": {"状态": "互信"},
}])
def test_run_relation_conflict_repair_single_unknown_top_level_key_fails_window_without_relation_write():
"""首轮冲突后的单条 repair 也必须经过机械合并,未知键不得进入关系写事务。"""
first = [
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作",
"其他字段": {"当前状态": "互信"}},
{"甲方": 12, "乙方": 11, "关系类型": "对手", "本窗演变": "公开决裂",
"其他字段": {"当前状态": "敌对"}},
]
repaired = [{
"甲方": 11,
"乙方": 12,
"关系类型": "盟友",
"本窗演变": "继续合作",
"其他字段": {"当前状态": "互信"},
"正文证据": "两人共同迎敌",
}]
db, prompts, update_calls, insert_calls, undo_calls, second_failure = _run_relation_repair_case(
first, repaired, return_error=True
)
relation_cards = [
row for row in db.drafts.values()
if row["payload"].get("type") == pu.RELATION_TYPE
]
check("relation-repair-单条未知键CLI非零", second_failure is not None,
detail=str(second_failure))
check("relation-repair-单条未知键每次只补一次重整", len(prompts) == 4,
detail=f"calls={len(prompts)}")
check("relation-repair-单条未知键计算后零关系写入",
update_calls == 0 and insert_calls == 0 and not relation_cards,
detail=f"update={update_calls}, insert={insert_calls}, cards={relation_cards}")
check("relation-repair-单条未知键最终整窗失败",
db.window_status == "failed" and undo_calls == 2,
detail=f"status={db.window_status}, undo_calls={undo_calls}")
def test_run_relation_conflict_repair_conflict_fails_window_without_relation_write():
"""关系专用重整仍冲突时不放宽比较,整窗重试后仍零关系写并失败关闭。"""
conflicting = [
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作",
"其他字段": {"当前状态": "互信"}},
{"甲方": 12, "乙方": 11, "关系类型": "对手", "本窗演变": "公开决裂",
"其他字段": {"当前状态": "敌对"}},
]
db, prompts, update_calls, insert_calls, undo_calls, second_failure = _run_relation_repair_case(
conflicting, conflicting, return_error=True
)
check("relation-repair-二次冲突CLI非零", second_failure is not None,
detail=str(second_failure))
relation_cards = [
row for row in db.drafts.values()
if row["payload"].get("type") == pu.RELATION_TYPE
]
check("relation-repair-二次冲突每次只补一次重整", len(prompts) == 4,
detail=f"calls={len(prompts)}")
check("relation-repair-二次冲突零关系写入",
update_calls == 0 and insert_calls == 0 and not relation_cards,
detail=f"update={update_calls}, insert={insert_calls}, cards={relation_cards}")
check("relation-repair-二次冲突最终整窗失败",
db.window_status == "failed" and len(db.window_errors) == 2,
detail=str(db.window_errors))
check("relation-repair-两次失败均clean后停止", undo_calls == 2,
detail=f"undo_calls={undo_calls}")
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 == pu.OBSERVATION_MODEL_KEYS:
return ({"新名字": [], "已知实体新信息": [
{"名称": "安若雪", "型": "character", "观察点": "模型观察", "出场章": []}
]}, {})
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"):
# 该 fake 专门验证外部漂移后的失败状态,不验证 CLI 停止策略,因此显式保留旁路。
pu._run(
8, 0, 0, "MiniMax-M3", 0, False,
raise_on_second_failure=False, validate_window_input=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 == pu.OBSERVATION_MODEL_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"):
# 该 fake 专门验证外部漂移后的失败状态,不验证 CLI 停止策略,因此显式保留旁路。
pu._run(
8, 0, 0, "MiniMax-M3", 0, False,
raise_on_second_failure=False, validate_window_input=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 == pu.OBSERVATION_MODEL_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"):
# 该 fake 专门验证提交前拒绝外部漂移,不验证 CLI 停止策略,因此显式保留旁路。
pu._run(
8, 0, 0, "MiniMax-M3", 0, False,
raise_on_second_failure=False, validate_window_input=False,
)
expected_calls = 2 if name == "revision改版" else 1
check(f"relation-participant-{name}-prompt后提交拒绝",
len(relation_calls) == expected_calls, detail=f"calls={len(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 == pu.OBSERVATION_MODEL_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"):
# 该 fake 专门验证 alias gate 失败状态,不验证 CLI 停止策略,因此显式保留旁路。
pu._run(
8, 0, 0, "MiniMax-M3", 0, False,
raise_on_second_failure=False, validate_window_input=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 == pu.OBSERVATION_MODEL_KEYS:
return ({
"新名字": [],
"已知实体新信息": [{"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": [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_apply_prepared_cards = pu.apply_prepared_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()
state.setdefault("revisions", {})[701] = 0
def run_real_prepare(sess, touched):
embed_calls.append([embed_text])
content_hash = hashlib.sha256(f"{embed_text}|{pu.EMBED_MODEL}".encode()).hexdigest()
return [(701, embed_text, content_hash, 0, [0.9])]
def run_real_finalize(work_id, win_no, input_sha, relation_state_sha, prepared):
return real_apply_prepared_cards(_EmbeddingConn(embedding_state), work_id, prepared)
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,
"prepare_touched_cards",
side_effect=run_real_prepare,
), \
patch.object(pu, "_finalize_window", side_effect=run_real_finalize):
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], [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, *, undo_effect=None):
"""执行一个完整窗直到 embedding prepare 冲突,供补偿边界反例复用。"""
def fake_m3_json(prompt, model, need_keys, system=None):
if need_keys == pu.OBSERVATION_MODEL_KEYS:
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": []}
],
}, {})
if need_keys == ("更新",):
return ({"更新": []}, {})
raise AssertionError(f"不应出现的离线模型调用:{need_keys}")
def owner_conflict(sess, touched):
callback = state.get("before_owner_conflict")
if callback:
callback()
raise pu.EmbeddingOwnershipConflict("离线 owner 冲突")
def capture_state(conn, work_id, current_window_id):
return {
"drafts": [(
deepcopy(state.get("payload")), state.get("draft_status", "pending"),
state.setdefault("revisions", {}).get(701, 0),
)],
"aliases": [], "presence": [], "card_state": [], "audits": [], "windows": [],
}
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, "_capture_window_state", side_effect=capture_state), \
patch.object(pu, "prepare_touched_cards", side_effect=owner_conflict), \
patch.object(pu, "undo_window", side_effect=undo_effect) as undo:
result = CliRunner().invoke(
pu.cli, ["run", "--work-id", "8", "--semantic-dedup"]
)
return result, undo
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_compensation_exception_persists_durable_failed_and_next_run_stops():
"""clean undo 异常必须持久化 compensation-failed,下一次 run 失败关闭。"""
state = {
"window_status": "pending",
"track_events": True,
"payload": {"type": "character", "名称": "安若雪", "字段": {}, "_work_id": 8},
"revisions": {701: 0},
}
def fail_compensation(*args, **kwargs):
raise RuntimeError("undo离线故障")
result, undo = _run_owner_conflict_case(state, undo_effect=fail_compensation)
error_message = str(state.get("window_error", ""))
check(
"compensation-durable-undo异常持久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("compensation-durable-确实进入undo", undo.called)
durable_events = state.get("events", [])[-2:]
check(
"compensation-durable-failed与commit同事务",
[event for event, _ in durable_events] == ["failed", "commit"]
and durable_events[0][1] == durable_events[1][1],
detail=str(state.get("events")),
)
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()):
second = CliRunner().invoke(
pu.cli, ["run", "--work-id", "8", "--semantic-dedup"]
)
check(
"compensation-durable-下次run立即阻断",
second.exit_code != 0 and "禁止自动恢复" in second.output,
detail=second.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 == pu.OBSERVATION_MODEL_KEYS:
return ({"新名字": [], "已知实体新信息": [
{"名称": "安若雪", "型": "character", "观察点": "状态变化", "出场章": [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())
if normalized.startswith("SELECT draft_payload, revision FROM muse_knowledge_draft"):
draft_id = params[0]
row = self.state["drafts"].get(draft_id)
if not row or row.get("deleted") or row.get("status", "pending") != "pending":
return _CardResult(None)
return _CardResult((row["payload"], row.get("revision", 0)))
if normalized.startswith("SELECT content_hash FROM example_knowledge_embedding"):
draft_id = params[1]
return _CardResult(rows=[
(row["content_hash"],) for row in self.state["embeddings"]
if row.get("draft_id") == draft_id and not row.get("deleted")
])
if normalized.startswith(
"SELECT work_id, draft_payload, status, revision, source_type, deleted"):
draft_id = params[0]
row = self.state["drafts"].get(draft_id)
if not row:
return _CardResult(None)
return _CardResult((
row.get("work_id", 8), row["payload"], row.get("status", "pending"),
row.get("revision", 0), row.get("source_type", pu.SOURCE_TYPE),
row.get("deleted", False),
))
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_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 == pu.OBSERVATION_MODEL_KEYS:
return ({
"新名字": [],
"已知实体新信息": [],
}, {})
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 == pu.OBSERVATION_MODEL_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: set()}, {701: 0}),
), \
patch.object(pu, "m3_json", side_effect=drift_during_observe), \
patch.object(pu, "undo_window"):
# 该 fake 专门验证外部状态漂移后的持久化,不验证 CLI 停止策略,因此显式保留旁路。
pu._run(
work_id=8,
max_windows=0,
max_calls=0,
model="MiniMax-M3",
redo_window=0,
semantic_on=False,
raise_on_second_failure=False,
validate_window_input=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 == pu.OBSERVATION_MODEL_KEYS:
return ({
"新名字": [],
"已知实体新信息": [
{"名称": "安若雪", "型": "character", "观察点": "出现新变化", "出场章": ["70"]}
],
}, {})
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:
# 该 fake 的材料由 load_window_material 单独提供,显式跳过真实章域一致性校验。
pu._run(8, 0, 0, "MiniMax-M3", 0, False, validate_window_input=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)
check("relation-同一无序人物对最多一条", "同一无序人物对最多一条" in rel)
check("relation-多维关系必须合并不得拆条",
"必须合并为一条综合记录" in rel
and "不得按关系维度拆成多条" 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)
# ── ⑦ 两阶段窗口事务:纯 hash/marker 合同与连接边界 ──
def _fence_fixture():
"""构造稳定的窗口输入与七域状态,供逐域漂移反例复用。"""
window = {"id": 91, "window_no": 7, "from_chapter": 70, "to_chapter": 71, "deleted": False}
chapters = [
{
"chapter_id": 7001,
"order_no": 70,
"title": "甲章",
"status": "published",
"revision": 3,
"block_id": 7101,
"block_order_no": 1,
"block_type": "scene",
"block_revision": 4,
"content_text": "正文甲",
},
{
"chapter_id": 7002,
"order_no": 71,
"title": "乙章",
"status": "published",
"revision": 5,
"block_id": 7102,
"block_order_no": 1,
"block_type": "scene",
"block_revision": 6,
"content_text": "正文乙",
},
]
schemas = [("character", 11, {"字段": ["名称"]}), ("item", 12, {"字段": ["名称"]})]
state = {
"drafts": [(701, {"名称": "安若雪"}, "pending", 3, False)],
"aliases": [(1, "雪姐", False)],
"presence": [(2, 7, 70, "安若雪", False)],
"card_state": [(701, 7)],
"audits": [(3, 701, 7, "阵营", None, "联邦")],
"windows": [(91, 7, 70, 71, False, "processing", "旧 marker")],
"embeddings": [(4, "digest-a")],
}
return window, chapters, schemas, state
def test_window_fence_hashes_and_markers():
"""input/state hash 必须覆盖合同规定的全部漂移面,且 marker 只接受两个阶段。"""
window, chapters, schemas, state = _fence_fixture()
with patch.object(pu, "_script_sha256", return_value="a" * 64):
input_sha = pu._window_input_sha(window, chapters, schemas, work_title="测试书")
check("fence-input-sha256", len(input_sha) == 64)
input_changes = [
("window-id", {**window, "id": 92}, chapters, schemas, "测试书", "a" * 64),
("window-no", {**window, "window_no": 8}, chapters, schemas, "测试书", "a" * 64),
("window-from", {**window, "from_chapter": 69}, chapters, schemas, "测试书", "a" * 64),
("window-to", {**window, "to_chapter": 72}, chapters, schemas, "测试书", "a" * 64),
("window-deleted", {**window, "deleted": True}, chapters, schemas, "测试书", "a" * 64),
("work-title", window, chapters, schemas, "标题漂移", "a" * 64),
("chapter-id", window, [{**chapters[0], "chapter_id": 9999}, chapters[1]], schemas, "测试书", "a" * 64),
("chapter-title", window, [{**chapters[0], "title": "标题漂移"}, chapters[1]], schemas, "测试书", "a" * 64),
("chapter-status", window, [{**chapters[0], "status": "draft"}, chapters[1]], schemas, "测试书", "a" * 64),
("chapter-revision", window, [{**chapters[0], "revision": 99}, chapters[1]], schemas, "测试书", "a" * 64),
("block-id", window, [{**chapters[0], "block_id": 9999}, chapters[1]], schemas, "测试书", "a" * 64),
("block-order", window, [{**chapters[0], "block_order_no": 2}, chapters[1]], schemas, "测试书", "a" * 64),
("block-type", window, [{**chapters[0], "block_type": "chapter"}, chapters[1]], schemas, "测试书", "a" * 64),
("block-revision", window, [{**chapters[0], "block_revision": 99}, chapters[1]], schemas, "测试书", "a" * 64),
("chapter-content", window, [{**chapters[0], "content_text": "正文漂移"}, chapters[1]], schemas, "测试书", "a" * 64),
("schema-version", window, chapters, [("character", 99, {"字段": ["名称"]})], "测试书", "a" * 64),
("schema-contract", window, chapters, [("character", 11, {"字段": ["名称", "别名"]})], "测试书", "a" * 64),
("script-sha", window, chapters, schemas, "测试书", "b" * 64),
]
for name, changed_window, changed_chapters, changed_schemas, title, code_sha in input_changes:
with patch.object(pu, "_script_sha256", return_value=code_sha):
changed_sha = pu._window_input_sha(
changed_window, changed_chapters, changed_schemas, work_title=title,
)
check(f"fence-input-{name}漂移", changed_sha != input_sha)
state_sha = pu._window_state_sha(state, current_window_id=91)
check("fence-state-sha256", len(state_sha) == 64)
for domain in ("drafts", "aliases", "presence", "card_state", "audits", "windows", "embeddings"):
changed = deepcopy(state)
changed[domain] = deepcopy(changed[domain]) + [("漂移",)]
check(f"fence-state-{domain}漂移", pu._window_state_sha(changed, 91) != state_sha)
for name, changed_embedding in (
("元数据", (4, "digest-b")),
("向量摘要", (4, "digest-c")),
("行ID", (5, "digest-a")),
):
changed = deepcopy(state)
changed["embeddings"][0] = changed_embedding
check(f"fence-state-embedding-{name}漂移", pu._window_state_sha(changed, 91) != state_sha)
mutable = deepcopy(state)
mutable["windows"][0] = (91, 7, 70, 71, False, "failed", "任意错误文本")
check("fence-state-当前窗status-error不自引用", pu._window_state_sha(mutable, 91) == state_sha)
for stage in ("entity", "relation"):
marker = pu._upgrade_marker(stage, input_sha, state_sha)
check(f"fence-marker-{stage}-往返", pu._parse_upgrade_marker(marker) == (stage, input_sha, state_sha))
for invalid in (None, "", "upgrade-fence:v2:entity:a:b", "upgrade-fence:v1:batch:a:b",
"upgrade-fence:v1:entity:not-sha:not-sha"):
try:
pu._parse_upgrade_marker(invalid)
except pu.CompensationFenceConflict:
check(f"fence-marker-invalid-{invalid}", True)
else:
check(f"fence-marker-invalid-{invalid}", False, detail="非法 marker 被接受")
class _CaptureInputConn:
"""输入快照 fake:用于验证空章域和缺章域在送模前失败关闭。"""
def __init__(self, rows):
self.rows = rows
def execute(self, query, params=()):
normalized = " ".join(query.split())
if normalized.startswith("SELECT id, window_no, from_chapter, to_chapter, deleted"):
return _CardResult((91, 7, 70, 71, False))
if normalized.startswith("SELECT title FROM muse_content_work"):
return _CardResult(("测试书",))
if normalized.startswith("SELECT c.id, c.order_no, c.title, c.status, c.revision"):
return _CardResult(rows=self.rows)
if normalized.startswith("SELECT s.schema_key, s.active_version_id"):
return _CardResult(rows=[])
return _CardResult()
def test_capture_window_input_rejects_incomplete_or_empty_content():
"""捕获阶段必须覆盖完整章域并保证每章拼接正文非空。"""
for name, rows in (
("空章域", []),
("缺少一章", _offline_window_input_rows(70, 70)),
("正文为空", [*_offline_window_input_rows(70, 70), (*_offline_window_input_rows(71, 71)[0][:9], "")]),
):
try:
pu._capture_window_input(_CaptureInputConn(rows), 8, 7)
except pu.CompensationFenceConflict:
check(f"capture-{name}-失败关闭", True)
else:
check(f"capture-{name}-失败关闭", False, detail="不完整/空正文被送入模型")
class _CaptureStateConn:
"""窗口状态摘要 fake:按 SQL 中的 CASE 语义计算逐行摘要并记录绑定参数。"""
def __init__(self, windows):
self.windows = windows
self.window_sql = None
self.window_params = None
def execute(self, query, params=()):
normalized = " ".join(query.split())
if normalized.startswith("SELECT w.id, encode(sha256(convert_to("):
self.window_sql = normalized
self.window_params = params
uses_current_case = "CASE WHEN w.id=%s THEN" in normalized
current_window_id = params[0] if uses_current_case else None
rows = []
for window in self.windows:
payload = deepcopy(window)
if uses_current_case and window["id"] == current_window_id:
payload.pop("status", None)
payload.pop("error_message", None)
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
rows.append((window["id"], hashlib.sha256(raw.encode("utf-8")).hexdigest()))
return _CardResult(rows=rows)
return _CardResult(rows=[])
def test_capture_window_state_binds_current_window_and_preserves_other_status():
"""只有当前 marker 窗忽略 status/error,其他窗口的完整行变化必须使摘要漂移。"""
base = [
{"id": 91, "window_no": 7, "status": "processing", "error_message": "marker-a", "deleted": False},
{"id": 92, "window_no": 8, "status": "pending", "error_message": None, "deleted": False},
]
base_conn = _CaptureStateConn(base)
base_state = pu._capture_window_state(base_conn, 8, 91)
changed_current = deepcopy(base)
changed_current[0].update(status="failed", error_message="marker-b")
current_state = pu._capture_window_state(_CaptureStateConn(changed_current), 8, 91)
check("state-当前窗status-error不改变摘要", current_state == base_state)
check(
"state-窗口SQL绑定current-window-id",
base_conn.window_params[0] == 91
and "CASE WHEN w.id=%s THEN" in base_conn.window_sql
and "-'status'-'error_message'" in base_conn.window_sql,
detail=f"sql={base_conn.window_sql}, params={base_conn.window_params}",
)
changed_other = deepcopy(base)
changed_other[1].update(status="failed", error_message="other-window-error")
other_state = pu._capture_window_state(_CaptureStateConn(changed_other), 8, 91)
check("state-其他窗status-error改变摘要", other_state != base_state)
check(
"state-七域窗口查询仍只返回逐行id-sha",
all(len(row) == 2 and len(row[1]) == 64 for row in base_state["windows"]),
)
check("state-窗口查询不回传to_jsonb整行", "SELECT to_jsonb(w)" not in base_conn.window_sql)
class _WindowMaterialConn:
"""正文材料 fake:记录排序 SQL,模拟多 block 章节返回。"""
def __init__(self):
self.sql = None
def execute(self, query, params=()):
self.sql = " ".join(query.split())
return _CardResult(rows=[
(70, "甲章", "block-1"),
(70, "甲章", "block-2"),
(71, "乙章", "block-1"),
])
def test_load_window_material_orders_blocks_like_capture_input():
"""材料加载必须按章号、block.order_no、block.id 与 input 捕获保持同序。"""
conn = _WindowMaterialConn()
pu.load_window_material(conn, 8, 70, 71)
check(
"material-多block排序与capture-input一致",
"ORDER BY c.order_no, b2.order_no, b2.id" in conn.sql,
detail=conn.sql,
)
def test_run_defaults_enforce_safety_contract():
"""内部默认调用必须开启章域校验和二次失败异常,CLI 不应再提供旁路参数。"""
defaults = pu._run.__kwdefaults__
check("run-默认强制二次失败抛ClickException", defaults["raise_on_second_failure"] is True)
check("run-默认强制窗口输入校验", defaults["validate_window_input"] is True)
cli_source = inspect.getsource(pu.run.callback)
check(
"run-cli使用安全默认不显式旁路",
"raise_on_second_failure" not in cli_source
and "validate_window_input" not in cli_source,
detail=cli_source,
)
def test_two_phase_external_calls_and_atomic_embedding_contract():
"""外部调用期间连接必须为零;向量 apply 与 done 共用一个事务,失败不得放行。"""
tracker = {"connections": 0, "external": [], "applied": [], "status": "processing"}
def external(kind, value):
tracker["external"].append((kind, tracker["connections"]))
if value == "fail":
raise RuntimeError("离线嵌入失败")
return f"prepared:{value}"
prepared = pu._call_external_without_connection(tracker, external, "observe", "entity")
check("two-phase-observe零连接", prepared == "prepared:entity" and tracker["external"][-1] == ("observe", 0))
prepared = pu._call_external_without_connection(tracker, external, "relation", "relation")
check("two-phase-relation零连接", prepared == "prepared:relation" and tracker["external"][-1] == ("relation", 0))
prepared = pu._call_external_without_connection(tracker, external, "embed", "vector")
check("two-phase-embed零连接", prepared == "prepared:vector" and tracker["external"][-1] == ("embed", 0))
try:
pu._call_external_without_connection(tracker, external, "embed", "fail")
except RuntimeError:
check("two-phase-普通嵌入失败整窗失败", tracker["status"] == "processing")
else:
check("two-phase-普通嵌入失败整窗失败", False, detail="嵌入异常被放行")
class AtomicConn:
"""最小事务感知 fake:未 commit 的向量与 done 必须一起回滚。"""
def __init__(self):
self.pending = []
self.committed = []
def apply_embedding(self, item):
self.pending.append(("embedding", item))
def mark_done(self):
self.pending.append(("status", "done"))
def commit(self):
self.committed.extend(self.pending)
self.pending.clear()
def rollback(self):
self.pending.clear()
conn = AtomicConn()
pu._apply_prepared_and_done(conn, ["v1"], apply_one=conn.apply_embedding, mark_done=conn.mark_done)
check("two-phase-embed+done同事务", conn.committed == [("embedding", "v1"), ("status", "done")])
broken = AtomicConn()
try:
pu._apply_prepared_and_done(
broken, ["v1"],
apply_one=lambda item: broken.apply_embedding(item) or (_ for _ in ()).throw(RuntimeError("crash")),
mark_done=broken.mark_done,
)
except RuntimeError:
broken.rollback()
check("two-phase-crash不提交向量和done", broken.committed == [] and broken.pending == [])
def test_recovery_and_redo_rejection_contracts():
"""恢复判定与 redo 双入口零 DB 拒绝必须机械且确定。"""
input_sha = "a" * 64
state_sha = "b" * 64
marker = pu._upgrade_marker("entity", input_sha, state_sha)
check("recovery-exact允许undo", pu._recovery_decision(marker, input_sha, state_sha) == "undo")
for name, current_input, current_state in (
("input", "c" * 64, state_sha),
("drafts", input_sha, "c" * 64),
("aliases", input_sha, "d" * 64),
("presence", input_sha, "e" * 64),
("card_state", input_sha, "f" * 64),
("audits", input_sha, "1" * 64),
("windows", input_sha, "2" * 64),
):
check(
f"recovery-{name}漂移补偿失败",
pu._recovery_decision(marker, current_input, current_state) == "compensation-failed",
)
check(
"recovery-非法marker补偿失败",
pu._recovery_decision("broken", input_sha, state_sha) == "compensation-failed",
)
db_calls, lock_calls = [], []
with patch.object(pu.psycopg, "connect", side_effect=lambda *_: db_calls.append(True)), \
patch.object(
pu,
"upgrade_work_lock",
side_effect=lambda *_: lock_calls.append(True) or nullcontext(),
):
result = CliRunner().invoke(pu.cli, ["run", "--work-id", "8", "--redo-window", "7"])
check(
"redo-wrapper机械拒绝非零",
result.exit_code != 0 and "backup" in result.output and "reset/rebuild" in result.output,
)
check("redo-wrapper拒绝发生在advisory与业务DB前", db_calls == [] and lock_calls == [])
try:
pu._run(8, 0, 0, "MiniMax-M3", 7, False)
except pu.click.ClickException as exc:
check("redo-run首行机械拒绝", "backup" in str(exc) and db_calls == [])
else:
check("redo-run首行机械拒绝", False, detail="_run 接受了 redo")
class _LegacyRecoveryConn:
"""恢复命令离线夹具:用安全墓碑及异常软删验证 SQL 过滤。"""
def __init__(self, active_artifact=None, non_reset_deleted=None,
embedding_variant=None):
self.window_status = "failed"
self.window_error = "legacy parser error"
self.active_artifact = active_artifact
self.non_reset_deleted = non_reset_deleted
self.drafts = [{
"id": 701, "work_id": 8, "source_type": pu.SOURCE_TYPE,
"origin": "升格@窗7", "deleted": True, "updater": "upgrade-reset",
"status": "pending",
}]
self.aliases = [{"work_id": 8, "window_no": 7, "deleted": True,
"updater": "upgrade-reset"}]
self.presence = []
self.card_states = []
self.audits = []
self.embeddings = [{"draft_id": 701, "deleted": True, "entity_id": None}]
if non_reset_deleted == "draft":
self.drafts[0]["updater"] = "upgrade"
elif non_reset_deleted == "draft-undo":
self.drafts[0]["updater"] = "upgrade-undo"
elif non_reset_deleted == "embedding-draft":
self.drafts[0]["updater"] = "upgrade"
elif non_reset_deleted == "embedding-draft-undo":
self.drafts[0]["updater"] = "upgrade-undo"
elif non_reset_deleted == "draft-status":
self.drafts[0]["status"] = "confirmed"
elif non_reset_deleted == "alias":
self.aliases[0]["updater"] = "upgrade"
elif non_reset_deleted == "alias-undo":
self.aliases[0]["updater"] = "upgrade-undo"
if active_artifact == "draft":
self.drafts[0]["deleted"] = False
elif active_artifact == "embedding":
self.embeddings[0]["deleted"] = False
elif active_artifact == "embedding-owner":
self.embeddings[0]["entity_id"] = 9001
elif active_artifact == "embedding-not-deleted":
self.embeddings[0]["deleted"] = False
elif active_artifact == "card_state":
self.card_states.append({"draft_id": 701, "watermark_window": 7})
elif active_artifact == "audit":
self.audits.append({"draft_id": 701, "window_no": 7})
elif active_artifact == "alias":
self.aliases[0]["deleted"] = False
elif active_artifact == "presence":
self.presence.append({"work_id": 8, "window_no": 7, "deleted": False})
elif active_artifact == "presence-deleted":
self.presence.append({"work_id": 8, "window_no": 7, "deleted": True})
self.processing_count = 0
self.writes = []
self.commits = 0
self.queries = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, query, params=()):
normalized = " ".join(query.split())
self.queries.append(normalized)
if normalized.startswith("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"):
return _CardResult()
if normalized.startswith("LOCK TABLE"):
return _CardResult()
if normalized.startswith("SELECT id, window_no, from_chapter, to_chapter, status, error_message, deleted"):
return _CardResult((91, 7, 70, 71, self.window_status, self.window_error, False))
if normalized.startswith("SELECT count(*) FROM example_upgrade_window"):
return _CardResult((self.processing_count,))
if normalized.startswith("SELECT count(*)"):
if "example_upgrade_audit" in normalized:
self._assert_artifact_sql("audits", normalized, "a.window_no=%s")
if "deleted=FALSE" in normalized:
raise AssertionError("audit 计数不得依赖不存在的 deleted 列")
return _CardResult((sum(row["window_no"] == 7 for row in self.audits),))
if "example_upgrade_alias" in normalized:
self._assert_artifact_sql(
"aliases", normalized, "a.deleted=TRUE", "a.updater = ANY(%s)",
)
allowed = self._allowed_tombstone_updaters(params)
return _CardResult((sum(
row["work_id"] == 8 and row["window_no"] == 7
and not (row["deleted"] and row["updater"] in allowed)
for row in self.aliases
),))
if "example_upgrade_presence" in normalized:
self._assert_artifact_sql("presence", normalized, "p.window_no=%s")
if "p.deleted" in normalized or "p.updater" in normalized:
raise AssertionError("presence 没有可证明 reset 来源的列,任何本窗行都必须阻断")
return _CardResult((sum(
row["work_id"] == 8 and row["window_no"] == 7
for row in self.presence
),))
if "example_knowledge_embedding" in normalized:
self._assert_artifact_sql(
"embeddings", normalized, "e.entity_id IS NULL", "e.deleted=TRUE",
"d.deleted=TRUE", "d.updater = ANY(%s)", "d.status='pending'",
)
allowed = self._allowed_tombstone_updaters(params)
return _CardResult((sum(
row["draft_id"] == 701 and not (
row["entity_id"] is None and row["deleted"]
and self.drafts[0]["deleted"]
and self.drafts[0]["updater"] in allowed
and self.drafts[0]["status"] == "pending"
)
for row in self.embeddings
),))
if "example_upgrade_card_state" in normalized:
self._assert_artifact_sql(
"card_state", normalized, "JOIN muse_knowledge_draft d ON d.id=s.draft_id",
"d.work_id=%s", "d.source_type=%s",
)
if "d.deleted=FALSE" in normalized:
raise AssertionError("card_state 计数不得豁免 deleted draft 关联行")
return _CardResult((sum(
row["draft_id"] == 701 and row["watermark_window"] == 7
for row in self.card_states
),))
if "muse_knowledge_draft" in normalized:
self._assert_artifact_sql(
"new_cards", normalized, "d.deleted=TRUE", "d.updater = ANY(%s)",
"d.status='pending'",
)
allowed = self._allowed_tombstone_updaters(params)
return _CardResult((sum(
row["work_id"] == 8 and row["source_type"] == pu.SOURCE_TYPE
and row["origin"] == "升格@窗7" and not (
row["deleted"] and row["updater"] in allowed
and row["status"] == "pending"
)
for row in self.drafts
),))
return _CardResult((0,))
if normalized.startswith("UPDATE example_upgrade_window"):
self.writes.append((normalized, params))
self.window_error = params[0]
return _CardResult(("failed",))
return _CardResult()
def commit(self):
self.commits += 1
def _assert_artifact_sql(self, name, normalized, *required):
"""要求生产查询显式携带活跃语义,防止只把 fixture 计数改成零。"""
missing = [fragment for fragment in required if fragment not in normalized]
if missing:
raise AssertionError(f"{name} 计数 SQL 缺少过滤条件:{missing}; sql={normalized}")
@staticmethod
def _allowed_tombstone_updaters(params):
"""从生产 SQL 的数组参数读取本次上下文允许的墓碑 updater。"""
for value in params:
if isinstance(value, list):
return set(value)
raise AssertionError(f"产物计数 SQL 未绑定 allowed_tombstone_updaters:params={params}")
class _CompensationConn(_LegacyRecoveryConn):
"""无 marker 普通补偿夹具,复用七域计数并显式保留窗口状态。"""
def __init__(self, non_reset_deleted=None):
super().__init__(non_reset_deleted=non_reset_deleted)
self.window_status = "pending"
self.window_error = None
def execute(self, query, params=()):
normalized = " ".join(query.split())
if normalized.startswith("SELECT id, status, error_message FROM example_upgrade_window"):
self.queries.append(normalized)
return _CardResult((91, self.window_status, self.window_error))
result = super().execute(query, params)
if normalized.startswith("UPDATE example_upgrade_window SET status='failed'"):
self.window_status = "failed"
return result
def _compensation_baseline_fixture(non_reset_deleted="draft"):
"""构造含历史非 allowlist 墓碑的 attempt baseline 补偿夹具。"""
conn = _CompensationConn(non_reset_deleted=non_reset_deleted)
conn.input_snapshot = {"window": "baseline"}
conn.state_snapshot = {
"drafts": [(701, "draft-baseline")],
"aliases": [(801, "alias-baseline")],
"presence": [],
"card_state": [],
"audits": [],
"windows": [{
"id": 91,
"window_no": 7,
"from_chapter": 70,
"to_chapter": 71,
"status": "pending",
"error_message": None,
"updater": "upgrade",
}],
"embeddings": [(901, "embedding-baseline")],
}
def capture_input(current_conn, work_id, win_no):
"""返回夹具当前 input;测试通过修改 snapshot 模拟外部 input 漂移。"""
return current_conn.input_snapshot, [], [], None
def capture_state(current_conn, work_id, current_window_id):
"""返回夹具当前七域状态;测试通过修改 snapshot 模拟外部 state 漂移。"""
return deepcopy(current_conn.state_snapshot)
expected_input_sha = pu._window_input_sha(conn.input_snapshot, [], [], None)
expected_state_sha = pu._window_state_sha(conn.state_snapshot, 91)
expected_state_counts = pu._window_state_domain_counts(conn.state_snapshot)
return (
conn,
capture_input,
capture_state,
expected_input_sha,
expected_state_sha,
expected_state_counts,
)
def _run_baseline_compensation(conn, capture_input, capture_state,
expected_input_sha=None, expected_state_sha=None,
expected_state_counts=None, error="阶段异常"):
"""调用补偿入口,统一注入离线 input/state 捕获。"""
kwargs = {}
if expected_input_sha is not None:
kwargs["expected_input_sha"] = expected_input_sha
if expected_state_sha is not None:
kwargs["expected_state_sha"] = expected_state_sha
if expected_state_counts is not None:
kwargs["expected_state_counts"] = expected_state_counts
with patch.object(pu.psycopg, "connect", return_value=conn), \
patch.object(pu, "_capture_window_input", side_effect=capture_input), \
patch.object(pu, "_capture_window_state", side_effect=capture_state):
return pu._compensate_window(8, 7, error, **kwargs)
def test_compensation_attempt_baseline_exact_allows_old_non_allowlist_tombstone():
"""本次 attempt 零漂移时,历史非 allowlist 软删不应阻断 retryable-clean。"""
(conn, capture_input, capture_state, expected_input_sha,
expected_state_sha, expected_state_counts) = _compensation_baseline_fixture()
_run_baseline_compensation(
conn,
capture_input,
capture_state,
expected_input_sha,
expected_state_sha,
expected_state_counts,
error="本次 attempt 外部调用异常",
)
check(
"compensation-baseline-exact-旧非allowlist墓碑可retry",
conn.window_status == "failed"
and str(conn.window_error).startswith(pu.RETRYABLE_CLEAN_PREFIX)
and pu.COMPENSATION_FAILED_PREFIX not in str(conn.window_error),
detail=str(conn.window_error),
)
def test_compensation_attempt_baseline_input_drift_blocks():
"""attempt baseline 的 input 漂移必须失败关闭,不能套用历史墓碑豁免。"""
(conn, capture_input, capture_state, expected_input_sha,
expected_state_sha, expected_state_counts) = _compensation_baseline_fixture()
conn.input_snapshot = {"window": "input-drift"}
try:
_run_baseline_compensation(
conn,
capture_input,
capture_state,
expected_input_sha,
expected_state_sha,
expected_state_counts,
error="input 漂移异常",
)
except pu.CompensationFenceConflict:
pass
else:
check("compensation-baseline-input-漂移阻断", False, detail="未抛出 CompensationFenceConflict")
check(
"compensation-baseline-input-漂移持久failed",
conn.window_status == "failed"
and str(conn.window_error).startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=str(conn.window_error),
)
def test_compensation_attempt_baseline_state_drift_blocks():
"""attempt baseline 的七域 state 漂移必须失败关闭。"""
(conn, capture_input, capture_state, expected_input_sha,
expected_state_sha, expected_state_counts) = _compensation_baseline_fixture()
conn.state_snapshot["drafts"].append((702, "state-drift"))
try:
_run_baseline_compensation(
conn,
capture_input,
capture_state,
expected_input_sha,
expected_state_sha,
expected_state_counts,
error="state 漂移异常",
)
except pu.CompensationFenceConflict:
pass
else:
check("compensation-baseline-state-漂移阻断", False, detail="未抛出 CompensationFenceConflict")
check(
"compensation-baseline-state-漂移持久failed",
conn.window_status == "failed"
and str(conn.window_error).startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=str(conn.window_error),
)
def test_compensation_without_attempt_baseline_keeps_strict_tombstone_rule():
"""没有完整 baseline 时继续按旧规则阻断非 allowlist 墓碑。"""
conn = _CompensationConn(non_reset_deleted="draft")
try:
_run_baseline_compensation(conn, None, None, error="缺少 baseline")
except pu.CompensationFenceConflict:
pass
else:
check("compensation-no-baseline-严格墓碑阻断", False, detail="未抛出 CompensationFenceConflict")
check(
"compensation-no-baseline-持久compensation-failed",
conn.window_status == "failed"
and str(conn.window_error).startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=str(conn.window_error),
)
def test_compensation_failed_preserves_original_error_and_artifact_counts():
"""baseline 漂移的 failed 断点要保留原异常、input/state 对比和产物计数。"""
(conn, capture_input, capture_state, expected_input_sha,
expected_state_sha, expected_state_counts) = _compensation_baseline_fixture()
conn.state_snapshot["aliases"].append((802, "artifact-drift"))
original_error = "原始 LLM 异常:应保留"
current_input_sha = pu._window_input_sha(conn.input_snapshot, [], [], None)
current_state_sha = pu._window_state_sha(conn.state_snapshot, 91)
try:
_run_baseline_compensation(
conn,
capture_input,
capture_state,
expected_input_sha,
expected_state_sha,
expected_state_counts,
error=original_error,
)
except pu.CompensationFenceConflict:
pass
else:
check("compensation-failed-证据保留阻断", False, detail="未抛出 CompensationFenceConflict")
message = str(conn.window_error)
check(
"compensation-failed-保留原异常",
original_error in message,
detail=message,
)
check(
"compensation-failed-保留input-state对比",
expected_input_sha in message
and current_input_sha in message
and expected_state_sha in message
and current_state_sha in message,
detail=message,
)
check(
"compensation-failed-保留artifact-counts",
"artifact_counts=" in message and "new_cards" in message,
detail=message,
)
def test_compensation_tombstone_domains_are_separate():
"""普通补偿只放行 alias/embedding/new_card 的 upgrade-undo 墓碑,其余产物仍阻断。"""
for tombstone in ("draft-undo", "alias-undo", "embedding-draft-undo"):
clean = _CompensationConn(non_reset_deleted=tombstone)
with patch.object(pu.psycopg, "connect", return_value=clean):
pu._compensate_window(8, 7, "普通阶段失败")
check(
f"compensation-{tombstone}-upgrade-undo墓碑放行",
clean.window_status == "failed"
and str(clean.window_error).startswith(pu.RETRYABLE_CLEAN_PREFIX)
and pu.COMPENSATION_FAILED_PREFIX not in str(clean.window_error),
detail=str(clean.window_error),
)
for artifact, row in (
("presence", {"work_id": 8, "window_no": 7, "updater": "upgrade-undo"}),
("card_state", {"draft_id": 701, "watermark_window": 7, "updater": "upgrade-undo"}),
("audit", {"draft_id": 701, "window_no": 7, "updater": "upgrade-undo"}),
):
blocked = _CompensationConn()
getattr(blocked, {
"presence": "presence",
"card_state": "card_states",
"audit": "audits",
}[artifact]).append(row)
with patch.object(pu.psycopg, "connect", return_value=blocked):
try:
pu._compensate_window(8, 7, "普通阶段失败")
except pu.CompensationFenceConflict:
pass
else:
check(f"compensation-{artifact}-看似undo仍阻断", False,
detail="未抛 CompensationFenceConflict")
check(
f"compensation-{artifact}-看似undo持久compensation-failed",
blocked.window_status == "failed"
and str(blocked.window_error).startswith(pu.COMPENSATION_FAILED_PREFIX),
detail=str(blocked.window_error),
)
invalid = _CompensationConn()
try:
pu._window_artifact_counts(invalid, 8, 7, ("upgrade",))
except ValueError as exc:
check("compensation-非allowlist-updater拒绝", "不允许" in str(exc), detail=str(exc))
else:
check("compensation-非allowlist-updater拒绝", False,
detail="非 allowlist updater 被接受")
check("compensation-非法updater拒绝不查库", invalid.queries == [], detail=str(invalid.queries))
def test_recover_legacy_failed_cli_contract():
"""legacy failed 只允许清理副作用为零的窗口,只有安全 reset 墓碑可豁免。"""
help_result = CliRunner().invoke(pu.maintenance_cli, ["recover-legacy-failed", "--help"])
check(
"recover-legacy-failed-help",
help_result.exit_code == 0
and "preview" in help_result.output
and "execute" in help_result.output
and "confirmation-sha" in help_result.output
and "confirm-no-live-process" in help_result.output,
detail=help_result.output,
)
conn = _LegacyRecoveryConn()
with patch.object(pu.psycopg, "connect", return_value=conn):
preview = CliRunner().invoke(
pu.maintenance_cli, ["recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--preview"]
)
check("recover-legacy-failed-preview成功", preview.exit_code == 0, detail=preview.output)
preview_data = json.loads(preview.output)
check("recover-legacy-failed-preview六类产物与processing",
set(preview_data["artifact_counts"]) == {
"audits", "aliases", "presence", "new_cards", "card_state", "embeddings",
} and preview_data["artifact_counts"] == {
"audits": 0, "aliases": 0, "presence": 0,
"new_cards": 0, "card_state": 0, "embeddings": 0,
} and preview_data["processing_count"] == 0
and preview_data["window"]["status"] == "failed", detail=preview.output)
check("recover-legacy-failed-preview零副作用", conn.writes == [] and conn.commits == 0)
check(
"recover-legacy-failed-preview首条事务语句是RR只读",
conn.queries[0] == "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY",
detail=str(conn.queries),
)
with patch.object(pu.psycopg, "connect", return_value=conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()):
execute = CliRunner().invoke(
pu.maintenance_cli,
[
"recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--execute",
"--confirmation-sha", preview_data["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("recover-legacy-failed-execute成功", execute.exit_code == 0, detail=execute.output)
check(
"recover-legacy-failed-execute保持failed并标retryable-clean",
conn.window_status == "failed" and conn.window_error.startswith(pu.RETRYABLE_CLEAN_PREFIX),
detail=conn.window_error,
)
drift_conn = _LegacyRecoveryConn()
drift_conn.processing_count = 1
with patch.object(pu.psycopg, "connect", return_value=drift_conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()):
drift = CliRunner().invoke(
pu.maintenance_cli,
[
"recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--execute",
"--confirmation-sha", preview_data["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("recover-legacy-failed-漂移非零", drift.exit_code != 0, detail=drift.output)
check("recover-legacy-failed-漂移零副作用", drift_conn.writes == [] and drift_conn.commits == 0)
for artifact in ("draft", "embedding", "alias", "presence", "card_state", "audit"):
active_conn = _LegacyRecoveryConn(active_artifact=artifact)
with patch.object(pu.psycopg, "connect", return_value=active_conn):
active_preview = CliRunner().invoke(
pu.maintenance_cli, ["recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--preview"]
)
active_data = json.loads(active_preview.output)
check(
f"recover-legacy-failed-active-{artifact}-preview非零",
active_preview.exit_code == 0 and active_data["artifact_counts"][{
"draft": "new_cards",
"embedding": "embeddings",
"alias": "aliases",
"presence": "presence",
"card_state": "card_state",
"audit": "audits",
}[artifact]] > 0,
detail=active_preview.output,
)
with patch.object(pu.psycopg, "connect", return_value=active_conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()):
active_execute = CliRunner().invoke(
pu.maintenance_cli,
[
"recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--execute",
"--confirmation-sha", active_data["confirmation_sha"],
"--confirm-no-live-process",
],
)
check(
f"recover-legacy-failed-active-{artifact}-execute拒绝",
active_execute.exit_code != 0 and active_conn.writes == [] and active_conn.commits == 0,
detail=active_execute.output,
)
for artifact in ("draft", "embedding-draft", "draft-status", "alias"):
non_reset_conn = _LegacyRecoveryConn(non_reset_deleted=artifact)
with patch.object(pu.psycopg, "connect", return_value=non_reset_conn):
non_reset_preview = CliRunner().invoke(
pu.maintenance_cli, ["recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--preview"]
)
non_reset_data = json.loads(non_reset_preview.output)
key = {"draft": "new_cards", "embedding-draft": "embeddings",
"draft-status": "new_cards",
"alias": "aliases"}[artifact]
check(
f"recover-legacy-failed-non-reset-{artifact}-阻断",
non_reset_preview.exit_code == 0 and non_reset_data["artifact_counts"][key] > 0,
detail=non_reset_preview.output,
)
for artifact in ("draft-undo", "embedding-draft-undo", "alias-undo"):
undo_conn = _LegacyRecoveryConn(non_reset_deleted=artifact)
with patch.object(pu.psycopg, "connect", return_value=undo_conn):
undo_preview = CliRunner().invoke(
pu.maintenance_cli, ["recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--preview"]
)
undo_data = json.loads(undo_preview.output)
key = {"draft-undo": "new_cards", "embedding-draft-undo": "embeddings",
"alias-undo": "aliases"}[artifact]
check(
f"recover-legacy-failed-{artifact}-默认仍阻断",
undo_preview.exit_code == 0 and undo_data["artifact_counts"][key] > 0,
detail=undo_preview.output,
)
deleted_presence_conn = _LegacyRecoveryConn(active_artifact="presence-deleted")
with patch.object(pu.psycopg, "connect", return_value=deleted_presence_conn):
deleted_presence_preview = CliRunner().invoke(
pu.maintenance_cli, ["recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--preview"]
)
deleted_presence_data = json.loads(deleted_presence_preview.output)
check(
"recover-legacy-failed-deleted-presence-仍阻断",
deleted_presence_preview.exit_code == 0
and deleted_presence_data["artifact_counts"]["presence"] > 0,
detail=deleted_presence_preview.output,
)
for artifact in ("embedding-owner", "embedding-not-deleted"):
embedding_conn = _LegacyRecoveryConn(active_artifact=artifact)
with patch.object(pu.psycopg, "connect", return_value=embedding_conn):
embedding_preview = CliRunner().invoke(
pu.maintenance_cli, ["recover-legacy-failed", "--work-id", "8", "--window-no", "7", "--preview"]
)
embedding_data = json.loads(embedding_preview.output)
check(
f"recover-legacy-failed-{artifact}-阻断",
embedding_preview.exit_code == 0
and embedding_data["artifact_counts"]["embeddings"] > 0,
detail=embedding_preview.output,
)
class _QualityRepairConn:
"""单卡质量修复 fake DB:只实现该命令的读快照、CAS 和 embedding 写 SQL。"""
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("queries", []).append(normalized)
if normalized.startswith("SET TRANSACTION") or normalized.startswith("LOCK TABLE"):
return _CardResult()
if normalized.startswith(
"SELECT work_id, draft_payload, status, revision, source_type, updater, deleted"):
draft = self.state["draft"]
return _CardResult((
draft["work_id"], draft["payload"], draft["status"], draft["revision"],
draft["source_type"], draft["updater"], draft["deleted"],
))
if normalized.startswith(
"SELECT id, draft_id, content_hash, embed_text, model, dimensions, embedding,"):
rows = [
(
row["id"], row["draft_id"], row["content_hash"], row["embed_text"],
row["model"], row["dimensions"], row["embedding"], row.get("entity_id"),
row["deleted"],
)
for row in sorted(self.state["embeddings"], key=lambda item: item["id"])
if row["draft_id"] == params[1]
]
return _CardResult(rows=rows)
if normalized.startswith("SELECT id, canonical_name, alias, evidence_window"):
rows = [
row for row in self.state["aliases"]
if row[0] == params[2] or row[1] == params[3] or row[2] == params[4]
]
return _CardResult(rows=rows)
if normalized.startswith("SELECT id, window_no, chapter_no, entity_type, name, observation"):
return _CardResult(rows=[row for row in self.state["presence"] if row[4] == params[2]])
if normalized.startswith("SELECT draft_id, work_id, watermark_window, tenant_id"):
return _CardResult(rows=list(self.state["card_state"]))
if normalized.startswith("SELECT id, content_hash, entity_id FROM example_knowledge_embedding"):
rows = [
(row["id"], row["content_hash"], row.get("entity_id"))
for row in self.state["embeddings"]
if row["draft_id"] == params[1] and not row["deleted"]
]
return _CardResult(rows=rows)
if normalized.startswith("SELECT e.draft_id, e.entity_id, e.deleted"):
content_hash = params[1]
row = next(
(
item for item in self.state["embeddings"]
if item["content_hash"] == content_hash and item["model"] == pu.EMBED_MODEL
),
None,
)
if row is None:
return _CardResult()
owner = self.state["draft"] if row["draft_id"] == pu.QUALITY_REPAIR_DRAFT_ID else None
return _CardResult((
row["draft_id"], row.get("entity_id"), row["deleted"],
owner["deleted"] if owner else True,
owner["payload"] if owner else None,
))
if normalized.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s"):
draft = self.state["draft"]
if draft["revision"] != params[6]:
return _CardResult()
draft["payload"] = json.loads(params[0])
draft["revision"] += 1
draft["updater"] = params[1]
self.state["writes"].append("UPDATE muse_knowledge_draft")
return _CardResult((draft["revision"],))
if normalized.startswith("UPDATE example_knowledge_embedding SET deleted=TRUE"):
_, tenant_id, draft_id, keep_hash = params
assert tenant_id == pu.TENANT
for row in self.state["embeddings"]:
if row["draft_id"] == draft_id and not row["deleted"] \
and row.get("entity_id") is None and row["content_hash"] != keep_hash:
row["deleted"] = True
self.state["writes"].append("UPDATE example_knowledge_embedding")
return _CardResult()
if normalized.startswith("INSERT INTO example_knowledge_embedding"):
(
draft_id, content_hash, embed_text, model, dimensions, embedding,
creator, updater, tenant_id,
) = params
assert tenant_id == pu.TENANT
existing = next(
(
row for row in self.state["embeddings"]
if row["content_hash"] == content_hash and row["model"] == model
),
None,
)
if existing is not None and existing.get("entity_id") is not None:
return _CardResult()
if existing is None:
existing = {
"id": max((row["id"] for row in self.state["embeddings"]), default=41253) + 1,
"entity_id": None,
}
self.state["embeddings"].append(existing)
existing.update({
"draft_id": draft_id,
"content_hash": content_hash,
"embed_text": embed_text,
"model": model,
"dimensions": dimensions,
"embedding": embedding,
"deleted": False,
"creator": creator,
"updater": updater,
})
self.state["writes"].append("INSERT example_knowledge_embedding")
return _CardResult((draft_id,))
raise AssertionError(f"质量修复 fake DB 未覆盖 SQL:{normalized}")
def commit(self):
self.state["commits"] += 1
def _quality_repair_old_payload():
"""构造与已核实 work8 快照同形、但仍为 item 的旧 payload。"""
return {
"type": "item",
"名称": pu.QUALITY_REPAIR_NAME,
"别名": [],
"一句话摘要": "天启神国主星相关物件。",
"字段": {
"类别": "兵器",
"品阶": "要塞级",
"来历": "天启神国主星",
"能力与限制": "镜面护盾和主炮",
"当前持有者": "天启神国",
"知情范围": "天启神国",
"流转计划": "未知",
"演变历程": [{"章": 566, "台阶": "物件登场", "周期": "登场"}],
"演变概括": "已有物件记录",
"前身": "",
"后继": "",
},
"出场章": list(pu.QUALITY_REPAIR_OLD_APPEARANCES),
"来源": "升格@窗109",
"状态": "草稿",
"目标库": "本书作品库",
"可见范围": "本书私有",
"_work_id": pu.QUALITY_REPAIR_WORK_ID,
}
def _quality_repair_state():
payload = _quality_repair_old_payload()
embed_text = pu.build_embed_text(payload)
content_hash = hashlib.sha256(f"{embed_text}|{pu.EMBED_MODEL}".encode()).hexdigest()
return {
"draft": {
"work_id": pu.QUALITY_REPAIR_WORK_ID,
"payload": payload,
"status": "pending",
"revision": 6,
"source_type": pu.SOURCE_TYPE,
"deleted": False,
"updater": "upgrade",
},
"embeddings": [{
"id": 41253,
"draft_id": pu.QUALITY_REPAIR_DRAFT_ID,
"content_hash": content_hash,
"embed_text": embed_text,
"model": pu.EMBED_MODEL,
"dimensions": pu.EMBED_DIM,
"embedding": json.dumps([0.1] * pu.EMBED_DIM),
"entity_id": None,
"deleted": False,
}],
"aliases": [
(pu.QUALITY_REPAIR_REVOKED_ALIAS_ID, pu.QUALITY_REPAIR_ALIAS_CANONICAL,
pu.QUALITY_REPAIR_ALIAS_VALUE, pu.QUALITY_REPAIR_ALIAS_EVIDENCE_WINDOW,
pu.QUALITY_REPAIR_ALIAS_VERDICT_BY,
pu.QUALITY_REPAIR_REVOKED_ALIAS_UPDATER, True),
],
"presence": [
(pu.QUALITY_REPAIR_PRESENCE_ID, pu.QUALITY_REPAIR_PRESENCE_WINDOW,
pu.QUALITY_REPAIR_PRESENCE_CHAPTER, "location", pu.QUALITY_REPAIR_NAME,
pu.QUALITY_REPAIR_PRESENCE_OBSERVATION, False),
],
"card_state": [
(pu.QUALITY_REPAIR_DRAFT_ID, pu.QUALITY_REPAIR_WORK_ID,
pu.QUALITY_REPAIR_CARD_WATERMARK, pu.TENANT),
],
"writes": [],
"queries": [],
"commits": 0,
}
def _quality_repair_preview(state):
conn = _QualityRepairConn(state)
with patch.object(pu.psycopg, "connect", return_value=conn):
result = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--preview",
],
)
check("quality-repair-preview成功", result.exit_code == 0, detail=result.output)
data = json.loads(result.output)
check("quality-repair-preview只读", state["writes"] == [] and state["commits"] == 0)
check(
"quality-repair-preview使用RR只读",
state["queries"][0] == "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY",
detail=str(state["queries"]),
)
return data
def test_quality_repair_payload_mapping():
state = _quality_repair_state()
target = pu._quality_repair_target_payload(
state["draft"]["payload"], pu.QUALITY_REPAIR_WORK_ID, pu.QUALITY_REPAIR_DRAFT_ID,
)
check("quality-repair-类型改为location", target["type"] == "location")
check(
"quality-repair-只保留location字段",
set(target["字段"]) == pu.QUALITY_REPAIR_LOCATION_FIELDS
and not (set(target["字段"]) & {"类别", "品阶", "当前持有者", "能力与限制"}),
)
milestones = target["字段"]["演变历程"]
check(
"quality-repair-演变保留403并加入已核实高光",
[item["章"] for item in milestones] == [403, 566, 587]
and target["出场章"] == sorted(set(pu.QUALITY_REPAIR_OLD_APPEARANCES) | {403}),
)
def test_quality_repair_preview_contract():
data = _quality_repair_preview(_quality_repair_state())
check(
"quality-repair-preview摘要完整",
data["mode"] == "preview"
and data["work_id"] == 8
and data["draft_id"] == 25360
and data["old_revision"] == 6
and data["new_revision"] is None
and data["old_embedding_id"] == 41253
and data["presence_id"] == 10879
and data["revoked_alias_id"] == 8738
and data["card_state_watermark"] == 109
and data["audit_rows_written"] == 0
and isinstance(data["confirmation_sha"], str)
and len(data["confirmation_sha"]) == 64,
detail=json.dumps(data, ensure_ascii=False),
)
check(
"quality-repair-preview目标payload可审查",
data["target_payload"]["type"] == "location"
and set(data["target_payload"]["字段"]) == pu.QUALITY_REPAIR_LOCATION_FIELDS,
)
def test_quality_repair_execute_atomically_updates_payload_and_embedding():
preview_state = _quality_repair_state()
preview = _quality_repair_preview(preview_state)
execute_state = _quality_repair_state()
conn = _QualityRepairConn(execute_state)
with patch.object(pu.psycopg, "connect", return_value=conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "embed_texts", return_value=([[0.9] * pu.EMBED_DIM], set())):
result = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--execute", "--confirmation-sha", preview["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("quality-repair-execute成功", result.exit_code == 0, detail=result.output)
output = json.loads(result.output)
active = [row for row in execute_state["embeddings"] if not row["deleted"]]
check(
"quality-repair-execute-draft-CAS递增",
execute_state["draft"]["payload"]["type"] == "location"
and execute_state["draft"]["revision"] == 7
and execute_state["draft"]["updater"] == pu.QUALITY_REPAIR_UPDATER
and output["new_revision"] == 7,
detail=str(execute_state["draft"]),
)
check(
"quality-repair-execute-唯一活embedding更新",
len(active) == 1
and active[0]["id"] != 41253
and active[0]["content_hash"] == output["new_embedding_hash"]
and active[0]["draft_id"] == pu.QUALITY_REPAIR_DRAFT_ID,
detail=str(execute_state["embeddings"]),
)
check(
"quality-repair-execute-向量为1024维",
len(json.loads(active[0]["embedding"])) == pu.EMBED_DIM,
)
check(
"quality-repair-execute-同事务提交且不写窗口域",
execute_state["commits"] == 1
and execute_state["writes"] == [
"UPDATE muse_knowledge_draft", "UPDATE example_knowledge_embedding",
"INSERT example_knowledge_embedding",
]
and execute_state["aliases"] == _quality_repair_state()["aliases"]
and execute_state["presence"] == _quality_repair_state()["presence"]
and execute_state["card_state"] == _quality_repair_state()["card_state"]
and not any("example_upgrade_audit" in sql for sql in execute_state["writes"]),
detail=json.dumps(execute_state, ensure_ascii=False, default=str),
)
def test_quality_repair_rejects_stale_confirmation_before_embedding():
state = _quality_repair_state()
preview = _quality_repair_preview(state)
state["draft"]["revision"] = 7
embed_calls = []
with patch.object(pu.psycopg, "connect", return_value=_QualityRepairConn(state)), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(
pu, "embed_texts", side_effect=lambda *args: embed_calls.append(args) or ([[0.9]], set())
):
result = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--execute", "--confirmation-sha", preview["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("quality-repair-旧confirmation拒绝", result.exit_code != 0, detail=result.output)
check(
"quality-repair-旧confirmation不调用embedding不写入",
not embed_calls and state["writes"] == [] and state["commits"] == 0,
)
def test_quality_repair_rejects_final_snapshot_drift_and_bad_embedding():
preview = _quality_repair_preview(_quality_repair_state())
precheck_state = _quality_repair_state()
drift_state = _quality_repair_state()
drift_state["draft"]["revision"] = 7
precheck_conn = _QualityRepairConn(precheck_state)
final_conn = _QualityRepairConn(drift_state)
with patch.object(pu.psycopg, "connect", side_effect=[precheck_conn, final_conn]), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "embed_texts", return_value=([[0.9] * pu.EMBED_DIM], set())):
drift = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--execute", "--confirmation-sha", preview["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("quality-repair-锁内快照漂移拒绝", drift.exit_code != 0, detail=drift.output)
check(
"quality-repair-锁内漂移无提交",
final_conn.state["writes"] == [] and final_conn.state["commits"] == 0,
)
bad_state = _quality_repair_state()
bad_conn = _QualityRepairConn(bad_state)
with patch.object(pu.psycopg, "connect", return_value=bad_conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "embed_texts", return_value=([], {0})):
bad = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--execute", "--confirmation-sha", preview["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("quality-repair-embedding不完整拒绝", bad.exit_code != 0, detail=bad.output)
check(
"quality-repair-embedding不完整无写入",
bad_state["writes"] == [] and bad_state["commits"] == 0,
)
malformed_state = _quality_repair_state()
malformed_conn = _QualityRepairConn(malformed_state)
with patch.object(pu.psycopg, "connect", return_value=malformed_conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "embed_texts", return_value=(None, None)):
malformed = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--execute", "--confirmation-sha", preview["confirmation_sha"],
"--confirm-no-live-process",
],
)
check("quality-repair-embedding返回形状非法拒绝", malformed.exit_code != 0, detail=malformed.output)
check(
"quality-repair-embedding返回形状非法无写入",
malformed_state["writes"] == [] and malformed_state["commits"] == 0,
)
def test_quality_repair_rejects_invalid_vector_dimension_and_values():
"""embedding 必须是 1024 维有限数值列表,任何脏值均不得进入事务。"""
preview = _quality_repair_preview(_quality_repair_state())
invalid_vectors = (
("wrong-dimension", [0.0]),
("nan", [float("nan")] * pu.EMBED_DIM),
("inf", [float("inf")] * pu.EMBED_DIM),
("bool", [False] * pu.EMBED_DIM),
("string", ["0.0"] * pu.EMBED_DIM),
)
for label, vector in invalid_vectors:
state = _quality_repair_state()
conn = _QualityRepairConn(state)
with patch.object(pu.psycopg, "connect", return_value=conn), \
patch.object(pu, "upgrade_work_lock", return_value=nullcontext()), \
patch.object(pu, "_embed_session", return_value=object()), \
patch.object(pu, "embed_texts", return_value=([vector], set())):
result = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--execute", "--confirmation-sha", preview["confirmation_sha"],
"--confirm-no-live-process",
],
)
check(f"quality-repair-{label}-向量拒绝", result.exit_code != 0, detail=result.output)
check(
f"quality-repair-{label}-零写入",
state["writes"] == [] and state["commits"] == 0,
)
def test_quality_repair_rejects_alias_and_presence_duplicates():
"""alias/presence 关联集合必须是已核实的唯一行,不能靠 ID 忽略重复。"""
cases = (
(
"alias-active-duplicate",
lambda state: state["aliases"].append(
(9001, "其他地点", pu.QUALITY_REPAIR_ALIAS_VALUE, 109,
pu.QUALITY_REPAIR_ALIAS_VERDICT_BY, "upgrade", False)
),
),
(
"alias-canonical-name-duplicate",
lambda state: state["aliases"].append(
(9002, pu.QUALITY_REPAIR_NAME, "另一称谓", 109,
pu.QUALITY_REPAIR_ALIAS_VERDICT_BY, "upgrade", False)
),
),
(
"presence-item-duplicate",
lambda state: state["presence"].append(
(9003, 57, 403, "item", pu.QUALITY_REPAIR_NAME,
pu.QUALITY_REPAIR_PRESENCE_OBSERVATION, False)
),
),
(
"presence-observation-drift",
lambda state: state["presence"].__setitem__(
0,
(
pu.QUALITY_REPAIR_PRESENCE_ID,
pu.QUALITY_REPAIR_PRESENCE_WINDOW,
pu.QUALITY_REPAIR_PRESENCE_CHAPTER,
"location",
pu.QUALITY_REPAIR_NAME,
"错误 observation",
False,
),
),
),
)
for label, mutate in cases:
state = _quality_repair_state()
mutate(state)
conn = _QualityRepairConn(state)
with patch.object(pu.psycopg, "connect", return_value=conn):
result = CliRunner().invoke(
pu.maintenance_cli,
[
"repair-card-quality", "--work-id", "8", "--draft-id", "25360",
"--preview",
],
)
check(f"quality-repair-{label}-失败关闭", result.exit_code != 0, detail=result.output)
check(
f"quality-repair-{label}-零副作用",
state["writes"] == [] and state["commits"] == 0,
)
if __name__ == "__main__":
for fn in (test_classify_new_name, test_resolve_canonical_name,
test_classify_candidate_alias_same_type_merges,
test_classify_candidate_alias_cross_type_does_not_merge,
test_classify_cross_type_hits_without_normalization_fail_closed,
test_classify_multiple_candidate_aliases_same_draft_merges,
test_classify_multiple_candidate_aliases_different_drafts_fails_closed,
test_cross_type_alias_arbitration_same_entity_updates_existing_plan,
test_same_type_multi_owner_exact_name_skips_arbitration_and_merges,
test_cross_type_alias_arbitration_different_entity_keeps_new_card,
test_cross_type_different_entity_new_card_claims_only_remaining_aliases,
test_cross_type_alias_arbitration_rejects_invalid_cases,
test_cross_type_alias_arbitration_uncertain_fails_closed_without_planner,
test_observe_prompt_known_entity_info_requires_type,
test_observe_prompt_is_incremental_two_array_contract,
test_model_observation_chapters_are_mechanical_and_verified,
test_model_observation_type_alias_is_deterministically_normalized,
test_mechanical_presence_scans_canonical_and_alias_for_changed_draft,
test_model_observation_pure_presence_is_rejected_before_repair,
test_normalize_known_entity_observations,
test_observation_structure_repair_work8_regressions,
test_observation_structure_repair_conservation_gates,
test_run_repair_then_cross_type_arbitration_then_planner,
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_run_alias_paths_store_real_canonical_name,
test_run_new_card_registers_normalized_name_and_aliases_same_window,
test_new_card_same_window_update_uses_database_initial_revision,
test_run_exact_and_disjoint_duplicate_entity_updates_merge_once,
test_normalize_entity_updates_merges_compatible_milestones_only,
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_relation_conflict_repair_merges_to_one_card,
test_repaired_relation_merge_order_and_reverse_are_stable,
test_repaired_relation_merge_rejects_unknown_top_level_dimensions,
test_run_relation_conflict_repair_single_unknown_top_level_key_fails_window_without_relation_write,
test_run_relation_conflict_repair_conflict_fails_window_without_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_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_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, test_window_fence_hashes_and_markers,
test_capture_window_input_rejects_incomplete_or_empty_content,
test_two_phase_external_calls_and_atomic_embedding_contract,
test_recovery_and_redo_rejection_contracts,
test_compensation_attempt_baseline_exact_allows_old_non_allowlist_tombstone,
test_compensation_attempt_baseline_input_drift_blocks,
test_compensation_attempt_baseline_state_drift_blocks,
test_compensation_without_attempt_baseline_keeps_strict_tombstone_rule,
test_compensation_failed_preserves_original_error_and_artifact_counts,
test_compensation_tombstone_domains_are_separate,
test_recover_legacy_failed_cli_contract,
test_quality_repair_payload_mapping,
test_quality_repair_preview_contract,
test_quality_repair_execute_atomically_updates_payload_and_embedding,
test_quality_repair_rejects_stale_confirmation_before_embedding,
test_quality_repair_rejects_final_snapshot_drift_and_bad_embedding,
test_quality_repair_rejects_invalid_vector_dimension_and_values,
test_quality_repair_rejects_alias_and_presence_duplicates,
test_capture_window_state_binds_current_window_and_preserves_other_status,
test_load_window_material_orders_blocks_like_capture_input,
test_run_defaults_enforce_safety_contract):
fn()
print(f"\n全部离线自测通过:{_passed} 项(未发网络/嵌入/LLM 调用)")