From a1066eba8be762024d97bdeab6f38e0510445ecd Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 22 Jul 2026 18:13:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E4=B8=93=E7=94=A8=E9=87=8D=E6=95=B4=E5=90=8E?= =?UTF-8?q?=E5=86=8D=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parse-book/scripts/parse_upgrade.py | 47 +++++++- .../scripts/test_parse_upgrade_offline.py | 109 ++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/.claude/skills/parse-book/scripts/parse_upgrade.py b/.claude/skills/parse-book/scripts/parse_upgrade.py index 2585a81..c51313e 100644 --- a/.claude/skills/parse-book/scripts/parse_upgrade.py +++ b/.claude/skills/parse-book/scripts/parse_upgrade.py @@ -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")} 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 a6d0bc7..cb20826 100644 --- a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py +++ b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py @@ -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,