修复: 合并同卡重复实体更新输出
This commit is contained in:
parent
bfccabfcaa
commit
d015f1d321
@ -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 = []
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user