diff --git a/.claude/skills/parse-book/scripts/parse_upgrade.py b/.claude/skills/parse-book/scripts/parse_upgrade.py index d05e05a..2585a81 100644 --- a/.claude/skills/parse-book/scripts/parse_upgrade.py +++ b/.claude/skills/parse-book/scripts/parse_upgrade.py @@ -2398,6 +2398,13 @@ def _run(work_id, max_windows, max_calls, model, redo_window, semantic_on): did = name_map[nm][0] to_update.setdefault(did, []).append(it["观察点"]) pres_add.setdefault(did, set()).update(_int_chaps(it.get("出场章"))) + # 在任何实体更新写入前汇总纯出场,确保同卡全部章节由一次 merge 或机械收尾消费。 + for it in obs.get("纯出场", []): + nm = (it.get("名称") or "").strip() + if nm in name_map: + pres_add.setdefault(name_map[nm][0], set()).update( + _int_chaps(it.get("出场章")) + ) # ④ 卡更新(分批≤6) items = sorted(to_update.items()) for i in range(0, len(items), UPDATE_BATCH): @@ -2501,13 +2508,7 @@ def _run(work_id, max_windows, max_calls, model, redo_window, semantic_on): relation_touched.add( _insert_relation_card(conn, work_id, win_no, rp) ) - # ⑥ 机械收尾:出场章统一并入(纯出场 + 被更新卡,抽检#2)+ 窗置 done - for it in obs.get("纯出场", []): - nm = (it.get("名称") or "").strip() - if nm in name_map: - pres_add.setdefault(name_map[nm][0], set()).update( - _int_chaps(it.get("出场章")) - ) + # ⑥ 机械收尾:仅处理未被更新模型消费的出场章,然后窗置 done。 presence_touched = set() for did, chs in pres_add.items(): if not chs: 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 91eb8d3..a6d0bc7 100644 --- a/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py +++ b/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py @@ -2438,53 +2438,71 @@ def test_run_pure_presence_rejects_external_state_and_revision_conflicts(): def test_run_presence_paths_accept_numeric_strings(): - """run 的已知更新归并卡与纯出场都把数字字符串章号落成整数。""" + """同卡横跨新信息与纯出场时,有无模型更新都只走一次实体写入并落全章节。""" - state = { - "window_status": "pending", - "payload": { - "type": "character", - "名称": "安若雪", - "别名": [], - "字段": {}, - "出场章": [], - "_work_id": 8, - }, - } + def run_case(updates): + state = { + "window_status": "pending", + "revisions": {701: 0}, + "payload": { + "type": "character", + "名称": "安若雪", + "别名": [], + "字段": {}, + "出场章": [], + "_work_id": 8, + }, + } - def fake_m3_json(prompt, model, need_keys, system=None): - if need_keys == ("新名字", "已知实体新信息", "纯出场"): - return ({ - "新名字": [], - "已知实体新信息": [ - {"名称": "安若雪", "观察点": "出现新变化", "出场章": ["70"]} - ], - "纯出场": [{"名称": "安若雪", "出场章": ["71"]}], - }, {}) - return ({"更新": []}, {}) + def fake_m3_json(prompt, model, need_keys, system=None): + if need_keys == ("新名字", "已知实体新信息", "纯出场"): + return ({ + "新名字": [], + "已知实体新信息": [ + {"名称": "安若雪", "观察点": "出现新变化", "出场章": ["70"]} + ], + "纯出场": [{"名称": "安若雪", "出场章": ["71"]}], + }, {}) + return ({"更新": deepcopy(updates)}, {}) - with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \ - patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \ - patch.object( - pu, - "load_window_material", - return_value=("安若雪连续出现", {70: "安若雪出现新变化。", 71: "安若雪继续前进。"}), - ), \ - patch.object( - pu, - "load_known", - return_value=({"安若雪": (701, "character", "旧摘要")}, {}, {}, {701: 0}), - ), \ - patch.object(pu, "m3_json", side_effect=fake_m3_json): - pu._run( - work_id=8, - max_windows=0, - max_calls=0, - model="MiniMax-M3", - redo_window=0, - semantic_on=False, - ) - check("run-presence-归并与纯出场数字字符串均落库", state["payload"]["出场章"] == [70, 71]) + with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \ + patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \ + patch.object( + pu, + "load_window_material", + return_value=("安若雪连续出现", {70: "安若雪出现新变化。", 71: "安若雪继续前进。"}), + ), \ + patch.object( + pu, + "load_known", + return_value=({"安若雪": (701, "character", "旧摘要")}, {}, {}, {701: 0}), + ), \ + 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, \ + patch.object( + pu, "_write_locked_upgrade_draft", wraps=pu._write_locked_upgrade_draft + ) as write_mock: + pu._run(8, 0, 0, "MiniMax-M3", 0, False) + return state, merge_mock.call_count, write_mock.call_count + + updated, merge_calls, write_calls = run_case([ + {"draft_id": 701, "变更字段": {"一句话摘要": "新摘要"}, "别名新增": []}, + ]) + check("run-presence-有更新时只写一次", + merge_calls == 1 and write_calls == 1 and updated["revisions"][701] == 1, + detail=f"merge={merge_calls}, write={write_calls}, state={updated}") + check("run-presence-有更新时一次消费两数组章节", + updated["window_status"] == "done" and updated["payload"]["出场章"] == [70, 71], + detail=str(updated)) + + unchanged, merge_calls, write_calls = run_case([]) + check("run-presence-无更新时由机械收尾写一次", + merge_calls == 0 and write_calls == 1 and unchanged["revisions"][701] == 1, + detail=f"merge={merge_calls}, write={write_calls}, state={unchanged}") + check("run-presence-无更新时两数组章节仍落库", + unchanged["window_status"] == "done" and unchanged["payload"]["出场章"] == [70, 71], + detail=str(unchanged)) def test_load_known_projection():