范围(不含 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 存储边界冲突留待单独治理。
5925 lines
261 KiB
Python
5925 lines
261 KiB
Python
#!/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 调用)")
|