Files
AgentLab/backend/app/engine/governance.py
T
ThearchyHelios 9727c21d8d 可点击证据第四期:结论句由模型裁判;只引单元格的问数据图能发成受管
一、结论句裁判

- 报告节点新增 claims: judge:写完、核对完之后,把结论句和它引用的证据摘录交给裁判模型,
  判为支持 / 部分支持 / 不支持 / 不是结论,附一句理由;判定只标注,不改写正文
- 送裁判的句子按固定规则预筛:带引用的、含方向词或因果词的送去判,短句、过渡句不送
- 证据摘录:指标写名称、值和原式;查询给 SQL 前 300 字和被引用的行,被引用的列一定给到;遮罩列一律不给;
  规则里写明「数字已经由系统核对过,不要复算」
- 一份报告合并成一次调用,超出句数就分批;结构化输出失败时退回 JSON 解析,再失败记为未裁判
- 裁判模型的优先级:节点上写的 > 设置里的「证据裁判模型」> Copilot 的模型;思考关闭;
  和写作模型相同时给警告(等于自己审自己)
- rewrite_once(默认关):把不支持的句子和理由交回写作者只改这几句,再裁一次;
  改写稿冒出原稿没有的问题就不采用,保留原稿和原来的判定

二、成本上限

- 每份报告的句数、金额、时长,探索运行每次点击的金额,全局每日金额,每一项都可以自定义,也都可以设成不限;
  默认 40 句、$0.05、30 秒,每次点击 $0.01,每天 $2
- 调用前先估算,超出上限按优先级截断:先判带引用的,再判含方向词、因果词的,其余记为未裁判并写明「已到上限」;
  不报错,也不让报告失败
- 每日花费按本地日期计数,持久化在设置表里;重跑同一次运行不重复计费;价格目录里没有的模型只计次数,并写明估不出金额

三、正式运行与探索运行

- 正式运行在报告节点内裁判,判定写进报告文档、随文档封存;不支持的按 on_unsupported(degrade / withhold)降档或不予出具,
  裁判没跑成记缺口,不能判完整出具
- 探索运行按需裁判:POST /api/runs/{id}/evidence/judge,点开哪句判哪句;判定作为 evidence.judged 事件追加在封存之后,
  标「封存后追加」,封存核对仍然一致;只读封存范围内的工件;同一句不重复判;运行在裁判期间被接着跑时不记录
- 正式运行调这个接口返回 409,写明「正式运行的裁判在节点内完成」

四、治理与自动修复

- G3 放开 judge:受管级别写 judge 必须写预算(judge.max_cost_usd,或显式写 null 表示不限)
- 发布前自动修复:缺预算时给选项(默认金额 / 不限),不替人选;
  从 require_citation 改成 judge 不算降低要求,judge 改回 off、withhold 放宽成 degrade 算降低要求
- Copilot 手册:受管模板可以写 judge 并带预算;问数据按需裁判,不替人打开结论句策略

五、界面

- 结论句句末放徽标:支持 ◆、部分支持 ◇、不支持 ! 整句浅底、未裁判 ?;不用表示「已核对」的绿色;n / N 也跳到不支持的句子
- 证据面板里「模型的解释」:带「模型判断 · 模型名 · 非确定」,封存后追加的另外标出;
  「请模型判断这句」只在探索运行、还没判过时出现,触顶时写明已到上限和怎么调
- 出具横幅多一段结论句计数;设置页新增「证据裁判」一组(裁判模型、各项上限、今日花费),设成不限时写明费用受什么约束;
  报告节点可以配 judge;运行轨迹里裁判相关的事件都有中文标题

六、门禁修正

- 引用模式下,图里没有口径卡(只引查询单元格)的,出具契约不再要求 metrics_from,受管门禁也不再要求 required:
  strict 加 cells 已经保证每个数都点得开、对不上就不予出具;有口径卡的照旧要求。
  以前问数据的图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
2026-09-29 11:10:53 +08:00

