Files
AgentLab/backend/app/engine/lineage.py
T
ThearchyHelios 32dbfcfb52 复核修正(后端):子图里的报告不再被判「疑似被修改」、驳回出口的双盲裁决照样降档、可验证报告与推演按卡所在的层找产出、子图那一步写名字
- B1 子工作流里有报告撰写节点时:它的 report.checked 进了父运行(节点 id「sub/write」),报告文档却记子图里的 id「write」,
  证据接口、可验证报告、质询拿两边一比判「疑似被修改」,评测读答案也说答案不能当证据。
  - nodes/report:报告文档、产出工件记这次运行里的节点 id(ctx.run.global_id),和事件同一个;nodes/io 子图自己的出具契约
    按同一个 id 核对,_issuance.report.node_id 同样写这次运行里的 id
  - api/evidence:_reports 默认只认父图上的报告(子图的报告是子工作流那一步内部的东西,父图的答案是子工作流出口的值),
    nested=True 连子图里的一起给;点名要子图报告的(?report=sub/write)照样点得开(_named_reports);按成果挑报告的兜底
    不再挑中子图的报告。成果上没有 _issuance 时退回最后一条 issuance 事件,子图成果节点的那条不认
  - bundle:出具档位(_tier)同样不认子图成果节点的 issuance,verifier.js 同一个认法;子图报告的文档在引用链上照常核对
- B2 双盲裁决被驳回、走驳回出口接到出具成果时:节点产出换成 exit_payload,出具缺口和 dual_check 台账条目跟着丢了,审核人驳回的
  那组数照样完整出具(比选「两边都不对」还宽)。NodeRejected 加 exit_updates(节点在驳回之前已算出的产出字段、台账、用量),
  compiler._rejected_exit 并进去;双盲复算驳回时照记缺口「指标「X」两路结果不一致,审核人驳回了裁决」和一条结论为驳回的
  dual_check。核对过别的关卡:人工节点、工具调用审批、动作确认、代码执行审批都在产出缺口、台账之前就被驳回,没有可丢的
- B4 子图里的卡转抄同一张子图里的卡、按位置取格:可验证报告(bundle/build._outputs_of)和推演(api/whatif._sources)按这次
  运行里的 id 查产出,子图卡上记的是子图里的 id,查不到就整张遮住、推演不了;父图有同名节点时还拿父图那个当依据。改成按卡
  所在那一层的 id 查(engine/lineage.in_scope / scoped_id,证据面板 _InputMasks 同一个认法),转抄的卡认不出时的兜底同样换层
- B6 子图里的一步出错、被驳回后 Run.error_node_id 是「sub/x」:实例页(apps/resolve)、通知(notify/service)照终态事件上记的
  名字写「子工作流 › x」(run_node_label),不露出内部 id

测试:test_subgraph_evidence(子图含报告:证据图按父图、评测读答案不报被改、子图报告点得开、可验证报告核对通过且不写子图的
档位、子图自己的契约照常;子图卡在可验证报告里不被遮、推演还原得出、父图同名节点不当依据;子图那一步的名字)、
test_dual_check_node(驳回出口接到出具成果照样降档、台账照记、证据面板认得)。每条都去掉修复回跑过,确实失败
2026-10-18 18:15:37 +08:00

