Files
AgentLab/backend/app/engine/autofix.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

1169 lines
65 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.
"""发布前自动修复。
发布门禁拦下的问题,以前只能自己动手、或者打开 Copilot 描述一遍。这里把「答案唯一、不降低
要求」的那些做成确定性的修复,一律先给预览,人确认后前端走现有的保存接口:
- auto:答案唯一。出口上游恰好一张口径卡、审批从「全部自动放行」改成「仅危险工具需要审批」…
- choice:要人拿主意。required 选哪几个指标、多张口径卡里选哪几张——列候选,不替人选,
也不给「默认全选」
- assist:结构性的(协作团队节点、多个出口…),交给 Copilot,产出同样只是预览
任何修复都不能降低要求(forbidden_changes);应用后重跑 validate 和门禁,错误必须变少、
不能冒出新的错误(judge),做不到的修复丢弃并写明原因。
本模块是纯函数:库里的东西(子工作流当前的发布版本、钉在别处的口径卡有哪些指标)由调用方
查好传进来。改图直接改原始的图字典,只动修复涉及的那几个字段:坐标、连线 id、视口这些
前端的东西原样留着,存回去不会丢。
"""
from __future__ import annotations
import copy
import re
from typing import Any, Callable
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",
)
#: 修复落在全图上、不落在任何节点上的问题:问题各挂在跟随全图默认的那几个节点上(点一条能定位),
#: 修复只有一条(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") -> None:
self.spec = spec
self.nodes = spec.node_map()
self.versions = versions or {}
self.cards = cards or {}
self.level = level
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")
_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,
}
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) -> list[dict[str, Any]]:
"""这批问题能怎么修:Fix 列表,按问题出现的顺序,同一处只出一条。
versions:子工作流 id → 它当前的发布版本号(没发布过为 None)。
cards:钉在别处(caliber_from)的口径卡节点 id → 那一版里的指标定义,用来列 required 的候选。
返回的 Fix 里以 _ 开头的键是应用时要用的内部信息,交给前端前用 public() 去掉。
"""
ctx = _Ctx(spec, versions, cards, level)
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'}"
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 是同一个退路。
"""
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")
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) -> dict[str, Any]:
"""发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。"""
issues = publish_issues(spec, level=level)
fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
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))]
#: 每类问题怎么改图。大多是同一种「按节点字段写值」,改全图默认、改成果字段的各走一格;留着这张表是为了
#: 某类修复要特殊处理时只换它自己的那一格(测试里也借它模拟一条出错的修复规则)
_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
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 {}
keys = dict.fromkeys(path.split(".", 1)[1] 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) -> dict[str, Any]:
"""在副本上逐个应用修复,每一个都复核:降低要求、错误没变少或者冒出新错误的,丢弃并写明原因。
返回 {graph, changes, ops, applied, rejected, handoff, remaining, fixes, ok}。graph 只是预览,不落库。
handoff 是人在 choice 里选了「交给 Copilot」的修复 id:图不动,调用方据此把剩下的 error 交给 Copilot。
"""
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)
plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
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)
# choice 里人亲手选的值不算「替人决定」(比如把沙箱代码标成取数);别的降低要求照样拦
chosen = {(str(nid), path) for nid, path, _, _ in edits} if fix["kind"] == "choice" else set()
why = ";".join(forbidden_changes(current, trial, chosen=chosen)) or judge(
issues, trial_issues, target=(fix["code"], fix["node_id"]))
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)
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[str, str | None] | None = None) -> str | None:
"""改完是不是更好了:不能冒出新的 error,error 不能变多;要修的那一处必须没了。
target 是 (问题编号, 节点 id):修的是一条警告(strict)时,error 不增加、那条警告消失就算更好;
修的是 error,或者不指定 target(Copilot 兜底),error 必须变少。返回 None 表示更好,否则是原因。
"""
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 = target
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))
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