#!/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 调用)")