Files
AgentLab/backend/app/engine/schema.py
T

1591 lines
89 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 math
import re
from collections import defaultdict
from enum import StrEnum
from typing import Any, Iterable, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from app.engine.labels import TYPE_LABEL, field_label, judge_label, option_label, q, type_label # noqa: F401
# 双盲复算、反方审稿的叫法在 labels.py;校验、执行器和老的调用方照旧从这里取
from app.engine.labels import ( # noqa: F401
CHALLENGE_LABEL,
CHALLENGE_ON_REFUTED_LABEL,
DUAL_CHECK_FIELD_LABEL,
DUAL_CHECK_ON_REJECT_LABEL,
)
class NodeType(StrEnum):
"""画布上可用的节点类型。新增类型 = 加一个枚举值 + 一个执行器 + 一个前端渲染。"""
INPUT = "input"
OUTPUT = "output"
LLM = "llm" # 单次模型调用
AGENT = "agent" # 带工具循环的 ReAct agent
SUPERVISOR = "supervisor" # 多 agent 调度
TOOL = "tool" # 直接调用一个工具
CODE = "code" # 沙箱里跑代码
MERGE = "merge" # 合并查询:几次查询的完整结果在库外按键合并(engine/merge_query.py)
BRANCH = "branch" # 条件分支
LOOP = "loop" # 循环 / 迭代
SUBGRAPH = "subgraph" # 嵌套另一个工作流
MEMORY = "memory" # 长期记忆读写
RETRIEVE = "retrieve" # 知识库检索
TRANSFORM = "transform" # 数据整形
HUMAN = "human" # 人工介入
VALIDATE = "validate" # 结构化校验 + 自动重试
METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源
REPORT = "report" # 报告撰写:模型只写引用标记,数字由系统从口径卡取出来渲染
# 下面两种目前只是占位(执行器原样放行),各自的工作包补上真正的逻辑
DUAL_CHECK = "dual_check" # 双盲复算:另起一路独立算一遍,和原结果比对
CHALLENGE = "challenge" # 反方审稿:专挑报告的毛病,提出反驳意见
# 节点类型在画布上的叫法(TYPE_LABEL / type_label)从 labels.py 导入:报错和门禁文案用它,
# 而不是枚举值——用户在界面上从没见过 supervisor、llm 这些词
class Position(BaseModel):
x: float = 0
y: float = 0
class NodeData(BaseModel):
"""节点上的可编辑内容。config 保持松散 dict,各执行器自己解析成强类型模型,
这样前端加字段不会让整张图反序列化失败。"""
label: str = ""
description: str = ""
config: dict[str, Any] = Field(default_factory=dict)
class GraphNode(BaseModel):
id: str
type: NodeType
position: Position = Field(default_factory=Position)
data: NodeData = Field(default_factory=NodeData)
@field_validator("id")
@classmethod
def _valid_id(cls, v: str) -> str:
if not v or not v.replace("_", "").replace("-", "").isalnum():
raise ValueError(f"节点 ID 只能包含字母、数字、下划线和连字符,当前为「{v}」")
# LangGraph 保留了这两个名字
if v in ("__start__", "__end__"):
raise ValueError(f"「{v}」是保留的节点 ID,请换一个")
return v
@property
def config(self) -> dict[str, Any]:
return self.data.config
@property
def title(self) -> str:
return self.data.label or self.id
class GraphEdge(BaseModel):
id: str = ""
source: str
target: str
# 分支节点用 sourceHandle 区分走哪条路:"true"/"false" 或自定义 case key
sourceHandle: str | None = None
targetHandle: str | None = None
label: str = ""
@model_validator(mode="after")
def _fill_id(self) -> "GraphEdge":
if not self.id:
self.id = f"{self.source}:{self.sourceHandle or ''}->{self.target}"
return self
@property
def branch_key(self) -> str | None:
return self.sourceHandle if self.sourceHandle not in (None, "out", "source") else None
class GraphSpec(BaseModel):
"""一整张可执行的图。前端 React Flow 的画布状态直接序列化成它。"""
nodes: list[GraphNode] = Field(default_factory=list)
edges: list[GraphEdge] = Field(default_factory=list)
viewport: dict[str, Any] = Field(default_factory=dict)
# 图级别的默认值,节点没配 provider/model 时回退到这里
defaults: dict[str, Any] = Field(default_factory=dict)
def node_map(self) -> dict[str, GraphNode]:
return {n.id: n for n in self.nodes}
def outgoing(self, node_id: str) -> list[GraphEdge]:
return [e for e in self.edges if e.source == node_id]
def incoming(self, node_id: str) -> list[GraphEdge]:
return [e for e in self.edges if e.target == node_id]
def entry_nodes(self) -> list[GraphNode]:
"""入口 = input 节点;没有 input 节点时退化为所有无入边的节点。"""
inputs = [n for n in self.nodes if n.type == NodeType.INPUT]
if inputs:
return inputs
return [n for n in self.nodes if not self.incoming(n.id)]
def terminal_nodes(self) -> list[GraphNode]:
outputs = [n for n in self.nodes if n.type == NodeType.OUTPUT]
if outputs:
return outputs
return [n for n in self.nodes if not self.outgoing(n.id)]
# --------------------------------------------------------------------------
# 驳回出口(docs/design/orchestration-round/00-总方案.md §3)
# --------------------------------------------------------------------------
#: 会被人驳回、能接「驳回」出口的节点:人工节点(审批、补充输入、编辑稿件)、调用工具(工具调用审批、写入前的动作确认)、
#: 沙箱代码(代码执行审批)、双盲复算(裁决)。和 context.REJECTION_KINDS 的六种关卡一一对得上
GATE_TYPES = frozenset({NodeType.HUMAN, NodeType.TOOL, NodeType.CODE, NodeType.DUAL_CHECK})
#: 「驳回」出口的 sourceHandle(同 context.REJECT_HANDLE)
REJECT_HANDLE = "rejected"
def approve_mode(spec: GraphSpec, node: GraphNode) -> bool:
"""人工节点是不是审批模式(和 NodeContext.cfg 同一个规则:节点上没写取图级 defaults,都没写是审批)。审批模式本来就有
「批准」「驳回」两个出口,按 __decision__ 路由;别的关卡只有一个普通出口,接了驳回出口才多一个。"""
if node.type != NodeType.HUMAN:
return False
mode = node.config.get("mode")
return (spec.defaults.get("mode") if mode in (None, "") else mode) in (None, "", "approve")
def reject_targets(spec: GraphSpec, node: GraphNode) -> list[str]:
"""这个关卡节点「驳回」出口连到的节点(按连线的先后);不是关卡、没接驳回出口的为空。
接了:驳回(含超时驳回)时这一步正常结束、沿这几条边往下走(engine/compiler);没接:驳回让运行结束(cancelled +
end_reason=rejected),和以前一字不差。审批模式另有一条老规矩:没设「驳回即终止」时,驳回本来就按 __decision__ 走
驳回边(没接就结束在这里、运行成功),那一条照旧。"""
if node.type not in GATE_TYPES:
return []
return [e.target for e in spec.outgoing(node.id) if e.sourceHandle == REJECT_HANDLE]
# --------------------------------------------------------------------------
# 校验
# --------------------------------------------------------------------------
class ValidationIssue(BaseModel):
#: info 是建议、不是问题(evidence.upgrade_available「可以升级为可追溯结构」):不挡运行、不挡发布,
#: 自查、门禁、修复复核都只数 error
level: Literal["error", "warning", "info"] = "error"
node_id: str | None = None
edge_id: str | None = None
message: str
#: 节点配置里出问题的那个字段:prompt、cases[1].condition、agents[0].system。
#: 检查器靠它把问题落到具体的输入框下面;说不清是哪个字段的(连线、图级)为空
field: str | None = None
#: 机器认的问题编号(governed.agent_approval_never、contract.required_missing…)。发布前自动修复
#: 靠它认出是哪一类问题;文案会改,编号不改。没有编号的问题只能人看着修
code: str | None = None
#: 这条问题对应的修复 id(engine/autofix.py 算出来的)。只在发布检查的结果里填
fix: str | None = None
#: 发布门禁里基于数据目录的 SQL 检查(governance._lint_sql):这条检查本来的级别(error / warning)。level 说的是
#: 拦不拦(已发布档一律 warning),前端要按它说清「错误级问题,已发布只提醒、受管会拦」。别的问题没有这一项,
#: 序列化时也不出现
sql_level: Literal["error", "warning"] | None = Field(default=None, exclude_if=lambda v: v is None)
class ValidationResult(BaseModel):
ok: bool = True
issues: list[ValidationIssue] = Field(default_factory=list)
def add(self, message: str, *, level: str = "error", node_id: str | None = None,
edge_id: str | None = None, field: str | None = None, code: str | None = None,
sql_level: str | None = None) -> None:
self.issues.append(
ValidationIssue(level=level, message=message, node_id=node_id, # type: ignore[arg-type]
edge_id=edge_id, field=field, code=code, sql_level=sql_level) # type: ignore[arg-type]
)
if level == "error":
self.ok = False
def topology_of(spec: GraphSpec) -> tuple[tuple, tuple]:
"""图的骨架:节点的 id 与类型、加上所有的边。**不含任何配置。**
断点续跑要用它。LangGraph 的 checkpoint 是按节点名存的(编译时
`builder.add_node(node.id, …)`),而节点配置是运行时从 spec 现读的
(`NodeContext.cfg`)。所以拿一张只改了配置的图去续,checkpoint 对得上,
前面跑过的节点结果照样能用;一旦增删节点或改连线,checkpoint 里的
通道状态就和新图对不上了——续下去跑出来的是一份似是而非的结果,
比直接报错糟得多。
这正是 resume 那边踩过的坑:不看有没有 checkpoint 就发 Command,
LangGraph 拿空 state 从 START 重跑,最后还落成 succeeded。
"""
return (
tuple(sorted((n.id, str(n.type)) for n in spec.nodes)),
tuple(sorted((e.source, e.target, e.sourceHandle or "") for e in spec.edges)),
)
def back_edges(
node_ids: Iterable[str], edges: Iterable[GraphEdge], *, roots: Iterable[str] = (),
) -> set[tuple[str, str, str]]:
"""DFS 森林里的回边,以 (source, target, sourceHandle) 表示。剔掉它们,剩下的必然无环。
哪条边算"回边"取决于 DFS 从哪里出发。排版不在乎,按节点顺序起算就行;
执行语义在乎——得从入口(roots)起算,否则循环体里的一条普通前向边可能被
误判成回边。两端有一端不在 node_ids 里的悬空边直接忽略。
迭代式而不是递归:一张几百个节点的图不该因为递归深度挂掉。
"""
WHITE, GREY, BLACK = 0, 1, 2
state = dict.fromkeys(node_ids, WHITE)
adj: dict[str, list[GraphEdge]] = defaultdict(list)
for e in edges:
if e.source in state and e.target in state:
adj[e.source].append(e)
back: set[tuple[str, str, str]] = set()
for root in [*roots, *state]:
if state.get(root) != WHITE:
continue
stack: list[tuple[str, int]] = [(root, 0)]
state[root] = GREY
while stack:
node_id, i = stack[-1]
out = adj[node_id]
if i >= len(out):
state[node_id] = BLACK
stack.pop()
continue
stack[-1] = (node_id, i + 1)
edge = out[i]
target = edge.target
if state[target] == GREY:
back.add((edge.source, edge.target, edge.sourceHandle or ""))
elif state[target] == WHITE:
state[target] = GREY
stack.append((target, 0))
return back
def loop_steps(spec: GraphSpec) -> int:
"""图里的循环按各自声明的轮数跑满,最多要走多少步(LangGraph 的 superstep)。
整次运行的步数上限(max_graph_steps)防的是没人把关的环;loop 节点自己带着
max_iterations,那是作者明说的轮数上限。以前两者打架时全局值悄悄赢:「测试 1」
的 while 循环配了 1005 轮、每轮 2 步,跑到 100 轮上下就被 GraphRecursionError
掐断——校验通过,跑到一半才死。runner 把这个数加在全局上限之上。
取上界不求精确:一个节点最多跑 Π(包住它的各层循环的 max_iterations + 1) 次,
逐个加起来。一步至少跑一个节点,所以步数只会比这少。
"""
bodies = loop_bodies(spec)
if not bodies:
return 0
loops = spec.node_map()
runs: dict[str, int] = defaultdict(lambda: 1)
for loop_id, body in bodies.items():
try:
cap = int(loops[loop_id].config.get("max_iterations", 10) or 10)
except (TypeError, ValueError):
cap = 10 # 运行到这个节点时同样会报错,这里不替它报
for n in body | {loop_id}:
runs[n] *= max(cap, 0) + 1
return sum(runs.values())
def loop_bodies(spec: GraphSpec) -> dict[str, set[str]]:
"""每个循环节点的循环体(不含循环节点本身)。
循环体 = 从 body 出口出发、不经过循环节点本身、又能绕回它的那些节点。
没标出口的边路由时按 default 走,body 也会走它。
"""
loops = [n for n in spec.nodes if n.type == NodeType.LOOP]
if not loops:
return {}
succ: dict[str, list[str]] = defaultdict(list)
pred: dict[str, list[str]] = defaultdict(list)
for e in spec.edges:
succ[e.source].append(e.target)
pred[e.target].append(e.source)
def reach(starts: Iterable[str], step: dict[str, list[str]], avoid: str) -> set[str]:
seen: set[str] = set()
stack = [s for s in starts if s != avoid]
while stack:
n = stack.pop()
if n not in seen:
seen.add(n)
stack.extend(x for x in step[n] if x != avoid)
return seen
out: dict[str, set[str]] = {}
for loop in loops:
entries = [e.target for e in spec.outgoing(loop.id) if e.sourceHandle != "done"]
out[loop.id] = reach(entries, succ, loop.id) & reach(pred[loop.id], pred, loop.id)
return out
def innermost_loops(spec: GraphSpec) -> dict[str, str]:
"""节点 -> 直接包住它的那一层循环。嵌套时取循环体最小的那个。"""
bodies = loop_bodies(spec)
out: dict[str, str] = {}
for loop_id, body in sorted(bodies.items(), key=lambda kv: -len(kv[1])):
for n in body:
out[n] = loop_id # 由大到小覆盖,最后留下的是最内层
return out
def expression_fields(node: GraphNode) -> list[tuple[str, str, str]]:
"""这个节点上按表达式求值的字段:(给人看的名字, 原文, 字段路径)。
跟着模式走:foreach 循环的 condition、模板模式整形的 expression 不参与求值,
拿它们报错就是误报。
"""
cfg = node.config
out: list[tuple[str, str, str]] = []
def add(label: str, value: Any, field: str) -> None:
if isinstance(value, str) and value.strip():
out.append((label, value, field))
add("跳过条件", cfg.get("skip_if"), "skip_if")
if node.type == NodeType.BRANCH and cfg.get("mode", "expression") == "expression":
for i, case in enumerate(cfg.get("cases") or []):
case = case or {}
add(f"分支「{case.get('label') or case.get('key') or '?'}」的条件", case.get("condition"),
f"cases[{i}].condition")
elif node.type == NodeType.LOOP and cfg.get("mode", "foreach") == "while":
add("循环条件", cfg.get("condition"), "condition")
elif node.type == NodeType.TRANSFORM and cfg.get("mode", "expression") == "expression":
add("整形表达式", cfg.get("expression"), "expression")
elif node.type == NodeType.METRICS:
for i, d in enumerate(cfg.get("metrics") or []):
d = d or {}
add(f"指标「{d.get('id') or '?'}」的表达式", d.get("expression"), f"metrics[{i}].expression")
return out
# 代码节点可能往 stdout 写东西的迹象。宁可漏判不可误判:这条是 error 级、会挡住运行,
# 所以间接的写法(子进程、exec、附带文件里的 print)都算。整行只有一个 {{ }} 的,
# 那行代码是运行时从上游拿的(⑤号示例整段就是 {{ vars.code }}),看不见,不猜;
# 而 `state = {{ vars.state | json }}` 这种只是填一个值,照查
_MAY_WRITE_STDOUT = re.compile(
r"\b(print|pprint|exec)\s*\(|\bstdout\b|\bos\.(system|write|popen)\b|\bsubprocess\b"
r"|^\s*\{\{.*\}\}\s*$", re.M)
def _check_case_keys(node: GraphNode, cases: list[Any], result: ValidationResult) -> None:
"""分支出口的 id 就是 case 的 key,所以 key 得唯一、非空,而且不能占用兜底出口的名字。
default 只报警告:现存的 ⑥ 模板就写着 key=default、条件为 true,运行语义上等价于
兜底,是自洽的;升成 error 会让它和所有从它复制出来的图直接不能跑。可它在画布上
会和「其他」撞成同一个出口,跑完两条一起点亮——得说出来。
重复和空 key 是真坏了:路由只认 key,重复的那个永远轮不到,空的根本连不出边。
"""
seen: set[str] = set()
for i, case in enumerate(cases, start=1):
case = case or {}
key = str(case.get("key") or "").strip()
name = case.get("label") or key or f"第 {i} 个"
field = f"cases[{i - 1}].key"
if not key:
result.add(f"「条件分支」的分支「{name}」没有标识,无法连线,也不会被选中",
node_id=node.id, field=field)
continue
if key == "default":
result.add(
f"分支「{name}」的标识用了「default」:这是「其他」出口的保留名,两者会合并成"
"同一个出口,运行后无法区分走的是哪一条。请换一个标识,例如 team",
level="warning", node_id=node.id, field=field,
)
elif key in seen:
result.add(f"「条件分支」中有两个分支的标识都是「{key}」:路由只按标识区分,"
"后一个分支不会被选中。请换一个不重复的标识", node_id=node.id, field=field)
seen.add(key)
def _ancestors(spec: GraphSpec, node_id: str) -> set[str]:
"""沿连线往回走能到的所有节点(不含自己)。循环里的节点彼此都算上游。"""
parents: dict[str, list[str]] = defaultdict(list)
for e in spec.edges:
parents[e.target].append(e.source)
seen: set[str] = set()
stack = list(parents[node_id])
while stack:
cur = stack.pop()
if cur not in seen:
seen.add(cur)
stack.extend(parents[cur])
seen.discard(node_id)
return seen
def _check_report_sources(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None:
"""报告的 metrics_from 只能指向它上游的口径卡。
指错了运行时不会报错:目录里就是没有那张卡的指标,模型要么写不出数、要么被
判成引用不存在——在画图时说破比跑完再猜原因便宜得多。
"""
sources = node.config.get("metrics_from")
if sources in (None, "", []):
return # 不写就取所有上游口径卡
if isinstance(sources, str):
sources = [sources]
code = "report.metrics_from_invalid"
if not isinstance(sources, list) or not all(isinstance(s, str) and s for s in sources):
result.add("「报告撰写」的「指标来自」需要选择口径卡节点", node_id=node.id,
field="metrics_from", code=code)
return
nodes = spec.node_map()
upstream = _ancestors(spec, node.id)
for src in sources:
target = nodes.get(src)
if target is None:
result.add(f"「报告撰写」的「指标来自」选择了「{src}」,但工作流中不存在这个节点",
node_id=node.id, field="metrics_from", code=code)
elif target.type != NodeType.METRICS:
result.add(f"「报告撰写」的「指标来自」选择了「{target.title}」,它是「{type_label(target.type)}」"
"节点,不是口径卡;报告中的指标只能来自口径卡",
node_id=node.id, field="metrics_from", code=code)
elif src not in upstream:
result.add(f"「报告撰写」的「指标来自」选择的口径卡「{target.title}」不在它的上游:撰写报告时"
"该口径卡尚未计算,报告无法引用它的指标。请把口径卡连到报告撰写节点之前",
node_id=node.id, field="metrics_from", code=code)
#: 审核人没有采用原流程的结果(选了盲算,或者判断两边都不对)时:degrade 计入缺口、出具降档(默认)/ fail 判为失败。
#: 各配置项和这两个取值的叫法(DUAL_CHECK_FIELD_LABEL、DUAL_CHECK_ON_REJECT_LABEL)在 labels.py
DUAL_CHECK_ON_REJECT = ("degrade", "fail")
def dual_check_card(spec: GraphSpec, node: GraphNode) -> tuple[str | None, str | None]:
"""双盲复算要复算的口径卡:(节点 id, None) 或 (None, 为什么认不出)。写了「口径卡」(card)就是它,否则取上游唯一的
那张口径卡。校验和节点执行用的是这一个认法;写了的那个节点存不存在、是不是口径卡、在不在上游由调用方查。"""
card = node.config.get("card")
if card not in (None, ""):
return (card, None) if isinstance(card, str) else (None, f"「{DUAL_CHECK_FIELD_LABEL['card']}」需要选择一个口径卡节点")
upstream = _ancestors(spec, node.id)
cards = [n for n in spec.nodes if n.id in upstream and n.type == NodeType.METRICS]
if not cards:
return None, "「双盲复算」的上游没有口径卡:它复算的是口径卡上的指标,请把它接在口径卡之后"
if len(cards) > 1:
names = "、".join(f"「{n.title}」" for n in cards[:5])
return None, (f"「双盲复算」的上游有 {len(cards)} 张口径卡({names}),"
f"请在「{DUAL_CHECK_FIELD_LABEL['card']}」中选择要复算的那一张")
return cards[0].id, None
def _check_dual_check(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None:
"""双盲复算的配置:口径卡必须是它上游的口径卡;复算的指标、口径说明的键必须是那张卡上的指标 ID;开关、
误差、处置的取值;不能配失败重试。指标定义从别的工作流引用来的口径卡(caliber_from),画图时不知道有哪些指标,
不查指标 ID。"""
cfg = node.config
label = DUAL_CHECK_FIELD_LABEL
def add(message: str, field: str, code: str) -> None:
result.add(message, node_id=node.id, field=field, code=code)
switch = cfg.get("enabled")
# 用 is 比:0 == False、1 == True,用 in 比会放过去,执行时却按「跟随设置」走(执行器认的是 True / False 本身)
if not (switch is None or switch is True or switch is False):
add(f"「{label['enabled']}」只能选跟随设置、开启或关闭,当前为「{switch}」", "enabled",
"dual_check.option_invalid")
try:
retries = int(cfg.get("retries", 0) or 0) # 和 compiler 取重试次数的写法一样
except (TypeError, ValueError):
retries = 0
if retries > 0:
# 失败都是确定的(审核人没采用原流程、口径卡配错):重试时 @task 的调用序号往后走、对不上缓存,盲算整轮
# 再做一遍、再花一次钱,还要审核人再选一次
add(f"「双盲复算」节点不支持「{field_label('retries')}」:它的失败是确定的(审核人没有采用原流程的结果、"
"口径卡配置有误),重试只会把盲算再做一遍并再次请审核人选择。请把失败重试次数设为 0", "retries",
"dual_check.retries")
if cfg.get("on_reject") not in (None, "", *DUAL_CHECK_ON_REJECT):
allowed = "或".join(f"「{v}」" for v in DUAL_CHECK_ON_REJECT_LABEL.values())
add(f"「{label['on_reject']}」只能选{allowed},当前为「{cfg.get('on_reject')}」", "on_reject",
"dual_check.option_invalid")
tolerance = cfg.get("tolerance")
if tolerance not in (None, "") and (isinstance(tolerance, bool) or not isinstance(tolerance, (int, float))
or not math.isfinite(tolerance) or tolerance < 0):
add(f"「{label['tolerance']}」需要填写不小于 0 的数(例如 0.000001 表示百万分之一),留空表示按设置,"
f"当前为「{tolerance}」", "tolerance", "dual_check.option_invalid")
card_id, problem = dual_check_card(spec, node)
if problem:
add(problem, "card", "dual_check.card_invalid")
return
target = spec.node_map().get(card_id or "")
if target is None:
add(f"「{label['card']}」选择了「{card_id}」,但工作流中不存在这个节点", "card", "dual_check.card_invalid")
return
if target.type != NodeType.METRICS:
add(f"「{label['card']}」选择了「{target.title}」,它是「{type_label(target.type)}」节点,不是口径卡;"
"双盲复算只能复算口径卡上的指标", "card", "dual_check.card_invalid")
return
if target.id not in _ancestors(spec, node.id):
add(f"「{label['card']}」选择的口径卡「{target.title}」不在双盲复算的上游:复算时这张卡还没有计算。"
"请把口径卡连到双盲复算之前", "card", "dual_check.card_invalid")
return
if target.config.get("caliber_from") not in (None, "", {}):
known = None
else:
known = [str(d.get("id")) for d in target.config.get("metrics") or [] if isinstance(d, dict) and d.get("id")]
hint = (",可选的指标 ID:" + "、".join(known[:12])) if known else ""
metrics = cfg.get("metrics")
if metrics not in (None, "", []):
if not isinstance(metrics, list) or not all(isinstance(m, str) and m.strip() for m in metrics):
add(f"「{label['metrics']}」需要填写口径卡上的指标 ID 列表,留空表示复算输入来自查询的全部指标", "metrics",
"dual_check.metrics_invalid")
elif known is not None and (unknown := [m for m in metrics if m not in known]):
add(f"「{label['metrics']}」中的「{'、'.join(unknown[:5])}」不是口径卡「{target.title}」上的指标 ID{hint}",
"metrics", "dual_check.metrics_invalid")
definitions = cfg.get("definitions")
if definitions not in (None, "", {}):
if not isinstance(definitions, dict) or not all(isinstance(k, str) and isinstance(v, str)
for k, v in definitions.items()):
add(f"「{label['definitions']}」需要写成「指标 ID → 口径说明」的对照,口径说明写成文字", "definitions",
"dual_check.definitions_invalid")
elif known is not None and (unknown := [k for k in definitions if k not in known]):
add(f"「{label['definitions']}」中的「{'、'.join(unknown[:5])}」不是口径卡「{target.title}」上的指标 ID{hint}",
"definitions", "dual_check.definitions_invalid")
def _produces_query(node: GraphNode) -> bool:
"""这个节点的产出是一份存成快照的查询结果:调用数据库查询工具的「调用工具」节点,或另一个合并查询。"""
if node.type == NodeType.MERGE:
return True
return node.type == NodeType.TOOL and str(node.config.get("tool") or "").startswith("db_query__")
def _check_merge(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None:
"""合并查询的输入和合并 SQL。规则和执行时(engine/merge_query.py)同一套,画图时就说破,不等运行到这一步才失败。
输入只认能稳定取到一份查询快照的节点。Agent 查过的库不行:一次循环里可能查好几次,取哪一次说不准,
拿错一次合并出来的数照样像模像样——所以报 error,并说清楚把那条查询单独放进「调用工具」节点。
"""
from app.engine.merge_query import MAX_INPUTS, alias_problem, sql_problem
cfg = node.config
inputs = cfg.get("inputs")
if not isinstance(inputs, dict) or not inputs:
result.add("「合并查询」还没有配置输入:至少选择一个上游的查询节点,并给它起一个别名(合并 SQL 里的表名)",
node_id=node.id, field="inputs", code="merge.inputs_missing")
else:
if len(inputs) > MAX_INPUTS:
result.add(f"「合并查询」最多 {MAX_INPUTS} 个输入,这里有 {len(inputs)} 个。请先在源库里合并一部分",
node_id=node.id, field="inputs", code="merge.inputs_missing")
nodes = spec.node_map()
upstream = _ancestors(spec, node.id)
seen: dict[str, str] = {}
for alias, source in inputs.items():
field = f"inputs.{alias}"
if problem := alias_problem(alias):
result.add(f"「合并查询」的{problem}", node_id=node.id, field=field, code="merge.alias_invalid")
elif alias.lower() in seen:
result.add(f"「合并查询」的别名「{seen[alias.lower()]}」和「{alias}」只差大小写:SQLite 的表名不分大小写,"
"会被当成同一张表。请换一个别名", node_id=node.id, field=field, code="merge.alias_invalid")
else:
seen[alias.lower()] = alias
target = nodes.get(source) if isinstance(source, str) else None
who = f"「合并查询」的输入「{alias}」"
if not isinstance(source, str) or not source:
result.add(f"{who}还没有选择节点", node_id=node.id, field=field, code="merge.input_invalid")
elif target is None:
result.add(f"{who}选择了「{source}」,但工作流中不存在这个节点", node_id=node.id, field=field,
code="merge.input_invalid")
elif target.type == NodeType.AGENT:
result.add(f"{who}选择了「{target.title}」(Agent):Agent 可能查询多次,无法确定合并哪一次的结果。"
"请把要合并的那条查询单独放进「调用工具」节点(选择数据库查询工具),再把它选作输入",
node_id=node.id, field=field, code="merge.input_invalid")
elif target.type == NodeType.TOOL and not _produces_query(target):
result.add(f"{who}选择的「{target.title}」调用的不是数据库查询工具:合并查询只能合并数据库查询的结果",
node_id=node.id, field=field, code="merge.input_invalid")
elif not _produces_query(target):
result.add(f"{who}选择的「{target.title}」是「{type_label(target.type)}」节点,不产出查询结果。"
"输入只能是调用数据库查询工具的「调用工具」节点,或另一个「合并查询」节点",
node_id=node.id, field=field, code="merge.input_invalid")
elif source not in upstream:
result.add(f"{who}选择的「{target.title}」不在合并查询的上游:合并时它还没有运行。请把它连到合并查询之前",
node_id=node.id, field=field, code="merge.input_invalid")
sql = cfg.get("sql")
if not isinstance(sql, str) or not sql.strip():
result.add("「合并查询」还没有填写合并 SQL", node_id=node.id, field="sql", code="merge.sql_invalid")
elif problem := sql_problem(sql):
result.add(problem, node_id=node.id, field="sql", code="merge.sql_invalid")
#: 报告撰写节点的结论句策略(claims)。off:结论句不参与判档(默认,保持以前的行为);
#: require_citation:没挂引用的结论句计入缺口、按出具档位降档。这两档是确定性的(发布前自动修复
#: 给人选的也只有这两档)
REPORT_CLAIMS = ("off", "require_citation")
#: judge:结论句由另一个模型按证据逐句判断(engine/judge.py),子配置写在节点的 judge 里
CLAIMS_JUDGE = "judge"
CLAIMS_VALUES = (*REPORT_CLAIMS, CLAIMS_JUDGE)
#: judge 子配置能写的键。三个上限写 null 表示不限;没写的键运行时取设置里的默认(设置 → judge 一组)
JUDGE_KEYS = ("provider", "model", "max_claims", "max_cost_usd", "timeout_s", "rewrite_once", "on_unsupported")
#: 证据相矛盾时正式运行怎么判档:degrade 降档(默认)/ withhold 不予出具。探索运行只标注
JUDGE_ON_UNSUPPORTED = ("degrade", "withhold")
#: 「没有指定模型」那条提示的编号。节点(或全图默认)点名了接入、而那个接入本身不存在或已停用时,它和接入那条问题
#: 互相矛盾(一条说照常执行、一条说会失败),接口层读了接入表以后按编号去掉它(provider_refs.quiet_unset_model)
MODEL_UNSET = "model.unset"
def _named_provider_of(spec: GraphSpec, node: GraphNode) -> str | None:
"""节点执行时点名的接入名:节点上没写(None、空串)就取全图默认,同 NodeContext.cfg;只有空白、不是字符串的不算
点了名(同 provider_refs._name)。"""
value = node.config.get("provider")
if value in (None, ""):
value = spec.defaults.get("provider")
return value.strip() or None if isinstance(value, str) else None
def claims_problem(value: Any) -> str | None:
"""claims 的取值有什么问题,没问题返回 None。校验和报告撰写节点执行时报的是同一句话。"""
if value in (None, "") or (isinstance(value, str) and value in CLAIMS_VALUES):
return None
allowed = [f"「{option_label('claims', v)}」" for v in CLAIMS_VALUES]
return (f"「{field_label('claims')}」只能选{'、'.join(allowed[:-1])}或{allowed[-1]},当前为「{value}」")
def _positive(value: Any, *, integer: bool = False) -> bool:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
return False
return not integer or int(value) == value
def judge_problems(value: Any) -> list[tuple[str, str]]:
"""claims: judge 的子配置有什么问题:[(字段, 人话)],没问题返回 []。校验和节点执行时报的是同一句话。
上限写 null 就是不限(和没写不一样:没写取设置里的默认)。键写错了也报:写成 max_cost 的预算会被
悄悄当成没写、按默认值花钱。
"""
if value in (None, ""):
return []
if not isinstance(value, dict):
return [("judge", f"「{field_label('judge')}」设置格式有误,需要是一个对象"
f'(例如 {{"max_cost_usd": 0.05, "max_claims": 40}}),当前为「{value}」')]
out: list[tuple[str, str]] = []
for key, item in value.items():
field = f"judge.{key}"
if key not in JUDGE_KEYS:
known = "、".join(f"「{judge_label(k)}」" for k in JUDGE_KEYS)
out.append((field, f"「{field_label('judge')}」中无法识别「{key}」,可设置的项为:{known}"))
elif key in ("provider", "model") and item is not None and not isinstance(item, str):
what = "模型接入的名称" if key == "provider" else "模型 ID"
out.append((field, f"「{judge_label(key)}」需要填写{what},留空则使用设置中的「证据裁判」模型,"
f"当前为「{item}」"))
elif key == "max_claims" and item is not None and not _positive(item, integer=True):
out.append((field, f"「{judge_label(key)}」需要填写正整数(每份报告最多裁判的句数),或选择不限,"
f"当前为「{item}」"))
elif key == "max_cost_usd" and item is not None and not _positive(item):
out.append((field, f"「{judge_label(key)}」需要填写大于 0 的金额(每份报告的裁判费用上限),或选择不限,"
f"当前为「{item}」"))
elif key == "timeout_s" and item is not None and not _positive(item):
out.append((field, f"「{judge_label(key)}」需要填写大于 0 的秒数(每份报告的裁判时长上限),或选择不限,"
f"当前为「{item}」"))
elif key == "rewrite_once" and not isinstance(item, bool):
out.append((field, f"「{judge_label(key)}」只能是开启或关闭,当前为「{item}」"))
elif key == "on_unsupported" and item not in JUDGE_ON_UNSUPPORTED:
allowed = "或".join(f"「{option_label(key, v)}」" for v in JUDGE_ON_UNSUPPORTED)
out.append((field, f"「{judge_label(key)}」只能选{allowed},当前为「{item}」"))
return out
def _check_report_claims(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None:
"""claims 写错了挡住运行:执行时报告节点照样会报这一句,与其跑完取数、算完口径卡才死在写报告
那一步,不如在画图时说破。claims 是 judge 时再查 judge 子配置的形状;裁判模型和写作模型写成
同一个的给警告——等于自己审自己。
和运行时同一个取值规则:节点上没写就取图级 defaults(NodeContext.cfg)。
"""
def effective(key: str) -> Any:
value = node.config.get(key)
return spec.defaults.get(key) if value in (None, "") else value
value = effective("claims")
if problem := claims_problem(value):
result.add(problem, node_id=node.id, field="claims", code="report.claims_invalid")
if value != CLAIMS_JUDGE:
return
judge = effective("judge")
for field, message in judge_problems(judge):
result.add(message, node_id=node.id, field=field, code="report.judge_invalid")
judge_model = judge.get("model") if isinstance(judge, dict) else None
if isinstance(judge_model, str) and judge_model and judge_model == effective("model"):
result.add(f"裁判模型与写作模型相同(「{judge_model}」),难以发现写作模型自身的错误。"
f"请在「{field_label('judge')}」中选择另一个模型", level="warning", node_id=node.id, field="judge.model",
code="report.judge_same_model")
def contract_needs_metrics(contract: dict[str, Any]) -> bool:
"""出具契约要不要写 metrics_from(指标来自哪几张口径卡)。
旧写法按数值回指叙述,没有口径卡就无从回指,一定要写。引用模式(report_from)下,数字回指靠
报告里的引用标记:只引查询单元格(cells)的问数据图根本没有口径卡,不该逼它写;契约里列了
required / expected 指标的,指标总得有个来处,照样要写。运行时 io.py 也是这么判的。
"""
if contract.get("report_from") in (None, ""):
return True
return bool(contract.get("required") or contract.get("expected"))
def _check_report_from(node: GraphNode, report_from: Any, spec: GraphSpec,
result: ValidationResult) -> None:
"""引用模式的契约:report_from 必须是出口上游的报告撰写节点。"""
field, code = "contract.report_from", "contract.report_from_invalid"
if not isinstance(report_from, str):
result.add("出具契约的「报告来自」需要选择一个「报告撰写」节点", node_id=node.id, field=field, code=code)
return
target = spec.node_map().get(report_from)
if target is None:
result.add(f"出具契约的「报告来自」选择了「{report_from}」,但工作流中不存在这个节点",
node_id=node.id, field=field, code=code)
elif target.type != NodeType.REPORT:
result.add(f"出具契约的「报告来自」选择了「{target.title}」,它是「{type_label(target.type)}」节点,"
"不是「报告撰写」节点:出具契约只核对报告撰写节点产出的文档",
node_id=node.id, field=field, code=code)
elif report_from not in _ancestors(spec, node.id):
result.add(f"出具契约的「报告来自」选择的「{target.title}」不在该成果节点的上游:核对时这份报告还没有写出。"
"请把报告撰写节点连到成果节点之前",
node_id=node.id, field=field, code=code)
# --------------------------------------------------------------------------
# 反方审稿节点(engine/nodes/challenge.py):审哪份报告、各项配置
# --------------------------------------------------------------------------
#: 结论被推翻时怎么办:degrade 计入出具缺口、出具降档(默认)/ flag 只标注 / fail 判节点失败、运行停止。
#: 各项配置和这三个取值在界面上的叫法(CHALLENGE_LABEL、CHALLENGE_ON_REFUTED_LABEL)在 labels.py:报错写叫法,不写键名
CHALLENGE_ON_REFUTED = ("degrade", "flag", "fail")
def _challenge_field(key: str) -> str:
return f"「{type_label(NodeType.CHALLENGE)}」的「{CHALLENGE_LABEL[key]}」"
def challenge_problems(config: dict[str, Any]) -> list[tuple[str, str]]:
"""反方审稿各项配置有什么问题(要审的报告另见 challenge_report):[(字段, 人话)],没问题返回 []。
校验和节点执行时报的是同一句话。
三个上限留空(不写或写 null)就是沿用设置里「核查」一节的值,和「启用」留空一个意思。节点上没有「不限」的写法:
要放开上限去设置里改,免得一个节点悄悄把核查的花费放开了。
"""
out: list[tuple[str, str]] = []
for key, words in (("max_claims", "每份报告最多反查的句数"), ("max_queries", "每句最多执行的查询次数")):
value = config.get(key)
if value not in (None, "") and not _positive(value, integer=True):
out.append((key, f"{_challenge_field(key)}需要填写正整数({words}),留空表示沿用设置,当前为「{value}」"))
value = config.get("timeout_s")
if value not in (None, "") and not _positive(value):
out.append(("timeout_s", f"{_challenge_field('timeout_s')}需要填写大于 0 的秒数,留空表示沿用设置,"
f"当前为「{value}」"))
for key, what in (("provider", "模型接入的名称"), ("model", "模型 ID")):
value = config.get(key)
if value is not None and not isinstance(value, str):
out.append((key, f"{_challenge_field(key)}需要填写{what},留空表示使用设置中的核查模型,当前为「{value}」"))
value = config.get("on_refuted")
if value not in (None, "") and value not in CHALLENGE_ON_REFUTED:
names = [f"「{CHALLENGE_ON_REFUTED_LABEL[v]}」" for v in CHALLENGE_ON_REFUTED]
out.append(("on_refuted", f"{_challenge_field('on_refuted')}只能选{'、'.join(names[:-1])}或{names[-1]},"
f"当前为「{value}」"))
value = config.get("enabled")
if value is not None and not isinstance(value, bool):
out.append(("enabled", f"{_challenge_field('enabled')}只能是开启、关闭或跟随设置,当前为「{value}」"))
return out
def challenge_report(spec: GraphSpec, node: GraphNode) -> tuple[str | None, str | None]:
"""反方审稿审的是哪份报告:(报告撰写节点 id, None);定不下来时 (None, 为什么)。校验、执行器、审批卡都按它认。
写了「要审的报告」就按写的,必须是它上游的报告撰写节点。没写就从上游找:直接连着的报告撰写节点只有一个,
就是它(「初稿 → 终稿 → 反方审稿」审终稿);否则整个上游里只有一个报告撰写节点才取它。两份以上说不准审哪一份,
宁可让人选,也不能悄悄审错一份。
"""
who, label = f"「{type_label(NodeType.CHALLENGE)}」", CHALLENGE_LABEL["report"]
nodes = spec.node_map()
upstream = _ancestors(spec, node.id)
chosen = node.config.get("report")
if chosen in (None, ""):
direct = list(dict.fromkeys(e.source for e in spec.incoming(node.id)
if (n := nodes.get(e.source)) is not None and n.type == NodeType.REPORT))
if len(direct) == 1:
return direct[0], None
found = [n for n in spec.nodes if n.id in upstream and n.type == NodeType.REPORT]
if len(found) == 1:
return found[0].id, None
if not found:
return None, (f"{who}的上游没有「报告撰写」节点:反方审稿只审报告撰写节点写出的报告。"
f"请把它接在报告撰写节点之后,或在「{label}」中选择一个")
names = "、".join(f"「{n.title}」" for n in found[:5]) + ("等" if len(found) > 5 else "")
return None, f"{who}的上游有多个「报告撰写」节点({names}),请在「{label}」中选择要审的那一份"
if not isinstance(chosen, str):
return None, f"{who}的「{label}」需要选择一个「报告撰写」节点,当前为「{chosen}」"
target = nodes.get(chosen)
if target is None:
return None, f"{who}的「{label}」选择了「{chosen}」,但工作流中不存在这个节点"
if target.type != NodeType.REPORT:
return None, (f"{who}的「{label}」选择了「{target.title}」,它是「{type_label(target.type)}」节点,"
"不是「报告撰写」节点:反方审稿只审报告撰写节点写出的报告")
if chosen not in upstream:
return None, (f"{who}的「{label}」选择的「{target.title}」不在它的上游:审稿时这份报告还没有写出。"
"请把报告撰写节点连到反方审稿之前")
return chosen, None
def _check_challenge(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None:
"""反方审稿的配置写错了挡住运行:执行时节点照样会报这一句,与其取数、写完报告才死在审稿这一步,不如在画图时说破。
反方模型和要审的报告的写作模型写成同一个的给警告:等于自己审自己。
两种「看着配了、其实不生效」的接法也在这里说破:
- 「报错并停止运行」配「出错时:记录错误并继续」:节点报的错被当成普通错误跳过,被推翻的结论照常完整出具,比默认的
降档还松,挡住运行。不放进 challenge_problems:执行时再报一遍的错照样会被这个设置吞掉,执行时改按降档处置
- 「出具降档」靠成果节点的出具契约收上游的出具缺口(nodes/io.py 只认成果节点的上游):反方审稿接在侧枝上、或者在
成果之后,降档不会发生,给警告。图里没有带出具契约的成果节点时无所谓出具,不提醒
"""
who = f"「{type_label(NodeType.CHALLENGE)}」"
for field, message in challenge_problems(node.config):
result.add(message, node_id=node.id, field=field, code="challenge.config_invalid")
on_refuted = node.config.get("on_refuted") or "degrade"
if on_refuted == "fail" and node.config.get("on_error") == "continue":
result.add(f"{_challenge_field('on_refuted')}选了「{CHALLENGE_ON_REFUTED_LABEL['fail']}」,"
f"「{field_label('on_error')}」却是「{option_label('on_error', 'continue')}」:结论被推翻时节点报的错"
"会被当作普通错误跳过,被推翻的结论照常完整出具。"
f"请把「{CHALLENGE_LABEL['on_refuted']}」改为「{CHALLENGE_ON_REFUTED_LABEL['degrade']}」,"
f"或把「{field_label('on_error')}」改回「中断整个运行」",
node_id=node.id, field="on_refuted", code="challenge.config_invalid")
if on_refuted == "degrade":
issued = [n for n in spec.nodes if n.type == NodeType.OUTPUT and n.config.get("contract")]
if issued and not any(node.id in _ancestors(spec, n.id) for n in issued):
report_kind, output_kind = type_label(NodeType.REPORT), type_label(NodeType.OUTPUT)
result.add(f"{who}不在带出具契约的「{output_kind}」节点上游:结论被推翻时,「{output_kind}」节点收不到它记下的"
f"出具缺口,出具不会降档。请改为「{report_kind} → {type_label(NodeType.CHALLENGE)} → {output_kind}」"
f"的接法;只需记录的,把「{CHALLENGE_LABEL['on_refuted']}」改为「{CHALLENGE_ON_REFUTED_LABEL['flag']}」",
level="warning", node_id=node.id, code="challenge.no_issuance")
report, problem = challenge_report(spec, node)
if report is None:
result.add(problem or f"{who}找不到要审的报告", node_id=node.id, field="report", code="challenge.report_invalid")
return
writer = spec.node_map()[report]
model = node.config.get("model")
if isinstance(model, str) and model and model == (writer.config.get("model") or spec.defaults.get("model")):
result.add(f"反方模型与报告「{writer.title}」的写作模型相同(「{model}」),等于自己审自己,难以发现写作模型自身"
f"的错误。请在「{CHALLENGE_LABEL['model']}」中选择另一个模型", level="warning", node_id=node.id,
field="model", code="challenge.same_model")
def validate_graph(spec: GraphSpec) -> ValidationResult:
"""编译前的静态检查。错误会挡住运行,警告只在画布上提示。"""
result = ValidationResult()
nodes = spec.node_map()
if not spec.nodes:
result.add("画布上还没有节点,请先从节点库拖入一个节点")
return result
# 引用完整性
for edge in spec.edges:
if edge.source not in nodes:
result.add(f"连线的起点节点「{edge.source}」不存在", edge_id=edge.id)
if edge.target not in nodes:
result.add(f"连线的终点节点「{edge.target}」不存在", edge_id=edge.id)
source = nodes.get(edge.source)
if edge.sourceHandle == REJECT_HANDLE and source is not None \
and source.type not in GATE_TYPES and source.type != NodeType.BRANCH:
# 「驳回」出口只在会被人驳回的节点上有意义:别的节点上这条线不会被路由,每次都会走(条件分支的 case 标识
# 可以叫 rejected,那是分支自己的出口)
result.add(f"「{source.title}」不会被人驳回,不能接「驳回」出口:只有人工节点(审批、补充输入、编辑稿件)、"
"调用工具(工具调用审批、动作确认)、沙箱代码(代码执行审批)、双盲复算能接。请把这条连线改接到"
"节点的普通出口", node_id=source.id, edge_id=edge.id, code="graph.reject_exit_invalid")
if not spec.entry_nodes():
result.add("找不到起始节点:每个节点都有上游连线,工作流中存在环路且没有起点")
# 节点级必填项
for node in spec.nodes:
cfg = node.config
if node.type == NodeType.BRANCH:
cases = cfg.get("cases") or []
if not cases:
result.add("「条件分支」至少需要配置一个分支条件", node_id=node.id, field="cases")
handles = {e.sourceHandle for e in spec.outgoing(node.id)}
_check_case_keys(node, cases, result)
for i, case in enumerate(cases):
key = str((case or {}).get("key") or "").strip()
if key and key != "default" and key not in handles:
result.add(
f"分支「{key}」还没有连线", level="warning", node_id=node.id,
field=f"cases[{i}].key",
)
if "default" not in handles:
result.add(
"建议为「条件分支」的「其他」出口添加连线,处理所有条件都不满足的情况",
level="warning",
node_id=node.id,
field="cases",
)
elif node.type == NodeType.TOOL:
if not cfg.get("tool"):
result.add("「调用工具」节点还没有选择工具", node_id=node.id, field="tool")
# 写入动作的确认派给谁(应用平台 §3.8):写法同人工节点
from app.engine.lint_process import assignee_problem
if problem := assignee_problem(cfg.get("assignee")):
result.add(problem, node_id=node.id, field="assignee", code="human.assignee_invalid")
elif node.type == NodeType.HUMAN:
# 人工节点的流程配置(派给谁、会签、时限、超时、表单,§3.7)。运行时没法照着写错的配置执行,按错误报,
# 各级发布和发起运行都挡。实现在 engine/lint_process.py
from app.engine.lint_process import human_issues
for code, message, field in human_issues(node):
result.add(message, node_id=node.id, field=field, code=code)
if cfg.get("stop_on_reject") and approve_mode(spec, node) and reject_targets(spec, node):
# 驳回即终止优先(以前就是这样):驳回出口那条线走不到。两样都写着,说明有一样不是作者的本意
result.add("这个审批设了「驳回即终止运行」,「驳回」出口的连线不会走到:驳回时运行直接结束。"
"要让驳回之后走那条路,请关掉「驳回即终止运行」", level="warning", node_id=node.id,
field="stop_on_reject", code="human.stop_on_reject_exit")
elif node.type == NodeType.SUBGRAPH:
if not cfg.get("workflow_id"):
result.add("「子工作流」节点还没有选择要嵌套的工作流", node_id=node.id,
field="workflow_id")
elif node.type == NodeType.VALIDATE:
if not cfg.get("schema"):
result.add("「结构校验」节点需要一个 JSON Schema", node_id=node.id, field="schema")
elif node.type == NodeType.METRICS:
defs = cfg.get("metrics") or []
# 钉住别的工作流里的口径卡时,指标定义运行时从那一版里取,本地不用再写
if not defs and not cfg.get("caliber_from"):
result.add("口径卡还没有定义指标", node_id=node.id, field="metrics")
for i, d in enumerate(defs):
if not d.get("id") or not d.get("expression"):
result.add(f"指标定义缺少 ID 或表达式:{d.get('id') or '(空)'}",
node_id=node.id, field=f"metrics[{i}]")
elif node.type == NodeType.DUAL_CHECK:
_check_dual_check(node, spec, result)
elif node.type == NodeType.REPORT:
_check_report_sources(node, spec, result)
_check_report_claims(node, spec, result)
elif node.type == NodeType.MERGE:
_check_merge(node, spec, result)
elif node.type == NodeType.CHALLENGE:
_check_challenge(node, spec, result)
elif node.type == NodeType.OUTPUT:
contract = cfg.get("contract")
if contract and not isinstance(contract, dict):
# 字符串、列表、数字:后面的 .get 会崩,发布、发布检查、发起运行一起 500。
# 报成 error,两档发布和运行前都挡住
result.add("出具契约必须是一个 JSON 对象", node_id=node.id, field="contract",
code="contract.not_object")
elif contract and not contract.get("metrics_from") and contract_needs_metrics(contract):
result.add("出具契约缺少「指标来自」(提供指标的口径卡)",
node_id=node.id, field="contract.metrics_from", code="contract.metrics_from_missing")
if isinstance(contract, dict) and contract.get("report_from") not in (None, ""):
_check_report_from(node, contract["report_from"], spec, result)
elif node.type == NodeType.LOOP:
if not spec.outgoing(node.id):
result.add("「循环」节点没有循环体", node_id=node.id)
if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.REPORT):
if not (cfg.get("model") or spec.defaults.get("model")):
# 点名了接入就用那个接入的默认模型,照实说是哪个:检查器、问题面板里只写「默认模型」,人会以为是第一个
# 可用的接入。那个接入本身不存在或已停用时,接口层去掉这一条,只留接入的问题(MODEL_UNSET)
named = _named_provider_of(spec, node)
result.add(
f"没有指定模型,将使用接入「{named}」的默认模型" if named else "没有指定模型,将使用默认模型",
level="warning",
node_id=node.id,
field="model",
code=MODEL_UNSET,
)
# 表达式:用运行时同一个 parse_expression 先解析一遍。以前写错了只有跑到那一步
# 才知道——前面的步骤白跑、钱白花,报错还停在半路(开发库里四次运行死在
# "表达式里不允许出现 Set",就是条件里套了 {{ }})
from app.engine.expressions import ExpressionError, parse_expression
for node in spec.nodes:
for label, text, field in expression_fields(node):
try:
_, unwrapped, unknown = parse_expression(text)
except ExpressionError as e:
result.add(f"{label}有误:{e}(原文:{text})", node_id=node.id, field=field)
continue
if unwrapped:
result.add(
f"{label}中的 {{{{ }}}} 是多余的:这里是表达式,不是模板,"
f"已按 {'、'.join(unwrapped)} 理解", level="warning", node_id=node.id,
field=field,
)
if unknown:
result.add(
f"{label}中的 {'、'.join(unknown)} 不是可用的名称,运行时会取到空值"
"(变量需写完整路径,例如 vars.x、input.x、nodes.某节点.text)",
level="warning", node_id=node.id, field=field,
)
cfg = node.config
if node.type == NodeType.LOOP and cfg.get("mode", "foreach") == "while" \
and not str(cfg.get("condition") or "").strip():
result.add("条件循环没有设置「继续条件」,循环体不会执行", level="warning", node_id=node.id,
field="condition")
if node.type == NodeType.TRANSFORM and cfg.get("mode", "expression") == "expression" \
and not str(cfg.get("expression") or "").strip():
result.add("「数据整形」节点还没有填写表达式", node_id=node.id, field="expression")
# assign_to 拿到的是 stdout。脚本最后一行写个裸表达式不会输出(那是 notebook
# 的行为)——变量是空的,下游的循环条件一上来就不成立、成果也是空的,而整次
# 运行照样"成功"。开发库里的「测试 1」就是这样:修好条件之后跑通了,结果为空
if node.type == NodeType.CODE and cfg.get("assign_to") \
and (cfg.get("language") or "python").lower() == "python":
files = cfg.get("files")
source = "\n".join([str(cfg.get("code") or ""),
*(map(str, files.values()) if isinstance(files, dict) else ())])
if not _MAY_WRITE_STDOUT.search(source):
var = cfg["assign_to"]
result.add(
f"代码把输出交给 vars.{var},但代码中没有 print:Python 脚本最后一行的表达式"
f"不会自动输出,vars.{var} 将为空。请用 print 输出结果;如需向下游传递对象,"
"请使用 print(json.dumps(结果))", node_id=node.id, field="code",
)
_check_named_tools(spec, result)
for issue in [*text_parse_issues(spec), *evidence_issues(spec)]:
result.add(issue.message, level=issue.level, node_id=issue.node_id, field=issue.field, code=issue.code)
# 旧结构(报告是模型调用写的、问数据的答案是 agent 的原话…)能一键升级成可追溯的:只给一条建议,
# 画布上的快速修复据此打开升级预览。放在函数里导入:upgrade 反过来要用这里的 GraphSpec
from app.engine.upgrade import HINT_CODE, upgrade_hint
if hint := upgrade_hint(spec):
result.add(hint, level="info", code=HINT_CODE)
# 孤儿节点
for node in spec.nodes:
if node.type in (NodeType.INPUT, NodeType.OUTPUT):
continue
if not spec.incoming(node.id) and not spec.outgoing(node.id):
result.add("节点没有任何连线,不会被执行", level="warning", node_id=node.id)
# 变量问题也进校验结果:模板取不到值只会渲染成空字符串,不报错,
# 所以拼错一个变量名在运行时是完全静默的——只能在这里说破。
# 放在最后导入,避免 variables 反过来 import schema 形成环。
from app.engine.variables import analyze as _analyze_vars
for issue in _analyze_vars(spec).issues:
# info 级是"产出没人用"这类提示,不进校验(它不影响能不能跑)
if issue.level == "info":
continue
result.add(issue.message, level=issue.level, node_id=issue.node_id,
field=issue.field or None)
return result
# --------------------------------------------------------------------------
# 提示词点名的工具,节点得真的绑了它
#
# 一次真实运行:助手改图时把三个查库 agent 的 tools 丢了,提示词里还写着「用
# db_query__… 查」。模型拿不到工具,只能写一句「假设调用工具」,再往下编;校验节点
# 的修复又替它编了个 0。这张图在跑之前就能认出来。Copilot 的自查读的是同一份
# error,搭图时就会交回模型去修。
#
# 这条是 error,会挡住运行,还会作为 Copilot 自查的阻断项交回模型(模型可能反过来
# 把提示词里禁用的工具绑上去),所以宁可漏判不可误判:
# - 认得出的工具名才算点名:内置工具、db_query__ / db_schema__ / mcp: 这几种形状,
# 以及这张图里别的节点绑过的名字;
# - 「不要用 X」「Never call X」「X 的结果」不是在要求用它;
# - calculator 这种本身就是个普通词的工具名,只有写在反引号里、或者紧跟在「用 /
# 调用 / use / call」后面才算点名;「可以用」「如有需要」「if needed」这类非强制的
# 说法也一样。这两种只报 warning。
# --------------------------------------------------------------------------
_DYNAMIC_TOOL = r"db_(?:query|schema)__[A-Za-z0-9_\-]+|mcp:[\w\-]+/[\w\-.]+"
# 工具名紧挨着汉字是常态(「用python_exec核对」),所以边界只排 ASCII 标识符字符
_EDGE_BEFORE, _EDGE_AFTER = r"(?<![A-Za-z0-9_:/\-])", r"(?![A-Za-z0-9_\-])"
_TEMPLATE_SPAN = re.compile(r"\{\{.*?\}\}", re.S)
_NEGATED = re.compile(
r"(?:(?:不要|不用|不必|无需|无须|毋须|不需要|不再|别再|别|禁止|停止|不能|不得|不应|不可|避免|切勿|勿|严禁)"
r"\s*(?:再|去)?(?:使用|调用|用|执行|运行|跑)?"
r"|(?<![A-Za-z])(?:don['’]?t|do\s+not|never|avoid|without|must\s+not|mustn['’]?t|should\s+not"
r"|shouldn['’]?t|cannot|can['’]t|no\s+need\s+to|not|no)\s+(?:(?:use|call|invoke|run|execute|using|calling"
r"|invoking|running|rely\s+on|touch)\s+)?(?:the\s+|any\s+)?)"
r"\s*[「“\"'`]?\s*$", re.I)
_REFERENCED = re.compile(r"^\s*[」”\"'`]?\s*(的|返回|给出|查到|产出|输出|结果"
r"|(?:'s\b)|(?:results?|outputs?|returned|returns)\b)", re.I)
# 普通词形的工具名(calculator)要有明确的「用」字眼紧挨着才算点名
_PLAIN_WORD = re.compile(r"[A-Za-z][a-z]*(?:-[a-z]+)*")
_USE_VERB = re.compile(r"(?:使用|调用|用|(?<![A-Za-z])(?:use|call|invoke|run))\s*$", re.I)
# 非强制的说法:同一句里出现就只算提到,不算要求
_OPTIONAL = re.compile(r"可以|可选|如有需要|如需|若需|必要时|需要时|有需要|酌情"
r"|(?<![A-Za-z])(?:may|might|can|could|optionally|if\s+(?:needed|necessary|required)"
r"|when\s+needed|feel\s+free)(?![A-Za-z])", re.I)
_CLAUSE_END = re.compile(r"[。;;!!??\n]|\.(?:\s|$)")
# 笼统地要求用工具、却没点名
_VAGUE = re.compile(r"(调用|使用|用|借助|通过)\s*(相关|对应|可用|数据库|查询)?\s*工具"
r"|查询?数据库|查库|执行\s*SQL|跑\s*SQL", re.I)
def _bound_tools(cfg: dict[str, Any]) -> set[str]:
return {t for t in cfg.get("tools") or [] if isinstance(t, str)}
def _covers(bound: set[str], tool: str) -> bool:
"""tool 在不在 bound 里。mcp:<server>/*(或只写 mcp:<server>)绑的是这个 server 的全部工具。"""
if tool in bound:
return True
if not tool.startswith("mcp:"):
return False
server = tool[4:].partition("/")[0]
return any(b in (f"mcp:{server}", f"mcp:{server}/", f"mcp:{server}/*") for b in bound)
def _named_tool_pattern(spec: GraphSpec) -> re.Pattern[str]:
from app.tools.registry import all_specs
bound: set[str] = set()
for node in spec.nodes:
cfg = node.config
bound.update(_bound_tools(cfg))
if isinstance(cfg.get("tool"), str):
bound.add(cfg["tool"])
for member in cfg.get("agents") or []:
if isinstance(member, dict):
bound.update(_bound_tools(member))
names = sorted({*all_specs(), *bound}, key=len, reverse=True)
literal = "|".join(re.escape(n) for n in names if n)
body = f"{_DYNAMIC_TOOL}|{literal}" if literal else _DYNAMIC_TOOL
return re.compile(f"{_EDGE_BEFORE}(?:{body}){_EDGE_AFTER}")
# 列举里的分隔:「严禁调用 web_search、web_fetch、http_request 等」「never call a, b or c」
_LIST_TAIL = re.compile(r"(?:[\s、,,/和或及与跟以「」“”\"'`]|(?<![A-Za-z])(?:or|and|nor)(?![A-Za-z]))+$",
re.I)
def _negated(plain: str, start: int, pattern: re.Pattern[str]) -> bool:
"""这个工具名前面是不是一句「不要用」。列举里排在后面的也算:往回跳过前面的工具名和顿号。"""
head = plain[max(0, start - 120):start]
while True:
trimmed = _LIST_TAIL.sub("", head)
last = None
for last in pattern.finditer(trimmed):
pass
if last is not None and last.end() == len(trimmed):
trimmed = trimmed[:last.start()]
if trimmed == head:
break
head = trimmed
return bool(_NEGATED.search(head[-40:] + " "))
def _clause(plain: str, start: int, end: int) -> str:
"""工具名所在的那一句。"""
before = [m.end() for m in _CLAUSE_END.finditer(plain, 0, start)]
after = _CLAUSE_END.search(plain, end)
return plain[before[-1] if before else 0:after.start() if after else len(plain)]
def _asked_tools(text: str, pattern: re.Pattern[str]) -> dict[str, bool]:
"""文本里被要求使用的工具名,按出现顺序。值是「是不是明确要求」:否的只算提到了。"""
# {{ }} 里的是模板引用,不是在点名工具;换成等长空白,位置不变
plain = _TEMPLATE_SPAN.sub(lambda m: " " * len(m.group(0)), text)
found: dict[str, bool] = {}
for m in pattern.finditer(plain):
if _negated(plain, m.start(), pattern) or _REFERENCED.match(plain[m.end():m.end() + 12]):
continue
name = m.group(0)
explicit = not _OPTIONAL.search(_clause(plain, m.start(), m.end()))
if explicit and _PLAIN_WORD.fullmatch(name):
quoted = plain[m.start() - 1:m.start()] == "`" and plain[m.end():m.end() + 1] == "`"
explicit = quoted or bool(_USE_VERB.search(plain[max(0, m.start() - 12):m.start()]))
found[name] = found.get(name, False) or explicit
return found
def _asks_vaguely(text: str) -> bool:
plain = _TEMPLATE_SPAN.sub(" ", text)
return any(not _NEGATED.search(plain[max(0, m.start() - 12):m.start()])
for m in _VAGUE.finditer(plain))
def _check_named_tools(spec: GraphSpec, result: ValidationResult) -> None:
agents = [n for n in spec.nodes if n.type in (NodeType.AGENT, NodeType.SUPERVISOR)]
if not agents:
return
pattern = _named_tool_pattern(spec)
def check(node: GraphNode, texts: list[tuple[str, Any]], tools: Any, who: str = "") -> None:
bound = {t for t in tools or [] if isinstance(t, str)}
vague_at: str | None = None
reported: set[str] = set()
asked: dict[str, tuple[bool, str]] = {}
for field, text in texts:
if not isinstance(text, str) or not text.strip():
continue
for tool, explicit in _asked_tools(text, pattern).items():
# system 和 prompt 里都点了同一个工具,报一次就够:指到先出现的那处,
# 哪一处是明确要求就按要求算
seen = asked.get(tool)
asked[tool] = (explicit or bool(seen and seen[0]), seen[1] if seen else field)
if not bound and vague_at is None and _asks_vaguely(text):
vague_at = field
for tool, (explicit, field) in asked.items():
if _covers(bound, tool):
continue
reported.add(tool)
if explicit:
message = (f"{who}的提示词要求用「{tool}」,但没有给这个成员绑定它" if who
else f"提示词要求用「{tool}」,但节点没有绑定它")
where = "该成员的工具" if who else f"「{field_label('tools')}」"
result.add(f"{message}:运行时模型无法调用该工具,可能会编造结果。"
f"请把它加入{where},或删除提示词中的这项要求",
node_id=node.id, field=field)
else:
result.add(f"{who + '的' if who else ''}提示词提到了「{tool}」,但{'这个成员' if who else '节点'}没有"
"绑定它:如果需要模型调用它,运行时将无法调用。如有需要,请把它加入工具",
level="warning", node_id=node.id, field=field)
# 已经点名报过了,那句笼统的就不再重复
if vague_at is not None and not reported:
result.add(f"{who or '提示词'}要求调用工具,但{'这个成员' if who else '这个节点'}没有绑定任何"
"工具:模型无法调用工具,只能假设调用结果。请绑定要用的工具,或删除这项要求",
level="warning", node_id=node.id, field=vague_at)
for node in agents:
cfg = node.config
if node.type == NodeType.AGENT:
check(node, [("system", cfg.get("system")), ("prompt", cfg.get("prompt"))],
cfg.get("tools"))
continue
members = [m for m in cfg.get("agents") or [] if isinstance(m, dict)]
team_tools: set[str] = set()
for i, member in enumerate(members):
name = member.get("name") or f"agent{i}"
team_tools.update(_bound_tools(member))
check(node, [(f"agents[{i}].system", member.get("system"))], member.get("tools"),
who=f"成员「{name}」")
# 协作目标是给调度者看的:点了名的工具,至少得有一个成员拿得到
goal = cfg.get("goal")
if isinstance(goal, str):
for tool, explicit in _asked_tools(goal, pattern).items():
if _covers(team_tools, tool):
continue
if explicit:
result.add(f"团队目标要求用「{tool}」,但没有任何成员绑定该工具:调度者分派的成员"
"都无法调用它。请为负责的成员添加该工具,或删除目标中的这项要求",
node_id=node.id, field="goal")
else:
result.add(f"团队目标提到了「{tool}」,但没有任何成员绑定该工具:如果需要成员"
"调用它,目前都无法调用。如有需要,请为负责的成员添加",
level="warning", node_id=node.id, field="goal")
# --------------------------------------------------------------------------
# 不按 JSON 解析模型写的文字
#
# 真实踩过的一张图:agent(自由文字)→ transform `{{ nodes.query_logs.text }}`(按 JSON
# 解析)→ 口径卡。模型在 JSON 字符串里夹了没转义的英文引号,整形节点一解析就失败。模型写的
# 文字不是数据:数要交给口径卡,就让 agent 按 output_schema 交结构化字段、开 cite_fields
# 逐个标出处。这里只报 warning(旧工作流照跑);Copilot 自查把它当成要打回的问题。
# --------------------------------------------------------------------------
_MODEL_TEXT_TYPES = (NodeType.AGENT, NodeType.LLM, NodeType.SUPERVISOR)
_TEXT_REF = re.compile(
r"nodes\s*(?:\.\s*(?P<a>[\w-]+)|\[\s*['\"](?P<b>[^'\"]+)['\"]\s*\])"
r"\s*(?:\.\s*text(?![\w-])|\[\s*['\"]text['\"]\s*\])")
_VAR_REF = re.compile(r"vars\s*(?:\.\s*(?P<a>[\w-]+)|\[\s*['\"](?P<b>[^'\"]+)['\"]\s*\])")
def _writes_text(node: GraphNode) -> bool:
"""这个节点的产出是不是模型写的文字(而不是系统序列化的结构化数据)。
llm 配了 output_schema 走原生结构化输出,text 是系统 json.dumps 的;agent 只有同时
开了 cite_fields,assign_to 才拿到核对过的 data——它的 .text 仍然是模型的原话。
"""
if node.type == NodeType.LLM:
return not node.config.get("output_schema")
return node.type in _MODEL_TEXT_TYPES
def _text_sources(text: str, spec: GraphSpec, *, template: bool) -> list[GraphNode]:
"""一段模板(或表达式)读了哪些节点的模型原话。模板里过了 | json 的不算:那是合法的 JSON 字符串。"""
nodes = spec.node_map()
pieces = [m.group(0)[2:-2] for m in _TEMPLATE_SPAN.finditer(text)] if template else [text]
found: dict[str, GraphNode] = {}
for piece in pieces:
head, *filters = [p.strip() for p in piece.split("|")] if template else [piece]
if "json" in filters:
continue
for m in _TEXT_REF.finditer(head):
node = nodes.get(m.group("a") or m.group("b"))
if node is not None and _writes_text(node):
found.setdefault(node.id, node)
for m in _VAR_REF.finditer(head):
var = m.group("a") or m.group("b")
for node in spec.nodes:
if str(node.config.get("assign_to") or "").strip() != var or node.type not in _MODEL_TEXT_TYPES:
continue
structured = (node.type == NodeType.LLM and node.config.get("output_schema")) or (
node.type == NodeType.AGENT and node.config.get("output_schema")
and node.config.get("cite_fields") is True)
if not structured:
found.setdefault(node.id, node)
return list(found.values())
def _descendants(spec: GraphSpec, node_id: str) -> set[str]:
children: dict[str, list[str]] = defaultdict(list)
for e in spec.edges:
children[e.source].append(e.target)
seen: set[str] = set()
stack = list(children[node_id])
while stack:
cur = stack.pop()
if cur not in seen:
seen.add(cur)
stack.extend(children[cur])
seen.discard(node_id)
return seen
def _feeds_caliber(node: GraphNode, spec: GraphSpec) -> bool:
"""下游有口径卡读了这个节点的产出(nodes.<id> 或 vars.<assign_to>)。"""
var = str(node.config.get("assign_to") or "").strip()
reads = [rf"nodes\s*(?:\.\s*{re.escape(node.id)}(?![\w-])|\[\s*['\"]{re.escape(node.id)}['\"]\s*\])"]
if var:
reads.append(rf"vars\s*(?:\.\s*{re.escape(var)}(?![\w-])|\[\s*['\"]{re.escape(var)}['\"]\s*\])")
pattern = re.compile("|".join(reads))
nodes = spec.node_map()
for nid in _descendants(spec, node.id):
card = nodes.get(nid)
if card is not None and card.type == NodeType.METRICS and any(
pattern.search(str((d or {}).get("expression") or "")) for d in card.config.get("metrics") or []):
return True
return False
def _structured_fix(source: GraphNode) -> str:
if source.type == NodeType.AGENT:
var = str(source.config.get("assign_to") or "").strip() or "变量名"
return (f"为「{source.title}」配置「结构化输出 Schema」并开启「按出处核对字段」,口径卡直接读取 "
f"vars.{var}.字段(每个字段都核对到查询结果中的对应单元格)")
if source.type == NodeType.LLM:
return (f"为「{source.title}」配置「结构化输出 Schema」;数字需要进入口径卡时,请改用 Agent 并开启"
"「按出处核对字段」")
return "数字需要进入口径卡时,请改用 Agent,配置「结构化输出 Schema」并开启「按出处核对字段」"
def _json_spans(template: str) -> list[tuple[str, bool]]:
"""模板里每个 {{ }}:(里面的表达式, 它是不是落在 JSON 的引号里)。只数模板自己写的字,跳过 {{ }}。"""
out: list[tuple[str, bool]] = []
inside, last = False, 0
for m in _TEMPLATE_SPAN.finditer(template):
text, i = template[last:m.start()], 0
while i < len(text):
if inside and text[i] == "\\":
i += 2
continue
if text[i] == '"':
inside = not inside
i += 1
out.append((m.group(0)[2:-2].strip(), inside))
last = m.end()
return out
def text_parse_issues(spec: GraphSpec) -> list[ValidationIssue]:
"""整形节点解析 agent / llm 写的文字、产出流向口径卡或按 JSON 解析:一个节点一条 warning。"""
out: list[ValidationIssue] = []
for node in spec.nodes:
if node.type != NodeType.TRANSFORM:
continue
mode = node.config.get("mode", "expression")
field = "expression" if mode == "expression" else "template"
sources = _text_sources(str(node.config.get(field) or ""), spec, template=field == "template")
if not sources:
continue
who = "、".join(f"「{s.title}」({type_label(s.type)})" for s in sources[:3])
spans = [(expr, quoted) for expr, quoted in _json_spans(str(node.config.get(field) or ""))
if _text_sources(f"{{{{ {expr} }}}}", spec, template=True)] if mode == "json" else []
if spans and all(quoted for _, quoted in spans):
# 模型的文字放在模板的引号里:不是在解析它,是在拼字符串,少的是 | json
expr = spans[0][0]
message = (f"模板中的 {{{{ {expr} }}}} 位于 JSON 引号内:{who}写的文字只要包含英文引号或换行,JSON 就会"
f"格式错误,导致整个节点失败。请改写为 {{{{ {expr} | json }}}}(外面不要再加引号)")
elif mode == "json":
message = (f"「数据整形」节点按 JSON 解析{who}写的文字:模型写的 JSON 常含有未转义的英文引号,一旦解析失败,"
f"整个节点就会失败。请改为让它直接输出结构化数据:{_structured_fix(sources[0])}")
elif _feeds_caliber(node, spec):
message = (f"口径卡的数字需要经过这个「数据整形」节点,从{who}写的文字中提取:文字不是结构化数据,提取不可靠,"
f"也无法追溯出处。{_structured_fix(sources[0])}")
else:
continue
out.append(ValidationIssue(level="warning", node_id=node.id, field=field, message=message))
return out
# --------------------------------------------------------------------------
# 证据链相关的配置:agent 的结构化字段、沙箱代码的角色、钉住别处的口径卡
# --------------------------------------------------------------------------
EVIDENCE_ROLES = ("source", "compute")
_REF_HEAD = re.compile(r"^(vars|nodes)\.([\w-]+)")
def _caliber_inputs(card: GraphNode, spec: GraphSpec) -> list[tuple[int, GraphNode]]:
"""口径卡每个指标表达式里读到的节点:(指标下标, 产出它的节点)。"""
from app.engine.expressions import ExpressionError, leaf_refs, parse_expression
nodes = spec.node_map()
out: list[tuple[int, GraphNode]] = []
for i, d in enumerate(card.config.get("metrics") or []):
try:
tree, _, _ = parse_expression(str((d or {}).get("expression") or ""))
except ExpressionError:
continue
for path in leaf_refs(tree):
m = _REF_HEAD.match(path.split("cell(", 1)[-1].lstrip())
if not m:
continue
root, name = m.groups()
if root == "nodes":
producers = [nodes[name]] if name in nodes else []
else:
producers = [n for n in spec.nodes if str(n.config.get("assign_to") or "").strip() == name]
out.extend((i, n) for n in producers)
return out
def _is_zero(node: Any) -> bool:
"""常量 0(0、0.0、-0、+0);布尔不算。"""
import ast
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
node = node.operand
return isinstance(node, ast.Constant) and type(node.value) in (int, float) and node.value == 0
def _divides(node: Any) -> bool:
import ast
return any(isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Div, ast.FloorDiv, ast.Mod)) for n in ast.walk(node))
def zero_for_undefined(expression: str) -> bool:
"""口径卡的表达式在算不出来时给了 0:某个条件表达式(x if c else y,嵌套的也算)一边有除法、另一边是常量 0——
`round((a - b) / b, 4) if b else 0`、`0 if not b else a / b`。分母为 0、没有数据时给 0,报告里写成「0%」「持平」,
读的人当成真的没变;应给 None(显示「—」)。按语法树认,解析不了的不算(另有表达式错误的报法)。"""
import ast
from app.engine.expressions import ExpressionError, parse_expression
try:
tree, _, _ = parse_expression(expression)
except (ExpressionError, ValueError, TypeError):
return False
for n in ast.walk(tree):
if isinstance(n, ast.IfExp) and ((_divides(n.body) and _is_zero(n.orelse))
or (_divides(n.orelse) and _is_zero(n.body))):
return True
return False
def evidence_issues(spec: GraphSpec) -> list[ValidationIssue]:
from app.engine.governance import UPGRADE_POLICIES, UPGRADE_POLICY_LABELS
out: list[ValidationIssue] = []
def add(message: str, node: GraphNode, field: str, level: str = "warning", code: str | None = None) -> None:
out.append(ValidationIssue(level=level, node_id=node.id, field=field, message=message, # type: ignore[arg-type]
code=code))
for node in spec.nodes:
cfg = node.config
if node.type == NodeType.AGENT and cfg.get("output_schema") and cfg.get("cite_fields") is not True:
add("「结构化输出 Schema」需要同时开启「按出处核对字段」才会生效(会多一次抽取调用):开启后,Agent 执行"
"结束时按 Schema 抽取字段,每个字段都核对到查询结果中的对应单元格;不开启时,「结果存为变量」得到的"
"仍是自由文本",
node, "output_schema", code="evidence.cite_fields_off")
elif node.type == NodeType.CODE:
role = cfg.get("evidence_role")
if role not in (None, "") and role not in EVIDENCE_ROLES:
add(f"「证据角色」只能是「取数」或「计算」,当前为「{role}」", node,
"evidence_role", level="error")
elif node.type == NodeType.METRICS:
if cfg.get("caliber_from") not in (None, "", {}):
out.extend(_caliber_from_issues(node, UPGRADE_POLICIES, UPGRADE_POLICY_LABELS, spec))
else:
for i, d in enumerate(cfg.get("metrics") or []):
if isinstance(d, dict) and zero_for_undefined(str(d.get("expression") or "")):
add(f"指标「{d.get('name') or d.get('id') or i + 1}」在算不出来时给了 0(条件表达式的一边是除法,"
"另一边是 0):分母为 0、没有数据时应给 None(报告里显示「—」,写作者照实说算不出来),"
"写成 0 会被当成持平 / 0%。请改成「… if 分母 else None」",
node, f"metrics[{i}].expression", code="metrics.zero_for_undefined")
warned: set[str] = set()
for i, producer in _caliber_inputs(node, spec):
role = str(producer.config.get("evidence_role") or "compute")
if producer.type != NodeType.CODE or role == "source" or producer.id in warned:
continue
warned.add(producer.id)
add(f"口径卡的输入来自沙箱代码「{producer.title}」,它的「证据角色」是「计算」:沙箱中计算出的数字"
"无法追溯出处。负责取数的代码节点请将「证据角色」设为「取数」;业务计算请移到口径卡的表达式中",
node, f"metrics[{i}].expression",
code="evidence.caliber_compute_input")
return out
def _caliber_from_issues(node: GraphNode, policies: set[str], labels: dict[str, str],
spec: GraphSpec | None = None) -> list[ValidationIssue]:
"""钉住别的工作流里一张口径卡:{workflow_id, workflow_version, node_id} 三项都要写对。
可选的 nodes(节点改名映射,next-round 02 做法 4):那一版口径卡读的取数节点拷进这张图时改了名,{原 id: 这张图里的 id}。
形状和运行时同一个判断(metrics.rename_problem);改名后的节点要在这张图里、在口径卡上游——不然运行时口径卡取不到输入。"""
cfg = node.config
ref = cfg.get("caliber_from")
out: list[ValidationIssue] = []
def add(message: str, field: str, level: str = "error") -> None:
out.append(ValidationIssue(level=level, node_id=node.id, field=field, message=message)) # type: ignore[arg-type]
if not isinstance(ref, dict) or not str(ref.get("workflow_id") or "").strip() \
or not str(ref.get("node_id") or "").strip():
add(f"「{field_label('caliber_from')}」需要选定工作流、版本和其中的口径卡", "caliber_from")
return out
version = ref.get("workflow_version")
if isinstance(version, bool) or not (isinstance(version, int) or (isinstance(version, str) and version.isdigit())) \
or int(version) < 1:
add(f"「{field_label('caliber_from')}」需要选择具体的版本号(1、2…),当前为「{version}」:不固定版本,"
"口径会随上游变化", "caliber_from.workflow_version")
if ref.get("nodes") not in (None, {}):
from app.engine.nodes.metrics import rename_problem
if problem := rename_problem(ref.get("nodes")):
add(f"「{field_label('caliber_from')}」的节点改名映射有误:{problem}", "caliber_from.nodes")
elif spec is not None:
nodes = spec.node_map()
upstream = _ancestors(spec, node.id)
for old, new in ref["nodes"].items():
if new not in nodes:
add(f"「{field_label('caliber_from')}」把取数节点「{old}」改名为「{new}」,但工作流中没有节点「{new}」",
"caliber_from.nodes")
elif new not in upstream:
add(f"「{field_label('caliber_from')}」改名后的取数节点「{nodes[new].title}」不在口径卡的上游:口径卡计算时"
"它还没有执行。请把它连到口径卡之前", "caliber_from.nodes")
if cfg.get("metrics"):
add(f"已设置「{field_label('caliber_from')}」,将按所引用的口径卡计算,此处填写的「{field_label('metrics')}」"
"不会生效", "metrics", level="warning")
policy = cfg.get("upgrade_policy")
if policy not in (None, "") and policy not in policies:
allowed = [f"「{v}」" for v in labels.values()]
add(f"「{field_label('upgrade_policy')}」只能选{'、'.join(allowed[:-1])}或{allowed[-1]},当前为「{policy}」",
"upgrade_policy")
return out