Files
AgentLab/backend/app/engine/lineage.py
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

190 lines
9.4 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);
- 证据面板(api/evidence 的指标步骤):卡上记的输入里追不到原始数据的(untraced),照实说出处追到哪个节点为止;
- 应用检查(apps/lint):受管级应用里绑定的指标,按 static_stops 静态地认(只有图、没有运行),追不到的给提示。
"""
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)
def traced(item: Mapping[str, Any]) -> bool:
"""卡上记的一个输入追没追到原始数据:落在查询快照的一格上、核对得了快照(cell() 取的格、穿过 JSON 解析按位置取的格、
Agent 按出处交来的字段),或者是运行输入。"""
via = item.get("via")
if via in SNAPSHOT_VIAS:
return bool(item.get("artifact")) and item.get("status") in _CHECKED
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)]
# --------------------------------------------------------------------------
# 静态地认(只有图)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Stop:
"""一个追不到原始数据的输入:路径和停在哪个节点(认不出是哪个节点时为 None)。"""
path: str
node_id: str | None
def static_stops(nodes: Mapping[str, Mapping[str, Any]], expression: str) -> list[Stop]:
"""静态地认一个指标表达式里追不到原始数据的输入。nodes 是节点 id → {type, config}(apps/tree.node_map 的形状)。
和运行时同一套认法,只是不看值:cell() 的第一个参数是查询结果(调用工具、合并查询的产出,或原样解析它的整形节点)、
按位置取的格穿过原样解析的整形节点、运行输入、开了按出处核对字段的 Agent 交来的字段,算追到了;别的(表达式整形、
沙箱代码、模型调用、转抄别的口径卡……)停在写它的那个节点。表达式写错了的不在这里报(画布的校验报)。"""
try:
tree = parse_expression(expression)[0]
except ExpressionError:
return []
out: list[Stop] = []
for path in leaf_refs(tree):
stop = _static_stop(nodes, path)
if stop is not None:
out.append(stop)
return out
def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str) -> Stop | None:
type_of = lambda node_id: (nodes.get(node_id) or {}).get("type") # noqa: E731
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)]
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
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":
continue
if node.get("type") == "agent" and _cited(node, head.group(1) if head else "", field):
continue
return Stop(path, writer)
return None
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[")