修复: 关系冲突专用重整后再失败关闭

This commit is contained in:
zizi 2026-07-22 18:13:47 +08:00
parent 7f675922b2
commit a1066eba8b
2 changed files with 153 additions and 3 deletions

View File

@ -318,6 +318,8 @@ def relation_prompt(contracts, title, a, b, text, char_cards, existing_rels):
{render_entity_contracts(contracts, [RELATION_TYPE])}
【输出规则(只输出一个 JSON 对象;甲乙用 draft_id 指认)】
存储合同规定一对人物只对应一张关系卡:同一无序人物对(甲,乙)与(乙,甲)视为同一对,输出中同一无序人物对最多一条;完全相同的反向重复也不要重复输出。
如果同一人物对同时存在多个关系维度,必须合并为一条综合记录,统一写入一个「关系类型」、一个「本窗演变」和同一条「其他字段」对象,**不得按关系维度拆成多条**。
{{"关系": [{{"甲方": 数字, "乙方": 数字, "关系类型": "", "本窗演变": "≤60字", "其他字段": {{}}}}]}}
━━━ 本窗材料 ━━━
@ -332,6 +334,24 @@ def relation_prompt(contracts, title, a, b, text, char_cards, existing_rels):
{rels}"""
def relation_repair_prompt(contracts, title, a, b, text, char_cards, existing_rels,
first_relations):
"""构造关系冲突后的唯一一次专用重整提示,要求模型返回可直接替换的完整关系数组。"""
first_output = json.dumps(first_relations or [], ensure_ascii=False, indent=1)
return f"""{relation_prompt(contracts, title, a, b, text, char_cards, existing_rels)}
【关系专用重整】
首次关系输出如下,其中同一无序人物对出现了内容冲突的重复项:
{first_output}
请结合本窗正文与已有关系,对首次关系输出做一次完整重整后重新输出「关系」数组:
- 同一无序人物对最多保留一条关系记录;甲乙反向只算同一对。
- 同一人物对的多个关系维度必须合并进同一条综合记录的「关系类型」「本窗演变」「其他字段」,不得拆成多条。
- 不得选择性静默丢弃冲突信息,不得按首条或末条覆盖;合并时保留正文有证据的各维度信息。
- 除解决上述重复冲突外,仍遵守原有字段合同、正文证据和输出格式纪律。
"""
# ── 合并与审计(三类字段演进 + 撤销依据)──
WIN_PREFIX_RE = None # 延迟编译(模块顶部 import re 已有)
@ -2476,9 +2496,30 @@ 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)
)
raw_relations = rel_out.get("关系") or []
try:
relation_items = _normalize_relation_output(
raw_relations, set(id2name)
)
except RelationOutputConflict:
# 首轮关系冲突只给模型一次关系专用重整机会;重整仍冲突时保持失败关闭,
# 交由外层既有整窗重试与停止策略处理,禁止首条/末条覆盖或静默丢弃。
repaired_rel_out, _ = call(
relation_repair_prompt(
contracts,
title,
a,
b,
text,
char_cards,
rels,
raw_relations,
),
("关系",),
)
relation_items = _normalize_relation_output(
repaired_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")}

View File

@ -1306,6 +1306,109 @@ def test_run_conflicting_duplicate_relations_fail_before_any_relation_write():
check("relation-冲突重复整窗失败", db.window_status == "failed")
def _run_relation_repair_case(first_relations, repaired_relations):
"""执行关系首轮冲突与一次专用重整,返回模型调用和关系写入证据。"""
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 == ("新名字", "已知实体新信息", "纯出场"):
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:
pu._run(8, 0, 0, "MiniMax-M3", 0, False)
return db, relation_prompts, update_mock.call_count, insert_mock.call_count, undo_mock.call_count
def test_run_relation_conflict_repair_merges_to_one_card():
"""首轮同一无序人物对冲突时,关系专用重整合并后只写一张卡并完成整窗。"""
first = [
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "并肩作战",
"其他字段": {"当前状态": "互信"}},
{"甲方": 12, "乙方": 11, "关系类型": "上下级", "本窗演变": "接受指挥",
"其他字段": {"权力结构": "乙指挥甲"}},
]
repaired = [{
"甲方": 11, "乙方": 12, "关系类型": "盟友兼上下级",
"本窗演变": "并肩作战并接受指挥",
"其他字段": {"当前状态": "互信", "权力结构": "乙指挥甲"},
}]
db, prompts, update_calls, insert_calls, undo_calls = _run_relation_repair_case(first, repaired)
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}")
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_run_relation_conflict_repair_conflict_fails_window_without_relation_write():
"""关系专用重整仍冲突时不放宽比较,整窗重试后仍零关系写并失败关闭。"""
conflicting = [
{"甲方": 11, "乙方": 12, "关系类型": "盟友", "本窗演变": "继续合作",
"其他字段": {"当前状态": "互信"}},
{"甲方": 12, "乙方": 11, "关系类型": "对手", "本窗演变": "公开决裂",
"其他字段": {"当前状态": "敌对"}},
]
db, prompts, update_calls, insert_calls, undo_calls = _run_relation_repair_case(
conflicting, conflicting
)
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-二次冲突交给既有整窗重试", undo_calls == 1,
detail=f"undo_calls={undo_calls}")
def test_run_identical_duplicate_relations_write_existing_card_once():
"""完全相同的反向重复项规范成同一无序对,既有关系卡最多更新一次。"""
@ -2918,6 +3021,10 @@ def test_prompts_disciplines():
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)]
@ -2944,6 +3051,8 @@ if __name__ == "__main__":
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_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,