Files
AgentLab/backend/app/engine/governance.py
T
ThearchyHelios d57cdcaffc 驳回出口(后端):审批、补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决接了「驳回」出口时沿它往下走
以前这六类关卡被驳回一律抛 NodeRejected,运行收成 cancelled + end_reason=rejected,没有「驳回之后走另一条路」
(docs/design/orchestration-round/00-总方案.md §3)。现在画布上多一个可选的「驳回」出口(sourceHandle="rejected",和
分支、审批同一套连线机制):

- engine/schema:GATE_TYPES(人工、调用工具、沙箱代码、双盲复算)、reject_targets(驳回出口连到哪几步)、approve_mode;
  画布校验:别的节点接驳回出口报错(graph.reject_exit_invalid,条件分支的 case 标识可以叫 rejected,不算);审批同时设了
  「驳回即终止运行」和驳回边的给一条提示(human.stop_on_reject_exit)
- engine/compiler:接了驳回出口的关卡按出口路由(驳回出口 / 其余是普通出口,连线没写 sourceHandle 的照样是普通出口);
  NodeRejected 被接住时这一步正常结束:产出 NodeRejected.exit_payload()(rejected、decision、actor、note、kind、text,
  审批另带 approved=false、「结果存为变量」照旧写 false),node.finished 带 outcome=rejected、哪种关卡、结论、谁、备注、
  转到哪几步(to),trail 记 outcome=rejected;不重试、不被「出错时继续」接住,照旧
- engine/context:NodeRejected 加 stop(审批明说了驳回即终止的,驳回边照旧走不到)和 exit_payload;动作确认抛在发送之前,
  走出口时什么都没发
- engine/runner:成功结束的运行途经驳回出口时,run.finished 带 rejected_via(在哪一步、谁、结论、转到哪),照实说「途经
  驳回」;没有途经的终态事件一字不变
- engine/governance:发布检查各级都拦「驳回出口那条路走不到成果节点或结束」(reject_exit.no_end);退回上游再问一次的放过
- engine/layout:驳回出口排在普通出口下面
- apps/resolve:实例进度写「被驳回,转到「X」」(动作确认写「未确认(被驳回),转到…」,超时驳回写「超时驳回,转到…」);
  实例状态(实例页头、列表、通知同一句)写「「x」被驳回,转到「y」」,走到另一个成果节点的照旧写那个成果节点;退回修改后
  又批准了的按批准算;子工作流里的步骤不进实例进度
- 没接出口:行为一字不变(node.failed reason=rejected、运行 cancelled + rejected);审批没设驳回即终止、没接驳回边的老规矩
  照旧。接了驳回边的审批,实例进度那一步的说法从「已驳回」改成「被驳回,转到「已退回」」(test_app_instance_page 跟着改)

另:engine/context 的 RunContext 加 prefix / global_id、事件经 stream_writer 发(子工作流把父运行的写入口交下来),
这一步本身不改变最外层的事件,下一个提交接上子工作流

测试:test_reject_exit(五类关卡接了出口各走一遍、批准走普通出口、超时驳回走出口、没接一字不变、驳回即终止优先、
重试和出错时继续不管驳回、退回修改再问一次、校验与发布检查、布局、实例进度);test_process_actions(动作确认走出口
不发送);test_dual_check_node(裁决走出口)
2026-10-18 15:56:29 +08:00

