修复: 关系重复输出在写前失败关闭
This commit is contained in:
parent
ffa5266a03
commit
bfccabfcaa
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user