Files
AgentLab/backend/app/data/entity_patch.py
T
ThearchyHelios 8d9a712eae 业务本体与标准实体:跨源键值对齐、对账共变算子、健康证据链与实体助手
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手
- 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具
- 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测
- 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
2026-11-12 12:57:53 +08:00

842 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""标准业务实体的受控增量补丁规划器与落库器(E1)。
供交互式「实体助手」(Entity Copilot)与后台「扫描发现候选实体」共用同一套核验与合并内核:
1. 物理真实性硬校验:表列绑定必须在已启用数据源的 schema_cache 中真实存在;口径参数映射必须在对应工作流入口节点中真实存在。
2. 敏感遮罩隔离:遮罩列(masked_columns)严禁写入或更新 members.source_codes 枚举字典。
3. 剥离自封审批态:模型传入的 status / approved / verified / actor / version 一律忽略重算。
4. 双模式落库:
- mode="confirmed"(人工在实体助手点「保存修改」):严格校验 expected_versions CAS 乐观锁,绑定与参数落为 confirmed / human。
- mode="proposed"(后台主动发现):新增绑定与参数一律落为 proposed / ai,绝不覆盖人工已确认、已验证或已驳回的项。
"""
from __future__ import annotations
import copy
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.artifact_store import canonical_json, content_hash
from app.data import entities, introspect
from app.data.engine import masked_columns
from app.db.base import new_id, utcnow
from app.db.models import DataSource, Workflow, WorkflowVersion
from app.db.models_ontology import CanonicalEntity
OP_TYPES = (
"upsert_entity",
"upsert_binding",
"remove_binding",
"upsert_member",
"upsert_caliber_param",
)
_FORBIDDEN_KEYS = frozenset({
"status", "approve", "approved", "approval", "approver", "verified", "verify",
"actor", "role", "review", "reviewed", "reviewed_by", "updated_by", "created_by", "version",
})
@dataclass(slots=True)
class EntityPatchChange:
id: str
op: str
entity_key: str
entity_label: str
summary: str
before: Any
after: Any
reason: str
op_payload: dict[str, Any]
def as_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"op": self.op,
"entity_key": self.entity_key,
"entity_label": self.entity_label,
"summary": self.summary,
"before": self.before,
"after": self.after,
"reason": self.reason,
"op_payload": self.op_payload,
}
@dataclass(slots=True)
class EntityPatchPlan:
changes: list[EntityPatchChange] = field(default_factory=list)
problems: list[str] = field(default_factory=list)
expected_versions: dict[str, int] = field(default_factory=dict)
@dataclass(slots=True)
class _ValidationEnv:
sources_by_norm: dict[str, DataSource]
masked_by_source: dict[str, set[str]]
workflows_by_id: dict[str, Workflow]
workflows_by_name: dict[str, Workflow]
workflow_inputs: dict[str, set[str]]
workflow_cards: dict[str, set[str]]
entities_by_key: dict[str, CanonicalEntity]
def _strip_forbidden(raw: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in raw.items() if k not in _FORBIDDEN_KEYS}
def _extract_graph_inputs_and_cards(graph: Mapping[str, Any] | None) -> tuple[set[str], set[str]]:
inputs: set[str] = set()
cards: set[str] = set()
if not isinstance(graph, Mapping):
return inputs, cards
for node in graph.get("nodes") or []:
if not isinstance(node, Mapping):
continue
ntype = str(node.get("type") or "").strip()
nid = str(node.get("id") or "").strip()
if ntype == "metrics" and nid:
cards.add(nid)
if ntype == "input":
data = node.get("data") if isinstance(node.get("data"), Mapping) else {}
cfg = data.get("config") if isinstance(data.get("config"), Mapping) else (
node.get("config") if isinstance(node.get("config"), Mapping) else {}
)
for f in cfg.get("fields") or []:
if isinstance(f, Mapping):
fname = str(f.get("name") or "").strip()
if fname:
inputs.add(fname)
return inputs, cards
async def _load_env(session: AsyncSession) -> _ValidationEnv:
from app.data import table_versions
src_rows = list(
(await session.execute(select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name))).scalars()
)
sources_by_norm: dict[str, Any] = {}
masked_by_source: dict[str, set[str]] = {}
for row in src_rows:
try:
resolved = await table_versions.resolve_source(session, row)
except table_versions.SnapshotMissing:
resolved = row
key = row.name.strip().lower()
sources_by_norm[key] = resolved
sources_by_norm[row.id.strip().lower()] = resolved
masked_by_source[row.name.strip().lower()] = {
c.strip().lower() for c in masked_columns(row.options if isinstance(row.options, dict) else None)
}
wf_rows = list((await session.execute(select(Workflow))).scalars())
ver_rows = list(
(
await session.execute(
select(WorkflowVersion).where(WorkflowVersion.published_at.is_not(None))
)
).scalars()
)
workflows_by_id: dict[str, Workflow] = {}
name_counts: dict[str, int] = {}
for wf in wf_rows:
workflows_by_id[wf.id] = wf
if wf.name:
nkey = wf.name.strip().lower()
name_counts[nkey] = name_counts.get(nkey, 0) + 1
workflows_by_name = {
wf.name.strip().lower(): wf
for wf in wf_rows
if wf.name and name_counts.get(wf.name.strip().lower()) == 1
}
workflow_inputs: dict[str, set[str]] = {}
workflow_cards: dict[str, set[str]] = {}
for wf in wf_rows:
ins, cds = _extract_graph_inputs_and_cards(wf.graph if isinstance(wf.graph, dict) else None)
workflow_inputs.setdefault(wf.id, set()).update(ins)
workflow_cards.setdefault(wf.id, set()).update(cds)
for ver in ver_rows:
ins, cds = _extract_graph_inputs_and_cards(ver.graph if isinstance(ver.graph, dict) else None)
workflow_inputs.setdefault(ver.workflow_id, set()).update(ins)
workflow_cards.setdefault(ver.workflow_id, set()).update(cds)
ent_rows = await entities.list_entities(session)
entities_by_key = {row.key.strip().lower(): row for row in ent_rows}
return _ValidationEnv(
sources_by_norm=sources_by_norm,
masked_by_source=masked_by_source,
workflows_by_id=workflows_by_id,
workflows_by_name=workflows_by_name,
workflow_inputs=workflow_inputs,
workflow_cards=workflow_cards,
entities_by_key=entities_by_key,
)
def _is_column_masked(masked_set: set[str], table_name: str, column_name: str) -> bool:
col_low = column_name.strip().lower()
tbl_low = table_name.strip().lower()
short_tbl = tbl_low.rsplit(".", 1)[-1]
return (
col_low in masked_set
or f"{tbl_low}.{col_low}" in masked_set
or f"{short_tbl}.{col_low}" in masked_set
)
def _resolve_table_and_column(
source: DataSource,
raw_table: str,
raw_column: str,
) -> tuple[str | None, str | None, str | None]:
"""返回 (规范表名, 规范列名, 错误说明)。"""
cache = source.schema_cache if isinstance(source.schema_cache, Mapping) else {}
tables = cache.get("tables") if isinstance(cache.get("tables"), Mapping) else {}
if not tables:
return None, None, f"数据源「{source.name}」尚未探查表结构,无法核实表「{raw_table}」与列「{raw_column}」"
t_probe = raw_table.strip()
matched_table: str | None = None
meta: Mapping[str, Any] | None = None
if t_probe in tables:
matched_table, meta = t_probe, tables[t_probe]
else:
t_low = t_probe.lower()
t_short = t_low.rsplit(".", 1)[-1]
for tname, tmeta in tables.items():
if not isinstance(tmeta, Mapping):
continue
qual = str(tmeta.get("qualified") or tname).strip()
if tname.lower() == t_low or qual.lower() == t_low or tname.lower() == t_short:
matched_table, meta = tname, tmeta
break
if meta is None or matched_table is None:
sample = "、".join(list(tables.keys())[:15])
return None, None, f"数据源「{source.name}」中不存在表「{raw_table}」(已探查表如:{sample})"
cols = introspect.table_columns(dict(meta))
c_low = raw_column.strip().lower()
for col in cols:
cname = str(col.get("name") or "").strip()
if cname.lower() == c_low:
return matched_table, cname, None
col_sample = "、".join(str(c.get("name")) for c in cols[:20])
return None, None, f"数据源「{source.name}」表「{matched_table}」中不存在列「{raw_column}」(可用列:{col_sample})"
def _working_from_row(row: CanonicalEntity) -> dict[str, Any]:
return {
"id": row.id,
"key": row.key,
"label": row.label,
"description": str(row.description or ""),
"aliases": list(row.aliases or []),
"members": [copy.deepcopy(dict(m)) for m in (row.members or []) if isinstance(m, Mapping)],
"bindings": [copy.deepcopy(dict(b)) for b in (row.bindings or []) if isinstance(b, Mapping)],
"caliber_params": [copy.deepcopy(dict(p)) for p in (row.caliber_params or []) if isinstance(p, Mapping)],
"version": int(row.version or 1),
"is_new": False,
}
def _change_id(op: str, entity_key: str, identity: str) -> str:
return content_hash(canonical_json({"op": op, "entity_key": entity_key, "id": identity}))[:16]
def _normalize_single_op(
raw_op: Any,
*,
index: int,
env: _ValidationEnv,
working: dict[str, dict[str, Any]],
mode: Literal["confirmed", "proposed"],
) -> tuple[EntityPatchChange | None, str | None]:
if not isinstance(raw_op, Mapping):
return None, f"第 {index + 1} 项操作需要是对象"
cleaned = _strip_forbidden(raw_op)
op = str(cleaned.get("op") or "").strip()
if op not in OP_TYPES:
return None, f"第 {index + 1} 项操作类型「{op}」无效,支持:{', '.join(OP_TYPES)}"
if mode == "proposed" and op == "remove_binding":
return None, "后台发现模式不允许删除既有表列绑定(remove_binding)"
raw_key = cleaned.get("entity_key") or cleaned.get("key")
try:
entity_key = entities.normalize_entity_key(raw_key, field="entity_key")
except entities.EntityInvalid as exc:
return None, f"第 {index + 1} 项:{exc.message}"
reason = str(cleaned.get("reason") or "").strip()[:500]
note = str(cleaned.get("note") or "").strip()[:500]
# 初始化当前实体工作副本
state = working.get(entity_key)
if state is None and entity_key in env.entities_by_key:
state = _working_from_row(env.entities_by_key[entity_key])
working[entity_key] = state
if op == "upsert_entity":
raw_label = str(cleaned.get("label") or cleaned.get("entity_label") or "").strip()
if state is None and not raw_label:
return None, f"新建标准实体「{entity_key}」缺少中文名称(label)"
label = raw_label or (state["label"] if state else entity_key)
if len(label) > 100:
return None, f"标准实体「{entity_key}」名称不能超过 100 个字符"
desc = str(cleaned.get("description") if "description" in cleaned else (state["description"] if state else "") or "").strip()
raw_aliases = cleaned.get("aliases")
if raw_aliases is None:
new_aliases = list(state["aliases"]) if state else []
elif isinstance(raw_aliases, list):
merged = list(state["aliases"]) if (state and mode == "proposed") else []
seen_a = {a.lower() for a in merged}
for item in raw_aliases:
if isinstance(item, str) and item.strip() and item.strip().lower() not in seen_a:
seen_a.add(item.strip().lower())
merged.append(item.strip())
new_aliases = merged
else:
return None, f"标准实体「{entity_key}」的 aliases 需要是字符串数组"
if state is None:
working[entity_key] = {
"id": None,
"key": entity_key,
"label": label,
"description": desc,
"aliases": new_aliases,
"members": [],
"bindings": [],
"caliber_params": [],
"version": 0,
"is_new": True,
}
payload = {
"op": "upsert_entity",
"entity_key": entity_key,
"label": label,
"description": desc,
"aliases": new_aliases,
"reason": reason,
}
return EntityPatchChange(
id=_change_id(op, entity_key, "meta"),
op=op,
entity_key=entity_key,
entity_label=label,
summary=f"新建标准业务实体「{label}({entity_key})」",
before=None,
after={"label": label, "description": desc, "aliases": new_aliases},
reason=reason,
op_payload=payload,
), None
# 更新已有实体元信息(后台发现模式下不覆盖已有中文名与非空说明)
target_label = state["label"] if mode == "proposed" else label
target_desc = (state["description"] or desc) if mode == "proposed" else desc
before_meta = {
"label": state["label"],
"description": state["description"],
"aliases": list(state["aliases"]),
}
after_meta = {
"label": target_label,
"description": target_desc,
"aliases": new_aliases,
}
if before_meta == after_meta:
return None, None
state["label"] = target_label
state["description"] = target_desc
state["aliases"] = new_aliases
payload = {
"op": "upsert_entity",
"entity_key": entity_key,
"label": target_label,
"description": target_desc,
"aliases": new_aliases,
"reason": reason,
}
return EntityPatchChange(
id=_change_id(op, entity_key, "meta"),
op=op,
entity_key=entity_key,
entity_label=target_label,
summary=f"更新标准实体「{target_label}({entity_key})」基础信息",
before=before_meta,
after=after_meta,
reason=reason,
op_payload=payload,
), None
# 其余子项操作:若实体尚不存在,允许随操作附带 entity_label 自动初始化,否则明确报错
if state is None:
fallback_label = str(cleaned.get("entity_label") or "").strip()
if not fallback_label:
return (
None,
f"标准实体「{entity_key}」尚不存在,请先提供一条 upsert_entity(含 label 中文名称)或在操作中提供 entity_label",
)
state = {
"id": None,
"key": entity_key,
"label": fallback_label[:100],
"description": "",
"aliases": [],
"members": [],
"bindings": [],
"caliber_params": [],
"version": 0,
"is_new": True,
}
working[entity_key] = state
if op == "upsert_binding":
raw_src = str(cleaned.get("source_name") or "").strip()
raw_tbl = str(cleaned.get("table_name") or "").strip()
raw_col = str(cleaned.get("column_name") or "").strip()
if not raw_src or not raw_tbl or not raw_col:
return None, f"实体「{entity_key}」的表列绑定须完整填写 source_name、table_name、column_name"
src_row = env.sources_by_norm.get(raw_src.lower())
if src_row is None:
avail_src = "、".join(sorted({r.name for r in env.sources_by_norm.values()})) or "无"
return None, f"未找到已启用的数据源「{raw_src}」(当前可用数据源:{avail_src})"
canon_tbl, canon_col, problem = _resolve_table_and_column(src_row, raw_tbl, raw_col)
if problem or canon_tbl is None or canon_col is None:
return None, problem or "表列核验未通过"
target_status = "confirmed" if mode == "confirmed" else "proposed"
target_source = "human" if mode == "confirmed" else "ai"
bkey = (src_row.name.lower(), canon_tbl.lower(), canon_col.lower())
existing_idx = next(
(
i
for i, b in enumerate(state["bindings"])
if (
str(b.get("source_name") or "").lower(),
str(b.get("table_name") or "").lower(),
str(b.get("column_name") or "").lower(),
)
== bkey
),
None,
)
if existing_idx is not None:
cur = state["bindings"][existing_idx]
cur_status = str(cur.get("status") or "confirmed")
if mode == "proposed" and cur_status in ("confirmed", "verified", "rejected"):
return None, None
if (
cur_status == target_status
and str(cur.get("note") or "") == (note or str(cur.get("note") or ""))
and str(cur.get("reason") or "") == (reason or str(cur.get("reason") or ""))
):
return None, None
before_val = copy.deepcopy(cur)
updated = {
**cur,
"source_id": src_row.id,
"source_name": src_row.name,
"table_name": canon_tbl,
"column_name": canon_col,
"status": target_status,
"source": target_source,
"note": note or str(cur.get("note") or ""),
"reason": reason or str(cur.get("reason") or ""),
"updated_at": utcnow().isoformat(),
}
state["bindings"][existing_idx] = updated
else:
before_val = None
updated = {
"id": new_id()[:12],
"source_id": src_row.id,
"source_name": src_row.name,
"table_name": canon_tbl,
"column_name": canon_col,
"status": target_status,
"source": target_source,
"note": note,
"reason": reason,
"updated_at": utcnow().isoformat(),
}
state["bindings"].append(updated)
payload = {
"op": "upsert_binding",
"entity_key": entity_key,
"entity_label": state["label"],
"source_name": src_row.name,
"table_name": canon_tbl,
"column_name": canon_col,
"note": note,
"reason": reason,
}
action_word = "新增" if before_val is None else "更新"
return EntityPatchChange(
id=_change_id(op, entity_key, f"{src_row.name}.{canon_tbl}.{canon_col}"),
op=op,
entity_key=entity_key,
entity_label=state["label"],
summary=f"{action_word}实体「{state['label']}」表列绑定:{src_row.name}.{canon_tbl}.{canon_col}",
before=before_val,
after=updated,
reason=reason,
op_payload=payload,
), None
if op == "remove_binding":
raw_src = str(cleaned.get("source_name") or "").strip()
raw_tbl = str(cleaned.get("table_name") or "").strip()
raw_col = str(cleaned.get("column_name") or "").strip()
bkey = (raw_src.lower(), raw_tbl.lower(), raw_col.lower())
existing_idx = next(
(
i
for i, b in enumerate(state["bindings"])
if (
str(b.get("source_name") or "").lower(),
str(b.get("table_name") or "").lower(),
str(b.get("column_name") or "").lower(),
)
== bkey
),
None,
)
if existing_idx is None:
return None, f"实体「{entity_key}」中不存在待移除的绑定「{raw_src}.{raw_tbl}.{raw_col}」"
removed = state["bindings"].pop(existing_idx)
payload = {
"op": "remove_binding",
"entity_key": entity_key,
"source_name": removed.get("source_name"),
"table_name": removed.get("table_name"),
"column_name": removed.get("column_name"),
"reason": reason,
}
return EntityPatchChange(
id=_change_id(op, entity_key, f"{raw_src}.{raw_tbl}.{raw_col}"),
op=op,
entity_key=entity_key,
entity_label=state["label"],
summary=f"移除实体「{state['label']}」表列绑定:{removed.get('source_name')}.{removed.get('table_name')}.{removed.get('column_name')}",
before=removed,
after=None,
reason=reason,
op_payload=payload,
), None
if op == "upsert_member":
val = str(cleaned.get("value") or "").strip()
if not val:
return None, f"实体「{entity_key}」成员映射缺少标准值(value)"
label = str(cleaned.get("label") or val).strip() or val
raw_aliases = cleaned.get("aliases")
if raw_aliases is not None and not isinstance(raw_aliases, list):
return None, f"实体「{entity_key}」成员「{val}」的 aliases 需要是字符串数组"
raw_codes = cleaned.get("source_codes")
if raw_codes is not None and not isinstance(raw_codes, Mapping):
return None, f"实体「{entity_key}」成员「{val}」的 source_codes 需要是「数据源名 → 编码」对象"
canon_codes: dict[str, str] = {}
for src_k, code_v in (raw_codes or {}).items():
s_str = str(src_k or "").strip()
c_str = str(code_v or "").strip()
if not s_str or not c_str:
continue
src_row = env.sources_by_norm.get(s_str.lower())
canon_src = src_row.name if src_row is not None else s_str
# 检查该实体在此数据源上的绑定是否涉及敏感遮罩列
masked_set = env.masked_by_source.get(canon_src.lower(), set())
if masked_set:
for b in state["bindings"]:
if str(b.get("source_name") or "").lower() == canon_src.lower():
if _is_column_masked(masked_set, str(b.get("table_name") or ""), str(b.get("column_name") or "")):
return (
None,
f"数据源「{canon_src}」表列「{b.get('table_name')}.{b.get('column_name')}」为敏感遮罩列,禁止为其配置成员编码映射",
)
canon_codes[canon_src] = c_str
m_idx = next(
(i for i, m in enumerate(state["members"]) if str(m.get("value") or "").strip().lower() == val.lower()),
None,
)
if m_idx is not None:
cur_m = state["members"][m_idx]
before_m = copy.deepcopy(cur_m)
merged_aliases = list(cur_m.get("aliases") or [])
seen_ma = {a.lower() for a in merged_aliases if isinstance(a, str)}
for item in raw_aliases or []:
if isinstance(item, str) and item.strip() and item.strip().lower() not in seen_ma:
seen_ma.add(item.strip().lower())
merged_aliases.append(item.strip())
merged_codes = dict(cur_m.get("source_codes") or {})
for cs, cv in canon_codes.items():
if mode == "proposed" and cs in merged_codes and str(merged_codes[cs]).strip():
continue
merged_codes[cs] = cv
target_mlabel = str(cur_m.get("label") or val) if mode == "proposed" else label
after_m = {
"value": str(cur_m.get("value") or val),
"label": target_mlabel,
"aliases": merged_aliases,
"source_codes": merged_codes,
}
if before_m == after_m:
return None, None
state["members"][m_idx] = after_m
else:
before_m = None
clean_aliases = []
seen_ma = set()
for item in raw_aliases or []:
if isinstance(item, str) and item.strip() and item.strip().lower() not in seen_ma:
seen_ma.add(item.strip().lower())
clean_aliases.append(item.strip())
after_m = {
"value": val,
"label": label,
"aliases": clean_aliases,
"source_codes": canon_codes,
}
state["members"].append(after_m)
payload = {
"op": "upsert_member",
"entity_key": entity_key,
"entity_label": state["label"],
"value": after_m["value"],
"label": after_m["label"],
"aliases": after_m["aliases"],
"source_codes": after_m["source_codes"],
"reason": reason,
}
code_desc = ",".join(f"{k}={v}" for k, v in after_m["source_codes"].items())
suffix = f"({code_desc})" if code_desc else ""
return EntityPatchChange(
id=_change_id(op, entity_key, after_m["value"].lower()),
op=op,
entity_key=entity_key,
entity_label=state["label"],
summary=f"{'新增' if before_m is None else '更新'}实体「{state['label']}」成员「{after_m['label']}({after_m['value']})」{suffix}",
before=before_m,
after=after_m,
reason=reason,
op_payload=payload,
), None
# upsert_caliber_param
raw_wf = str(cleaned.get("workflow_id") or "").strip()
raw_card = str(cleaned.get("card_id") or "").strip()
raw_param = str(cleaned.get("param_name") or "").strip()
if not raw_wf or not raw_param:
return None, f"实体「{entity_key}」口径参数映射须提供 workflow_id 与 param_name"
wf_row = env.workflows_by_id.get(raw_wf) or env.workflows_by_name.get(raw_wf.lower())
if wf_row is None:
return None, f"不存在工作流「{raw_wf}」,请核对已发布口径的工作流 ID"
valid_inputs = env.workflow_inputs.get(wf_row.id, set())
canon_param = next((p for p in valid_inputs if p.lower() == raw_param.lower()), None)
if canon_param is None:
sample_in = "、".join(sorted(valid_inputs)) or "无入口参数"
return None, f"工作流「{wf_row.name}({wf_row.id})」入口节点不存在参数「{raw_param}」(可用参数:{sample_in})"
valid_cards = env.workflow_cards.get(wf_row.id, set())
canon_card = ""
if raw_card:
matched_card = next((c for c in valid_cards if c.lower() == raw_card.lower()), None)
if matched_card is None:
sample_cd = "、".join(sorted(valid_cards)) or "无口径卡节点"
return None, f"工作流「{wf_row.name}({wf_row.id})」中不存在口径卡「{raw_card}」(可用口径卡:{sample_cd})"
canon_card = matched_card
target_status = "confirmed" if mode == "confirmed" else "proposed"
target_source = "human" if mode == "confirmed" else "ai"
pkey = (wf_row.id, canon_card.lower(), canon_param.lower())
p_idx = next(
(
i
for i, p in enumerate(state["caliber_params"])
if (
str(p.get("workflow_id") or ""),
str(p.get("card_id") or "").lower(),
str(p.get("param_name") or "").lower(),
)
== pkey
),
None,
)
if p_idx is not None:
cur_p = state["caliber_params"][p_idx]
cur_status = str(cur_p.get("status") or "confirmed")
if mode == "proposed" and cur_status in ("confirmed", "verified", "rejected"):
return None, None
if cur_status == target_status and str(cur_p.get("note") or "") == (note or str(cur_p.get("note") or "")):
return None, None
before_p = copy.deepcopy(cur_p)
after_p = {
**cur_p,
"workflow_id": wf_row.id,
"card_id": canon_card,
"param_name": canon_param,
"status": target_status,
"source": target_source,
"note": note or reason or str(cur_p.get("note") or ""),
}
state["caliber_params"][p_idx] = after_p
else:
before_p = None
after_p = {
"id": new_id()[:12],
"workflow_id": wf_row.id,
"card_id": canon_card,
"param_name": canon_param,
"status": target_status,
"source": target_source,
"note": note or reason,
}
state["caliber_params"].append(after_p)
payload = {
"op": "upsert_caliber_param",
"entity_key": entity_key,
"entity_label": state["label"],
"workflow_id": wf_row.id,
"card_id": canon_card,
"param_name": canon_param,
"note": note or reason,
"reason": reason,
}
card_part = f" · {canon_card}" if canon_card else ""
return EntityPatchChange(
id=_change_id(op, entity_key, f"{wf_row.id}:{canon_card}:{canon_param}"),
op=op,
entity_key=entity_key,
entity_label=state["label"],
summary=f"{'新增' if before_p is None else '更新'}实体「{state['label']}」口径入参映射:{wf_row.name}{card_part} → {canon_param}",
before=before_p,
after=after_p,
reason=reason,
op_payload=payload,
), None
async def plan_entity_patch(
session: AsyncSession,
raw_ops: Sequence[Any],
*,
pending_ops: Sequence[Any] = (),
mode: Literal["confirmed", "proposed"] = "confirmed",
) -> EntityPatchPlan:
"""校验一批增量修改操作并生成结构化变更预览,不写入数据库。"""
env = await _load_env(session)
working: dict[str, dict[str, Any]] = {}
# 先回放界面待保存的操作,保证后续新增实体/成员在此基础上叠加
for p_idx, pop in enumerate(pending_ops or ()):
_normalize_single_op(pop, index=p_idx, env=env, working=working, mode=mode)
plan = EntityPatchPlan()
for idx, item in enumerate(raw_ops or ()):
change, problem = _normalize_single_op(item, index=idx, env=env, working=working, mode=mode)
if problem:
plan.problems.append(problem)
continue
if change is not None:
plan.changes.append(change)
touched_keys = {c.entity_key for c in plan.changes}
for ekey in sorted(touched_keys):
existing = env.entities_by_key.get(ekey)
plan.expected_versions[ekey] = int(existing.version or 1) if existing is not None else 0
return plan
async def apply_entity_patch(
session: AsyncSession,
raw_ops: Sequence[Any],
*,
mode: Literal["confirmed", "proposed"],
actor: str | None = None,
expected_versions: Mapping[str, int] | None = None,
) -> dict[str, Any]:
"""将一批增量操作合并并经 entities.create_entity / update_entity 统一落库。"""
if not raw_ops:
raise entities.EntityInvalid("没有可保存的实体修改项", field="operations")
env = await _load_env(session)
working: dict[str, dict[str, Any]] = {}
changes: list[EntityPatchChange] = []
problems: list[str] = []
for idx, item in enumerate(raw_ops):
change, problem = _normalize_single_op(item, index=idx, env=env, working=working, mode=mode)
if problem:
problems.append(problem)
continue
if change is not None:
changes.append(change)
if mode == "confirmed" and problems:
raise entities.EntityInvalid(";".join(problems), field="operations")
touched_keys = list(dict.fromkeys(c.entity_key for c in changes))
if mode == "confirmed" and expected_versions is not None:
for ekey in touched_keys:
if ekey not in expected_versions:
continue
exp_ver = int(expected_versions[ekey])
cur_row = env.entities_by_key.get(ekey)
cur_ver = int(cur_row.version or 1) if cur_row is not None else 0
if exp_ver != cur_ver:
label = cur_row.label if cur_row is not None else ekey
raise entities.EntityConflict(
f"标准实体「{label}」在对话期间已被修改(基准 v{exp_ver},当前 v{cur_ver}),请刷新后再应用修改",
field="expected_versions",
)
created_count = 0
updated_count = 0
saved_entities: list[dict[str, Any]] = []
for ekey in touched_keys:
state = working.get(ekey)
if state is None:
continue
payload: dict[str, Any] = {
"key": state["key"],
"label": state["label"],
"description": state["description"],
"aliases": state["aliases"],
"members": state["members"],
"bindings": state["bindings"],
"caliber_params": state["caliber_params"],
}
cur_row = env.entities_by_key.get(ekey)
if cur_row is None:
row = await entities.create_entity(session, payload, actor=actor)
created_count += 1
else:
payload["version"] = int(cur_row.version or 1)
row = await entities.update_entity(session, cur_row.id, payload, actor=actor)
updated_count += 1
saved_entities.append(entities.entity_to_dict(row))
proposed_bindings = sum(1 for c in changes if c.op == "upsert_binding")
proposed_params = sum(1 for c in changes if c.op == "upsert_caliber_param")
added_members = sum(1 for c in changes if c.op == "upsert_member")
return {
"applied_count": len(changes),
"created_entities": created_count,
"updated_entities": updated_count,
"bindings_changed": proposed_bindings,
"caliber_params_changed": proposed_params,
"members_changed": added_members,
"entities": saved_entities,
"problems": problems,
}