修复: 合并同卡重复实体更新输出

This commit is contained in:
zizi 2026-07-22 15:55:14 +08:00
parent bfccabfcaa
commit d015f1d321
2 changed files with 151 additions and 9 deletions

View File

@ -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 = []

View File

@ -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,