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