829 lines
47 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.
from __future__ import annotations
import re
from collections import defaultdict
from collections.abc import Mapping
from typing import Any, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.issuance import metrics_only
from app.engine.labels import contract_label, field_label, judge_label, option_label, q
from app.engine.schema import (
CLAIMS_JUDGE,
GraphNode,
GraphSpec,
NodeType,
ValidationResult,
_ancestors,
_caliber_inputs,
claims_problem,
contract_needs_metrics,
judge_problems,
type_label,
validate_graph,
)
# 口径升版的三种合法处置。没有默认值是刻意的:升版最容易静默翻车的地方
# 就是"上周和这周的数其实不可比,但报表看起来一切正常"。
UPGRADE_POLICIES = {"recompute", "dual", "incomparable"}
UPGRADE_POLICY_LABELS = {k: option_label("upgrade_policy", k) for k in ("recompute", "dual", "incomparable")}
#: 发布门禁的扩展检查(应用平台 §3.13):各包把自己的检查登记在这里,不改下面的主循环。
#: 每个检查的签名:check(spec, flag, *, level, strict) -> None,flag 同 lint_for_publish 里的那个
#: (message, code=, node_id=, hard=, field=)。登记见 engine/lint_process.py、engine/lint_capabilities.py
EXTRA_LINTS: list[Callable[..., None]] = []
def _load_extra_lints() -> None:
"""导入各包的检查模块(它们导入时把自己登记进 EXTRA_LINTS)。还没建的模块跳过。"""
import importlib
for name in ("app.engine.lint_process", "app.engine.lint_capabilities"):
try:
importlib.import_module(name)
except ModuleNotFoundError as e:
if e.name != name:
raise
def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None,
providers: list[Any] | None = None) -> ValidationResult:
"""发布门禁。
published:基础可运行性之外只给警告。
governed:受管模板,出具用的那一档 —— 把关节层的自由度收进边界里,
错误会挡住发布。
每条问题带机器编号(code):发布前自动修复(engine/autofix.py)按它认问题、出修复,
文案可以改,编号不能随便改。证据门禁 G1–G5 见 _lint_evidence。
checkers 是各数据源基于数据目录的 SQL 检查器(data/sqlcheck.load_checkers_by_name),由调用方读好传进来:
门禁本身不查库。给了的话,调用工具节点里写死的 SQL 对照目录查一遍(_lint_sql)。
providers 是模型接入表(provider_refs.load_providers),同样由调用方读好传进来。给了的话,节点点名的接入
不存在或已停用、只写了模型而提供它的接入已停用的每一处一条 error(provider.missing / provider.disabled),
两档都挡:正式运行到那里一定失败,或者少一块核查。不给(一键升级、助手自查这类只看图本身的地方)就不查。
"""
result = ValidationResult()
strict = level == "governed"
def flag(message: str, *, code: str, node_id: str | None = None, hard: bool = False,
field: str | None = None, sql_level: str | None = None, always: bool = False) -> None:
# always:各级都报错(§3.13 的 action.write_in_agent、action.prod_missing),不只受管级
result.add(message, level="error" if (always or (hard and strict)) else "warning", node_id=node_id,
field=field, code=code, sql_level=sql_level)
has_contract_output = False
for node in spec.nodes:
cfg = node.config
# 文案用画布上的叫法:节点标题 +(节点类型),参数写界面上的选项文字
who = f"「{node.title}」({type_label(node.type)})"
if node.type == NodeType.SUPERVISOR:
# 全动态规划属于探索层。受管模板里出现它,等于把 XAgent 搬上了生产线。
flag(
f"{who}:受管级别不允许使用全动态规划的节点,其分工和轮数由模型在运行时决定,"
"无法事先审核。请改用配置固定的 Agent 或「模型调用」节点",
code="governed.supervisor", node_id=node.id, hard=True,
)
elif node.type == NodeType.SUBGRAPH:
if not cfg.get("workflow_version"):
flag(
f"{who}没有固定版本,口径会随上游最新版本变化。请在节点中选定一个版本",
code="governed.subgraph_unpinned", node_id=node.id, hard=True, field="workflow_version",
)
elif node.type == NodeType.AGENT:
tools = cfg.get("tools") or []
if cfg.get("approval") == "never" and tools:
flag(
f"{who}的审批策略是「{option_label('approval', 'never')}」,受管级别要求危险工具至少经过人工审批。"
f"请改为「{option_label('approval', 'dangerous')}」",
code="governed.agent_approval_never", node_id=node.id, hard=True, field="approval",
)
elif cfg.get("approval") in (None, "") and spec.defaults.get("approval") == "never" and tools:
# 节点自己没写,运行时跟随全图默认(context.approval_mode:节点 > 图级 defaults > 全局)。
# 全图默认写着全部自动放行,这个 agent 就是全部自动放行,只是没写在它自己身上
flag(
f"{who}没有设置审批策略,跟随{field_label('defaults')}中的「{option_label('approval', 'never')}」;受管级别要求危险工具至少"
f"经过人工审批。请将{field_label('defaults')}中的审批策略改为「{option_label('approval', 'dangerous')}」,或在该节点上单独设置",
code="governed.default_approval_never", node_id=node.id, hard=True, field="approval",
)
if not tools:
flag(f"{who}没有配置可用工具,建议改用「模型调用」节点",
code="governed.agent_no_tools", node_id=node.id, field="tools")
elif len(tools) > 10:
flag(
f"{who}的可用工具有 {len(tools)} 个,授权范围过宽,建议只保留完成任务所需的工具",
code="governed.agent_tools_wide", node_id=node.id, field="tools",
)
elif node.type == NodeType.OUTPUT:
contract = cfg.get("contract")
if contract:
has_contract_output = True
_lint_contract(contract, node_id=node.id, spec=spec, flag=flag, strict=strict)
elif node.type == NodeType.CODE:
if cfg.get("network"):
flag(f"{who}开启了「允许联网」:受管级别建议把取数集中到「调用工具」节点"
"(那里会保存取数快照)", code="governed.code_network", node_id=node.id, field="network")
_lint_evidence(spec, flag, strict=strict)
_lint_reject_exits(spec, flag)
_lint_sql(spec, flag, checkers)
_load_extra_lints()
for check in EXTRA_LINTS:
check(spec, flag, level=level, strict=strict)
if providers is not None:
from app.engine.provider_refs import issues as provider_issues
for issue in provider_issues(spec, providers, level="error"):
result.add(issue.message, level="error", node_id=issue.node_id, field=issue.field, code=issue.code)
if strict and not has_contract_output:
result.add(
"受管级别要求至少一个「成果」节点声明出具契约,否则无法判定出具档位",
level="error", code="governed.no_contract",
)
return result
def publish_issues(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None,
providers: list[Any] | None = None) -> list[Any]:
"""发布时拦的全部问题:validate_graph 和 lint_for_publish 的并集,顺序也照发布接口。
发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。checkers、providers 见
lint_for_publish:这几处都要传同一份,否则检查和发布对 SQL、对模型接入的判断不一致。
"""
gate = lint_for_publish(spec, level=level, checkers=checkers, providers=providers).issues
return [*_not_repeated(validate_graph(spec).issues, gate, spec), *gate]
def _lint_reject_exits(spec: GraphSpec, flag: Callable[..., None]) -> None:
"""「驳回」出口那条路要能走到成果节点或者结束(一个不再往下连的节点)(总方案 §3):走不到的,驳回之后只能一直绕下去,
运行会在步数上限处中止。各级都报错——这不是受管才有的要求,任何一次驳回都会把运行拖到那里。
按图上的可达性认:从驳回出口连到的节点往下走,碰到成果节点或者没有出边的节点就算走得到。绕回关卡自己(「退回修改」:
改完再问一次)算走得到:再问一次还能批准、走普通出口。受管级里驳回路径上的出具照常判档、契约照常核对,不另立规矩。"""
from app.engine.schema import reject_targets
nodes = spec.node_map()
for gate in spec.nodes:
targets = [t for t in reject_targets(spec, gate) if t in nodes]
if not targets or _reaches_an_end(spec, targets):
continue
flag(f"{_who(gate)}的「驳回」出口接到{'、'.join(f'「{nodes[t].title}」' for t in dict.fromkeys(targets))}之后,"
"走不到任何成果节点或结束:驳回之后运行只能一直绕下去,会在步数上限处中止。请让驳回之后的那条路接到成果节点,"
"或者在一个不再往下连的节点结束",
code="reject_exit.no_end", node_id=gate.id, always=True)
def _reaches_an_end(spec: GraphSpec, start: list[str]) -> bool:
nodes = spec.node_map()
seen: set[str] = set()
todo = list(start)
while todo:
cur = todo.pop()
if cur in seen or cur not in nodes:
continue
seen.add(cur)
outgoing = [e.target for e in spec.outgoing(cur) if e.target in nodes]
if nodes[cur].type == NodeType.OUTPUT or not outgoing:
return True
todo.extend(outgoing)
return False
def _lint_sql(spec: GraphSpec, flag: Callable[..., None], checkers: Mapping[str, Any] | None) -> None:
"""调用工具节点里写死的 SQL 对照数据目录检查(data/sqlcheck.py)。
error 级(依据有确证的重复计算、存量跨期求和)按硬性问题报:受管级别挡住发布,已发布级别只警告——和别的
受管要求同一个口径。warning 两档都只提示。info 的依据只是推断(模型起草、命名推断),不进门禁:发布时
摆一排「推断,未确认」的提示,只会让人学会无视门禁。编号就是规则编号(fanout_sum…),没有确定性的修复,
改 SQL 交给人或助手。Agent 节点的 SQL 要到运行时才写出来,不在这里查。
"""
if not checkers:
return
from app.data.sqlcheck import graph_checks
for node, check in graph_checks(spec.nodes, checkers):
if check.level == "info":
continue
# sql_level:检查本来的级别。已发布档 level 一律 warning,界面靠它说清这是错误级、只是这一档不拦
flag(check.message, code=check.code, node_id=node.id, hard=check.level == "error", field="args.sql",
sql_level=check.level)
#: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues)
_REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off"})
def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[Any]:
"""门禁已经点了名的,validate 那条同义的提示不再列:门禁那条带编号、带修复,一处问题只说一遍。
G5 落在 agent 上,validate 那条也落在 agent 上;G4 落在喂数的沙箱代码上,validate 那条落在口径卡读它的
那条指标上,按口径卡的输入对回去。没被门禁覆盖的(比如不给口径卡供数的 agent)照旧提示。
节点点名的接入不存在或已停用(门禁的 provider.*)时,validate 那条「没有指定模型,将使用接入「X」的默认模型」
和它矛盾,同样不列(provider_refs.quiet_unset_model)。
"""
from app.engine.provider_refs import quiet_unset_model
cited = {i.node_id for i in gate if i.code == "governed.caliber_agent_cite_fields"}
compute = {i.node_id for i in gate if i.code == "governed.caliber_compute_input"}
covered = {("evidence.cite_fields_off", nid, "output_schema") for nid in cited}
for card in spec.nodes:
if card.type == NodeType.METRICS and compute:
covered |= {("evidence.caliber_compute_input", card.id, f"metrics[{i}].expression")
for i, producer in _caliber_inputs(card, spec) if producer.id in compute}
kept = [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered]
return quiet_unset_model(spec, kept, gate)
def _lint_contract(
contract: Any, *, node_id: str, spec: GraphSpec, flag: Any, strict: bool
) -> None:
"""契约要查内容,不能只查这个 key 在不在。
只看 key 存不存在的话,contract={"metrics_from": "随便一个字符串"} 就满足
"受管模板必须声明出具契约"这条硬门禁了 —— 一个结构上保证每次都出 formal 的
空壳模板,能合法发布成受管模板。门禁写成这样,还不如没有:它给的是
"这张图过过闸"的错觉。
"""
if not isinstance(contract, dict):
flag("出具契约必须是一个 JSON 对象", code="contract.not_object", node_id=node_id, hard=True,
field="contract")
return
metric_nodes = {n.id for n in spec.nodes if n.type == NodeType.METRICS}
sources = contract.get("metrics_from") or []
if isinstance(sources, str):
sources = [sources]
if not sources and contract_needs_metrics(contract):
flag("出具契约没有设置「指标来自」(提供指标的口径卡),叙述中的数字无法对应到指标",
code="contract.metrics_from_missing", node_id=node_id, hard=True, field="contract.metrics_from")
for src in sources:
if src not in metric_nodes:
flag(
f"出具契约的「指标来自」选择了{q(src)},但工作流中没有这个口径卡节点"
"(可能填写有误,或该节点尚未创建)",
code="contract.metrics_from_invalid", node_id=node_id, hard=True, field="contract.metrics_from",
)
# 引用模式(report_from)核对的是报告撰写节点产出的文档,数字回指靠文档里的引用标记,
# 用不上 narrative。report_from 指错了(不是报告撰写节点、不在出口上游)由 validate_graph
# 报 error(contract.report_from_invalid),发布时两者一起过,照样挡住
# 只写了「指标来自」、上游没有报告撰写节点的是指标出具(纯指标看板,board_only):出具的就是指标本身,核对成果字段
# 里的数和指标的出处,不缺核对。上游有报告的照旧要指明核哪一份——不然报告的正文没人核
by_metrics = board_only(contract, spec, node_id)
if contract.get("report_from") in (None, "") and not str(contract.get("narrative") or "").strip() \
and not by_metrics:
flag("出具契约既没有设置「报告来自」(核对哪个「报告撰写」节点的文档),也没有填写「叙述」,"
"不会执行数字核对" + ("" if sources else ";纯指标看板请设置「指标来自」,按指标出具"),
code="contract.report_from_missing", node_id=node_id, hard=True, field="contract.report_from")
if strict and by_metrics:
_lint_metric_lineage(spec, sources, flag, node_id=node_id)
# 引用模式下整张图没有口径卡(只引查询单元格的问数据图):没有指标可列,strict + cells 已经保证
# 每个数点得开出处、对不上就不予出具(用户 2026-09-29 拍板)。有口径卡的照旧要列必需指标——
# 按图里有没有口径卡判,不按契约写没写 metrics_from,免得不写 metrics_from 就绕过去
cells_only = contract.get("report_from") not in (None, "") and not metric_nodes
if strict and not (contract.get("required") or []) and not cells_only:
flag(
"受管级别的出具契约必须设置「必需指标」,否则「不予出具」这一档无法触发",
code="contract.required_missing", node_id=node_id, hard=True, field="contract.required",
)
# 没有口径卡的图,报告里的数只能直接引用查询单元格;受管正式运行不认没声明的单元格引用,
# 报告撰写节点(numbers strict、on_violation fail)每次都会失败。写不写要作者定:修复给选项
if strict and cells_only and contract.get("cells") is not True:
flag(
"工作流中没有口径卡,报告中的数字只能直接引用查询单元格。受管级别需要在出具契约中开启「单元格引用」,"
"否则正式运行时报告撰写节点无法引用任何数字而失败;也可以添加口径卡,把数字登记为指标",
code="contract.cells_undeclared", node_id=node_id, hard=True, field="contract.cells",
)
if strict and not contract.get("strict"):
flag(
"受管级别建议开启出具契约的「严格模式」:未开启时,无法追溯出处的数字只会使出具降档,不会被拦截",
code="contract.strict_off", node_id=node_id, field="contract.strict",
)
claims = contract.get("claims")
if strict and isinstance(claims, dict) and claims.get("on_uncited") == "ignore":
# 受管级别的正式运行把 ignore 当 degrade(io._claims),要求没有降低;只是写法看上去像放宽了
flag(
f"出具契约中「{contract_label('on_uncited')}」设为「{option_label('on_uncited', 'ignore')}」,但受管级别的"
"正式运行仍会把未附依据的结论句计入缺口,这项设置不会生效。"
f"请改为「{option_label('on_uncited', 'degrade')}」或「{option_label('on_uncited', 'withhold')}」",
code="contract.claims_ignored", node_id=node_id, field="contract.claims",
)
# --------------------------------------------------------------------------
# 证据门禁 G1–G5:受管出具要在结构上保证「报告里的每个数都点得开出处」
#
# G1 带契约的出口核对报告撰写节点的文档,成果字段的文字只能来自报告撰写节点
# G2 模型写的文字不绕过报告撰写节点直接流进出口(从图上追)
# G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation(或 judge,带上预算)
# G4 口径卡的输入不来自计算角色的沙箱代码
# G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields(模型调用节点不能给口径卡供数)
#
# 只看图和配置,不看运行结果。只在发布和发布前检查时起作用:已经发布的版本照常运行,
# 下次发布才按这些规则查。受管级别挡住发布,已发布级别只给警告。
# --------------------------------------------------------------------------
#: 报告撰写节点在受管级别里要写明的三项:(配置键, 要求的值, 为什么)。给人看的时候键和值都换成
#: labels 里的界面叫法
REPORT_POLICY = (
("numbers", "strict", "没有出处的数字判为违规"),
("on_violation", "fail", "重写后仍有违规时节点失败,不带着问题出具"),
("claims", "require_citation", "未附依据的结论句计入缺口,出具按档位降档"),
)
#: 除了 REPORT_POLICY 里写的那个值,还认的更严的写法。claims: judge 在挂引用之外再请另一个模型按证据逐句
#: 裁判,不支持的按 on_unsupported 判档——比 require_citation 严,但要写预算(见 judge_budget_missing)
REPORT_POLICY_ALSO: dict[str, tuple[str, ...]] = {"claims": (CLAIMS_JUDGE,)}
#: 不限的写法:judge.max_cost_usd 写 null。说给人听的时候照 engine/judge 设置项的说法
JUDGE_UNLIMITED = "不设上限,费用只受句数、时长上限和每日上限约束"
#: 产出本身就是证据、或者由系统确定地算出来的节点。成果字段取它们的值,不算「别的节点写的字」
_TRACEABLE = (NodeType.REPORT, NodeType.METRICS, NodeType.INPUT, NodeType.TOOL, NodeType.RETRIEVE, NodeType.MERGE)
_WRITERS = (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR)
#: 往对话里写消息的节点(emit_message 没关时),「最后一条消息」就是它们里最后跑的那个写的
_EMITTERS = (*_WRITERS, NodeType.REPORT)
#: 把上游的值原样或加工后往下传的节点:追文字的来路要穿过它们
_RELAYS = (NodeType.TRANSFORM, NodeType.CODE, NodeType.VALIDATE, NodeType.HUMAN, NodeType.LOOP)
#: 往回追口径卡的输入时穿过的整形节点。沙箱代码不穿过:它自己就是 G4 要判的
_RESHAPERS = (NodeType.TRANSFORM, NodeType.VALIDATE)
#: 只决定走哪条路、不把值往下带的配置
_CONTROL_KEYS = frozenset({"skip_if", "condition", "cases"})
_TEMPLATE = re.compile(r"\{\{(.*?)\}\}", re.S)
# 模板和表达式里对值的引用。nodes.<id>…、vars.<名字>… 落得到具体节点;last_message、messages
# 是最后写消息的那个节点写的;output、loops(和整个 nodes / vars)说不准来自谁
_REF = re.compile(
r"(?<![\w.])(?P<root>nodes|vars|last_message|messages|output|loops)(?![\w-])"
r"(?P<path>(?:\s*(?:\.\s*[\w-]+|\[\s*(?:'[^']*'|\"[^\"]*\"|-?\d+)\s*\]))*)")
_SEGMENT = re.compile(r"\.\s*([\w-]+)|\[\s*(?:'([^']*)'|\"([^\"]*)\"|(-?\d+))\s*\]")
#: (根, 名字, 往里取的路径)。last_message 这类没有名字
Ref = tuple[str, str, tuple[str, ...]]
_LAST: Ref = ("last_message", "", ())
def _refs(text: str, *, template: bool) -> list[Ref]:
out: list[Ref] = []
for piece in [m.group(1) for m in _TEMPLATE.finditer(text)] if template else [text]:
for m in _REF.finditer(piece):
path = tuple(next(g for g in s.groups() if g is not None) for s in _SEGMENT.finditer(m.group("path")))
root = m.group("root")
if root in ("nodes", "vars") and path:
out.append((root, path[0], path[1:]))
else:
out.append((root, "", path))
return out
def _strings(value: Any) -> list[str]:
"""配置里所有带 {{ }} 的字符串。"""
if isinstance(value, str):
return [value] if "{{" in value else []
if isinstance(value, dict):
return [s for v in value.values() for s in _strings(v)]
if isinstance(value, list):
return [s for v in value for s in _strings(v)]
return []
def _template_refs(value: Any) -> list[Ref]:
return [r for text in _strings(value) for r in _refs(text, template=True)]
def _flow_refs(node: GraphNode) -> list[Ref]:
"""这个节点的产出读了哪些值。判断走哪条路的条件不算;校验节点没写 source 时校验的是最后一条消息。"""
out: list[Ref] = []
for key, value in node.config.items():
if key in _CONTROL_KEYS:
continue
out += _refs(value, template=False) if key == "expression" and isinstance(value, str) \
else _template_refs(value)
if node.type == NodeType.VALIDATE and node.config.get("source") in (None, ""):
out.append(_LAST)
return out
def _effective(spec: GraphSpec, node: GraphNode, key: str, default: Any = None) -> Any:
"""和运行时 NodeContext.cfg 同一个规则:节点上没写就取图级 defaults。"""
value = node.config.get(key)
return spec.defaults.get(key, default) if value in (None, "") else value
def judge_budget_missing(spec: GraphSpec, node: GraphNode) -> bool:
"""claims 是 judge、生效的 judge 配置里却没有 max_cost_usd 这个键(G3)。
写 null 是显式的不限,算写了;没写的键运行时取设置里的默认——受管出具要作者自己定。judge 本身写坏了
(不是对象、max_cost_usd 写成了别的东西)由 validate 的 report.judge_invalid 报,这里不重复。
judge 和 claims 一样按运行时的规则取:节点上写了就整个用节点的,不和图级 defaults 拼。
"""
if _effective(spec, node, "claims") != CLAIMS_JUDGE:
return False
judge = _effective(spec, node, "judge")
if any(field in ("judge", "judge.max_cost_usd") for field, _ in judge_problems(judge)):
return False
return not (isinstance(judge, dict) and "max_cost_usd" in judge)
class _Graph:
"""一张图上反复要用的查找:谁产出了某个值、最后一条消息是谁写的。"""
def __init__(self, spec: GraphSpec) -> None:
self.spec = spec
self.nodes = spec.node_map()
self.parents: dict[str, list[str]] = defaultdict(list)
for e in spec.edges:
self.parents[e.target].append(e.source)
def producers(self, root: str, name: str) -> list[GraphNode]:
"""nodes.<name> 就是那个节点;vars.<name> 是用 assign_to 写它的节点、同名的入口字段、
每轮注入它的循环。"""
if not name or root not in ("nodes", "vars"):
return []
if root == "nodes":
return [self.nodes[name]] if name in self.nodes else []
out = []
for n in self.spec.nodes:
cfg = n.config
if n.type == NodeType.INPUT:
writes = {str(f.get("name") or "").strip() for f in cfg.get("fields") or [] if isinstance(f, dict)}
elif n.type == NodeType.LOOP and cfg.get("mode", "foreach") == "foreach":
item = str(cfg.get("item_var") or "item").strip()
writes = {item, f"{item}_index"}
else:
writes = set()
if name in writes or str(cfg.get("assign_to") or "").strip() == name:
out.append(n)
return out
def last_writers(self, node_id: str) -> list[GraphNode]:
"""到这个节点时,最后一条消息可能是谁写的:沿连线往回走,每条路上遇到的第一个写消息的节点。"""
found: set[str] = set()
seen = {node_id}
stack = list(self.parents[node_id])
while stack:
cur = stack.pop()
if cur in seen or cur not in self.nodes:
continue
seen.add(cur)
n = self.nodes[cur]
if n.type in _EMITTERS and _effective(self.spec, n, "emit_message", True):
found.add(cur)
continue
stack.extend(self.parents[cur])
return [n for n in self.spec.nodes if n.id in found]
def sources(self, consumer: str, ref: Ref) -> list[GraphNode]:
root, name, _ = ref
return self.last_writers(consumer) if root in ("last_message", "messages") else self.producers(root, name)
def _who(node: GraphNode) -> str:
return f"「{node.title}」({type_label(node.type)})"
def _traceable(node: GraphNode) -> bool:
return node.type in _TRACEABLE or (node.type == NodeType.CODE and node.config.get("evidence_role") == "source")
def _model_text(node: GraphNode, ref: Ref) -> bool:
"""顺着这个引用拿到的是不是模型写的话。开了 cite_fields 的 agent,assign_to(和 nodes.<id>.data)
拿到的是逐格核对过的字段,那是数据;它的原话、最后一条消息照样是模型写的。"""
if node.type in (NodeType.LLM, NodeType.SUPERVISOR):
return True
if node.type != NodeType.AGENT:
return False
root, _, path = ref
cited = bool(node.config.get("output_schema")) and node.config.get("cite_fields") is True
return not (cited and (root == "vars" or path[:1] == ("data",)))
def _exit_refs(node: GraphNode) -> list[tuple[str, str | None, list[Ref]]]:
"""出口交出去的每个字段:(字段路径, 字段名, 引用)。没配字段时交的是最后一条消息(io.run_output)。"""
fields = node.config.get("fields")
if not fields:
return [("fields", None, [_LAST])]
return [(f"fields[{i}].value", str(f["name"]), _template_refs(f.get("value", "")))
for i, f in enumerate(fields if isinstance(fields, list) else [])
if isinstance(f, dict) and f.get("name")]
def _untraceable_sources(graph: _Graph, exit_id: str, refs: list[Ref]) -> list[str]:
"""一个成果字段里核对不了出处的来源,一个来源一句(「叙述」(模型调用)、最后一条消息…)。"""
out: list[str] = []
for ref in refs:
root, name, _ = ref
if root in ("output", "loops") or (root in ("nodes", "vars") and not name):
out.append(f"{q(root)}中的值(无法确定来自哪个节点)")
elif root in ("last_message", "messages"):
out += [f"最后一条消息(由{_who(w)}写出)" for w in graph.last_writers(exit_id) if w.type != NodeType.REPORT]
else:
# 开了 cite_fields 的 agent 交的字段逐格核对过,是数据不是模型写的话——和 G2(_model_text)同一口径
out += [_who(p) for p in graph.producers(root, name)
if not _traceable(p) and (p.type != NodeType.AGENT or _model_text(p, ref))]
return list(dict.fromkeys(out))
def exit_text_fields(spec: GraphSpec, exit_node: GraphNode, graph: _Graph | None = None,
) -> list[tuple[str, str | None, list[str]]]:
"""G1:出口里文字不是来自报告撰写节点的字段:(字段路径, 字段名, 来源)。字段名为 None 是没配字段。
发布前自动修复(autofix)按同一份结果改字段,门禁和修复认的是同一批字段。
"""
graph = graph or _Graph(spec)
out = []
for field, name, refs in _exit_refs(exit_node):
if bad := _untraceable_sources(graph, exit_node.id, refs):
out.append((field, name, bad))
return out
def _text_leaks(graph: _Graph, exit_node: GraphNode) -> dict[str, tuple[GraphNode, set[tuple[str, ...]]]]:
"""流进这个出口、没经过报告撰写节点的模型原话:写字的节点 id → (节点, 途经的节点标题)。
途经为空的是出口字段直接取的。沿着模板和表达式里的引用往回追,穿过整形、沙箱代码、校验、人工审批、
循环;碰到报告撰写节点就停——模型写的话进了报告,就只是写作者的素材。
"""
found: dict[str, tuple[GraphNode, set[tuple[str, ...]]]] = {}
seen: set[str] = set()
todo: list[tuple[str, Ref, tuple[str, ...]]] = [
(exit_node.id, ref, ()) for _, _, refs in _exit_refs(exit_node) for ref in refs]
while todo:
consumer, ref, via = todo.pop(0)
for source in graph.sources(consumer, ref):
if source.type == NodeType.REPORT or source.id == exit_node.id:
continue
if source.type in _WRITERS:
if _model_text(source, ref):
found.setdefault(source.id, (source, set()))[1].add(via)
elif source.type in _RELAYS and source.id not in seen:
seen.add(source.id)
todo += [(source.id, r, (*via, source.title)) for r in _flow_refs(source)]
return found
def _card_feeders(graph: _Graph, card: GraphNode) -> list[GraphNode]:
"""口径卡的输入是谁产出的:穿过整形、校验节点往回追到真正的来源。"""
out: dict[str, GraphNode] = {}
seen: set[str] = set()
todo = [producer for _, producer in _caliber_inputs(card, graph.spec)]
while todo:
node = todo.pop(0)
if node.id in seen or node.id == card.id:
continue
seen.add(node.id)
if node.type in _RESHAPERS:
todo += [s for ref in _flow_refs(node) for s in graph.sources(node.id, ref)]
else:
out.setdefault(node.id, node)
return list(out.values())
def board_only(contract: Any, spec: GraphSpec, exit_id: str) -> bool:
"""按指标出具(纯指标看板):契约只写了「指标来自」(issuance.metrics_only),成果上游没有报告撰写节点,成果字段里也
没有模型写的话。有报告的,报告正文要按引用核对;有模型写的话的,那就是叙述——两种都不能因为契约少写了一样就改成
只核指标。门禁和成果节点(nodes/io)用这一处。"""
if not metrics_only(contract):
return False
nodes = spec.node_map()
exit_node = nodes.get(exit_id)
if exit_node is None or any(nodes[a].type == NodeType.REPORT for a in _ancestors(spec, exit_id) if a in nodes):
return False
return not exit_text_fields(spec, exit_node)
def _lint_metric_lineage(spec: GraphSpec, sources: list[str], flag: Callable[..., None], *, node_id: str) -> None:
"""受管级别按指标出具:每个指标的输入都要追到原始数据(查询快照的格、运行输入),静态地按 engine/lineage 认。
没有叙述可核,指标的出处就是出具的全部依据;追不到的那一个,「完整出具」这一档就没有根据。"""
from app.engine.lineage import static_stops
nodes = {n.id: {"type": str(n.type), "title": n.title, "config": n.config} for n in spec.nodes}
for card_id in sources:
card = nodes.get(card_id)
if card is None or card["type"] != str(NodeType.METRICS):
continue
for metric in card["config"].get("metrics") or []:
if not isinstance(metric, dict) or not metric.get("expression"):
continue
stops = static_stops(nodes, str(metric["expression"]))
if not stops:
continue
where = "、".join(dict.fromkeys(
f"{s.path}(停在「{nodes[s.node_id]['title']}」)" if s.node_id in nodes else s.path for s in stops[:3]))
flag(f"受管级别按指标出具时,每个指标都要追到原始数据:口径卡「{card['title']}」的指标"
f"「{metric.get('name') or metric.get('id')}」的输入 {where} 没追到。"
"请在口径卡里用 cell() 按格取数,或让数据整形只原样解析调用工具节点的结果",
code="governed.metric_untraced", node_id=card_id, hard=True, field="metrics")
def _lint_evidence(spec: GraphSpec, flag: Callable[..., None], *, strict: bool) -> None:
graph = _Graph(spec)
outputs = [n for n in spec.nodes if n.type == NodeType.OUTPUT]
contracted = {n.id for n in outputs if isinstance(n.config.get("contract"), dict) and n.config["contract"]}
# G1
for node in outputs:
if node.id not in contracted:
continue
contract = node.config["contract"]
# 两样都没写的由 contract.report_from_missing 报,这里只管「写了叙述、没写 report_from」
if contract.get("report_from") in (None, "") and str(contract.get("narrative") or "").strip():
flag(f"{_who(node)}的出具契约使用「叙述」核对,没有设置「报告来自」:数字只能按数值匹配,出处可能不唯一。"
"受管级别要求核对「报告撰写」节点产出的文档,每个数字都有唯一的出处。"
"请把「报告来自」设为成果节点上游的报告撰写节点",
code="governed.report_from_required", node_id=node.id, hard=True, field="contract.report_from")
for field, name, bad in exit_text_fields(spec, node, graph):
head = (f"{_who(node)}的成果字段「{name}」取自{'、'.join(bad)}的产出" if name is not None
else f"{_who(node)}没有配置成果字段,输出的是{'、'.join(bad)}")
flag(f"{head}:受管级别出具时,给人看的文字只能来自「报告撰写」节点,其他节点写的文字无法追溯出处。"
+ ("请改为取报告撰写节点的正文" if name is not None else "请为成果节点添加一个字段,取报告撰写节点的正文"),
code="governed.exit_text_source", node_id=node.id, hard=True, field=field)
# G2:每个出口都算。受管级别是 error,已发布级别是警告——和别的 G 规则一样,级别只管轻重,不管范围
flagged: set[str] = set()
for node in outputs:
for writer, vias in _text_leaks(graph, node).values():
indirect = sorted(v for v in vias if v)
# 带契约的出口里字段直接取的,G1 已经点名了那个字段
if writer.id in flagged or (node.id in contracted and not indirect):
continue
flagged.add(writer.id)
via = f"(经过{'、'.join(f'「{t}」' for t in indirect[0])})" if indirect else ""
flag(f"{_who(writer)}写的文字未经「报告撰写」节点,就进入了成果节点「{node.title}」{via}:模型写的文字不能"
"作为证据,其中的数字和表名都无法追溯出处。请改由报告撰写节点撰写(它只写引用标记,数字由系统填入),"
"成果节点改为取报告的正文",
code="governed.text_bypass", node_id=writer.id, hard=True)
# G3
for node in spec.nodes:
if node.type != NodeType.REPORT:
continue
loose = []
for key, want, why in REPORT_POLICY:
value = _effective(spec, node, key)
if key == "claims" and claims_problem(value):
continue # 写了不认识的值,validate 的 report.claims_invalid 报
also = REPORT_POLICY_ALSO.get(key, ())
if value != want and value not in also:
now = "当前未设置" if value in (None, "") else f"当前为「{option_label(key, value)}」"
more = "".join(f"或更严格的「{option_label(key, v)}」" for v in also)
loose.append((key, f"「{field_label(key)}」设为「{option_label(key, want)}」{more}({now})"))
if loose:
flag(f"{_who(node)}需要调整核对规则:{';'.join(text for _, text in loose)}。"
"受管级别要求报告撰写节点使用最严格的核对规则",
code="governed.report_policy", node_id=node.id, hard=True, field=loose[0][0])
if judge_budget_missing(spec, node):
flag(f"{_who(node)}的结论句由模型裁判,但没有设置每份报告的裁判金额上限。请在「{field_label('judge')}」中"
f"填写「{judge_label('max_cost_usd')}」,或明确选择不限({JUDGE_UNLIMITED})。"
"受管级别不允许裁判费用沿用设置中的默认值",
code="governed.judge_budget", node_id=node.id, hard=True, field="judge.max_cost_usd")
# G4、G5
feeders: dict[str, tuple[GraphNode, list[str]]] = {}
for card in spec.nodes:
if card.type == NodeType.METRICS:
for feeder in _card_feeders(graph, card):
feeders.setdefault(feeder.id, (feeder, []))[1].append(f"「{card.title}」")
for feeder, cards in feeders.values():
to = f"口径卡{'、'.join(dict.fromkeys(cards))}"
cfg = feeder.config
if feeder.type == NodeType.CODE and cfg.get("evidence_role") != "source":
role = "" if cfg.get("evidence_role") else "(未设置时默认为「计算」)"
flag(f"{_who(feeder)}的产出提供给了{to},但它的「证据角色」是「计算」{role}:沙箱中计算出的数字无法追溯出处。"
"若该节点负责取数,请将「证据角色」设为「取数」;若负责计算,请把计算移到口径卡的表达式中",
code="governed.caliber_compute_input", node_id=feeder.id, hard=True, field="evidence_role")
elif feeder.type == NodeType.AGENT and not cfg.get("output_schema"):
flag(f"{_who(feeder)}为{to}提供数据,但未配置「结构化输出 Schema」,交出的是自由文本,口径卡读取的数字"
"无法追溯出处。请配置「结构化输出 Schema」并开启「按出处核对字段」,使每个字段都核对到查询结果中的"
"对应单元格",
code="governed.caliber_agent_schema", node_id=feeder.id, hard=True, field="output_schema")
elif feeder.type == NodeType.AGENT and cfg.get("cite_fields") is not True:
flag(f"{_who(feeder)}为{to}提供数据,已配置「结构化输出 Schema」,但未开启「按出处核对字段」:字段没有"
"逐一核对到查询结果,口径卡读取的仍是模型给出的数字。请开启「按出处核对字段」",
code="governed.caliber_agent_cite_fields", node_id=feeder.id, hard=True, field="cite_fields")
elif feeder.type == NodeType.LLM:
flag(f"{_who(feeder)}为{to}提供数据:「模型调用」节点不调用工具,写出的数字没有出处。请改由 Agent"
"(配置「结构化输出 Schema」并开启「按出处核对字段」)或「调用工具」节点取数",
code="governed.caliber_model_input", node_id=feeder.id, hard=True)
async def unresolved_caliber_upgrades(
session: AsyncSession, spec: GraphSpec
) -> tuple[list[str], list[dict[str, Any]]]:
"""检查钉了版本的上游是否有未处置的新版本:子工作流(方法卡),以及用 caliber_from
钉住别处口径卡的口径卡节点——两者是同一套规则。
返回 (阻断性错误, 需记录的升版事件)。有新版本但没声明 upgrade_policy
的直接阻断 formal 启动 —— 这是"被迫处置",不是提醒。
"""
from app.apps.versions import latest_published
errors: list[str] = []
events: list[dict[str, Any]] = []
for node in spec.nodes:
extra: dict[str, Any] = {}
if node.type == NodeType.SUBGRAPH:
pinned = node.config.get("workflow_version")
wf_id = node.config.get("workflow_id")
what = f"「{node.title}」(子工作流)固定在 v{pinned}"
elif node.type == NodeType.METRICS and isinstance(node.config.get("caliber_from"), dict):
ref = node.config["caliber_from"]
pinned, wf_id = ref.get("workflow_version"), ref.get("workflow_id")
extra = {"caliber_node": ref.get("node_id")}
if isinstance(ref.get("nodes"), dict) and ref["nodes"]:
# 拷进这张图时改了名的取数节点(next-round 02 做法 4):升版事件照记映射,证据面板说得清这张卡读的是哪几个
# 节点;升不升版、怎么处置和没改名的卡同一套规则
extra["caliber_nodes"] = dict(ref["nodes"])
what = f"「{node.title}」(口径卡)引用的口径卡固定在 v{pinned}"
else:
continue
if not pinned or not wf_id or not str(pinned).isdigit():
continue
# 只看已发布且未退役的版本(F3):工作流每保存一次就留一个快照,上游改一下画布不算升版,不该逼下游处置
newest = await latest_published(session, str(wf_id))
latest = newest.version if newest is not None else None
if latest is None or latest <= int(pinned):
continue
policy = node.config.get("upgrade_policy")
if policy not in UPGRADE_POLICIES:
errors.append(
f"{what},但上游已有 v{latest}。"
f"发起正式运行前,请在节点的「{field_label('upgrade_policy')}」中选择一种处置:"
+ _either(UPGRADE_POLICY_LABELS.values())
)
else:
events.append(
{
"node_id": node.id,
"workflow_id": wf_id,
"pinned": int(pinned),
"latest": int(latest),
"policy": policy,
"policy_label": UPGRADE_POLICY_LABELS[policy],
**extra,
}
)
return errors, events
def _either(labels: Any) -> str:
"""「甲」、「乙」或「丙」。"""
items = [f"「{v}」" for v in labels]
return items[0] if len(items) == 1 else "、".join(items[:-1]) + "或" + items[-1]
#: 发起正式运行时记下「这次按不按受管出具」的那条事件(log · info,不进主流程)
GOVERNANCE_CODE = "run_governance"
def governance_note(*, governed: bool, workflow_name: str, version: int | None = None) -> dict[str, Any]:
"""发起正式运行时落进事件流的那条记录(runs.start_run 调用)。
governed 由调用方按这次钉住的版本发布时记下的级别定(WorkflowVersion.level),老版本没记过的
看工作流当时的 status。
"""
which = f"「{workflow_name}」v{version}" if version else f"「{workflow_name}」"
message = (f"本次正式运行按受管级别出具:{which}以受管级别发布,之后修改工作流或发布级别不影响本次运行"
if governed else
f"本次正式运行按已发布级别出具:{which}未以受管级别发布")
return {"level": "info", "code": GOVERNANCE_CODE, "governed": governed, "message": message}
async def governed_formal(run_id: str) -> bool:
"""这次运行是不是受管级别的正式运行:从按受管级别发布的版本发起。
受管的判断沿用发布门禁的口径(lint_for_publish 的 strict 档位)。探索运行、已发布级别的
正式运行都不是。
报告节点和出口复核各问一次、前后隔着审批和重放,所以按发起时记下的那条事件算(start_run
在封存范围内记的 run_governance,来源是钉住的那一版发布时记下的级别);没有这条记录的是
加这条之前发起的运行,照旧看工作流现在的 status。
"""
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent, Workflow
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None or run.run_class != "formal" or not run.workflow_id:
return False
# 这条事件没有 node_id,发起时就记下了:只翻不属于任何节点的日志
notes = (await session.execute(
select(RunEvent.data).where(RunEvent.run_id == run_id, RunEvent.type == "log",
RunEvent.node_id.is_(None)).order_by(RunEvent.seq))).scalars()
for data in notes:
if isinstance(data, dict) and data.get("code") == GOVERNANCE_CODE:
return data.get("governed") is True
workflow = await session.get(Workflow, run.workflow_id)
return workflow is not None and workflow.status == "governed"
def cells_allowed(contract: Any, *, governed: bool) -> bool:
"""报告能不能直接引用查询单元格。受管级别的正式运行要在契约里显式写 "cells": true;
探索运行和已发布级别一律允许(用户拍板)。"""
return not governed or (isinstance(contract, dict) and contract.get("cells") is True)