387 lines
22 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.
"""口径卡的输入追没追到原始数据:一处认定,三处用。
「原始数据」是查询快照里的格(调用工具节点、合并查询交回的结果;Agent 按出处交来的字段落在的那一格)和运行输入(发起
运行的人填的)。证据面板上的「有出处」说的是另一件事:这个数是口径卡按登记的表达式确定性算出来的、拿代入式复算一致。
两件事分开说,这里只管前一件:
- 运行时(nodes/metrics._input_of):「只是把调用工具节点的结果原样按 JSON 解析」的数据整形节点(parsed_tool)不改动任何
一格,vars.<x>.rows[i][j](row_cell)确定地对到那份查询快照的第 i 行第 j 列,和 cell() 一样按快照核对,记下穿过的整形
节点(through);转抄另一张口径卡的指标(nodes.<卡>.metrics[i].value,metric_copy:对账卡、召回卡的「口径」那一项就是
这么写的)跨卡往下追——接上那个指标自己的出处,那个指标的输入都追到了原始数据,这一项才算追到(upstream.traced);
转抄的转抄一层层接着追,最多 MAX_COPY_DEPTH 层(循环里抄自己上一轮的卡每一轮多一层,到了上限就算没追到);
- 证据面板(api/evidence 的指标步骤):卡上记的输入里追不到原始数据的(untraced),照实说出处追到哪个节点为止;
- 应用检查(apps/lint)、受管级发布检查(governance):按 static_stops 静态地认(只有图、没有运行),追不到的给提示。
转抄同一张图里另一张口径卡的指标,和运行时一样跨卡按那个指标的式子接着认(最多 MAX_COPY_DEPTH 层);被抄的卡按
caliber_from 固定在别处的版本时,静态看不到那一版的图,照旧停在那张卡。
读子工作流出口的字段(nodes.<子图>.output.<字段>,docs/design/orchestration-round/00-总方案.md §2)跨子图往下追,三处同一套:
- 运行时:子图跑完时按出口模板认每个字段的出处(multi.run_subgraph 的 provenance:模板整串就是一个取值链的字段,在子图
的状态里按口径卡认输入的同一套认;出自子图运行输入的再按子工作流节点的「输入」模板往父图追一层),父图口径卡读这个
字段时接上它(metrics._subgraph_field),卡上记的就是子图里那一格的出处(via、artifact、locator、status),另记
subgraph、field——traced / untraced 照常认;子图里追不到原始数据的,父图照样追不到、照样降档;
- 证据面板:卡上记的节点 id 按卡所在的那一层认(api/evidence 的 _scoped),一路给到子图里的那一格;
- 静态:按子工作流节点固定的那一版图(lint_process.subgraph_spec,调用方用 with_subgraphs 放进来)认出口字段的模板,接着
在子图里认;子图的运行输入按子工作流节点的「输入」模板回到父图认。最多 MAX_SUB_DEPTH 层(和运行时的嵌套上限一样),
看不到子图的(没放进来、读不到)照旧停在子工作流节点。
"""
from __future__ import annotations
import ast
import re
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
#: 整形节点的模板整串就是一个节点的全部产出:{{ nodes.fetch }}(不带过滤器、前后不带别的字)
_WHOLE_NODE = re.compile(r"^\s*\{\{\s*nodes\.([A-Za-z0-9_\-]+)\s*\}\}\s*$")
_REF_HEAD = re.compile(r"^(vars|nodes|input)\.([A-Za-z_一-鿿][\w\-一-鿿]*)(.*)$", re.S)
#: 落在查询快照一格上的取值方式(cell() 取的格、Agent 按出处交来的字段),和 engine/recompute、dual_check 同一组
SNAPSHOT_VIAS = ("tool_cell", "agent_field")
#: 核对得了快照的状态:一致,或者对不上(对不上另有说法,但出处是追到了的)
_CHECKED = ("verified", "mismatch")
#: 直接交回查询结果(带快照)的节点:cell() 的第一个参数指到它们就是快照
_TABLE_NODES = ("tool", "merge")
def parsed_tool(node_type: Any, config: Mapping[str, Any] | None, type_of: Callable[[str], Any]) -> str | None:
"""这个节点只是把调用工具节点的结果原样按 JSON 解析:数据整形、按 JSON 解析(mode=json)、模板整串就是
{{ nodes.<id> }}、那个节点是调用工具节点。是的话交回那个调用工具节点的 id,不是返回 None。
只认这一种:表达式、模板拼出来的结构即使带着 rows 和快照 id,行也可能被筛过、换过顺序,第 i 行对不到快照的第 i 行。"""
cfg = config or {}
if str(node_type) != "transform" or cfg.get("mode") != "json":
return None
m = _WHOLE_NODE.match(str(cfg.get("template") or ""))
if not m or str(type_of(m.group(1)) or "") != "tool":
return None
return m.group(1)
@dataclass(frozen=True)
class RowCell:
"""按位置取的一格:<root>.<name>.rows[row][column]。root 是 vars 或 nodes,row 是从 0 数的行号,column 是列号或列名。"""
root: str
name: str
row: int
column: int | str
@property
def table(self) -> str:
"""那份结果本身的取值链:vars.q"""
return f"{self.root}.{self.name}"
def row_cell(path: str) -> RowCell | None:
"""路径正好是 vars.q.rows[0][0]、nodes.parse.rows[1]['gmv'] 这种按位置取一格的样子时拆开;行号是非负整数字面量,
列是非负整数或文本字面量。别的样子(负数下标、下标是变量、多走了一层)返回 None。"""
try:
tree = ast.parse(path, mode="eval").body
except SyntaxError:
return None
if not isinstance(tree, ast.Subscript) or not isinstance(tree.value, ast.Subscript):
return None
column, row, rows = tree.slice, tree.value.slice, tree.value.value
if not (isinstance(rows, ast.Attribute) and rows.attr == "rows" and isinstance(rows.value, ast.Attribute)
and isinstance(rows.value.value, ast.Name) and rows.value.value.id in ("vars", "nodes")):
return None
if not (isinstance(row, ast.Constant) and type(row.value) is int and row.value >= 0):
return None
if not (isinstance(column, ast.Constant) and ((type(column.value) is int and column.value >= 0)
or (isinstance(column.value, str) and column.value))):
return None
return RowCell(rows.value.value.id, rows.value.attr, row.value, column.value)
#: 转抄另一张口径卡的指标时跨卡往下追的层数上限
MAX_COPY_DEPTH = 8
def metric_copy(path: str) -> tuple[str, str, int] | None:
"""路径正好是 nodes.<卡>.metrics[i].value、vars.<卡的 assign_to>.metrics[i].value 这种转抄一个指标的值的样子时拆开:
(root, 名字, 第几个指标)。下标是非负整数字面量;别的样子(整张卡、清单文字、指标的名称单位、下标是变量)为 None。"""
try:
tree = ast.parse(path, mode="eval").body
except SyntaxError:
return None
if not (isinstance(tree, ast.Attribute) and tree.attr == "value" and isinstance(tree.value, ast.Subscript)):
return None
index, metrics = tree.value.slice, tree.value.value
if not (isinstance(metrics, ast.Attribute) and metrics.attr == "metrics"
and isinstance(metrics.value, ast.Attribute) and isinstance(metrics.value.value, ast.Name)
and metrics.value.value.id in ("vars", "nodes")):
return None
if not (isinstance(index, ast.Constant) and type(index.value) is int and index.value >= 0):
return None
return metrics.value.value.id, metrics.value.attr, index.value
def traced(item: Mapping[str, Any]) -> bool:
"""卡上记的一个输入追没追到原始数据:落在查询快照的一格上、核对得了快照(cell() 取的格、穿过 JSON 解析按位置取的格、
Agent 按出处交来的字段),或者是运行输入;转抄另一张口径卡的指标的,那个指标的输入都追到了(运行时跨卡追好记在
upstream 里:traced 为真、那张卡的指标集落了工件)。"""
via = item.get("via")
if via in SNAPSHOT_VIAS:
return bool(item.get("artifact")) and item.get("status") in _CHECKED
if via == "metric":
up = item.get("upstream")
return isinstance(up, Mapping) and up.get("traced") is True and bool(up.get("artifact"))
return via == "input"
def untraced(inputs: Iterable[Any]) -> list[dict[str, Any]]:
"""卡上记的输入里追不到原始数据的。没有值的不算(缺输入另有说法:值都没有,谈不上追到哪里)。"""
return [item for item in inputs
if isinstance(item, dict) and item.get("value") is not None and not traced(item)]
# --------------------------------------------------------------------------
# 卡上记的节点 id 在哪一层
# --------------------------------------------------------------------------
def scoped_id(card_node: Any, node_id: Any) -> Any:
"""口径卡上记的节点 id(输入来自哪个节点、穿过的整形节点、转抄的卡、取值链里写的 nodes.X)是卡所在那一层图里的 id:
子工作流里的卡(这次运行里的 id「子工作流节点 id/卡」)记的是子图里的 id,换成这次运行里的(接上同一个前缀)。最外层
的卡原样;经子工作流出口接上的出处(multi.run_subgraph 记的「子工作流节点 id/…」)已经是卡这一层的 id,同样接上卡的
前缀就对了。证据面板、可验证报告、推演都按这一个认。"""
card = str(card_node or "")
if not isinstance(node_id, str) or not node_id or "/" not in card:
return node_id
return f"{card.rsplit('/', 1)[0]}/{node_id}"
def in_scope(table: Mapping[str, Any], card_node: Any) -> dict[str, Any]:
"""按这次运行里的节点 id 记的表(每个节点那一刻的产出、跑完的时刻)→ 按口径卡所在那一层的 id 记的:子工作流里的卡只看
同一张子图里的(去掉前缀;父图里同名的节点不算),最外层的卡原样。卡上的取值链按它这一层的 id 查(scoped_id 的反面)。"""
card = str(card_node or "")
if "/" not in card:
return dict(table)
prefix = card.rsplit("/", 1)[0] + "/"
return {key[len(prefix):]: value for key, value in table.items() if key.startswith(prefix)}
# --------------------------------------------------------------------------
# 静态地认(只有图)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Stop:
"""一个追不到原始数据的输入:路径和停在哪个节点(认不出是哪个节点时为 None)。"""
path: str
node_id: str | None
#: 出口字段的模板整串就是一个取值链:{{ nodes.card.metrics[0].value }}(不带过滤器、不调函数、前后不带别的字)
_WHOLE_REF = re.compile(r"^\s*\{\{\s*((?:nodes|vars|input)\.[^{}|()]+?)\s*\}\}\s*$", re.S)
def whole_ref(template: Any) -> str | None:
"""模板整串就是一个取值链时交回它(成果字段的值就是那个值本身,渲染只是转成文字);别的写法为 None:拼了别的字、
过了过滤器的值,对不上那一格的值。子工作流出口的出处,运行时(multi._output_provenance)和静态(_exit_template)
都按这一个判断认。"""
m = _WHOLE_REF.match(template) if isinstance(template, str) else None
return m.group(1).strip() if m else None
#: 静态地跨子工作流往下追的层数上限:和运行时子工作流的嵌套上限(multi._MAX_DEPTH)一样
MAX_SUB_DEPTH = 3
@dataclass(frozen=True)
class _Scope:
"""静态地认时所在的那一层图:节点 id → {type, title?, config}。是子工作流时记着父图那一层和它在父图里的节点 id(子图的
运行输入按那个子工作流节点的「输入」模板回到父图认)。"""
nodes: Mapping[str, Mapping[str, Any]]
parent: "_Scope | None" = None
via: str | None = None
level: int = 0
def static_stops(nodes: Mapping[str, Mapping[str, Any]], expression: str, *, _depth: int = 0) -> list[Stop]:
"""静态地认一个指标表达式里追不到原始数据的输入。nodes 是节点 id → {type, config}(apps/tree.node_map 的形状)。
和运行时同一套认法,只是不看值:cell() 的第一个参数是查询结果(调用工具、合并查询的产出,或原样解析它的整形节点)、
按位置取的格穿过原样解析的整形节点、运行输入、开了按出处核对字段的 Agent 交来的字段,算追到了;转抄同一张图里
另一张口径卡的指标,按那个指标的式子接着认(它的输入都追到了才算);读子工作流出口的字段,按那一版子图的出口模板
接着认(_through_sub);别的(表达式整形、沙箱代码、模型调用、转抄固定在别处版本的口径卡、看不到的子图……)停在
写它的那个节点。表达式写错了的不在这里报(画布的校验报)。"""
return _stops(_Scope(nodes), expression, _depth)
def _stops(scope: _Scope, expression: str, depth: int) -> list[Stop]:
try:
tree = parse_expression(expression)[0]
except ExpressionError:
return []
out: list[Stop] = []
for path in leaf_refs(tree):
stop = _static_stop(scope, path, depth)
if stop is not None:
out.append(stop)
return out
def _copied_metric(nodes: Mapping[str, Mapping[str, Any]], path: str) -> tuple[str, str] | None:
"""path 转抄同一张图里一张口径卡的第 i 个指标时:(写它的卡, 那个指标的式子)。卡按 caliber_from 固定在别处版本的、
认不出是哪张卡的、下标越界的为 None。"""
copy = metric_copy(path)
if copy is None:
return None
root, name, index = copy
writers = _writers(nodes, f"{root}.{name}")
if len(writers) != 1:
return None
card = nodes.get(writers[0]) or {}
cfg = card.get("config") or {}
if card.get("type") != "metrics" or isinstance(cfg.get("caliber_from"), dict):
return None
metrics = cfg.get("metrics") or []
if index >= len(metrics) or not isinstance(metrics[index], dict) or not metrics[index].get("expression"):
return None
return writers[0], str(metrics[index]["expression"])
def _static_stop(scope: _Scope, path: str, depth: int = 0) -> Stop | None:
nodes = scope.nodes
type_of = lambda node_id: (nodes.get(node_id) or {}).get("type") # noqa: E731
if (copied := _copied_metric(nodes, path)) is not None:
card_id, expression = copied
if depth < MAX_COPY_DEPTH and not _stops(scope, expression, depth + 1):
return None
return Stop(path, card_id)
if (parts := cell_parts(path)) is not None:
writers = _writers(nodes, parts[0])
bad = [w for w in writers if not _table(nodes, w, type_of) and not _sub_table(scope, w, parts[0])]
return Stop(path, bad[0]) if bad or not writers else None
if (at := row_cell(path)) is not None:
writers = _writers(nodes, at.table)
node = nodes.get(writers[0]) if len(writers) == 1 else None
if node is not None and parsed_tool(node.get("type"), node.get("config"), type_of):
return None
head = _REF_HEAD.match(path)
if head and head.group(1) == "input":
return None if _from_parent(scope, head.group(2), depth) else Stop(path, scope.via)
writers = _writers(nodes, path)
if not writers:
return Stop(path, None)
field = head.group(3).lstrip(".") if head else ""
for writer in writers:
node = nodes[writer]
if node.get("type") == "input":
if head and head.group(1) == "vars" and not _from_parent(scope, head.group(2), depth):
return Stop(path, scope.via)
continue
if node.get("type") == "agent" and _cited(node, head.group(1) if head else "", field):
continue
if node.get("type") == "subgraph" and head and _through_sub(scope, writer, head, depth):
continue
return Stop(path, writer)
return None
def _from_parent(scope: _Scope, name: str, depth: int) -> bool:
"""运行输入的一个字段算不算原始数据:最外层的是(发起运行的人填的);子工作流的运行输入是父图按子工作流节点的「输入」
模板渲染出来的,那个模板整串就是一个取值链、在父图里追得到原始数据,才算(同运行时 multi._lifted)。"""
if scope.parent is None:
return True
templates = ((scope.parent.nodes.get(scope.via or "") or {}).get("config") or {}).get("input")
path = whole_ref(templates.get(name)) if isinstance(templates, dict) and name else None
return path is not None and _static_stop(scope.parent, path, depth + 1) is None
def _sub_scope(scope: _Scope, node_id: str) -> _Scope | None:
"""子工作流节点 node_id 引用的那一版图(lint_process.subgraph_spec 读好放进来的),看不到、超过层数上限的为 None。"""
if scope.level >= MAX_SUB_DEPTH:
return None
from app.engine.lint_process import subgraph_spec
sub = subgraph_spec((scope.nodes.get(node_id) or {}).get("config") or {})
if sub is None:
return None
nodes = {n.id: {"type": str(n.type), "title": n.title, "config": n.config} for n in sub.nodes}
return _Scope(nodes, parent=scope, via=node_id, level=scope.level + 1)
def _exit_template(sub: _Scope, key: str) -> str | None:
"""子图出口字段 key 的模板整串所写的取值链(whole_ref);没有这个字段、几个成果节点写法不一样、不是整串一个
取值链的为 None。"""
found = {whole_ref(f.get("value", "")) for node in sub.nodes.values() if node.get("type") == "output"
for f in (node.get("config") or {}).get("fields") or [] if isinstance(f, dict) and f.get("name") == key}
return found.pop() if len(found) == 1 else None
def _sub_key(head: re.Match[str], node: Mapping[str, Any]) -> str | None:
"""nodes.<子图>.output.<字段> / vars.<子图的 assign_to>.<字段> 读的出口字段名;不是正好一个字段的为 None。"""
root, name, rest = head.group(1), head.group(2), head.group(3)
key = re.sub(r"\[\s*['\"]([^'\"\]]+)['\"]\s*\]", r".\1", rest).strip(".")
if root == "nodes":
if not key.startswith("output."):
return None
key = key[len("output."):]
elif root != "vars" or str((node.get("config") or {}).get("assign_to") or "").strip() != name:
return None
return key if key and "." not in key and "[" not in key else None
def _through_sub(scope: _Scope, writer: str, head: re.Match[str], depth: int) -> bool:
"""读子工作流出口的一个字段:那个字段在子图里追得到原始数据(出口模板整串就是一个取值链,接着在子图里认)。"""
key = _sub_key(head, scope.nodes.get(writer) or {})
sub = _sub_scope(scope, writer) if key else None
inner = _exit_template(sub, key) if sub is not None and key else None
return inner is not None and _static_stop(sub, inner, depth + 1) is None
def _sub_table(scope: _Scope, writer: str, source: str) -> bool:
"""cell() 的第一个参数是子工作流出口原样交出的一份查询结果(出口字段的模板整串就是 {{ nodes.<查询节点> }})。"""
head = _REF_HEAD.match(source)
if not head or (scope.nodes.get(writer) or {}).get("type") != "subgraph":
return False
key = _sub_key(head, scope.nodes.get(writer) or {})
sub = _sub_scope(scope, writer) if key else None
inner = _exit_template(sub, key) if sub is not None and key else None
if inner is None:
return False
writers = _writers(sub.nodes, inner)
type_of = lambda node_id: (sub.nodes.get(node_id) or {}).get("type") # noqa: E731
return bool(writers) and all(_table(sub.nodes, w, type_of) or _sub_table(sub, w, inner) for w in writers)
def _writers(nodes: Mapping[str, Mapping[str, Any]], source: str) -> list[str]:
"""取值链的根是谁写的:nodes.X 是 X;vars.X 是 assign_to 为 X 的节点(入口节点声明了字段 X 的也算)。"""
head = _REF_HEAD.match(source)
if not head:
return []
root, name = head.group(1), head.group(2)
if root == "nodes":
return [name] if name in nodes else []
if root == "input":
return [n for n, node in nodes.items() if node.get("type") == "input"]
out = []
for node_id, node in nodes.items():
cfg = node.get("config") or {}
if str(cfg.get("assign_to") or "").strip() == name:
out.append(node_id)
elif node.get("type") == "input" and any(isinstance(f, dict) and str(f.get("name") or "").strip() == name
for f in cfg.get("fields") or []):
out.append(node_id)
return out
def _table(nodes: Mapping[str, Mapping[str, Any]], node_id: str, type_of: Callable[[str], Any]) -> bool:
node = nodes.get(node_id) or {}
return node.get("type") in _TABLE_NODES or parsed_tool(node.get("type"), node.get("config"), type_of) is not None
def _cited(node: Mapping[str, Any], root: str, field: str) -> bool:
"""开了按出处核对字段的 Agent 交来的字段(vars.X.字段,或 nodes.<agent>.data.字段):逐格核对过快照,是原始数据。"""
cfg = node.get("config") or {}
if not (cfg.get("output_schema") and cfg.get("cite_fields") is True):
return False
return root == "vars" or field.startswith("data.") or field.startswith("data[")