diff --git a/.claude/skills/parse-book/scripts/parse_upgrade.py b/.claude/skills/parse-book/scripts/parse_upgrade.py index cc2110d..438ab23 100644 --- a/.claude/skills/parse-book/scripts/parse_upgrade.py +++ b/.claude/skills/parse-book/scripts/parse_upgrade.py @@ -100,6 +100,10 @@ class CompensationFenceConflict(RuntimeError): """窗提交后的 draft 已被外部改动,禁止补偿覆盖。""" +class RelationOutputConflict(RuntimeError): + """关系模型对同一无序实体对给出内容不同的重复项。""" + + COMPENSATION_FAILED_PREFIX = "compensation-failed:" @@ -782,6 +786,32 @@ def _update_relation_card(conn, work_id, win_no, draft_id, expected_revision, re return draft_id +def _normalize_relation_output(relations, valid_draft_ids): + """整批过滤并规范化关系输出;冲突重复必须在任何关系卡写入前失败。""" + + normalized = [] + relation_by_pair = {} + for relation in relations or []: + if not isinstance(relation, dict): + continue + first, second = relation.get("甲方"), relation.get("乙方") + # 保持既有语义:不在本窗核心角色内的锚和自关系直接忽略。 + if first not in valid_draft_ids or second not in valid_draft_ids or first == second: + continue + pair = tuple(sorted((first, second))) + item = deepcopy(relation) + item["甲方"], item["乙方"] = pair + if pair not in relation_by_pair: + relation_by_pair[pair] = item + normalized.append(item) + continue + if relation_by_pair[pair] != item: + raise RelationOutputConflict( + f"关系输出同一实体对存在冲突重复:甲方draft={pair[0]},乙方draft={pair[1]}" + ) + return normalized + + def _insert_relation_card(conn, work_id, win_no, payload): """新建关系卡并返回 draft id;逐字段审计和 state 任一步失败都会让窗事务回滚。""" @@ -2388,13 +2418,14 @@ def _run(work_id, max_windows, max_calls, model, redo_window, semantic_on): rel_out, _ = call(relation_prompt(contracts, title, a, b, text, char_cards, rels), ("关系",)) id2name = {d: p.get("名称") for d, p in char_cards} + relation_items = _normalize_relation_output( + rel_out.get("关系") or [], set(id2name) + ) exist = {tuple(sorted((r.get("甲方draft"), r.get("乙方draft")))): (rid, r) for rid, r in rels if r.get("甲方draft") and r.get("乙方draft")} - for r in [x for x in (rel_out.get("关系") or []) if isinstance(x, dict)]: + for r in relation_items: ja, yi = r.get("甲方"), r.get("乙方") - if ja not in id2name or yi not in id2name or ja == yi: - continue key = tuple(sorted((ja, yi))) evo_core = _strip_prefix(r.get("本窗演变", "")) if key in exist: diff --git a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py index e2ad906..5c5f2a1 100644 --- a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py +++ b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py @@ -1065,6 +1065,15 @@ class _RelationRunConn: return _CardResult(rows=[(8, 57, 60, "pending")]) if normalized.startswith("SELECT window_no, error_message FROM example_upgrade_window"): return _CardResult(rows=[]) + if normalized.startswith("UPDATE example_upgrade_window SET status='failed'"): + self.db.window_status = "failed" + if not hasattr(self.db, "window_errors"): + self.db.window_errors = [] + self.db.window_errors.append(params[0]) + return _CardResult() + if normalized.startswith("UPDATE example_upgrade_window SET status='done'"): + self.db.window_status = "done" + return _CardResult() if normalized.startswith("SELECT id, draft_payload, revision FROM muse_knowledge_draft") \ and "draft_payload->>'type'=%s" in normalized: return _CardResult(rows=[ @@ -1145,6 +1154,89 @@ def test_run_relation_cards_enter_touched_embedding(): check("relation-run-更新与新建关系均进入touched", embedded == [{701, 702}], detail=str(embedded)) +def _run_duplicate_relation_case(relations): + """执行同一无序人物对的重复关系输出,并返回关系写调用与稳定失败证据。""" + + characters = { + 11: {"type": "character", "名称": "甲", "字段": {}, "_work_id": 8}, + 12: {"type": "character", "名称": "乙", "字段": {}, "_work_id": 8}, + } + relation_payload = { + "type": pu.RELATION_TYPE, + "名称": "甲×乙", + "甲方draft": 11, + "乙方draft": 12, + "关系类型": "盟友", + "字段": {"演变轨迹": ["[窗3] 初次合作"]}, + "来源": "升格@窗3", + "_work_id": 8, + } + db = _RelationConn({**characters, 701: relation_payload}, {701: 3}) + db.window_status = "pending" + db.window_errors = [] + + def fake_m3_json(prompt, model, need_keys, system=None): + if need_keys == ("新名字", "已知实体新信息", "纯出场"): + return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {}) + if need_keys == ("关系",): + return ({"关系": deepcopy(relations)}, {}) + raise AssertionError(f"不应出现的离线模型调用:{need_keys}") + + name_map = { + "甲": (11, "character", ""), + "乙": (12, "character", ""), + } + with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RelationRunConn(db)), \ + patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \ + patch.object(pu, "load_window_material", return_value=( + "甲与乙的关系发生变化。", {57: "甲与乙的关系发生变化。"}, + )), \ + patch.object(pu, "load_known", return_value=(name_map, {}, {}, {11: 0, 12: 0})), \ + patch.object(pu, "m3_json", side_effect=fake_m3_json), \ + patch.object(pu, "undo_window"), \ + patch.object(pu, "_update_relation_card", wraps=pu._update_relation_card) as update_mock, \ + patch.object(pu, "_insert_relation_card", wraps=pu._insert_relation_card) as insert_mock: + pu._run(8, 0, 0, "MiniMax-M3", 0, False) + return db, update_mock.call_count, insert_mock.call_count + + +def test_run_conflicting_duplicate_relations_fail_before_any_relation_write(): + """同一无序实体对内容冲突时,整批预检必须在任何关系卡写入前稳定失败。""" + + db, update_calls, insert_calls = _run_duplicate_relation_case([ + {"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作", + "其他字段": {"当前状态": "互相信任"}}, + {"甲方": 12, "乙方": 11, "关系类型": "对手", "本窗演变": "公开决裂", + "其他字段": {"当前状态": "彼此敌对"}}, + ]) + check("relation-冲突重复零关系写入", update_calls == 0 and insert_calls == 0, + detail=f"update={update_calls}, insert={insert_calls}") + check("relation-冲突重复两次尝试错误稳定", + len(db.window_errors) == 2 and len(set(db.window_errors)) == 1, + detail=str(db.window_errors)) + check("relation-冲突重复整窗失败", db.window_status == "failed") + + +def test_run_identical_duplicate_relations_write_existing_card_once(): + """完全相同的反向重复项规范成同一无序对,既有关系卡最多更新一次。""" + + first = {"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作", + "其他字段": {"当前状态": "互相信任"}} + reversed_duplicate = {**deepcopy(first), "甲方": 12, "乙方": 11} + db, update_calls, insert_calls = _run_duplicate_relation_case([ + {"甲方": 11, "乙方": 11, "关系类型": "自关系"}, + {"甲方": 11, "乙方": 999, "关系类型": "窗外角色"}, + first, + reversed_duplicate, + ]) + check("relation-相同重复既有卡只更新一次", update_calls == 1 and insert_calls == 0, + detail=f"update={update_calls}, insert={insert_calls}") + check("relation-相同重复整窗成功", db.window_status == "done", + detail=str(db.window_errors)) + check("relation-相同重复只推进一次revision", db.drafts[701]["revision"] == 1, + detail=str(db.drafts[701])) + + def test_run_prompt_revision_rejects_entity_and_relation_stale_results(): """实体与关系模型调用期间 revision 变化时,旧模型结果都不得覆盖外部新值。""" @@ -2742,6 +2834,8 @@ if __name__ == "__main__": test_run_alias_paths_store_real_canonical_name, test_run_new_card_registers_normalized_name_and_aliases_same_window, test_run_relation_cards_enter_touched_embedding, + test_run_conflicting_duplicate_relations_fail_before_any_relation_write, + test_run_identical_duplicate_relations_write_existing_card_once, test_run_prompt_revision_rejects_entity_and_relation_stale_results, test_relation_participants_lock_before_prompt_and_reject_external_drift, test_run_alias_gate_precedes_empty_update_and_deleted_alias_fails_closed,