From d015f1d3215afe7d2da07966ec8ca4461ddb1431 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 22 Jul 2026 15:55:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=90=8C=E5=8D=A1=E9=87=8D=E5=A4=8D=E5=AE=9E=E4=BD=93=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parse-book/scripts/parse_upgrade.py | 69 ++++++++++++-- .../scripts/test_parse_upgrade_offline.py | 91 +++++++++++++++++++ 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/.claude/skills/parse-book/scripts/parse_upgrade.py b/.claude/skills/parse-book/scripts/parse_upgrade.py index 438ab23..d05e05a 100644 --- a/.claude/skills/parse-book/scripts/parse_upgrade.py +++ b/.claude/skills/parse-book/scripts/parse_upgrade.py @@ -104,6 +104,10 @@ class RelationOutputConflict(RuntimeError): """关系模型对同一无序实体对给出内容不同的重复项。""" +class EntityUpdateOutputConflict(RuntimeError): + """实体更新模型对同一 draft 的同一字段给出不同值。""" + + COMPENSATION_FAILED_PREFIX = "compensation-failed:" @@ -1049,6 +1053,53 @@ def _debut_milestone(milestones, entity_name, entity_type, aliases, chaps, win_n return [debut] + list(milestones or []) +def _normalize_entity_updates(updates, valid_draft_ids): + """整批合并有效实体更新;字段冲突必须在任何卡片或别名写入前失败。""" + + normalized = [] + update_by_draft = {} + aliases_by_draft = {} + for update in updates or []: + if not isinstance(update, dict): + continue + draft_id = update.get("draft_id") + if draft_id not in valid_draft_ids: + continue + changes = deepcopy(update.get("变更字段")) \ + if isinstance(update.get("变更字段"), dict) else {} + # 兼容模型把别名误放进变更字段的既有降级路径,但统一在批量阶段过滤和去重。 + raw_aliases = update.get("别名新增") + misplaced_aliases = changes.pop("别名新增", None) + alias_values = [] + for value in (raw_aliases, misplaced_aliases): + if isinstance(value, str): + alias_values.append(value) + elif isinstance(value, list): + alias_values.extend(alias for alias in value if isinstance(alias, str)) + if not changes and not alias_values: + continue + if draft_id not in update_by_draft: + merged = {"draft_id": draft_id, "变更字段": {}, "别名新增": []} + update_by_draft[draft_id] = merged + aliases_by_draft[draft_id] = set() + normalized.append(merged) + merged = update_by_draft[draft_id] + for field_name, new_value in changes.items(): + if field_name in merged["变更字段"]: + if merged["变更字段"][field_name] != new_value: + raise EntityUpdateOutputConflict( + f"实体更新输出同一字段存在冲突重复:draft_id={draft_id},field={field_name}" + ) + continue + merged["变更字段"][field_name] = new_value + seen_aliases = aliases_by_draft[draft_id] + for alias in alias_values: + if alias not in seen_aliases: + merged["别名新增"].append(alias) + seen_aliases.add(alias) + return normalized + + def merge_card( conn, draft_id, @@ -2381,15 +2432,15 @@ def _run(work_id, max_windows, max_calls, model, redo_window, semantic_on): # 每卡按其型的合同 key 集校验(+一句话摘要),越合同 key 裁剪留审计 did2keys = {d: {f["key"] for f in contracts.get(p.get("type"), {}).get("字段", [])} | {"一句话摘要"} for d, p, _ in cards} - for u in [x for x in (upd.get("更新") or []) if isinstance(x, dict)]: - if u.get("draft_id") in valid and (u.get("变更字段") or u.get("别名新增")): - merge_card(conn, u["draft_id"], win_no, - u.get("变更字段"), u.get("别名新增"), - valid_keys=did2keys.get(u["draft_id"]), - chapter_texts=chapter_texts, - appearance_chapters=pres_add.pop(u["draft_id"], set()), - known_aliases=aliases_by_draft.get(u["draft_id"], set()), - expected_revision=batch_revisions[u["draft_id"]]) + normalized_updates = _normalize_entity_updates(upd.get("更新") or [], valid) + for u in normalized_updates: + merge_card(conn, u["draft_id"], win_no, + u["变更字段"], u["别名新增"], + valid_keys=did2keys.get(u["draft_id"]), + chapter_texts=chapter_texts, + appearance_chapters=pres_add.pop(u["draft_id"], set()), + known_aliases=aliases_by_draft.get(u["draft_id"], set()), + expected_revision=batch_revisions[u["draft_id"]]) # ⑤ 关系增量(核心角色=本窗有更新的 character + 在场 character,≤8) relation_touched = set() char_cards = [] 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 5c5f2a1..91eb8d3 100644 --- a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py +++ b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py @@ -926,6 +926,7 @@ class _RunConn: return _CardResult(None) self.state["window_status"] = "failed" self.state["window_error"] = params[0] + self.state.setdefault("window_errors", []).append(params[0]) self.state["window_updater"] = "upgrade" if self.state.get("track_events"): self.state.setdefault("events", []).append(("failed", self.connection_id)) @@ -1045,6 +1046,94 @@ def test_run_new_card_registers_normalized_name_and_aliases_same_window(): {params[1] for params in candidate_rows} == {"白色游魂"}, detail=str(candidate_rows)) +def _run_duplicate_entity_update_case(updates): + """执行同一实体的重复更新输出,并返回写入口调用和窗失败证据。""" + + draft_id = 24473 + state = { + "window_status": "pending", + "revisions": {draft_id: 3}, + "payload": { + "type": "character", "名称": "安捷", "别名": [], "字段": {}, "_work_id": 8, + }, + } + contracts = _fake_contracts() + contracts["character"]["字段"].extend([ + {"key": "阵营", "说明": "当前所属阵营"}, + {"key": "当前状态", "说明": "人物当前状态"}, + ]) + + def fake_m3_json(prompt, model, need_keys, system=None): + if need_keys == ("新名字", "已知实体新信息", "纯出场"): + return ({ + "新名字": [], + "已知实体新信息": [ + {"名称": "安捷", "观察点": "安捷状态发生变化", "出场章": []}, + ], + "纯出场": [], + }, {}) + if need_keys == ("更新",): + return ({"更新": deepcopy(updates)}, {}) + raise AssertionError(f"不应出现的离线模型调用:{need_keys}") + + with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \ + patch.object(pu, "load_entity_contracts", return_value=contracts), \ + patch.object(pu, "load_window_material", return_value=("安捷保持警戒。", {70: "安捷保持警戒。"})), \ + patch.object(pu, "load_known", return_value=( + {"安捷": (draft_id, "character", "")}, {}, {}, {draft_id: 3}, + )), \ + patch.object(pu, "m3_json", side_effect=fake_m3_json), \ + patch.object(pu, "undo_window"), \ + patch.object(pu, "merge_card", wraps=pu.merge_card) as merge_mock: + pu._run(8, 0, 0, "MiniMax-M3", 0, False) + return state, merge_mock + + +def test_run_exact_and_disjoint_duplicate_entity_updates_merge_once(): + """相同项折叠、互补字段合并、别名稳定去重后,每个实体只写一次。""" + + exact = { + "draft_id": 24473, + "变更字段": {"阵营": "探索队"}, + "别名新增": ["小安", 7, "捷哥"], + } + disjoint = { + "draft_id": 24473, + "变更字段": {"当前状态": "保持警戒"}, + "别名新增": ["捷哥", None, "安队"], + } + state, merge_mock = _run_duplicate_entity_update_case([exact, deepcopy(exact), disjoint]) + check("entity-update-相同与互补重复只写一次", merge_mock.call_count == 1, + detail=str(merge_mock.call_args_list)) + args, kwargs = merge_mock.call_args + check("entity-update-互补字段按key合并", + args[3] == {"阵营": "探索队", "当前状态": "保持警戒"}, detail=str(args[3])) + check("entity-update-别名仅字符串稳定去重并集", + args[4] == ["小安", "捷哥", "安队"], detail=str(args[4])) + check("entity-update-仍使用模型读取时revision", + kwargs.get("expected_revision") == 3 and state["revisions"][24473] == 4, + detail=f"kwargs={kwargs}, revisions={state['revisions']}") + check("entity-update-合并后整窗成功", state["window_status"] == "done") + + +def test_run_conflicting_duplicate_entity_updates_fail_before_any_write(): + """同一实体同一字段值冲突时,整批预检必须在 merge_card 与 alias 写入前稳定失败。""" + + state, merge_mock = _run_duplicate_entity_update_case([ + {"draft_id": 24473, "变更字段": {"阵营": "探索队"}, "别名新增": ["小安"]}, + {"draft_id": 24473, "变更字段": {"阵营": "敌方"}, "别名新增": ["安队"]}, + ]) + check("entity-update-冲突重复零merge写入", merge_mock.call_count == 0, + detail=str(merge_mock.call_args_list)) + check("entity-update-冲突重复零alias写入", not state.get("alias_rows"), + detail=str(state.get("alias_rows"))) + check("entity-update-冲突重复两次尝试错误稳定", + len(state.get("window_errors", [])) == 2 + and len(set(state["window_errors"])) == 1, + detail=str(state.get("window_errors"))) + check("entity-update-冲突重复整窗失败", state["window_status"] == "failed") + + class _RelationRunConn: """复用关系内存库执行一个完整窗,验证关系 draft id 真进入提交后 touched。""" @@ -2833,6 +2922,8 @@ if __name__ == "__main__": test_merge_card_chapter_evidence, test_redo_cleans_legacy_appearance_chapters, test_run_alias_paths_store_real_canonical_name, test_run_new_card_registers_normalized_name_and_aliases_same_window, + test_run_exact_and_disjoint_duplicate_entity_updates_merge_once, + 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_identical_duplicate_relations_write_existing_card_once,