mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
7255ae98a5
- 「不指定接入」交给设置(核查模型、裁判模型)时:启用的接入全是演示类,设置这一级不管写的是什么,执行时落到的都是 演示模型,说明照实带上「(演示用,不调用真实模型)」、候选带 demo 标记;一个启用的接入都没有时不算落得稳(同别处 「换成内置演示模型」不算)。以前 land() 在判演示之前就把交给设置的一处当成落稳返回了; - 检查器里没有下拉框、发布前检查又给不出修复时,提示末尾补「无法改用其他接入:」加第一处落不稳的地方(Landing.why, 比如「成员「研究员」指定的模型「小模型」所属的模型接入「旧接入」已停用」),不再只剩「核对名称」让人去设置里找一个 找不到的名字; - 点名的接入名恰好是某个启用接入的模型:画布、发布前检查按这一处在检查器里有哪几栏写(provider_refs._model_note)—— 模型调用一类在节点的「模型」中选择它,接入在发布前检查里改用这个接入或选择「不指定接入」(修复里有哪项说哪项); 协作成员、全图默认只说可在发布前检查里改用;核查节点、报告自己的结论句裁判两栏都在,照设置页写这两栏的叫法。 不再叫人去一个没有的「接入」栏留空。运行时报错不分节点类型,「填写」改为「选择它」; - 修复候选修完以后落到演示类接入的带 demo 标记,发布弹窗据此在选项名旁加醒目的标签; - 性能:改一个节点的配置只影响它自己取模型的那几处,修复规划(autofix._ProviderPlan)每试一个候选只重算这个节点 (provider_refs.node_uses),不再深拷贝、重算整张图;问题提示只问给不给得出(provider_fixable,找到第一个落得稳的 就停);接入问题的引用整张图只算一遍。60 个节点都点名同一个停用接入时,画布校验里这一部分约 0.35 秒降到约 0.002 秒, 发布前检查约 0.72 秒降到约 0.01 秒,一次应用 30 条修复约 2.7 秒降到约 0.09 秒; - 助手:GET /api/copilot/model 另给 panel_error(写在助手栏「助手使用的模型」那一格下面,去向写「上方」,不把人指回这个 下拉框本身);接入名恰好是模型名时并成一句,不再两次提「助手使用的模型」;PUT /api/settings 里助手那一项的 422 用 助手报错那一句(那里只有一个选模型的下拉框,没有「换一个接入」的地方); - 裁判设置的格式检查写「留空表示跟随助手的模型」,同设置页空选项(engine/judge.py 一行,同核查那条的改法);「交给助手」 后只剩接入问题时写「助手无法处理」,有修复时另起半句「也可在各条问题下方选择改用的模型接入」。 测试:test_provider_ref_wording 加演示落在设置上、demo 标记、给不出修复时卡在哪里(含一个启用接入都没有)、各处的 接入名恰好是模型名、助手栏那一格的说法、助手那一项的 422、裁判格式检查、规划开销随节点数线性增长(数试算时算了多少处 取模型的地方);其余几个文件跟上新说法。新加的核心用例都做过去掉实现、测试变红的验证。
1456 lines
82 KiB
Python
1456 lines
82 KiB
Python
"""发布前自动修复。
|
||
|
||
发布门禁拦下的问题,以前只能自己动手、或者打开 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
|