670 lines
36 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 typing import Any, Callable
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.schema import (
CLAIMS_JUDGE,
GraphNode,
GraphSpec,
NodeType,
ValidationResult,
_caliber_inputs,
claims_problem,
contract_needs_metrics,
judge_problems,
type_label,
validate_graph,
)
# 口径升版的三种合法处置。没有默认值是刻意的:升版最容易静默翻车的地方
# 就是"上周和这周的数其实不可比,但报表看起来一切正常"。
UPGRADE_POLICIES = {"recompute", "dual", "incomparable"}
UPGRADE_POLICY_LABELS = {
"recompute": "用新口径回算历史",
"dual": "并排双印新旧口径",
"incomparable": "标注与历史不可比",
}
def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult:
"""发布门禁。
published:基础可运行性之外只给警告。
governed:受管模板,出具用的那一档 —— 把关节层的自由度收进边界里,
错误会挡住发布。
每条问题带机器编号(code):发布前自动修复(engine/autofix.py)按它认问题、出修复,
文案可以改,编号不能随便改。证据门禁 G1–G5 见 _lint_evidence。
"""
result = ValidationResult()
strict = level == "governed"
def flag(message: str, *, code: str, node_id: str | None = None, hard: bool = False,
field: str | None = None) -> None:
result.add(message, level="error" if (hard and strict) else "warning", node_id=node_id,
field=field, code=code)
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}的审批策略是「全部自动放行」,受管模板要求危险工具至少人工确认。"
"改成「仅危险工具需要审批」",
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}没写自己的审批策略,跟随全图默认的「全部自动放行」,受管模板要求危险工具至少"
"人工确认。把全图默认改成「仅危险工具需要审批」,或者在这个节点上单独设置",
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)
if strict and not has_contract_output:
result.add(
"受管模板至少要有一个「成果 / 出具」节点声明出具契约,否则三档出具无从谈起",
level="error", code="governed.no_contract",
)
return result
def publish_issues(spec: GraphSpec, *, level: str) -> list[Any]:
"""发布时拦的全部问题:validate_graph 和 lint_for_publish 的并集,顺序也照发布接口。
发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。
"""
gate = lint_for_publish(spec, level=level).issues
return [*_not_repeated(validate_graph(spec).issues, gate, spec), *gate]
#: 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)照旧提示。
"""
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}
return [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered]
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("出具契约没有声明 metrics_from(指标来自哪个「口径卡」节点),叙述里的数字无从回指",
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"出具契约的 metrics_from 指向 {src!r},但工作流里没有这个「口径卡」节点"
"(写错了,或者那个节点还没建)",
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),发布时两者一起过,照样挡住
if contract.get("report_from") in (None, "") and not str(contract.get("narrative") or "").strip():
flag("出具契约既没有声明 report_from(核对哪个「报告撰写」节点的文档),也没有声明 narrative"
"(叙述),数字回指校验不会执行", code="contract.report_from_missing", node_id=node_id, hard=True,
field="contract.report_from")
# 引用模式下整张图没有口径卡(只引查询单元格的问数据图):没有指标可列,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(
"受管模板的出具契约必须声明 required(必需指标),否则「不予出具」这一档永远触发不了",
code="contract.required_missing", node_id=node_id, hard=True, field="contract.required",
)
if strict and not contract.get("strict"):
flag(
"受管模板建议把出具契约设为 strict:非 strict 下未回指的数字只降档不拦截",
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(
"出具契约的 claims 写了 on_uncited: ignore,但受管级别的正式运行里没挂依据的结论句照样计入缺口"
"(按 degrade 处理),这个写法不会生效。改成 degrade,或者更严的 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(模型调用节点不能给口径卡供数)
#
# 只看图和配置,不看运行结果。只在发布和发布前检查时起作用:已经发布的版本照常运行,
# 下次发布才按这些规则查。受管级别挡住发布,已发布级别只给警告。
# --------------------------------------------------------------------------
#: 报告撰写节点在受管模板里要写明的三项:(配置键, 要求的值, 为什么)
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)
_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"{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 _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)}的出具契约用的是旧的叙述模式(narrative),没有声明 report_from:数字只能按数值"
"回头匹配,出处可能不唯一。受管出具核对「报告撰写」节点产出的文档,每个数都有唯一的出处——把 "
"report_from 指向出口上游的报告撰写节点",
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"现在是 {value}"
more = "".join(f",或者更严的 {v}" for v in also)
loose.append((key, f"{key}: {want}({why}{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)}的结论句由模型裁判(claims: judge),却没有写每份报告的裁判预算:在 judge 里写 "
"max_cost_usd——写一个金额(美元),或者显式写 null 表示不限(" + 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 = "是 compute" if cfg.get("evidence_role") else "没写,按计算算"
flag(f"{_who(feeder)}的产出喂给了{to},而它的角色是计算(evidence_role {role}):沙箱里算出来的数核对"
"不了出处。它其实是在取数的话,把 evidence_role 标成 source;是在做计算的话,把计算挪进口径卡的表达式",
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}供数,却没有配 output_schema:它交出来的是一段自由文字,口径卡读到的数核对不了"
"出处。给它配 output_schema 并开 cite_fields,每个字段都核对到查询结果里的那一格",
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}供数,配了 output_schema 却没开 cite_fields:字段没有逐个核对到查询结果,"
"口径卡读到的仍是模型报的数。打开 cite_fields",
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(配 output_schema "
"并开 cite_fields)或「调用工具」节点取数",
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.db.models import WorkflowVersion
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")}
what = f"「{node.title}」(口径卡)钉住的口径卡在 v{pinned}"
else:
continue
if not pinned or not wf_id or not str(pinned).isdigit():
continue
latest = (
await session.execute(
select(func.max(WorkflowVersion.version)).where(
WorkflowVersion.workflow_id == wf_id
)
)
).scalar()
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"正式运行前必须在节点上声明升版策略(upgrade_policy):"
+ " / ".join(f"{k}({v})" for k, v in UPGRADE_POLICY_LABELS.items())
)
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
#: 发起正式运行时记下「这次按不按受管出具」的那条事件(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)