"""发布前自动修复。 发布门禁拦下的问题,以前只能自己动手、或者打开 Copilot 描述一遍。这里把「答案唯一、不降低 要求」的那些做成确定性的修复,一律先给预览,人确认后前端走现有的保存接口: - auto:答案唯一。出口上游恰好一张口径卡、审批从「全部自动放行」改成「仅危险工具需要审批」… - choice:要人拿主意。required 选哪几个指标、多张口径卡里选哪几张——列候选,不替人选, 也不给「默认全选」 - assist:结构性的(协作团队节点、多个出口…),交给 Copilot,产出同样只是预览 任何修复都不能降低要求(forbidden_changes);应用后重跑 validate 和门禁,错误必须变少、 不能冒出新的错误(judge),做不到的修复丢弃并写明原因。 本模块是纯函数:库里的东西(子工作流当前的发布版本、钉在别处的口径卡有哪些指标)由调用方 查好传进来。改图直接改原始的图字典,只动修复涉及的那几个字段:坐标、连线 id、视口这些 前端的东西原样留着,存回去不会丢。 """ from __future__ import annotations import copy import re from typing import Any, Callable, Iterator from pydantic import BaseModel from app.engine.governance import ( JUDGE_UNLIMITED, REPORT_POLICY, REPORT_POLICY_ALSO, exit_text_fields, publish_issues, ) from app.engine.labels import CHOICE_LABEL, contract_label, field_label, option_label, q from app.engine.schema import ( CLAIMS_JUDGE, JUDGE_ON_UNSUPPORTED, REPORT_CLAIMS, GraphNode, GraphSpec, NodeType, _ancestors, claims_problem, ) #: 被选中的修复按这个顺序应用:先补契约骨架,再补契约里的各项,最后是节点上的开关。 #: 前面的修复可能改变后面的候选(metrics_from 决定 required 从哪几张卡里挑,report_from 决定 #: 成果字段默认接哪个报告撰写节点) _ORDER = ( "governed.no_contract", "contract.metrics_from_missing", "contract.metrics_from_invalid", "contract.report_from_missing", "contract.report_from_invalid", "governed.report_from_required", "governed.exit_text_source", "report.metrics_from_invalid", "contract.required_missing", "contract.cells_undeclared", "contract.strict_off", "contract.claims_ignored", "report.claims_invalid", "governed.report_policy", "governed.judge_budget", "governed.caliber_agent_cite_fields", "governed.caliber_compute_input", "governed.agent_approval_never", "governed.default_approval_never", "governed.subgraph_unpinned", "governed.supervisor", "contract.not_object", "governed.text_bypass", "governed.caliber_agent_schema", "governed.caliber_model_input", "provider.missing", "provider.disabled", ) #: 修复落在全图上、不落在任何节点上的问题:问题各挂在跟随全图默认的那几个节点上(点一条能定位), #: 修复只有一条(id 是 code:graph),改的是图级 defaults,一改几个节点一起好 _GRAPH_FIXES = frozenset({"governed.default_approval_never"}) #: 除了 error,这几条警告也给修复(受管档位下)。已发布档位的门禁只给警告,不借「一键修复」 #: 替人把审批、strict 这些往受管的要求上拧 _WARNING_FIXES = {"governed": frozenset({"contract.strict_off", "contract.claims_ignored"})} #: choice 类修复里「交给 Copilot」那个候选的值。候选上另有 handoff: true 标记:只认标记,不认值—— #: 别的 choice 拿节点 id 当候选值,节点 id 恰好叫 copilot 的报告撰写节点照样是个普通候选 HANDOFF = "copilot" ASSIST_ONLY = "这一处属于结构性问题,需要交给助手处理" STALE = "这一处问题已不存在,请重新检查" SOLVED = "前面的修复已一并解决了这一处" NEED_CHOICE = "这一处需要你选择:{label}" #: 修复和 Copilot 兜底只许改节点配置、补节点和连线。其余的图操作(删节点、删连线)和 #: 任何不认识的操作(发布、改级别…)一律当降低要求处理 _GRAPH_OPS = frozenset({"add_node", "update_node", "add_edge"}) # -------------------------------------------------------------------------- # 给出修复 # -------------------------------------------------------------------------- class _Ctx: def __init__(self, spec: GraphSpec, versions: dict[str, Any] | None, cards: dict[str, Any] | None, level: str = "governed", providers: list[Any] | None = None) -> None: self.spec = spec self.nodes = spec.node_map() self.versions = versions or {} self.cards = cards or {} self.level = level #: 模型接入表(provider_refs.load_providers):点名的接入出了问题时,候选从里面启用的那些里挑 self.providers = providers or [] self._provider_refs: dict[tuple[str | None, str], Any] | None = None def provider_ref(self, node_id: str | None, field: str) -> Any: """图里写着接入或模型的那一处(provider_refs.ProviderRef)。整张图只算一遍:每条接入问题都去扫一遍全图, 几十处同时坏掉时就是几十遍。""" if self._provider_refs is None: from app.engine.provider_refs import provider_refs self._provider_refs = {(r.node_id, r.field): r for r in provider_refs(self.spec)} return self._provider_refs.get((node_id, field)) def upstream(self, node_id: str, *types: NodeType) -> list[GraphNode]: """这个节点上游的某几类节点,按画布上的节点顺序。""" above = _ancestors(self.spec, node_id) return [n for n in self.spec.nodes if n.id in above and n.type in types] def metrics_of(self, card: GraphNode) -> list[dict[str, Any]]: """一张口径卡定义的指标。钉在别处(caliber_from)的卡本地没写,用调用方从那一版里查出来的。""" local = [d for d in card.config.get("metrics") or [] if isinstance(d, dict) and d.get("id")] if local and not isinstance(card.config.get("caliber_from"), dict): return local return [d for d in self.cards.get(card.id) or [] if isinstance(d, dict) and d.get("id")] def _title(node: GraphNode | None) -> str: return node.title if node is not None else "" def _auto(label: str, field: str, before: Any, after: Any) -> dict[str, Any]: return {"kind": "auto", "label": label, "field": field, "preview": {"field": field, "before": copy.deepcopy(before), "after": copy.deepcopy(after)}, "_set": {field: after}} def _choice(label: str, field: str, options: list[dict[str, Any]], *, multiple: bool = False, default: Any = None, **extra: Any) -> dict[str, Any]: out = {"kind": "choice", "label": label, "field": field, "options": options, "multiple": multiple, **extra} if default is not None: out["default"] = default return out def _assist(label: str, field: str | None = None) -> dict[str, Any]: # 界面上这类修复的按钮就叫「交给 Copilot」,label 只写要它做什么、或者为什么确定性修复做不了 return {"kind": "assist", "label": label, "field": field} def _node_options(nodes: list[GraphNode]) -> list[dict[str, Any]]: from app.engine.schema import type_label return [{"value": n.id, "label": f"「{n.title}」", "hint": type_label(n.type)} for n in nodes] def _contract(node: GraphNode) -> dict[str, Any]: contract = node.config.get("contract") return contract if isinstance(contract, dict) else {} def _as_list(value: Any) -> list[Any]: if value in (None, ""): return [] return [value] if isinstance(value, str) else list(value) if isinstance(value, list) else [value] def _cards_choice(ctx: _Ctx, node: GraphNode, before: Any, field: str) -> dict[str, Any]: cards = ctx.upstream(node.id, NodeType.METRICS) if len(cards) == 1: return _auto(f"出具契约的「指标来自」设为上游唯一的口径卡「{cards[0].title}」", field, before, [cards[0].id]) if cards: return _choice("选择出具契约的「指标来自」:从哪几个口径卡取指标", field, _node_options(cards), multiple=True) return _assist("成果节点上游没有口径卡,出具契约的「指标来自」无处可选", field) def _plan_metrics_from_missing(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _cards_choice(ctx, node, _contract(node).get("metrics_from"), "contract.metrics_from") def _plan_metrics_from_invalid(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None before = _contract(node).get("metrics_from") keep = [s for s in _as_list(before) if isinstance(s, str) and getattr(ctx.nodes.get(s), "type", None) == NodeType.METRICS] if keep: bad = [str(s) for s in _as_list(before) if s not in keep and str(s).strip()] return _auto(f"从出具契约的「指标来自」中去掉不存在的{'、'.join(map(q, bad)) or '空项'}", "contract.metrics_from", before, keep) return _cards_choice(ctx, node, before, "contract.metrics_from") def _reports_choice(ctx: _Ctx, node: GraphNode, before: Any) -> dict[str, Any]: """report_from 指向哪个报告撰写节点。以前没有报告撰写节点时退回让人选 narrative(旧的叙述模式), 可受管级别的叙述模式过不了 G1(governed.report_from_required),已发布级别这几条又只是警告、不给 修复——那个候选修了也发不出去,所以没有报告撰写节点就交给 Copilot 加一个。""" field = "contract.report_from" reports = ctx.upstream(node.id, NodeType.REPORT) if len(reports) == 1: return _auto(f"出具契约的「报告来自」设为上游唯一的「报告撰写」节点「{reports[0].title}」", field, before, reports[0].id) if reports: return _choice("选择出具契约的「报告来自」:核对哪个「报告撰写」节点的文档", field, _node_options(reports)) return _assist("成果节点上游没有「报告撰写」节点:需要在成果节点之前添加一个,出具契约才有文档可核对", field) def _plan_report_from_missing(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _reports_choice(ctx, node, _contract(node).get("report_from")) def _plan_report_from_required(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _reports_choice(ctx, node, _contract(node).get("report_from")) def _report_text(report_id: str) -> str: return f"{{{{ nodes.{report_id}.text }}}}" def _plan_exit_fields(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """G1:成果字段改成取报告撰写节点的正文。就一个候选也给选项——字段里放什么是作者的事, 改了就是换掉原来的内容;报告撰写节点一个都没有,交给 Copilot 加。""" if node is None: return None bad = exit_text_fields(ctx.spec, node) if not bad: return None named = [f"「{name}」" for _, name, _ in bad if name is not None] action = f"把成果字段{'、'.join(named)}改为取" if named else "为成果节点添加成果字段 result,取" reports = ctx.upstream(node.id, NodeType.REPORT) if not reports: return _assist(f"「{node.title}」上游没有报告撰写节点:需要在成果节点之前添加一个,{action}它的正文", "fields") options = [{**o, "hint": f"{o['hint']} · {_report_text(o['value'])}"} for o in _node_options(reports)] pinned = _contract(node).get("report_from") default = pinned if pinned in {r.id for r in reports} else reports[0].id if len(reports) == 1 else None return _choice(f"{action}哪个报告撰写节点的正文", "fields", options, default=default, _fields=[field for field, _, _ in bad]) def _plan_report_policy(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """G3:报告撰写节点写明最严的三项。答案唯一,只往严里改;写了不认识的 claims 由 claims_invalid 管。 已经写了更严的写法(claims: judge)的不动——改回 require_citation 就是替人放宽了。""" if node is None: return None writes: dict[str, Any] = {} before: dict[str, Any] = {} for key, want, _ in REPORT_POLICY: value = node.config.get(key) effective = ctx.spec.defaults.get(key) if value in (None, "") else value if effective in (want, *REPORT_POLICY_ALSO.get(key, ())): continue if not (key == "claims" and claims_problem(effective)): writes[key], before[key] = want, copy.deepcopy(value) if not writes: return None keys = list(writes) preview = ({"field": keys[0], "before": before[keys[0]], "after": writes[keys[0]]} if len(keys) == 1 else {"field": "、".join(keys), "before": before, "after": writes}) said = "、".join(f"「{field_label(k)}」设为「{option_label(k, v)}」" for k, v in writes.items()) return {"kind": "auto", "label": f"将「{node.title}」的{said}", "field": keys[0], "preview": preview, "_set": writes} def _plan_claims(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """claims 写了不认识的值。受管级别改成 require_citation:确定性的两档里只有它合规,答案唯一(judge 要花钱、 要写预算,不替人开);已发布级别 off 和 require_citation 都行,要人选。""" if node is None: return None before = node.config.get("claims") if ctx.level == "governed": return _auto(f"将「{node.title}」的「{field_label('claims')}」改为「{option_label('claims', 'require_citation')}」:" "受管级别出具要求每句结论挂依据", "claims", before, "require_citation") # value 是配置里的枚举值,label 和节点检查器的选项文字一致 hints = {"off": "结论句未附依据也不影响出具档位", "require_citation": "未附依据的结论句计入缺口"} options = [{"value": v, "label": CHOICE_LABEL["claims"][v], "hint": hints[v]} for v in REPORT_CLAIMS] return _choice(f"选择「{node.title}」的「{field_label('claims')}」如何处理", "claims", options) def _plan_judge_budget(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """G3:claims: judge 没写每份报告的裁判预算。写多少钱只有作者能定:候选是默认金额和「不限」,不替人选、 不给建议。judge 原来写在图级 defaults 里的,写到节点上时带上 defaults 那一整份——节点写了 judge 就整个 用节点的,只补一个 max_cost_usd 会把 defaults 里选的裁判模型、上限悄悄丢掉。""" from app.engine.judge import JUDGE_DEFAULTS if node is None: return None judge = node.config.get("judge") if judge in (None, ""): judge = ctx.spec.defaults.get("judge") base = copy.deepcopy(judge) if isinstance(judge, dict) else {} amount = JUDGE_DEFAULTS["report_max_cost_usd"] options = [ {"value": amount, "label": f"每份报告最多 ${amount:.2f}", "hint": "系统默认的每份报告上限;写入节点后,修改设置不影响这份报告"}, {"value": None, "label": "不限", "hint": JUDGE_UNLIMITED}, ] return _choice(f"为「{node.title}」的结论句裁判设置每份报告的金额上限", "judge.max_cost_usd", options, _judge=base) #: 「交给助手」候选的说明 _ASSIST_PREVIEW = "助手的修改同样只是预览,需要你决定的事项会交给你确认" def _plan_cells_undeclared(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """没有口径卡的受管图没写 cells:报告里的数要么直接引用查询单元格(写 cells: true),要么登记成口径卡 指标(交给 Copilot 加卡、改引用)。受管级别打开 cells 要作者显式决定,所以只给选项,不替人选。""" if node is None: return None options = [ {"value": True, "label": "开启「单元格引用」", "hint": "报告中的数字可直接查看查询结果中的对应单元格,每个单元格都对照快照核对"}, {"value": HANDOFF, "label": "交给助手:添加口径卡,把数字登记为指标", "hint": _ASSIST_PREVIEW, "handoff": True}, ] return _choice(f"「{node.title}」的报告只能引用查询单元格:开启「单元格引用」,还是改为引用口径卡指标?", "contract.cells", options) def _plan_compute_input(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """G4:沙箱代码到底是在取数还是在算,只有作者知道。取数的标成 source;在算的交给 Copilot 把计算 挪进口径卡——两样都不替人选,也不给建议。""" if node is None: return None options = [ {"value": "source", "label": "负责取数:将「证据角色」设为「取数」", "hint": "口径卡读取它时会记录出处"}, {"value": HANDOFF, "label": "负责计算:交给助手把计算移到口径卡", "hint": _ASSIST_PREVIEW, "handoff": True}, ] return _choice(f"「{node.title}」的产出提供给了口径卡:它负责取数,还是负责计算?", "evidence_role", options) def _plan_cite_fields(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _auto(f"开启「{node.title}」的「按出处核对字段」:每个字段都核对到查询结果中的对应单元格", "cite_fields", node.config.get("cite_fields"), True) def _plan_agent_schema(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: return _assist(f"为「{_title(node)}」配置「结构化输出 Schema」:输出哪些字段、字段类型需要由你或助手确定," "自动修复不会代为填写;配置好后再开启「按出处核对字段」", "output_schema") def _plan_model_input(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: return _assist(f"「{_title(node)}」写的数字进入了口径卡:需要改用 Agent(配置「结构化输出 Schema」、开启" "「按出处核对字段」)或「调用工具」节点取数。修复不会删除节点,助手会拟定一个修改方案") def _plan_text_bypass(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: return _assist(f"「{_title(node)}」写的文字需要改由报告撰写节点撰写:在它和成果节点之间添加一个报告撰写节点," f"成果节点改为取报告的正文。修复不会删除节点,「{_title(node)}」会保留在工作流中") def _plan_report_from_invalid(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _reports_choice(ctx, node, _contract(node).get("report_from")) def _plan_report_metrics_from(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None before = node.config.get("metrics_from") above = _ancestors(ctx.spec, node.id) keep = [s for s in _as_list(before) if isinstance(s, str) and s in above and getattr(ctx.nodes.get(s), "type", None) == NodeType.METRICS] cards = ctx.upstream(node.id, NodeType.METRICS) added = cards[0] if len(cards) == 1 and cards[0].id not in keep else None after = [*keep, *([added.id] if added else [])] bad = [str(s) for s in _as_list(before) if s not in keep and str(s).strip()] label = f"「{node.title}」的「指标来自」" + (f"去掉无效的{'、'.join(map(q, bad))}" if bad else "改为口径卡节点列表") return _auto(label + (f",补上上游唯一的口径卡「{added.title}」" if added else ""), "metrics_from", before, after) def _metric_options(ctx: _Ctx, cards: list[GraphNode]) -> list[dict[str, Any]]: options: dict[str, dict[str, Any]] = {} for card in cards: for d in ctx.metrics_of(card): mid = str(d["id"]) name = str(d.get("name") or mid) unit = str(d.get("unit") or "") options.setdefault(mid, {"value": mid, "label": name if name == mid else f"{name}({mid})", "hint": f"口径卡「{card.title}」" + (f" · {unit}" if unit else "")}) return list(options.values()) def _plan_required(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None named = [ctx.nodes[s] for s in _as_list(_contract(node).get("metrics_from")) if isinstance(s, str) and getattr(ctx.nodes.get(s), "type", None) == NodeType.METRICS] options = _metric_options(ctx, named or ctx.upstream(node.id, NodeType.METRICS)) if not options: return _assist("无法列出口径卡中的指标,因此无法提供「必需指标」的候选", "contract.required") # 不给默认:哪些指标缺了就不予出具,是作者的决定 return _choice("选择受管级别出具的「必需指标」:缺少其中任何一个都将不予出具", "contract.required", options, multiple=True) def _plan_strict(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _auto("开启出具契约的「严格模式」:没有出处的数字直接拦截,而不只是降档", "contract.strict", _contract(node).get("strict"), True) def _plan_claims_ignored(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """受管契约的 claims 写了 on_uncited: ignore:受管正式运行本来就按 degrade 办,改成 degrade 答案唯一、 不降低要求,只是让写法和实际一致。""" if node is None: return None claims = _contract(node).get("claims") if not isinstance(claims, dict): return None return _auto(f"将出具契约的「{contract_label('on_uncited')}」改为「{option_label('on_uncited', 'degrade')}」,与受管级别正式运行的" "实际处理保持一致", "contract.claims.on_uncited", claims.get("on_uncited"), "degrade") def _plan_approval(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None return _auto(f"将「{node.title}」的审批策略改为「{option_label('approval', 'dangerous')}」", "approval", node.config.get("approval"), "dangerous") def _plan_default_approval(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: followers = [n.title for n in ctx.spec.nodes if n.type == NodeType.AGENT and n.config.get("tools") and n.config.get("approval") in (None, "")] who = "、".join(f"「{t}」" for t in followers[:3]) + (f" 等 {len(followers)} 个节点" if len(followers) > 3 else "") return _auto(f"将{field_label('defaults')}中的审批策略改为「{option_label('approval', 'dangerous')}」({who or '跟随默认设置的节点'}一并生效)", "defaults.approval", ctx.spec.defaults.get("approval"), "dangerous") def _plan_subgraph(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return None wf = node.config.get("workflow_id") version = ctx.versions.get(wf) if wf else None if isinstance(version, int) and not isinstance(version, bool) and version > 0: return _auto(f"将「{node.title}」固定到上游当前的发布版本 v{version}", "workflow_version", node.config.get("workflow_version"), version) # 上游没发布过:不替人挑一个草稿版本钉上去 return _assist(f"「{node.title}」嵌套的工作流还没有发布版本,无法自动固定版本。请先发布该工作流,或交给助手处理", "workflow_version") def _plan_no_contract(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: if node is None: return _assist("工作流中有多个成果节点(或没有成果节点),无法确定应为哪个节点生成出具契约") cards = ctx.upstream(node.id, NodeType.METRICS) reports = ctx.upstream(node.id, NodeType.REPORT) if len(cards) != 1 or len(reports) != 1: return _assist(f"「{node.title}」上游的口径卡和报告撰写节点不是各恰好一个,无法确定出具契约应指向哪个", "contract") options = _metric_options(ctx, cards) if not options: return _assist(f"无法列出口径卡「{cards[0].title}」中的指标,因此无法提供「必需指标」的候选", "contract") return _choice(f"为「{node.title}」生成出具契约:核对「{reports[0].title}」的文档,指标来自「{cards[0].title}」," "再选择「必需指标」", "contract", options, multiple=True, _skeleton={"report_from": reports[0].id, "metrics_from": [cards[0].id], "strict": True}) def _plan_supervisor(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: # 修复不许删节点、不许覆盖或换类型,Copilot 替换不了它,只能拟一个拆分方案交给人 return _assist(f"「{_title(node)}」需要改为配置固定的 Agent 或「模型调用」节点:修复不能删除节点," "助手只会给出拆分建议,替换需要由你完成") def _plan_not_object(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: return _assist(f"「{_title(node)}」的出具契约不是一个 JSON 对象", "contract") #: 「不指定接入」(只写了模型的那一处是「不指定模型」)那个候选的值:去掉这一处写的接入(模型) PROVIDER_CLEAR = "" def _path_steps(path: str) -> list[str | int]: """agents[0].provider → ['agents', 0, 'provider']。""" return [int(i) if i else key for i, key in re.findall(r"\[(\d+)\]|([^.\[\]]+)", path)] def _step(root: Any, step: str | int) -> Any: if isinstance(step, int): return root[step] if isinstance(root, list) and step < len(root) else None return root.get(step) if isinstance(root, dict) else None def _read_path(root: Any, path: str) -> Any: for step in _path_steps(path): if (root := _step(root, step)) is None: return None return root def _put_path(root: dict[str, Any], path: str, value: Any) -> Any: """按路径写(provider、judge.provider、agents[0].provider),value 为 None 时去掉这一项。返回原值; 路径上缺了哪一层就什么都不写,返回 None。""" *heads, last = _path_steps(path) cur: Any = root for step in heads: cur = _step(cur, step) if not isinstance(cur, dict) or not isinstance(last, str): return None before = copy.deepcopy(cur.get(last)) if value is None: cur.pop(last, None) else: cur[last] = copy.deepcopy(value) return before def _model_path(path: str) -> str: """接入旁边的模型:provider → model,judge.provider → judge.model,agents[0].provider → agents[0].model。""" return path[: -len("provider")] + "model" def _landing_text(landed: list[tuple[Any, Any]]) -> str: """修完以后执行时改用哪里(provider_refs.land 的说法):各处落到同一处只说一次,落到不同处逐处说。""" groups: dict[str, list[str]] = {} for use, landing in landed: groups.setdefault(landing.where, []).append(use.who) if len(groups) == 1: return f"改用{next(iter(groups))}" return ";".join(f"{'、'.join(whos)}改用{where}" for where, whos in groups.items()) def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定(provider_choice)。""" ref = ctx.provider_ref(issue.get("node_id"), str(issue.get("field") or "")) return provider_choice(ctx.spec, ref, ctx.providers) if ref is not None else None class _ProviderPlan: """图里一处点名的接入(provider_refs.ProviderRef)的修复候选,一个一个算:provider_choice 要全部,provider_fixable (问题提示指不指到发布前检查)找到第一个落得稳的就停。 点名的接入不存在、已停用的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定 模型」和各个启用的接入(写在模型旁边的接入上)。 每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、 节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供 那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家 必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。 只否决这次改动造成的失败:改动波及的一处要是点名的接入没变、而那个接入本来就不存在或已停用(比如节点点名的接入 写错了,这次改的是它跟随的全图默认模型),它落不稳不是这次改动的事,它自己那条问题照样在、照样拦着发布。拿它否决 的话,两处互相牵连时谁都给不出修复,规划器自己卡住。 改的是节点上的一处时只重算这个节点(provider_refs.node_uses:节点只读自己的配置和全图默认),不复制、不重算整张图; 改的是全图默认时各节点都可能跟着变,整张图重算(这样的引用最多几处)。""" def __init__(self, spec: GraphSpec, ref: Any, providers: list[Any]) -> None: from app.engine.provider_refs import node_uses, uses self.spec, self.ref, self.providers = spec, ref, providers path = ref.field.removeprefix("defaults.") if ref.node_id is None else ref.field if ref.kind == "model": self.model_path, self.provider_path = path, path[: -len("model")] + "provider" else: self.provider_path, self.model_path = path, _model_path(path) self.prefix = "defaults." if ref.node_id is None else "" self.node = spec.node_map()[ref.node_id] if ref.node_id is not None else None model = _read_path(spec.defaults if self.node is None else self.node.config, self.model_path) self.model = model.strip() if isinstance(model, str) and model.strip() else None self.before = {use.key: use for use in (uses(spec) if self.node is None else node_uses(spec, self.node))} #: 一个候选都落不稳时说给人听的:第一个候选最后一次试算里第一处落不稳的地方(provider_refs.Landing.why) self.stuck = "" def _broken_before(self, use: Any) -> bool: """这一处点名的接入改动前后没变,而它本来就不存在或已停用:落不稳不是这次改动造成的。""" from app.providers.factory import ProviderNotConfigured, named_provider was = self.before.get(use.key) if was is None or use.provider is None or use.provider != was.provider: return False try: named_provider(self.providers, use.provider.name) except ProviderNotConfigured: return True return False def _trial(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[Any, Any]] | None, Any]: """在副本上改,改动波及的每一处(取法变了的)执行时落到哪里:(各处落到哪里,None);有一处因这次改动落不稳 时是(None,那一处的 Landing)。改完没有一处落到新地方也算没修好(比如它跟随的那一级也坏着、原因还一样)。""" from app.engine.provider_refs import land, node_uses, uses if self.node is None: defaults = copy.deepcopy(self.spec.defaults) for at, value in edits: _put_path(defaults, at, value) # 浅拷贝:节点原样共用,只换全图默认(uses 只读不写) after = uses(self.spec.model_copy(update={"defaults": defaults})) else: twin = self.node.model_copy(deep=True) for at, value in edits: _put_path(twin.config, at, value) after = node_uses(self.spec, twin) landed = [] for use in after: if self.before.get(use.key) == use: continue if not (landing := land(use, self.providers)).ok: if self._broken_before(use): continue return None, landing landed.append((use, landing)) return landed or None, None def _settle(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None: """先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。""" landed, failed = self._trial(edits) if landed is None and self.model and all(at != self.model_path for at, _ in edits): edits = [*edits, (self.model_path, None)] landed, failed = self._trial(edits) if landed is not None: return edits, landed if not self.stuck and failed is not None: self.stuck = failed.why return None def options(self) -> Iterator[tuple[dict[str, Any], list[tuple[str, Any]]]]: """落得稳的候选(选项,改法),先「不指定」,再按接入表的顺序各个启用的接入。""" from app.providers.factory import serves ref, model = self.ref, self.model if (got := self._settle([(self.model_path if ref.kind == "model" else self.provider_path, None)])) is not None: edits, landed = got what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "") option = {"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}", "hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"} yield _demo_mark(option, landed), edits seen = {PROVIDER_CLEAR} for p in self.providers: if not getattr(p, "enabled", False) or not getattr(p, "name", None) or p.name in seen: continue seen.add(p.name) if (got := self._settle([(self.provider_path, p.name)])) is None: continue edits, landed = got notes = [] if len(edits) > 1: # 这一处原来用的就是去掉的那个模型:换上的接入实际用哪个模型(多半是它的默认模型,上一级写了模型就是那个) mine = {landing.model for use, landing in landed if (was := self.before.get(use.key)) is not None and was.model is not None and (was.model.node_id, was.model.field) == (ref.node_id, self.prefix + self.model_path)} used = next(iter(mine)) if len(mine) == 1 else None said = [f"不提供模型「{model}」" if not serves(p, model) else f"模型「{model}」一并去掉"] if used: said.append(f"将改用{'它的默认模型' if used == p.default_model else '模型'}「{used}」") elif not serves(p, model): said.append("这一处的模型一并去掉") notes.append(",".join(said)) if getattr(p, "kind", None) == "mock": notes.append(DEMO_PROVIDER) yield _demo_mark({"value": p.name, "label": p.name, "hint": ";".join(notes) or None}, landed), edits def _demo_mark(option: dict[str, Any], landed: list[tuple[Any, Any]]) -> dict[str, Any]: """修完以后有一处落到演示类接入(provider_refs.Landing.demo):候选带上 demo 标记,发布弹窗在选项名旁加一个 醒目的「演示模型」标签。说明里那半句演示说明照旧写,只是淡色小字、排在长句末尾,不够醒目——K1 要防的就是 悄悄换成演示模型。""" return {**option, "demo": True} if any(landing.demo for _, landing in landed) else option def provider_choice(spec: GraphSpec, ref: Any, providers: list[Any]) -> dict[str, Any] | None: """图里一处点名的接入(provider_refs.ProviderRef)的修复:要人选的 choice(_edits 记每个候选怎么改),一个候选都 落不稳时为 None。候选怎么算见 _ProviderPlan。 要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id 带字段):协作节点自己的接入和成员的接入各修各的。候选的说明写清执行时改用哪里、模型去没去掉,落到演示类接入的 照实说并带 demo 标记。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。""" options: list[dict[str, Any]] = [] plans: dict[str, list[tuple[str, Any]]] = {} for option, edits in _ProviderPlan(spec, ref, providers).options(): options.append(option) plans[option["value"]] = edits if not options: # 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,提示里说去设置里核对、启用、卡在哪里 return None return _choice(f"选择{ref.who}改用的模型接入", ref.field, options, _edits=plans) def provider_fixable(spec: GraphSpec, ref: Any, providers: list[Any]) -> tuple[bool, str]: """这一处给不给得出修复:(给得出,卡在哪里)。provider_refs 拿它决定问题提示指不指到发布前检查——给不出修复就别让 人去那里找,改说第一处落不稳的地方(Landing.why)。找到第一个落得稳的候选就停:画布每改一下都要校验,不必为了 一句提示把每个候选都算一遍。""" plan = _ProviderPlan(spec, ref, providers) for _ in plan.options(): return True, "" return False, plan.stuck #: 候选本身是演示类接入(kind 为 mock)时的说明。「不指定」落到演示类接入时说明里另带 provider_refs.DEMO_LANDING DEMO_PROVIDER = "演示用的模型接入,不调用真实模型" _PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = { "governed.agent_approval_never": _plan_approval, "governed.default_approval_never": _plan_default_approval, "governed.subgraph_unpinned": _plan_subgraph, "governed.supervisor": _plan_supervisor, "governed.no_contract": _plan_no_contract, "contract.not_object": _plan_not_object, "contract.metrics_from_missing": _plan_metrics_from_missing, "contract.metrics_from_invalid": _plan_metrics_from_invalid, "contract.report_from_missing": _plan_report_from_missing, "contract.report_from_invalid": _plan_report_from_invalid, "contract.required_missing": _plan_required, "contract.strict_off": _plan_strict, "contract.claims_ignored": _plan_claims_ignored, "report.metrics_from_invalid": _plan_report_metrics_from, # 证据门禁 G1–G5 "governed.report_from_required": _plan_report_from_required, "governed.exit_text_source": _plan_exit_fields, "governed.text_bypass": _plan_text_bypass, "governed.report_policy": _plan_report_policy, "report.claims_invalid": _plan_claims, "governed.judge_budget": _plan_judge_budget, "contract.cells_undeclared": _plan_cells_undeclared, "governed.caliber_compute_input": _plan_compute_input, "governed.caliber_agent_cite_fields": _plan_cite_fields, "governed.caliber_agent_schema": _plan_agent_schema, "governed.caliber_model_input": _plan_model_input, # 点名的模型接入不存在或已停用(只在调用方交了接入表时才有这类问题) "provider.missing": _plan_provider, "provider.disabled": _plan_provider, } #: 点名的模型接入这一类:一处一条修复,复核时只认那个字段上的那一条(同一节点上别的字段可能还有同类问题) _PER_FIELD = frozenset({"provider.missing", "provider.disabled"}) def _as_dict(issue: Any) -> dict[str, Any]: return issue.model_dump() if isinstance(issue, BaseModel) else dict(issue) def _target(ctx: _Ctx, issue: dict[str, Any]) -> str | None: """修复落在哪个节点上。图级的「没有带契约的出口」落在唯一的那个出口上;改全图默认的不落在节点上。""" if issue.get("code") in _GRAPH_FIXES: return None if issue.get("node_id"): return str(issue["node_id"]) if issue.get("code") == "governed.no_contract": outputs = [n for n in ctx.spec.nodes if n.type == NodeType.OUTPUT] return outputs[0].id if len(outputs) == 1 else None return None def plan_fixes(spec: GraphSpec, issues: list[Any], *, level: str, versions: dict[str, Any] | None = None, cards: dict[str, list[dict[str, Any]]] | None = None, providers: list[Any] | None = None) -> list[dict[str, Any]]: """这批问题能怎么修:Fix 列表,按问题出现的顺序,同一处只出一条。 versions:子工作流 id → 它当前的发布版本号(没发布过为 None)。 cards:钉在别处(caliber_from)的口径卡节点 id → 那一版里的指标定义,用来列 required 的候选。 providers:模型接入表,点名的接入出了问题时列候选(governance.lint_for_publish 的同一份)。 返回的 Fix 里以 _ 开头的键是应用时要用的内部信息,交给前端前用 public() 去掉。 """ ctx = _Ctx(spec, versions, cards, level, providers) warned = _WARNING_FIXES.get(level, frozenset()) out: dict[str, dict[str, Any]] = {} for issue in map(_as_dict, issues): code = issue.get("code") planner = _PLANNERS.get(code or "") if planner is None or (issue.get("level") != "error" and code not in warned): continue target = _target(ctx, issue) fid = f"{code}:{target or 'graph'}" + (f":{issue.get('field') or ''}" if code in _PER_FIELD else "") if fid in out: continue fix = planner(ctx, issue, ctx.nodes.get(target) if target else None) if fix: out[fid] = {"id": fid, "code": code, "node_id": target, **fix} return list(out.values()) def public(fix: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in fix.items() if not k.startswith("_")} def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, Any]]: """问题的字典形状,带上对应的修复 id(fix)。validate 和门禁报的同一处问题指向同一个修复。 先按(编号、节点)认;认不出时,图级问题(没有节点)认同一编号的第一条修复,图级修复(不落在节点上,比如改全图 默认)认同一编号的每一条问题。前端 canvas/issues.fixFor 是同一个退路。 点名的模型接入(_PER_FIELD)一处一条修复,只认(编号、节点、字段)都对得上的那一条,没有就是没有:这一处一个 候选都落不稳时修复为空,退到同一节点、同一编号别处的修复上,接口里的 fix 就指到了另一处的修复。 """ exact = {(f["code"], f["node_id"], f.get("field")): f["id"] for f in fixes if f["code"] in _PER_FIELD} by_node = {(f["code"], f["node_id"]): f["id"] for f in fixes} by_code: dict[str, str] = {} by_graph: dict[str, str] = {} for f in fixes: by_code.setdefault(f["code"], f["id"]) if f["node_id"] is None: by_graph.setdefault(f["code"], f["id"]) out = [] for issue in map(_as_dict, issues): code = issue.get("code") if code in _PER_FIELD: out.append({**issue, "fix": exact.get((code, issue.get("node_id"), issue.get("field")))}) continue fid = by_node.get((code, issue.get("node_id"))) if code else None if fid is None and code: fid = by_code.get(code) if issue.get("node_id") is None else by_graph.get(code) out.append({**issue, "fix": fid}) return out def check(spec: GraphSpec, *, level: str, versions: dict[str, Any] | None = None, cards: dict[str, list[dict[str, Any]]] | None = None, checkers: dict[str, Any] | None = None, providers: list[Any] | None = None) -> dict[str, Any]: """发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。 checkers:基于数据目录的 SQL 检查器(data/sqlcheck),providers:模型接入表,都和发布接口传的是同一份 (governance.lint_for_publish)。 """ issues = publish_issues(spec, level=level, checkers=checkers, providers=providers) fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards, providers=providers) return {"level": level, "ok": not any(i.level == "error" for i in issues), "issues": annotate(issues, fixes), "fixes": [public(f) for f in fixes]} # -------------------------------------------------------------------------- # 应用修复 # -------------------------------------------------------------------------- def _node(graph: dict[str, Any], node_id: Any) -> dict[str, Any] | None: return next((n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id), None) def _config(node: dict[str, Any]) -> dict[str, Any]: data = node.setdefault("data", {}) if not isinstance(data.get("config"), dict): data["config"] = {} return data["config"] def _write(config: dict[str, Any], path: str, value: Any) -> Any: """按 a.b 写进配置,返回原值。中间缺的层补成对象。""" *heads, last = path.split(".") for key in heads: if not isinstance(config.get(key), dict): config[key] = {} config = config[key] before = copy.deepcopy(config.get(last)) config[last] = copy.deepcopy(value) return before def _edit(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str, str, Any, Any]]: """把一个修复写进图(原地改)。返回 [(节点 id, 字段, 原值, 新值)]。""" node = _node(graph, fix["node_id"]) if node is None: return [] if fix["kind"] == "auto": writes = dict(fix.get("_set") or {}) elif "_skeleton" in fix: writes = {"contract": {**fix["_skeleton"], "required": value}} else: writes = {fix["field"]: value} config = _config(node) return [(str(node["id"]), path, _write(config, path, after), copy.deepcopy(after)) for path, after in writes.items()] def _edit_defaults(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str | None, str, Any, Any]]: """改全图默认(graph.defaults):不落在任何节点上,节点 id 记 None,字段写全路径 defaults.x。""" if not isinstance(graph.get("defaults"), dict): graph["defaults"] = {} return [(None, path, _write(graph["defaults"], path.split(".", 1)[1], after), copy.deepcopy(after)) for path, after in (fix.get("_set") or {}).items() if path.startswith("defaults.")] def _edit_exit_fields(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str, str, Any, Any]]: """G1:文字不是来自报告撰写节点的成果字段,改成取选中那个报告的正文;只动门禁点了名的那几个字段。 出口没配字段的补一个 result——没配字段时成果的键就叫 result,下游照旧取得到。""" node = _node(graph, fix["node_id"]) if node is None: return [] config = _config(node) text = _report_text(str(value)) targets = list(fix.get("_fields") or []) if targets == ["fields"]: before = copy.deepcopy(config.get("fields")) config["fields"] = [{"name": "result", "value": text}] return [(str(node["id"]), "fields", before, copy.deepcopy(config["fields"]))] fields = config.get("fields") if isinstance(config.get("fields"), list) else [] out = [] for path in targets: m = re.fullmatch(r"fields\[(\d+)\]\.value", path) field = fields[int(m.group(1))] if m and int(m.group(1)) < len(fields) else None if isinstance(field, dict): out.append((str(node["id"]), path, copy.deepcopy(field.get("value")), text)) field["value"] = text return out def _edit_judge_budget(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str, str, Any, Any]]: """G3 的裁判预算:节点的 judge 写成(原来生效的那份 judge + 选中的 max_cost_usd)。选「不限」写显式的 null, 和没写不是一回事。变更按整份 judge 记:前后都是 null 的 max_cost_usd 在预览里分不出写没写。""" node = _node(graph, fix["node_id"]) if node is None: return [] config = _config(node) after = {**copy.deepcopy(fix.get("_judge") or {}), "max_cost_usd": value} before = _write(config, "judge", after) return [(str(node["id"]), "judge", before, copy.deepcopy(after))] def _edit_provider(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str | None, str, Any, Any]]: """点名的模型接入(provider.*):照规划时为选中的候选算好的改法写(_plan_provider 的 _edits:写上或去掉接入、 连带去掉的模型)。落在节点上改节点配置,落在全图默认上改 defaults(节点 id 记 None,字段写全路径 defaults.x)。""" if fix["node_id"] is None: if not isinstance(graph.get("defaults"), dict): graph["defaults"] = {} root, prefix = graph["defaults"], "defaults." else: node = _node(graph, fix["node_id"]) if node is None: return [] root, prefix = _config(node), "" edits: list[tuple[str | None, str, Any, Any]] = [] for path, after in (fix.get("_edits") or {}).get(value) or (): before = _put_path(root, path, after) if before is not None or after is not None: edits.append((fix["node_id"], prefix + path, before, copy.deepcopy(after))) return edits #: 每类问题怎么改图。大多是同一种「按节点字段写值」,改全图默认、改成果字段的各走一格;留着这张表是为了 #: 某类修复要特殊处理时只换它自己的那一格(测试里也借它模拟一条出错的修复规则) _EDITORS: dict[str, Callable[[dict[str, Any], dict[str, Any], Any], list[tuple[str | None, str, Any, Any]]]] = { code: (_edit_defaults if code in _GRAPH_FIXES else _edit) for code in _PLANNERS } _EDITORS["governed.exit_text_source"] = _edit_exit_fields _EDITORS["governed.judge_budget"] = _edit_judge_budget _EDITORS["provider.missing"] = _EDITORS["provider.disabled"] = _edit_provider def _handoff(fix: dict[str, Any], value: Any) -> bool: """选中的是不是「交给 Copilot」那个候选:只认候选上的 handoff 标记。""" return any(o.get("handoff") is True and o.get("value") == value for o in fix.get("options") or []) def _chosen(fix: dict[str, Any], choices: dict[str, Any]) -> tuple[Any, str]: """choice 类修复里人选的值,按候选校验。返回 (值, 不合格的原因)。""" if fix["kind"] != "choice": return None, "" if fix["id"] not in choices: return None, NEED_CHOICE.format(label=fix["label"]) value = choices[fix["id"]] allowed = [o["value"] for o in fix.get("options") or []] if fix.get("multiple"): if not isinstance(value, list) or not value: return None, f"请至少选择一项:{fix['label']}" if any(v not in allowed for v in value): return None, f"所选的{'、'.join(q(v) for v in value if v not in allowed)}不在候选项中" return [v for v in allowed if v in value], "" if value not in allowed: return None, f"所选的「{value}」不在候选项中" return value, "" def _op(graph: dict[str, Any], node_id: str | None, edits: list[tuple[str | None, str, Any, Any]]) -> dict[str, Any]: """一次修复落成的操作。落在节点上的是 update_node,形状同 Copilot 的操作流:config 只写改过的 顶层字段(整份新值);改全图默认的是 update_defaults,defaults 写改过的那几项。""" if node_id is None: defaults = graph.get("defaults") if isinstance(graph.get("defaults"), dict) else {} # 顶层那一项整份写:defaults.judge.provider 记 judge keys = dict.fromkeys(re.split(r"[.\[]", path.split(".", 1)[1], maxsplit=1)[0] for _, path, _, _ in edits if path.startswith("defaults.")) return {"op": "update_defaults", "defaults": {k: copy.deepcopy(defaults.get(k)) for k in keys}} config = _config(_node(graph, node_id) or {}) tops = dict.fromkeys(re.split(r"[.\[]", path, maxsplit=1)[0] for _, path, _, _ in edits) return {"op": "update_node", "id": node_id, "config": {k: copy.deepcopy(config.get(k)) for k in tops}} def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: dict[str, Any] | None = None, *, level: str, versions: dict[str, Any] | None = None, cards: dict[str, list[dict[str, Any]]] | None = None, checkers: dict[str, Any] | None = None, providers: list[Any] | None = None) -> dict[str, Any]: """在副本上逐个应用修复,每一个都复核:降低要求、错误没变少或者冒出新错误的,丢弃并写明原因。 返回 {graph, changes, ops, applied, rejected, handoff, remaining, fixes, ok}。graph 只是预览,不落库。 handoff 是人在 choice 里选了「交给 Copilot」的修复 id:图不动,调用方据此把剩下的 error 交给 Copilot。 checkers、providers 同 check()。 """ raw = graph.model_dump(mode="json") if isinstance(graph, GraphSpec) else graph current = copy.deepcopy(raw) spec = GraphSpec.model_validate(current) issues = publish_issues(spec, level=level, checkers=checkers, providers=providers) plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards, providers=providers) known = {f["id"] for f in plan} rank = {code: i for i, code in enumerate(_ORDER)} wanted = sorted(dict.fromkeys(fix_ids), key=lambda fid: rank.get(fid.split(":")[0], len(rank))) applied: list[str] = [] rejected: list[dict[str, str]] = [] handoff: list[str] = [] changes: list[dict[str, Any]] = [] ops: list[dict[str, Any]] = [] for fid in wanted: fix = next((f for f in plan if f["id"] == fid), None) if fix is None: rejected.append({"fix_id": fid, "reason": SOLVED if fid in known else STALE}) continue if fix["kind"] == "assist": rejected.append({"fix_id": fid, "reason": ASSIST_ONLY}) continue value, why = _chosen(fix, choices or {}) if why: rejected.append({"fix_id": fid, "reason": why}) continue if fix["kind"] == "choice" and _handoff(fix, value): handoff.append(fid) continue trial = copy.deepcopy(current) edits = _EDITORS.get(fix["code"], _edit)(trial, fix, value) try: trial_spec = GraphSpec.model_validate(trial) except Exception: # noqa: BLE001 - 修复规则把图改坏了:丢弃,不交给人一张存不回去的图 rejected.append({"fix_id": fid, "reason": "修复后的工作流结构无法解析,已放弃这项修复"}) continue trial_issues = publish_issues(trial_spec, level=level, checkers=checkers, providers=providers) # choice 里人亲手选的值不算「替人决定」(比如把沙箱代码标成取数);别的降低要求照样拦 chosen = {(str(nid), path) for nid, path, _, _ in edits} if fix["kind"] == "choice" else set() target = (fix["code"], fix["node_id"], fix.get("field")) if fix["code"] in _PER_FIELD \ else (fix["code"], fix["node_id"]) why = ";".join(forbidden_changes(current, trial, chosen=chosen)) or judge(issues, trial_issues, target=target) if why: rejected.append({"fix_id": fid, "reason": why}) continue current, spec, issues = trial, trial_spec, trial_issues applied.append(fid) titles = spec.node_map() changes += [{"fix_id": fid, "node_id": nid, "node_title": _title(titles.get(nid)), "field": path, "before": before, "after": after, "label": fix["label"]} for nid, path, before, after in edits] if edits: ops.append(_op(current, fix["node_id"], edits)) plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards, providers=providers) return {"graph": current, "changes": changes, "ops": ops, "applied": applied, "rejected": rejected, "handoff": handoff, "remaining": annotate(issues, plan), "fixes": [public(f) for f in plan], "ok": not any(i.level == "error" for i in issues)} # -------------------------------------------------------------------------- # 复核:修完必须更好,不许降低要求 # -------------------------------------------------------------------------- def _sig(issue: dict[str, Any]) -> tuple[Any, ...]: """同一处问题的认法。有编号的按(级别、编号、节点、连线、字段)认:文案里带节点标题,改个名 文案就变了,那还是同一处问题,不是冒出来的新问题。没编号的只能连文案一起比。""" head = (issue.get("level"), issue.get("code"), issue.get("node_id"), issue.get("edge_id"), issue.get("field")) return head if issue.get("code") else (*head, issue.get("message")) #: 只有人能拍板、修复和 Copilot 都不许代做的 error(受管级别打开 cells)。改动让它冒出来不算更糟:它有自己的 #: choice 修复,发布弹窗里一键选。拿它判作废的话,Copilot 能做的最好的一步(补上契约、把 cells 留给人问) #: 反而会被拒掉——用户就撞过这个死局。原来就有的照常计数 HUMAN_CHOICE = frozenset({"contract.cells_undeclared"}) def _errors(before: list[Any], after: list[Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """改动前后的 error;改完新冒出来的 HUMAN_CHOICE 不算进改完的那份。""" b_err = [i for i in map(_as_dict, before) if i.get("level") == "error"] seen = {_sig(i) for i in b_err} a_err = [i for i in map(_as_dict, after) if i.get("level") == "error" and not (i.get("code") in HUMAN_CHOICE and _sig(i) not in seen)] return b_err, a_err def worse(before: list[Any], after: list[Any]) -> str | None: """改完是不是更糟了:冒出新的 error,或者 error 变多了。返回原因,没有就是 None。 judge 在这之上还要求「修的那一处没了、错误变少」;一键升级(engine/upgrade.py)只要求不更糟—— 升级换的是结构,本来就不一定消掉哪条 error。 """ b_err, a_err = _errors(before, after) seen = {_sig(i) for i in b_err} new = [i for i in a_err if _sig(i) not in seen] if new: return f"修改后出现了新的问题:{new[0].get('message')}" if len(a_err) > len(b_err): return f"修改后错误增加了({len(b_err)} → {len(a_err)})" return None def judge(before: list[Any], after: list[Any], *, target: tuple[Any, ...] | None = None) -> str | None: """改完是不是更好了:不能冒出新的 error,error 不能变多;要修的那一处必须没了。 target 是 (问题编号, 节点 id):修的是一条警告(strict)时,error 不增加、那条警告消失就算更好; 修的是 error,或者不指定 target(Copilot 兜底),error 必须变少。返回 None 表示更好,否则是原因。 一处一条的修复(点名的模型接入)再带上字段 (问题编号, 节点 id, 字段):同一节点上别的字段还有同类问题不算没修好。 """ if why := worse(before, after): return why b = [_as_dict(i) for i in before] a = [_as_dict(i) for i in after] b_err, a_err = _errors(before, after) if target is not None: code, node_id, *at = target field = at[0] if at else None def hit(i: dict[str, Any]) -> bool: # 图级修复(node_id 为 None,改全图默认)管的是这个编号下挂在各个节点上的每一条 return i.get("code") == code and (node_id is None or i.get("node_id") in (node_id, None)) \ and (field is None or i.get("field") == field) still = [i for i in a if hit(i)] if still: return f"修改后这一处问题仍然存在:{still[0].get('message')}" if not any(i.get("level") == "error" for i in b if hit(i)): return None if len(a_err) >= len(b_err): return "修改后错误没有减少" return None def _nodes_of(graph: dict[str, Any]) -> dict[str, dict[str, Any]]: return {str(n.get("id")): n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id")} def _edge_key(edge: dict[str, Any]) -> tuple[str, str, str]: handle = edge.get("sourceHandle") return (str(edge.get("source")), str(edge.get("target")), "" if handle in (None, "out", "source") else str(handle)) def _cfg(node: dict[str, Any]) -> dict[str, Any]: config = (node.get("data") or {}).get("config") return config if isinstance(config, dict) else {} def _name(node: dict[str, Any]) -> str: return str((node.get("data") or {}).get("label") or node.get("id")) def _title_of(nodes: dict[str, dict[str, Any]], node_id: Any) -> str: """节点 id 换成画布上的名字;图里没有这个节点时照写 id。给人看的拒绝理由里不写节点 id。""" node = nodes.get(str(node_id)) return _name(node) if node is not None else str(node_id) #: 审批策略从严到宽:每次调用都审批 > 仅危险工具需要审批 > 全部自动放行 _APPROVAL_RANK = {"never": 0, "dangerous": 1, "always": 2} _APPROVAL_LABEL = {k: option_label("approval", k) for k in _APPROVAL_RANK} def _approval(value: Any) -> str | None: """审批策略的取值:没写、写 null、写空串都算没写(运行时一样往下回退)。""" return None if value in (None, "") else str(value) def _approval_word(value: str) -> str: return f"「{_APPROVAL_LABEL.get(value, value)}」" def _approval_lowered(who: str, prev: str | None, now: str | None, *, inherited: str | None, fallback: str | None) -> str | None: """审批策略有没有往宽里改。返回一句原因,没有就是 None。 prev / now:改之前、之后写明的值(None 是没写)。没写时运行时往下回退:节点退到全图默认, 全图默认退到全局设置——全局设置至多是「仅危险工具需要审批」,也可能是「全部自动放行」, 所以退到全局一律按可能全部放行算。inherited:改之前没写时实际跟随的值(节点跟全图默认; 全图默认没有可跟的,为 None)。fallback:改之后没写时退到的值(同理)。 """ if prev == now: return None if now is None: if prev is None or prev == "never": return None if fallback in _APPROVAL_RANK and _APPROVAL_RANK[fallback] >= _APPROVAL_RANK.get(prev, 0): return None return (f"删除了{who}的审批策略(原为{_approval_word(prev)}),将改为跟随" + (f"{field_label('defaults')}中的{_approval_word(fallback)}" if fallback else f"全局设置,可能为「{option_label('approval', 'never')}」")) if now not in _APPROVAL_RANK: return f"将{who}的审批策略改为无法识别的值「{now}」" base = prev if prev is not None else inherited if base in _APPROVAL_RANK and _APPROVAL_RANK[now] < _APPROVAL_RANK[base]: was = _approval_word(base) + ("" if prev is not None else f"(跟随{field_label('defaults')})") return f"将{who}的审批策略从{was}放宽为{_approval_word(now)}" return None def _numbers(value: Any) -> list[str]: return [str(v).strip() for v in _as_list(value) if str(v).strip()] def _contract_loosened(name: str, prior: Any, contract: Any, *, cells_chosen: bool = False) -> list[str]: """出具契约里替作者放宽口子的改动:allow_numbers 多出来的数(白名单里的数不用引用也能过), cells 从没开变成开(受管级别要作者显式声明,不由模型替人打开;cells_chosen 是人在 choice 里亲手选的, 不算)。原来没有契约也一样查。""" if not isinstance(contract, dict): return [] prior = prior if isinstance(prior, dict) else {} out = [] had = set(_numbers(prior.get("allow_numbers"))) extra = [n for n in dict.fromkeys(_numbers(contract.get("allow_numbers"))) if n not in had] if extra: out.append(f"在「{name}」出具契约的「{contract_label('allow_numbers')}」中新增了 {'、'.join(extra)}(这些数字无需出处即可通过)") if contract.get("cells") is True and prior.get("cells") is not True and not cells_chosen: out.append(f"开启了「{name}」出具契约的「单元格引用」(允许报告直接引用查询单元格),这一项需要由你决定") was, now = _claims_rank(prior.get("claims")), _claims_rank(contract.get("claims")) if was is not None and now is not None: looser = [f"{what}{words[a]} → {words[b]}" for (what, words), a, b in zip(_CLAIMS_AXES, was, now) if b < a] if looser: out.append(f"放宽了「{name}」出具契约对结论句的要求({';'.join(looser)})") return out #: 契约 claims 的严格程度分两样,各自从宽到严:没挂依据的结论句(不查 < 查了不算缺口 ignore < 计入缺口降档 #: degrade < 不予出具 withhold),证据不支持的结论句(不裁判 < 裁判、降档 < 裁判、不予出具)。judge 两样都管: #: 挂依据的要求和 require_citation 一样,另外按裁判的判定判档 _CLAIMS_AXES = (("未附依据的结论句", ("不检查", "仅标注", "计入缺口", "不予出具")), ("证据不支持的结论句", ("不裁判", "降档出具", "不予出具"))) def _claims_rank(value: Any) -> tuple[int, int] | None: """契约里 claims 的严格程度:(没挂依据的, 证据不支持的),各自越大越严,任何一样变小都算放宽。 认不出的写法不比较(出口按缺口处理,不会因此放松)。""" name = value.get("policy") if isinstance(value, dict) else value if name in (None, "", "off"): return 0, 0 if name not in ("require_citation", CLAIMS_JUDGE): return None on_uncited = value.get("on_uncited") if isinstance(value, dict) else None uncited = {"ignore": 1, "withhold": 3}.get(on_uncited, 2) if name != CLAIMS_JUDGE: return uncited, 0 on_unsupported = value.get("on_unsupported") if isinstance(value, dict) else None return uncited, 2 if on_unsupported == "withhold" else 1 def forbidden_changes(before: dict[str, Any], after: dict[str, Any], ops: list[dict[str, Any]] | None = None, *, chosen: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset(), retyped: set[tuple[str, str, str]] | frozenset[tuple[str, str, str]] = frozenset(), rewired: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset()) -> list[str]: """降低要求的改动,一条一句。空列表表示没有。确定性修复和 Copilot 兜底过的是同一道检查。 禁止: - 删节点、删连线;用 add_node 覆盖已有的节点、把节点换成别的类型(等于删了重建) - 删掉或清空出具契约、删掉 required 里的指标、把 strict 改成 false; 往 allow_numbers 里加数、替人打开 cells(chosen 里有 (出口 id, "contract.cells") 的是人在 choice 里亲手选的,不算) - 审批策略往宽里改(每次调用都审批 > 仅危险工具需要审批 > 全部自动放行)、删掉写明的审批策略, 把审批写成(或新加一个)「全部自动放行」;全图默认同理 - 取消子工作流钉住的版本 - 报告撰写节点的 numbers、on_violation、claims 往宽里改(claims 改成认不出的写法也算),claims 一直是 judge 时 judge.on_unsupported 从 withhold 退回去;关掉 agent 的 cite_fields - 替人把沙箱代码标成取数(evidence_role: source):它在取数还是在算只有作者知道。chosen 里的 (节点 id, 字段) 是人在 choice 里亲手选的,不算 - 改发布级别——级别不在图里,修复接口也从不写工作流的 status,所以只可能以一个不认识的 操作出现,按不认识的操作拒掉 一键升级(engine/upgrade.py)走同一道检查,只多两个由升级自己点名的例外:retyped 里的 (节点 id, 原类型, 新类型) 允许换类型(模型调用换成报告撰写,这是升级的核心);rewired 里的 (source, target) 允许删掉那条连线(中间插了报告撰写节点、改接的那一条)。发布前修复不传,照旧全拦。 """ old, new = _nodes_of(before), _nodes_of(after) reasons: list[str] = [] for op in ops or []: kind = op.get("op") if kind == "remove_node": reasons.append(f"删除节点「{_title_of(old, op.get('id'))}」") elif kind == "remove_edge": reasons.append(f"删除连线「{_title_of(old, op.get('source'))}」→「{_title_of(old, op.get('target'))}」") elif kind == "add_node": # Copilot 的改图流程遇到已有的 id 会拿新节点整个盖掉旧的:配置、位置全丢,类型也可能换了 added = op.get("node") target = added.get("id") if isinstance(added, dict) else None if target is not None and str(target) in old: reasons.append(f"用新节点覆盖已有的「{_name(old[str(target)])}」(等于删除后重建)") elif kind not in _GRAPH_OPS: reasons.append(f"使用了修复不允许的操作 {kind}(只能修改节点配置、添加节点和连线)") reasons += [f"删除了节点「{_name(n)}」" for nid, n in old.items() if nid not in new] kept = {_edge_key(e) for e in after.get("edges") or [] if isinstance(e, dict)} reasons += [f"删除了连线「{_title_of(old, s)}」→「{_title_of(old, t)}」" for s, t, _ in dict.fromkeys(_edge_key(e) for e in before.get("edges") or [] if isinstance(e, dict)) if (s, t, _) not in kept and (s, t) not in rewired] old_default = _approval((before.get("defaults") or {}).get("approval")) new_default = _approval((after.get("defaults") or {}).get("approval")) for nid, node in new.items(): was = old.get(nid) cfg, prev = _cfg(node), _cfg(was) if was else {} name = _name(node) if was is not None and was.get("type") != node.get("type") \ and (nid, str(was.get("type")), str(node.get("type"))) not in retyped: from app.engine.schema import type_label reasons.append(f"将「{_name(was)}」从{_type_word(was.get('type'), type_label)}换成了" f"{_type_word(node.get('type'), type_label)}(等于删除后重建)") approval, prior_approval = _approval(cfg.get("approval")), _approval(prev.get("approval")) if approval == "never" and prior_approval != "never": reasons.append(f"将「{name}」的审批策略改为「{option_label('approval', 'never')}」" if was else f"新增的「{name}」审批策略为「{option_label('approval', 'never')}」") elif why := _approval_lowered(f"「{name}」", prior_approval, approval, inherited=old_default, fallback=new_default): reasons.append(why) contract, prior = cfg.get("contract"), prev.get("contract") reasons += _contract_loosened(name, prior, contract, cells_chosen=(nid, "contract.cells") in chosen) if node.get("type") == "code" and cfg.get("evidence_role") == "source" \ and prev.get("evidence_role") != "source" and (nid, "evidence_role") not in chosen: reasons.append((f"将「{name}」的「证据角色」设为「取数」" if was else f"新增的「{name}」的「证据角色」为「取数」") + ":它负责取数还是计算需要由你决定") if was is None: continue if was.get("type") == node.get("type") == "report": reasons += _report_loosened(name, prev, cfg, before.get("defaults"), after.get("defaults")) if was.get("type") == node.get("type") == "agent" and prev.get("cite_fields") is True \ and cfg.get("cite_fields") is not True: reasons.append(f"关闭了「{name}」的「按出处核对字段」(字段不再逐一核对到查询结果)") elif was.get("type") == node.get("type") == "agent" and prev.get("cite_fields") is True \ and prev.get("output_schema") and not cfg.get("output_schema"): reasons.append(f"删除了「{name}」的「结构化输出 Schema」(「按出处核对字段」随之失效,字段不再逐一核对到查询结果)") if prev.get("workflow_version") and not cfg.get("workflow_version"): reasons.append(f"取消了「{name}」固定的版本") if not isinstance(prior, dict) or not prior: continue if not isinstance(contract, dict) or not contract: reasons.append(f"删除或清空了「{name}」的出具契约") continue if prior.get("strict") and not contract.get("strict"): reasons.append(f"关闭了「{name}」出具契约的「严格模式」") dropped = [m for m in _as_list(prior.get("required")) if m not in _as_list(contract.get("required"))] if dropped: reasons.append(f"从「{name}」出具契约的「必需指标」中删除了 {'、'.join(map(str, dropped))}") if new_default == "never" and old_default != "never": reasons.append(f"将{field_label('defaults')}中的审批策略改为「{option_label('approval', 'never')}」") elif why := _approval_lowered(field_label("defaults"), old_default, new_default, inherited=None, fallback=None): reasons.append(why) return reasons #: 报告撰写节点三项设置从严到宽的次序:没写时运行时的缺省值(on_violation 没写时探索运行按 flag) _REPORT_STRICT = {"numbers": ("strict", "strict"), "on_violation": ("fail", "flag"), "claims": ("require_citation", "off")} #: claims 从宽到严:off < require_citation < judge(在挂引用之外再请模型逐句裁判,不支持的按 on_unsupported #: 判档)。不在这里的写法不比较(validate 报 report.claims_invalid) _CLAIMS_ORDER = ("off", "require_citation", CLAIMS_JUDGE) def _report_loosened(name: str, prev: dict[str, Any], cfg: dict[str, Any], old_defaults: Any, new_defaults: Any) -> list[str]: """报告撰写节点的三项设置有没有往宽里改:numbers、on_violation 原来(按节点、再按全图默认)是最严的值, 改完不是了;claims 按 off < require_citation < judge 排,往下走一格也算(require_citation → judge 是收紧), 从 require_citation、judge 改成认不出的写法也算(原来就认不出的不比较)。claims 前后都是 judge 的, 再看 judge.on_unsupported 有没有从 withhold 退回去。""" def effective(config: dict[str, Any], defaults: Any, key: str, fallback: str) -> Any: value = config.get(key) if value in (None, ""): value = (defaults if isinstance(defaults, dict) else {}).get(key) return fallback if value in (None, "") else value out = [] for key, (strict, fallback) in _REPORT_STRICT.items(): was, now = effective(prev, old_defaults, key, fallback), effective(cfg, new_defaults, key, fallback) if key == "claims": if was not in _CLAIMS_ORDER or (was == "off" and now not in _CLAIMS_ORDER): continue if now not in _CLAIMS_ORDER: out.append(f"将「{name}」的「{field_label('claims')}」从「{option_label('claims', was)}」改为无法识别的「{now}」") continue if _CLAIMS_ORDER.index(now) >= _CLAIMS_ORDER.index(was): continue elif not (was == strict and now != strict): continue out.append(f"将「{name}」的「{field_label(key)}」从「{option_label(key, was)}」放宽为「{option_label(key, now)}」" + ("(删除了已设置的值,运行时改用默认值)" if cfg.get(key) in (None, "") else "")) if effective(prev, old_defaults, "claims", "off") == effective(cfg, new_defaults, "claims", "off") \ == CLAIMS_JUDGE and (why := _judge_loosened(name, prev, cfg, old_defaults, new_defaults)): out.append(why) return out def _judge_loosened(name: str, prev: dict[str, Any], cfg: dict[str, Any], old_defaults: Any, new_defaults: Any) -> str | None: """claims 一直是 judge 时,judge.on_unsupported 从 withhold(证据不支持的结论句不予出具)退回 degrade (只降档)。judge 和运行时(report.report_judge)同一个取法:节点上写了就整个用节点的,没写取图级 defaults;on_unsupported 没写、写了认不出的都按 degrade。所以补预算时把 judge 整个换成 {max_cost_usd: …}、节点另写一份不带 withhold 的 judge 盖掉全图默认,都算放宽。""" def block(config: dict[str, Any], defaults: Any) -> tuple[Any, bool]: own = config.get("judge") if own not in (None, ""): return own, False return (defaults if isinstance(defaults, dict) else {}).get("judge"), True def written(judge: Any) -> Any: return judge.get("on_unsupported") if isinstance(judge, dict) else None (old_block, old_inherited), (new_block, new_inherited) = block(prev, old_defaults), block(cfg, new_defaults) if written(old_block) != "withhold" or (raw := written(new_block)) == "withhold": return None def said(value: str) -> str: return f"「{option_label('on_unsupported', value)}」" was = said("withhold") + (f"(跟随{field_label('defaults')})" if old_inherited else "") if raw in JUDGE_ON_UNSUPPORTED: now = said(raw) + (f"(跟随{field_label('defaults')})" if new_inherited else "") elif raw not in (None, ""): now = f"无法识别的「{raw}」(运行时按{said('degrade')}处理)" elif new_inherited: now = said("degrade") + f"(跟随{field_label('defaults')};其中未设置此项,运行时使用默认值)" elif old_inherited: now = said("degrade") + (f"(节点上另行设置了{field_label('judge')},将整体使用节点的设置、不再跟随" f"{field_label('defaults')};节点未设置此项,运行时使用默认值)") else: now = said("degrade") + "(删除了已设置的值,运行时使用默认值)" return f"将「{name}」的「{field_label('judge.on_unsupported')}」从{was}放宽为{now}" def _type_word(node_type: Any, type_label: Callable[[Any], str]) -> str: try: return f"「{type_label(NodeType(node_type))}」" except ValueError: return f"「{node_type}」" def diff_changes(before: dict[str, Any], after: dict[str, Any], *, fix_id: str, label: str) -> list[dict[str, Any]]: """两张图之间给人看的逐项变更(Copilot 兜底用):节点配置按顶层字段比,新加的节点、连线各一条。""" old, new = _nodes_of(before), _nodes_of(after) out: list[dict[str, Any]] = [] for nid, node in new.items(): was = old.get(nid) if was is None: out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": None, "before": None, "after": {"type": node.get("type"), "config": copy.deepcopy(_cfg(node))}, "label": f"{label}:新增节点"}) continue if was.get("type") != node.get("type"): # forbidden_changes 现在就拒掉换类型;万一哪天放行,预览里也得看得出节点换了类型 out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": "type", "before": was.get("type"), "after": node.get("type"), "label": label}) if _name(was) != _name(node): out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": "label", "before": _name(was), "after": _name(node), "label": label}) cfg, prev = _cfg(node), _cfg(was) for key in dict.fromkeys([*prev, *cfg]): if prev.get(key) != cfg.get(key): out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": key, "before": copy.deepcopy(prev.get(key)), "after": copy.deepcopy(cfg.get(key)), "label": label}) known = {_edge_key(e) for e in before.get("edges") or [] if isinstance(e, dict)} for edge in after.get("edges") or []: if isinstance(edge, dict) and _edge_key(edge) not in known: out.append({"fix_id": fix_id, "node_id": None, "node_title": "", "field": "edge", "before": None, "after": {"source": edge.get("source"), "target": edge.get("target"), "sourceHandle": edge.get("sourceHandle")}, "label": f"{label}:新增连线"}) return out