修复: 关系重复输出在写前失败关闭

This commit is contained in:
zizi 2026-07-22 15:49:15 +08:00
parent ffa5266a03
commit bfccabfcaa
2 changed files with 128 additions and 3 deletions

View File

@ -100,6 +100,10 @@ class CompensationFenceConflict(RuntimeError):
"""窗提交后的 draft 已被外部改动,禁止补偿覆盖。""" """窗提交后的 draft 已被外部改动,禁止补偿覆盖。"""
class RelationOutputConflict(RuntimeError):
"""关系模型对同一无序实体对给出内容不同的重复项。"""
COMPENSATION_FAILED_PREFIX = "compensation-failed:" 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 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): def _insert_relation_card(conn, work_id, win_no, payload):
"""新建关系卡并返回 draft id;逐字段审计和 state 任一步失败都会让窗事务回滚。""" """新建关系卡并返回 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, rel_out, _ = call(relation_prompt(contracts, title, a, b, text,
char_cards, rels), ("关系",)) char_cards, rels), ("关系",))
id2name = {d: p.get("名称") for d, p in char_cards} 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) exist = {tuple(sorted((r.get("甲方draft"), r.get("乙方draft")))): (rid, r)
for rid, r in rels for rid, r in rels
if r.get("甲方draft") and r.get("乙方draft")} 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("乙方") 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))) key = tuple(sorted((ja, yi)))
evo_core = _strip_prefix(r.get("本窗演变", "")) evo_core = _strip_prefix(r.get("本窗演变", ""))
if key in exist: if key in exist:

View File

@ -1065,6 +1065,15 @@ class _RelationRunConn:
return _CardResult(rows=[(8, 57, 60, "pending")]) return _CardResult(rows=[(8, 57, 60, "pending")])
if normalized.startswith("SELECT window_no, error_message FROM example_upgrade_window"): if normalized.startswith("SELECT window_no, error_message FROM example_upgrade_window"):
return _CardResult(rows=[]) 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") \ if normalized.startswith("SELECT id, draft_payload, revision FROM muse_knowledge_draft") \
and "draft_payload->>'type'=%s" in normalized: and "draft_payload->>'type'=%s" in normalized:
return _CardResult(rows=[ 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)) 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(): def test_run_prompt_revision_rejects_entity_and_relation_stale_results():
"""实体与关系模型调用期间 revision 变化时,旧模型结果都不得覆盖外部新值。""" """实体与关系模型调用期间 revision 变化时,旧模型结果都不得覆盖外部新值。"""
@ -2742,6 +2834,8 @@ if __name__ == "__main__":
test_run_alias_paths_store_real_canonical_name, test_run_alias_paths_store_real_canonical_name,
test_run_new_card_registers_normalized_name_and_aliases_same_window, test_run_new_card_registers_normalized_name_and_aliases_same_window,
test_run_relation_cards_enter_touched_embedding, 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_run_prompt_revision_rejects_entity_and_relation_stale_results,
test_relation_participants_lock_before_prompt_and_reject_external_drift, test_relation_participants_lock_before_prompt_and_reject_external_drift,
test_run_alias_gate_precedes_empty_update_and_deleted_alias_fails_closed, test_run_alias_gate_precedes_empty_update_and_deleted_alias_fails_closed,