mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
子工作流的证据交回父图(后端):子图每一步进同一个运行、台账和口径卡交回、出口字段带出处、父图跨子图往下追
调查时发现:run_subgraph 只设了节点 id 前缀(node_prefix)没用上,子图在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给父图的流——子图里每一步在运行的事件里一条都没有,时间线看不到、封存也不含它查过的数。「事件本来就在同一个 运行里」此前并不成立(docs/design/orchestration-round/00-总方案.md §2)。 - engine/context + nodes/multi:子图拿到父运行的写入口(STREAM_KEY)和前缀(PREFIX_KEY),子图里的节点经它发事件,节点 id 「子工作流节点 id/…」,嵌套的一层层接上;工件按这次运行里的 id 记。子图里报的错(含被驳回)交回时节点 id 换成这一层的、 名字接上子工作流(runner 的 run.failed / run.cancelled 写「子工作流 › 那一步」),不再和父图里同名的节点混在一起 - nodes/multi:子图的台账条目加前缀并进父图 evidence(父图报告的目录据此收进子图里的查询和口径卡);子图口径卡的产出以 「子工作流节点 id/卡」进父图 nodes;出口每个字段按出口模板认出处(模板整串就是一个取值链、渲染出来正是那个值的才认, 同口径卡认输入的那一套),出自子图运行输入的按子工作流节点的「输入」模板再往父图追一层,追不到的记 subgraph_input - engine/compiler:子工作流节点自己的 node.finished 不再重记交回来的台账条目(子图那几步自己的 node.finished 已经带着) - nodes/metrics:父图口径卡读 nodes.<子图>.output.<字段>(或 assign_to 变量的字段)时接上那个字段的出处,另记 subgraph、 field;经子图出口转抄子图口径卡的指标照样带上不完整、存疑;cell(nodes.<子图>.output.<原样交出的查询结果>, …) 按台账 认出子图里的那份快照 - engine/lineage:静态认法跨子图往下追(出口模板 → 子图里接着认;子图的运行输入按「输入」模板回父图认;最多 3 层), 和运行时同一套;whole_ref 是两边共用的「整串一个取值链」判断。lint_process.load_subgraphs 往下读嵌套的子工作流, subgraph_spec 给静态认法取图;没读好放进来的照旧停在子工作流节点 - nodes/report:报告收证据的范围加上上游子工作流交回来的(evidence_from 写了子工作流节点的一并算) - nodes/io:指标出具的缺口里,停在子图里的说停在那个子工作流(和静态检查同一句) - api/evidence:卡上记的节点 id 按卡所在那一层认(_scoped:子图里的卡写子图里的 id),转抄、输入、追不到的那几步一路给到 子图里的那一格;子图里的节点按 node.started 记的名字接成「子工作流 › 节点」(node_label、subgraph_label);双盲复算条目 里的卡 id 同样换成这次运行里的 - apps/resolve:实例进度不单列子图里的步骤 - 口径账本的 use_caliber 不改;比对读封存的查询,子图里的查询如今在封存范围里,照常认得出 测试:test_subgraph_evidence(子图每一步进运行、报错点名子图里那一步、实例进度、父图口径卡跨子图追到查询格并完整出具、 子图里追不到的父图降档、不是整串取值链的停在子工作流、运行输入往父图追、嵌套两层、静态检查与运行时一致(含两层和输入 模板)、父图报告引用子图里的查询和口径卡并一路点到那一格、封存与可验证报告、子图里的遮罩照父图的运行)。去掉「交写入口」 回跑,五条失败(快照不在封存范围里)。后端全量 14472 通过
This commit is contained in:
+64
-16
@@ -209,6 +209,9 @@ class _Sealed:
|
||||
#: 按需裁判的判定(evidence.judged 事件),按先后:(seq, 节点, 载荷)。不管落在封存前后都收——
|
||||
#: 失败后接着跑的运行会把早先追加的判定封进新的清单里,它仍然是按需裁判的批注
|
||||
judged: list[tuple[int, str | None, dict[str, Any]]] = field(default_factory=list)
|
||||
#: 节点 id → node.started 上记的名字、类型。子工作流里的节点(「子工作流节点 id/…」)不在这次运行的图上,名字从这里取
|
||||
labels: dict[str, str] = field(default_factory=dict)
|
||||
types: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def sealed(self) -> bool:
|
||||
@@ -273,6 +276,11 @@ async def _sealed(run_id: str) -> _Sealed:
|
||||
out.retrievals.setdefault(str(data["artifact"]), {"node_id": node_id, "source": data.get("collection")})
|
||||
elif etype == "caliber.upgrade":
|
||||
out.upgrades[node_id] = data
|
||||
elif etype == "node.started":
|
||||
if isinstance(data.get("label"), str) and data["label"]:
|
||||
out.labels.setdefault(node_id, data["label"])
|
||||
if isinstance(data.get("node_type"), str) and data["node_type"]:
|
||||
out.types.setdefault(node_id, data["node_type"])
|
||||
if etype != "node.finished":
|
||||
continue
|
||||
if data.get("artifact"):
|
||||
@@ -280,7 +288,9 @@ async def _sealed(run_id: str) -> _Sealed:
|
||||
for entry in data.get("evidence") or []:
|
||||
if isinstance(entry, dict) and entry.get("artifact"):
|
||||
out.ledger.add((node_id, entry["artifact"]))
|
||||
out.entries.append({**entry, "node_id": node_id})
|
||||
# 条目里另记的节点 id(双盲复算复算的口径卡)是那个节点所在那一层图里的 id:换成这次运行里的
|
||||
out.entries.append({**entry, "node_id": node_id,
|
||||
**({"card": _scoped(node_id, entry["card"])} if entry.get("card") else {})})
|
||||
artifact = str(entry["artifact"])
|
||||
if entry.get("kind") == "query":
|
||||
out.queries[artifact] = {
|
||||
@@ -975,20 +985,21 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
|
||||
# 期 4:这个指标做没做过双盲复算、结果如何。没有双盲复算复核过这份指标集时没有这个键,老运行一字不差
|
||||
if checked := _dual_check_of(sealed, str(entry.get("node_id") or ""), artifact, metric_id, metric):
|
||||
step["dual_check"] = checked
|
||||
card_node = str(entry.get("node_id") or "")
|
||||
if metric is not None and metric.get("inputs"):
|
||||
# 输入追没追到原始数据(engine/lineage.py):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说
|
||||
# 出处追到哪个节点为止;卡上没有输入(常量)的没有可追的,不给这个键
|
||||
step["untraced"] = _untraced_steps(metric, sealed)
|
||||
step["untraced"] = _untraced_steps(metric, sealed, card_node)
|
||||
catalog = doc.get("catalog") or {}
|
||||
inputs: list[dict[str, Any]] = []
|
||||
#: 输入指到的查询快照 → 被引用的格;同一份快照只给一个查询步骤,格合在一起高亮
|
||||
wanted: dict[str, list[tuple[Any, Any]]] = {}
|
||||
held, word = await _metric_masks(metric or {}, str(entry.get("node_id") or ""), artifact, sealed, masks, card=card)
|
||||
for item in (metric or {}).get("inputs") or []:
|
||||
one = {"step": "input", **item}
|
||||
one = {"step": "input", **item, **_scoped_ids(item, card_node, sealed)}
|
||||
if item.get("through"):
|
||||
# 穿过原样解析查询结果的整形节点按位置取的格:面板写明经过的是哪个节点(图里的名字)
|
||||
one["through_label"] = _node_label(sealed, item["through"])
|
||||
one["through_label"] = _node_label(sealed, one["through"])
|
||||
artifact_of = item.get("artifact")
|
||||
if artifact_of and item.get("via") in ("tool_cell", "agent_field"):
|
||||
one["query"] = _query_alias(catalog, artifact_of)
|
||||
@@ -999,7 +1010,8 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
|
||||
one = _withheld(one, found)
|
||||
inputs.append(one)
|
||||
# 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):跨卡往下追,接上那个指标自己的一段出处,一直到查询格子
|
||||
upstream = await _upstream_steps(metric or {}, sealed, masks, catalog, wanted, {(artifact, str(metric_id))})
|
||||
upstream = await _upstream_steps(metric or {}, sealed, masks, catalog, wanted, {(artifact, str(metric_id))},
|
||||
card_node=card_node)
|
||||
if withheld := {path: _held_word(found) for path, found in held.items() if found.kind != "table"}:
|
||||
# 代入式里那几处同样写「已遮罩」(遮没遮核对不了的写「无法核对」):不然标签里遮着、代入式里亮着,同一个值换个
|
||||
# 地方照样看得到。整份结果当输入的不换:代入式里本来就只留着它的路径
|
||||
@@ -1015,7 +1027,7 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
|
||||
|
||||
async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Masks", catalog: dict[str, Any],
|
||||
wanted: dict[str, list[tuple[Any, Any]]], seen: set[tuple[str, str]],
|
||||
depth: int = 1) -> list[dict[str, Any]]:
|
||||
depth: int = 1, *, card_node: str = "") -> list[dict[str, Any]]:
|
||||
"""指标的输入里转抄另一张口径卡的指标的(nodes.<卡>.metrics[i].value,卡上记着 upstream),每个接一步 upstream:那张
|
||||
卡的那个指标(名字、值、式子、代入式、核对状态,取值按同一套遮罩),它自己的输入(同指标步骤的输入;落在查询快照上的
|
||||
并进 wanted,后面照常给查询步骤),追没追到原始数据。转抄的转抄一层层接着追(防环:同一个指标只接一次;最多
|
||||
@@ -1033,7 +1045,8 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask
|
||||
if (artifact, metric_id) in seen:
|
||||
continue
|
||||
seen.add((artifact, metric_id))
|
||||
node = str(item.get("node_id") or "")
|
||||
# 卡上记的是卡所在那一层图里的 id(子工作流里的卡转抄同一张子图里的卡;经子工作流出口转抄的已经是这一层的)
|
||||
node = str(_scoped(card_node, item.get("node_id")) or "")
|
||||
card, hash_ok = _load_card(artifact)
|
||||
found = _metric_in(card, metric_id)
|
||||
step: dict[str, Any] = {
|
||||
@@ -1060,7 +1073,7 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask
|
||||
if found.get("incomplete") else {}),
|
||||
**({"sql_check_failed": True, "sql_check_reason": found.get("sql_check_reason") or ""}
|
||||
if found.get("sql_check_failed") else {}),
|
||||
"untraced": _untraced_steps(found, sealed),
|
||||
"untraced": _untraced_steps(found, sealed, node),
|
||||
})
|
||||
if withheld := {path: _held_word(h) for path, h in held.items() if h.kind != "table"}:
|
||||
step["substituted"] = recompute.substituted_without(found, withheld)
|
||||
@@ -1068,7 +1081,7 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask
|
||||
for sub in found.get("inputs") or []:
|
||||
if not isinstance(sub, dict):
|
||||
continue
|
||||
one = {"step": "input", **sub}
|
||||
one = {"step": "input", **sub, **_scoped_ids(sub, node, sealed)}
|
||||
if sub.get("artifact") and sub.get("via") in SNAPSHOT_VIAS:
|
||||
one["query"] = _query_alias(catalog, sub["artifact"])
|
||||
if cell := _input_cell(sub, catalog):
|
||||
@@ -1079,27 +1092,60 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask
|
||||
inputs.append(one)
|
||||
step["inputs"] = inputs
|
||||
out.append(step)
|
||||
out += await _upstream_steps(found, sealed, masks, catalog, wanted, seen, depth + 1)
|
||||
out += await _upstream_steps(found, sealed, masks, catalog, wanted, seen, depth + 1, card_node=node)
|
||||
return out
|
||||
|
||||
|
||||
def _scoped(card_node: Any, node_id: Any) -> Any:
|
||||
"""口径卡上记的节点 id(输入来自哪个节点、穿过的整形节点、转抄的卡)是卡所在那一层图里的 id:子工作流里的卡
|
||||
(节点 id「子工作流节点 id/卡」)记的是子图里的 id,换成这次运行里的(接上同一个前缀)。最外层的卡原样;经子工作流
|
||||
出口接上的出处(multi.run_subgraph 记的)已经是卡这一层的 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 _scoped_ids(item: dict[str, Any], card_node: str, sealed: _Sealed) -> dict[str, Any]:
|
||||
"""输入步骤上的节点 id 换成这次运行里的(_scoped);落在子工作流里的另给图上的名字(node_label、subgraph_label:
|
||||
「子工作流 › 节点」),画布、记录页认不出这个 id 时照它写。最外层的输入不多带键,和以前一字不差。"""
|
||||
out: dict[str, Any] = {}
|
||||
for key in ("node_id", "through", "subgraph"):
|
||||
if isinstance(item.get(key), str) and item[key]:
|
||||
out[key] = _scoped(card_node, item[key])
|
||||
if "/" in str(out.get("node_id") or ""):
|
||||
out["node_label"] = _node_label(sealed, out["node_id"])
|
||||
if out.get("subgraph"):
|
||||
out["subgraph_label"] = _node_label(sealed, out["subgraph"])
|
||||
return out
|
||||
|
||||
|
||||
def _node_label(sealed: _Sealed, node_id: Any) -> str:
|
||||
"""封存的图里这个节点的名字,没有名字的写 id。"""
|
||||
"""封存的图里这个节点的名字,没有名字的写 id。子工作流里的节点(「子工作流节点 id/…」)不在图上,按 node.started 记的
|
||||
名字一层层接上:「子工作流 › 节点」。"""
|
||||
node = next((n for n in (sealed.graph or {}).get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id), {})
|
||||
return str((node.get("data") or {}).get("label") or node_id or "")
|
||||
if node or not isinstance(node_id, str) or "/" not in node_id:
|
||||
return str((node.get("data") or {}).get("label") or node_id or "")
|
||||
parts = node_id.split("/")
|
||||
names = [_node_label(sealed, parts[0])]
|
||||
for i in range(2, len(parts) + 1):
|
||||
here = "/".join(parts[:i])
|
||||
names.append(sealed.labels.get(here) or parts[i - 1])
|
||||
return " › ".join(names)
|
||||
|
||||
|
||||
def _untraced_steps(metric: dict[str, Any], sealed: _Sealed) -> list[dict[str, Any]]:
|
||||
def _untraced_steps(metric: dict[str, Any], sealed: _Sealed, card_node: str = "") -> list[dict[str, Any]]:
|
||||
"""指标的输入里追不到原始数据的:[{path, node_id, label, type}],label 是封存的图里那个节点的名字、type 是节点类型
|
||||
(面板按它写「数据整形」「沙箱代码」)。认不出是哪个节点的,node_id、label 为 None。"""
|
||||
(面板按它写「数据整形」「沙箱代码」)。认不出是哪个节点的,node_id、label 为 None。节点 id 按卡所在那一层认
|
||||
(_scoped)。"""
|
||||
nodes = {n.get("id"): n for n in (sealed.graph or {}).get("nodes") or [] if isinstance(n, dict)}
|
||||
out = []
|
||||
for item in untraced(metric.get("inputs") or []):
|
||||
node_id = item.get("node_id")
|
||||
node_id = _scoped(card_node, item.get("node_id"))
|
||||
node = nodes.get(node_id) or {}
|
||||
out.append({"path": item.get("path"), "node_id": node_id,
|
||||
"label": _node_label(sealed, node_id) if node_id else None,
|
||||
"type": node.get("type") or None})
|
||||
"type": node.get("type") or sealed.types.get(str(node_id or "")) or None})
|
||||
return out
|
||||
|
||||
|
||||
@@ -1495,6 +1541,8 @@ class _InputMasks:
|
||||
return None, True
|
||||
|
||||
def _output(self, node_id: Any) -> Any:
|
||||
# 卡的取值链里写的是卡所在那一层图里的 id(子工作流里的卡写子图里的 id),换成这次运行里的再找
|
||||
node_id = _scoped(self._card, node_id)
|
||||
if self._given is not None:
|
||||
return self._given(node_id)
|
||||
if self._outputs is None:
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
- 应用检查(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
|
||||
|
||||
@@ -140,20 +150,52 @@ class Stop:
|
||||
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(nodes, path, _depth)
|
||||
stop = _static_stop(scope, path, depth)
|
||||
if stop is not None:
|
||||
out.append(stop)
|
||||
return out
|
||||
@@ -179,16 +221,17 @@ def _copied_metric(nodes: Mapping[str, Mapping[str, Any]], path: str) -> tuple[s
|
||||
return writers[0], str(metrics[index]["expression"])
|
||||
|
||||
|
||||
def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str, depth: int = 0) -> Stop | None:
|
||||
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 static_stops(nodes, expression, _depth=depth + 1):
|
||||
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)]
|
||||
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)
|
||||
@@ -197,7 +240,7 @@ def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str, depth: int =
|
||||
return None
|
||||
head = _REF_HEAD.match(path)
|
||||
if head and head.group(1) == "input":
|
||||
return None
|
||||
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)
|
||||
@@ -205,13 +248,84 @@ def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str, depth: int =
|
||||
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)
|
||||
|
||||
@@ -147,9 +147,17 @@ class Subgraphs(dict):
|
||||
self.pin_problems: list[Any] = []
|
||||
|
||||
|
||||
#: 嵌套的子工作流往下读几层:和运行时的嵌套上限(multi._MAX_DEPTH)一样,再深的运行时本来就拒
|
||||
_LOAD_DEPTH = 3
|
||||
|
||||
|
||||
async def load_subgraphs(session: Any, spec: GraphSpec) -> Subgraphs:
|
||||
"""读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图)。读不到、不成形的跳过。
|
||||
固定的版本能不能用也一并读好(Subgraphs.pin_problems)。"""
|
||||
"""读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图),子图里再嵌套的一层层往下读(最多
|
||||
_LOAD_DEPTH 层,同一张图只读一次,互相引用的不会转圈)。读不到、不成形的跳过。固定的版本能不能用也一并读好
|
||||
(Subgraphs.pin_problems,只看这张图自己的子工作流节点)。
|
||||
|
||||
拿去用的:人工节点在子工作流里(_subgraph_humans)、口径卡的输入跨子工作流追到原始数据(engine/lineage 的静态认法,
|
||||
subgraph_spec)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.apps.versions import subgraph_pin_problems
|
||||
@@ -157,24 +165,39 @@ async def load_subgraphs(session: Any, spec: GraphSpec) -> Subgraphs:
|
||||
|
||||
out = Subgraphs()
|
||||
out.pin_problems = await subgraph_pin_problems(session, spec)
|
||||
for node in spec.nodes:
|
||||
if node.type != NodeType.SUBGRAPH:
|
||||
continue
|
||||
wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version")
|
||||
version = int(pinned) if str(pinned or "").isdigit() else None
|
||||
if not wid or (str(wid), version) in out:
|
||||
continue
|
||||
query = (select(WorkflowVersion.graph).where(WorkflowVersion.workflow_id == str(wid),
|
||||
WorkflowVersion.version == version)
|
||||
if version is not None else select(Workflow.graph).where(Workflow.id == str(wid)))
|
||||
graph = (await session.execute(query)).scalar_one_or_none()
|
||||
try:
|
||||
out[(str(wid), version)] = GraphSpec.model_validate(graph)
|
||||
except Exception: # noqa: BLE001 - 读不到、不成形:子工作流节点运行时自己会说清楚
|
||||
continue
|
||||
todo = [(spec, 0)]
|
||||
while todo:
|
||||
current, level = todo.pop(0)
|
||||
for node in current.nodes:
|
||||
if node.type != NodeType.SUBGRAPH:
|
||||
continue
|
||||
wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version")
|
||||
version = int(pinned) if str(pinned or "").isdigit() else None
|
||||
if not wid or (str(wid), version) in out:
|
||||
continue
|
||||
query = (select(WorkflowVersion.graph).where(WorkflowVersion.workflow_id == str(wid),
|
||||
WorkflowVersion.version == version)
|
||||
if version is not None else select(Workflow.graph).where(Workflow.id == str(wid)))
|
||||
graph = (await session.execute(query)).scalar_one_or_none()
|
||||
try:
|
||||
out[(str(wid), version)] = GraphSpec.model_validate(graph)
|
||||
except Exception: # noqa: BLE001 - 读不到、不成形:子工作流节点运行时自己会说清楚
|
||||
continue
|
||||
if level + 1 < _LOAD_DEPTH:
|
||||
todo.append((out[(str(wid), version)], level + 1))
|
||||
return out
|
||||
|
||||
|
||||
def subgraph_spec(config: Mapping[str, Any]) -> GraphSpec | None:
|
||||
"""子工作流节点(配置 config)引用的那一版图:load_subgraphs 读好、with_subgraphs 放进来的;没放进来、没读到的为 None。"""
|
||||
graphs = _SUBGRAPHS.get()
|
||||
if graphs is None:
|
||||
return None
|
||||
wid, pinned = config.get("workflow_id"), config.get("workflow_version")
|
||||
version = int(pinned) if str(pinned or "").isdigit() else None
|
||||
return graphs.get((str(wid or ""), version))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def with_subgraphs(graphs: Mapping[tuple[str, int | None], GraphSpec]) -> Iterator[None]:
|
||||
token = _SUBGRAPHS.set(graphs)
|
||||
@@ -222,12 +245,7 @@ def _agent_tool_sets(node: GraphNode) -> list[tuple[str, Mapping[str, Any], list
|
||||
|
||||
|
||||
def _subgraph_humans(spec: GraphSpec, node: GraphNode) -> list[str] | None:
|
||||
graphs = _SUBGRAPHS.get()
|
||||
if graphs is None:
|
||||
return None
|
||||
wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version")
|
||||
version = int(pinned) if str(pinned or "").isdigit() else None
|
||||
sub = graphs.get((str(wid or ""), version))
|
||||
sub = subgraph_spec(node.config)
|
||||
if sub is None:
|
||||
return None
|
||||
return [n.title for n in sub.nodes if n.type == NodeType.HUMAN]
|
||||
|
||||
@@ -351,8 +351,10 @@ def _untraced_gaps(metrics: list[dict[str, Any]], ctx: NodeContext) -> list[str]
|
||||
stops = untraced(m.get("inputs") or [])
|
||||
if not stops:
|
||||
continue
|
||||
where = "、".join(dict.fromkeys(f"{s.get('path')}(停在「{titles.get(s.get('node_id') or '', s.get('node_id') or '未知来源')}」)"
|
||||
for s in stops[:3]))
|
||||
# 停在子工作流里的(节点 id「子工作流节点 id/…」)说停在那个子工作流:这张图上看得到的是它,静态检查也这么说
|
||||
where = "、".join(dict.fromkeys(
|
||||
f"{s.get('path')}(停在「{titles.get(at, at or '未知来源')}」)"
|
||||
for s in stops[:3] for at in [str(s.get('node_id') or '').split('/', 1)[0]]))
|
||||
out.append(f"指标「{m.get('name') or m.get('id')}」的输入没追到原始数据:{where}")
|
||||
return out
|
||||
|
||||
|
||||
@@ -624,6 +624,9 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext,
|
||||
- vars.q.rows[0][0],q 由「只是把调用工具节点的结果原样按 JSON 解析」的整形节点写:同 cell(),via tool_cell,
|
||||
另记 through(穿过的整形节点)
|
||||
- agent 开了 cite_fields 的字段:via agent_field,接上它的 data_evidence(eid、核对状态)
|
||||
|
||||
读子工作流出口的一个字段(nodes.<子图>.output.<字段>、vars.<子图的 assign_to>.<字段>):接上子图记下的那个字段的出处
|
||||
(_subgraph_field),via 等照子图里认出的写,另记 subgraph、field;认不出的照旧记 via subgraph,停在子工作流。
|
||||
"""
|
||||
if (parts := cell_parts(path)) is not None:
|
||||
return _cell_input(path, value, parts, state, ctx, tctx or template_context(state))
|
||||
@@ -644,6 +647,9 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext,
|
||||
else:
|
||||
node_id = _writer_of(name, state, ctx)
|
||||
producer = nodes.get(node_id) if node_id else None
|
||||
if producer is not None and str(producer.type) == "subgraph" and head \
|
||||
and (linked := _subgraph_field(path, value, root, field, producer.id, state)) is not None:
|
||||
return linked
|
||||
entry: dict[str, Any] = {
|
||||
"path": path,
|
||||
"value": value if value is None or isinstance(value, (int, float, str, bool)) else _brief(value),
|
||||
@@ -667,6 +673,32 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext,
|
||||
return entry
|
||||
|
||||
|
||||
def _subgraph_field(path: str, value: Any, root: str, field: str, node_id: str,
|
||||
state: GraphState) -> dict[str, Any] | None:
|
||||
"""读子工作流出口的一个字段:接上子图记下的那个字段的出处(multi.run_subgraph 的 provenance,节点 id 已经是这一层图里
|
||||
的「子工作流节点 id/…」)。只认整个字段(nodes.<子图>.output.<字段>、vars.<assign_to>.<字段>,不再往里取),而且这次
|
||||
读到的值就是子图交出的那个;别的为 None,照一般的来历记(via subgraph,停在子工作流)。
|
||||
|
||||
接上的出处照子图里认出的写(via、node_id、artifact、locator、eid、status、upstream),path、value 换成这里读的,另记
|
||||
subgraph(经过的子工作流节点)、field(出口字段):engine/lineage.traced 照常认,证据面板照常一路给到子图里的那一格。"""
|
||||
result = (state.get("nodes") or {}).get(node_id)
|
||||
if not isinstance(result, dict) or not isinstance(result.get("provenance"), dict):
|
||||
return None
|
||||
key = _field_key(field)
|
||||
if root == "nodes":
|
||||
if not key.startswith("output."):
|
||||
return None
|
||||
key = key[len("output."):]
|
||||
elif root != "vars":
|
||||
return None
|
||||
output = result.get("output") if isinstance(result.get("output"), dict) else {}
|
||||
item = result["provenance"].get(key)
|
||||
if not key or not isinstance(item, dict) or key not in output or output[key] != value:
|
||||
return None
|
||||
return {**item, "path": path, "value": value if value is None or isinstance(value, (int, float, str, bool))
|
||||
else _brief(value), "subgraph": node_id, "field": key}
|
||||
|
||||
|
||||
def _copied_card(path: str, tctx: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
"""转抄另一张口径卡一个指标的输入(engine/lineage.metric_copy 认的写法):(那张卡这一次的产出, 那个指标)。不是这种
|
||||
写法、产出不是指标集、下标越界的为 None。"""
|
||||
@@ -702,9 +734,17 @@ def _upstream(path: str, tctx: dict[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def _copied_metric(item: dict[str, Any], tctx: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""卡上的一个输入转抄了另一张口径卡的哪个指标(这一次的产出里那一项);不是转抄的为 None。"""
|
||||
"""卡上的一个输入转抄了另一张口径卡的哪个指标(这一次的产出里那一项);不是转抄的为 None。
|
||||
|
||||
经子工作流出口转抄的(subgraph,子图出口字段就是子图里一张卡的指标):那张卡以「子工作流节点 id/卡」进了这一层的
|
||||
nodes(multi.run_subgraph),按 node_id 和 upstream 记的指标 id 找。"""
|
||||
if item.get("via") != "metric" or not isinstance(item.get("upstream"), dict):
|
||||
return None
|
||||
if item.get("subgraph"):
|
||||
card = (tctx.get("nodes") or {}).get(str(item.get("node_id") or ""))
|
||||
metrics = card.get("metrics") if isinstance(card, dict) and card.get("kind") == "metric_set" else None
|
||||
wanted = item["upstream"].get("metric")
|
||||
return next((m for m in metrics or [] if isinstance(m, dict) and m.get("id") == wanted), None)
|
||||
found = _copied_card(str(item.get("path") or ""), tctx)
|
||||
return found[1] if found is not None else None
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ from app.core.errors import describe_exception, raw_detail
|
||||
from app.core.events import EventType
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Workflow
|
||||
from app.engine.context import NodeContext, NodeError
|
||||
from app.engine.context import PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer
|
||||
from app.engine.lineage import whole_ref
|
||||
from app.engine.state import GraphState, message_text, template_context
|
||||
from app.engine.toolcalls import (
|
||||
TOOL_MARKUP_NUDGE,
|
||||
@@ -616,8 +617,17 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
"""嵌套执行另一个工作流。
|
||||
|
||||
子图共享同一个 run 的事件流(节点 id 会加前缀),所以在时间线上能看到它内部
|
||||
每一步;但它有独立的状态,不会污染父图的变量池。
|
||||
子图共享同一个 run 的事件流:节点 id 加前缀「子工作流节点 id/」(嵌套的一层层接上),事件经父运行交下来的写入口发
|
||||
(engine/context.STREAM_KEY;以前只设了前缀没用上,子图里每一步在运行的事件里一条都没有),时间线上看得到它内部每一步,
|
||||
封存也就覆盖了它查过的数。它有独立的状态,不会污染父图的变量池。
|
||||
|
||||
证据交回父图(docs/design/orchestration-round/00-总方案.md §2),三样:
|
||||
- 台账:子图记下的条目(查询快照、口径卡的指标集……)节点 id 加上前缀并进父图的 evidence,父图报告撰写的目录据此收进
|
||||
子图里的查询和口径卡(report.report_catalog),cell(nodes.<子图>.output.<原样交出的查询结果>, …) 也按台账认得出快照;
|
||||
- 口径卡:子图里口径卡的产出以「子工作流节点 id/卡」为 id 进父图的 nodes(目录要卡上的指标值;父图转抄它的指标时认得
|
||||
出那张卡、带上不完整和存疑);
|
||||
- 出处:出口的每个字段按出口模板认它来自哪里(_output_provenance),记在产出的 provenance 上;父图里读
|
||||
nodes.<子图>.output.<字段> 的口径卡据此接上子图里的那一格(metrics._subgraph_field),engine/lineage 同一套认法。
|
||||
"""
|
||||
from app.engine.compiler import compile_graph # 延迟导入,避免循环依赖
|
||||
from app.engine.schema import GraphSpec, loop_steps
|
||||
@@ -678,9 +688,10 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
approval_default=ctx.run.approval_default,
|
||||
tool_trust=ctx.run.tool_trust,
|
||||
agent_limits=ctx.run.agent_limits,
|
||||
# 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)
|
||||
# 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context:
|
||||
# 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀
|
||||
extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")},
|
||||
"node_prefix": f"{ctx.node.id}/"},
|
||||
PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", STREAM_KEY: stream_writer(ctx.run)},
|
||||
)
|
||||
|
||||
ctx.emit(EventType.LOG, level="info",
|
||||
@@ -711,6 +722,13 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
f"子工作流「{workflow.name}」已执行 {limit} 步仍未结束,已中止。可能存在没有轮数上限的环路,"
|
||||
"请在子工作流中用「循环」节点限制轮数",
|
||||
) from e
|
||||
except NodeError as e:
|
||||
# 子图里的节点报的错(含被驳回):节点 id 换成这一层图里的 id(「子工作流节点 id/…」),名字接上子工作流的名字。
|
||||
# 以前带的是子图里的 id,运行记下的出错节点会和父图里同名的节点混在一起
|
||||
titles = sub_spec.node_map()
|
||||
inner = getattr(e, "label", None) or (titles[e.node_id].title if e.node_id in titles else e.node_id)
|
||||
e.node_id, e.label = f"{ctx.node.id}/{e.node_id}", f"{ctx.node.title} › {inner}"
|
||||
raise
|
||||
|
||||
output = result_state.get("output") or {}
|
||||
result = {
|
||||
@@ -721,13 +739,94 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
"pinned": bool(pinned),
|
||||
"text": json.dumps(output, ensure_ascii=False) if output else "",
|
||||
}
|
||||
if provenance := _output_provenance(sub_spec, sub_run, result_state, output, state, ctx):
|
||||
# 出口每个字段的出处(没有能认的字段时不带这个键,产出和以前一字不差)
|
||||
result["provenance"] = provenance
|
||||
ctx.emit(EventType.LOG, level="info", message=f"子工作流「{workflow.name}」完成")
|
||||
|
||||
sub_nodes = result_state.get("nodes") or {}
|
||||
cards = {f"{ctx.node.id}/{nid}": payload for nid, payload in sub_nodes.items()
|
||||
if isinstance(payload, dict) and payload.get("kind") == "metric_set"}
|
||||
updates: dict[str, Any] = {
|
||||
"nodes": {ctx.node.id: result},
|
||||
"nodes": {ctx.node.id: result, **cards},
|
||||
"usage": result_state.get("usage") or {},
|
||||
}
|
||||
if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]:
|
||||
updates["evidence"] = ledger
|
||||
var_name = ctx.cfg("assign_to", "")
|
||||
if var_name:
|
||||
updates["vars"] = {var_name: output}
|
||||
return updates
|
||||
|
||||
|
||||
#: 台账条目里写着节点 id 的键:交回父图时一并加前缀(双盲复算的条目记着它复算的口径卡)
|
||||
_LEDGER_NODE_KEYS = ("node_id", "card")
|
||||
|
||||
|
||||
def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
"""子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。"""
|
||||
out = dict(entry)
|
||||
for key in _LEDGER_NODE_KEYS:
|
||||
if isinstance(out.get(key), str) and out[key]:
|
||||
out[key] = f"{node_id}/{out[key]}"
|
||||
return out
|
||||
|
||||
|
||||
def _output_provenance(sub_spec: Any, sub_run: Any, sub_state: dict[str, Any], output: dict[str, Any],
|
||||
state: GraphState, ctx: NodeContext) -> dict[str, dict[str, Any]]:
|
||||
"""子图出口每个字段的出处:{字段: 口径卡上一个输入的样子(via、node_id、artifact、locator、status、upstream……)}。
|
||||
|
||||
认法和口径卡认它的输入同一个(metrics._input_of),在子图的状态里认:字段的模板整串就是一个取值链(whole_ref),而且
|
||||
渲染出来的值正是那个值转成的文字,才算这个字段就是那个值;拼了别的字、过了过滤器的不认(不在结果里)。几个成果节点
|
||||
写同一个字段时按最后跑的那个。认出来的节点 id 换成父图这一层的(加「子工作流节点 id/」前缀)。
|
||||
|
||||
出自子图运行输入的(via input)再往父图追一层:子图的输入是父图按子工作流节点的「输入」模板渲染出来的,那个模板整串
|
||||
也是一个取值链时,按父图的状态接着认(追到父图的运行输入、查询格……);否则记 via subgraph_input,停在子工作流的
|
||||
入口——子图的运行输入对父图不是原始数据。"""
|
||||
from app.engine.expressions import _stringify
|
||||
from app.engine.nodes.metrics import _input_of, _value_of
|
||||
from app.engine.schema import NodeType
|
||||
|
||||
outputs = {n.id: n for n in sub_spec.nodes if n.type == NodeType.OUTPUT}
|
||||
fields: dict[str, tuple[Any, str]] = {}
|
||||
for step in sub_state.get("trail") or []:
|
||||
exit_node = outputs.get(step.get("node_id")) if "error" not in step else None
|
||||
for field in (exit_node.config.get("fields") or []) if exit_node is not None else []:
|
||||
if isinstance(field, dict) and field.get("name"):
|
||||
fields[str(field["name"])] = (exit_node, field.get("value", ""))
|
||||
tctx = template_context(sub_state)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for name, (exit_node, template) in fields.items():
|
||||
path = whole_ref(template)
|
||||
if path is None or name not in output:
|
||||
continue
|
||||
value = _value_of(path, tctx)
|
||||
if _stringify(value) != output[name]:
|
||||
continue
|
||||
item = _input_of(path, value, sub_state, NodeContext(node=exit_node, run=sub_run), tctx)
|
||||
out[name] = _lifted(item, state, ctx)
|
||||
return out
|
||||
|
||||
|
||||
def _lifted(item: dict[str, Any], state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
"""子图里认出的一个出处换到父图这一层:节点 id 加前缀;出自子图运行输入的,按子工作流节点的「输入」模板往父图追。"""
|
||||
from app.engine.expressions import _stringify
|
||||
from app.engine.nodes.metrics import _REF_HEAD, _input_of, _value_of
|
||||
|
||||
if item.get("via") == "input":
|
||||
head = _REF_HEAD.match(str(item.get("path") or ""))
|
||||
name = head.group(2) if head else ""
|
||||
templates = ctx.cfg("input", {}) or {}
|
||||
path = whole_ref(templates.get(name)) if isinstance(templates, dict) and name else None
|
||||
if path is not None:
|
||||
parent = template_context(state)
|
||||
value = _value_of(path, parent)
|
||||
if _stringify(value) == _stringify(item.get("value")):
|
||||
return _input_of(path, value, state, ctx, parent)
|
||||
return {"path": item.get("path"), "value": item.get("value"), "node_id": ctx.node.id, "via": "subgraph_input",
|
||||
**({"field": name} if name else {}), "status": item.get("status") or "ok"}
|
||||
out = dict(item)
|
||||
for key in ("node_id", "through", "subgraph"):
|
||||
if isinstance(out.get(key), str) and out[key]:
|
||||
out[key] = f"{ctx.node.id}/{out[key]}"
|
||||
return out
|
||||
|
||||
@@ -37,6 +37,7 @@ import asyncio
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
|
||||
@@ -140,16 +141,18 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[
|
||||
if isinstance(sources, str) and sources != "ancestors":
|
||||
sources = [sources]
|
||||
if isinstance(sources, list):
|
||||
# evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管
|
||||
# evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。
|
||||
# 写了子工作流节点的,它交回来的(子图里的查询,节点 id「子工作流节点 id/…」)一并算
|
||||
keep = {str(s) for s in sources}
|
||||
ledger = [e for e in ledger
|
||||
if e.get("kind") not in ("query", "retrieval", "schema") or e.get("node_id") in keep]
|
||||
if e.get("kind") not in ("query", "retrieval", "schema") or _outer(e.get("node_id")) in keep]
|
||||
nodes = state.get("nodes") or {}
|
||||
catalog = build_catalog(
|
||||
nodes=state.get("nodes") or {},
|
||||
nodes=nodes,
|
||||
ledger=ledger,
|
||||
inputs=state.get("input") or {},
|
||||
metrics_from=_setting(spec, node, "metrics_from") or None,
|
||||
allowed=_ancestors(spec, node.id),
|
||||
allowed=with_subgraphs(_ancestors(spec, node.id), [*nodes, *(e.get("node_id") for e in ledger)]),
|
||||
)
|
||||
if report_entities(spec, node) == "off":
|
||||
# 目录里没有表和字段,写作目录也就不列;compose / verify 另传 entities=False,解析不了的原因照实说
|
||||
@@ -161,6 +164,20 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[
|
||||
return catalog
|
||||
|
||||
|
||||
def _outer(node_id: Any) -> str:
|
||||
"""节点 id 在这一层图上是哪个节点:子工作流交回来的「子工作流节点 id/…」是那个子工作流节点,别的原样。"""
|
||||
return str(node_id or "").split("/", 1)[0]
|
||||
|
||||
|
||||
def with_subgraphs(allowed: set[str], ids: Iterable[Any]) -> set[str]:
|
||||
"""报告收证据的范围(祖先节点)加上范围里的子工作流交回来的那些(multi.run_subgraph:台账条目、口径卡的节点 id 是
|
||||
「子工作流节点 id/…」,嵌套的一层层接上):子工作流在报告上游,它里面查过的数、算过的口径卡也在上游
|
||||
(docs/design/orchestration-round/00-总方案.md §2)。"""
|
||||
out = set(allowed)
|
||||
out.update(i for i in ids if isinstance(i, str) and "/" in i and _outer(i) in allowed)
|
||||
return out
|
||||
|
||||
|
||||
def _masked_word() -> str:
|
||||
"""「已遮罩」:证据面板、推演同一个说法。api.evidence 导入了引擎里的模块,用到时再导入。"""
|
||||
from app.api.evidence import MASKED
|
||||
|
||||
@@ -1360,7 +1360,7 @@ def rejected_data(exc: NodeRejected, spec: GraphSpec) -> dict[str, Any]:
|
||||
"""被驳回的 run.cancelled 带什么:原因(rejected)、在哪一步(节点 id 和图上的名字)、哪种关卡、结论(驳回 / 超时
|
||||
驳回)、谁、备注。进封存:谁在哪一步驳回、为什么,事后查得到。"""
|
||||
node = spec.node_map().get(exc.node_id)
|
||||
return {"reason": END_REJECTED, "node_id": exc.node_id, "label": node.title if node else exc.node_id,
|
||||
return {"reason": END_REJECTED, "node_id": exc.node_id, "label": _label_of(exc, node),
|
||||
"kind": exc.kind, "decision": exc.decision, "actor": exc.actor, "note": exc.note}
|
||||
|
||||
|
||||
@@ -1390,10 +1390,18 @@ def _failure(exc: BaseException, spec: GraphSpec) -> tuple[str, tuple[EventType,
|
||||
data["detail"] = detail
|
||||
if node_id:
|
||||
node = spec.node_map().get(node_id)
|
||||
data.update(node_id=node_id, label=node.title if node else node_id)
|
||||
data.update(node_id=node_id, label=_label_of(exc, node))
|
||||
return message, (EventType.RUN_FAILED, data)
|
||||
|
||||
|
||||
def _label_of(exc: BaseException, node: Any) -> str:
|
||||
"""出错、被驳回的那一步在图上叫什么。子工作流里的那一步(节点 id「子工作流节点 id/…」)不在这张图上,名字由
|
||||
multi.run_subgraph 接好记在异常上(「子工作流 › 那一步」)。"""
|
||||
if node is not None:
|
||||
return node.title
|
||||
return str(getattr(exc, "label", None) or getattr(exc, "node_id", "") or "")
|
||||
|
||||
|
||||
async def _approval_default(*, run_class: str) -> str:
|
||||
"""工具审批策略的全局默认,取自设置里的「危险工具默认需要人工确认」。
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""子工作流的证据交回父图(docs/design/orchestration-round/00-总方案.md §2)。
|
||||
|
||||
以前子图只把出口的值交回父图:子图里的查询快照、口径卡留在子图自己的状态里,子图里每一步在运行的事件里一条都没有(只设了
|
||||
节点 id 前缀没用上,LangGraph 的 ainvoke 不把子图的 custom 事件交给父图的流),父图报告引用不到子图里的数,父图口径卡读
|
||||
子图出口的字段一律停在子工作流节点。现在:
|
||||
- 子图里每一步进同一个运行的事件流,节点 id「子工作流节点 id/…」,封存覆盖它们;
|
||||
- 子图的台账(查询快照、口径卡)交回父图:父图报告的目录收进它们,引用标记、证据面板一路点到子图里的那一格;
|
||||
- 子图出口的每个字段带出处,父图口径卡读它时跨子图往下追:追到原始数据的完整出具,子图里追不到的父图照样降档;
|
||||
静态检查(发布前、应用检查)和运行时同一套认法;嵌套两层照样;
|
||||
- 子图里的遮罩照父图的运行。
|
||||
数据是虚构店铺库(tests/ledger_samples.py)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import RunEvent
|
||||
from app.engine.lineage import traced, untraced
|
||||
from app.engine.runner import run_manager
|
||||
from tests import ledger_samples as S
|
||||
|
||||
PAID = "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid'"
|
||||
GMV = {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "expression": "cell(nodes.fetch, 0, 'gmv')"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def engine_up(tmp_path):
|
||||
await run_manager.setup()
|
||||
await S.fresh_source(tmp_path / "shop.db")
|
||||
yield
|
||||
await run_manager.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
from app.main import app
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
def node(nid: str, ntype: str, label: str | None = None, **config) -> dict:
|
||||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
|
||||
|
||||
|
||||
def metric(mid: str, expression: str, name: str = "") -> dict:
|
||||
return {"id": mid, "name": name or mid, "unit": "元", "decimals": 2, "expression": expression}
|
||||
|
||||
|
||||
def fetch_card_graph(*, card: dict | None = None, out: list[dict] | None = None, sql: str = PAID) -> dict:
|
||||
"""子工作流:取数 → 口径卡 → 出口(gmv 原样交出口径卡的指标,table 原样交出查询结果)。"""
|
||||
return S.chain(
|
||||
node("s", "input"),
|
||||
node("fetch", "tool", tool=S.tool(), args={"sql": sql}),
|
||||
card or node("card", "metrics", caliber="子口径", caliber_version="v1", metrics=[GMV]),
|
||||
node("o", "output", fields=out or [{"name": "gmv", "value": "{{ nodes.card.metrics[0].value }}"},
|
||||
{"name": "table", "value": "{{ nodes.fetch }}"}]))
|
||||
|
||||
|
||||
async def publish(graph: dict, name: str = "子流程") -> str:
|
||||
return await S.publish(name, graph=graph)
|
||||
|
||||
|
||||
def parent(sub_id: str, *middle: dict, contract_from: str | None = "top", out: list[dict] | None = None,
|
||||
sub_input: dict | None = None) -> dict:
|
||||
return S.chain(
|
||||
node("start", "input", fields=[{"name": "region"}]),
|
||||
node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1,
|
||||
**({"input": sub_input} if sub_input else {})),
|
||||
*middle,
|
||||
node("out", "output", fields=out or [{"name": "数", "value": "{{ nodes.top.metrics[0].value }}"}],
|
||||
**({"contract": {"metrics_from": contract_from}} if contract_from else {})))
|
||||
|
||||
|
||||
async def run(graph: dict, inputs: dict | None = None):
|
||||
started = await run_manager.start(graph=graph, input_payload=inputs or {})
|
||||
row = await S.wait(started.id)
|
||||
assert row.status == "succeeded", row.error
|
||||
return row
|
||||
|
||||
|
||||
async def events(run_id: str) -> list[RunEvent]:
|
||||
async with SessionLocal() as session:
|
||||
return list((await session.execute(select(RunEvent).where(RunEvent.run_id == run_id)
|
||||
.order_by(RunEvent.seq))).scalars())
|
||||
|
||||
|
||||
async def card(run_id: str, node_id: str) -> dict:
|
||||
from app.api.evidence import _sealed, _sealed_cards
|
||||
|
||||
sealed = await _sealed(run_id)
|
||||
[found] = [c for n, _, c in _sealed_cards(sealed) if n == node_id]
|
||||
return {m["id"]: m for m in found["metrics"]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 子图里每一步进同一个运行的事件流
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_subgraph_steps_are_in_the_run_with_prefixed_ids():
|
||||
sub_id = await publish(fetch_card_graph())
|
||||
row = await run(parent(sub_id, contract_from=None, out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}]))
|
||||
evs = await events(row.id)
|
||||
started = [(e.node_id, e.data.get("label")) for e in evs if e.type == "node.started"]
|
||||
assert [n for n, _ in started] == ["start", "sub", "sub/s", "sub/fetch", "sub/card", "sub/o", "out"]
|
||||
assert dict(started)["sub/fetch"] == "fetch"
|
||||
[end] = [e for e in evs if e.type == "tool.end" and e.node_id == "sub/fetch"]
|
||||
assert end.data.get("query_artifact")
|
||||
# 子图里的台账条目在子图那一步的 node.finished 上(节点 id 带前缀),子工作流节点自己的 node.finished 不再记一遍
|
||||
finished = {e.node_id: e for e in evs if e.type == "node.finished"}
|
||||
assert [x["kind"] for x in finished["sub/fetch"].data["evidence"]][:1] == ["tool"]
|
||||
assert "evidence" not in finished["sub"].data
|
||||
# 封存覆盖它们
|
||||
assert row.manifest_seq == evs[-1].seq
|
||||
assert row.output == {"数": "246.5"}
|
||||
|
||||
|
||||
async def test_the_instance_timeline_counts_the_subgraph_as_one_step():
|
||||
"""应用的实例进度只说这张图上的步骤:子图里的那几步(「sub/…」)不单列,子工作流算一步。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.apps.resolve import _timeline
|
||||
from app.db.models import Run
|
||||
|
||||
sub_id = await publish(fetch_card_graph())
|
||||
row = await run(parent(sub_id, contract_from=None, out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}]))
|
||||
async with SessionLocal() as session:
|
||||
steps = (await _timeline(session, SimpleNamespace(instance=await session.get(Run, row.id))))["steps"]
|
||||
assert [s["node"] for s in steps] == ["start", "sub", "out"]
|
||||
|
||||
|
||||
async def test_an_error_inside_the_subgraph_names_the_step_inside_it():
|
||||
sub_id = await publish(fetch_card_graph(sql="SELECT nope FROM nowhere"))
|
||||
started = await run_manager.start(graph=parent(sub_id, contract_from=None,
|
||||
out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}]),
|
||||
input_payload={})
|
||||
row = await S.wait(started.id)
|
||||
assert row.status == "failed" and row.error_node_id == "sub/fetch"
|
||||
[failed] = [e for e in await events(row.id) if e.type == "run.failed"]
|
||||
assert (failed.data["node_id"], failed.data["label"]) == ("sub/fetch", "取数子流程 › fetch")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 父图口径卡读子图出口的字段:跨子图往下追
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_parent_card_reading_a_subgraph_field_traces_to_the_query_cell_and_issues_formally(client):
|
||||
sub_id = await publish(fetch_card_graph())
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1",
|
||||
metrics=[metric("gmv", "float(nodes.sub.output.gmv)"), metric("raw", "cell(nodes.sub.output.table, 0, 'gmv')")])
|
||||
row = await run(parent(sub_id, top))
|
||||
metrics = await card(row.id, "top")
|
||||
[copy] = metrics["gmv"]["inputs"]
|
||||
# 子图出口 gmv 原样交出子图口径卡的指标:接上那个指标自己的出处,一路追到取数查询
|
||||
assert (copy["via"], copy["node_id"], copy["subgraph"], copy["field"]) == ("metric", "sub/card", "sub", "gmv")
|
||||
assert copy["path"] == "nodes.sub.output.gmv" and copy["value"] == "246.5"
|
||||
assert copy["upstream"]["traced"] is True and traced(copy)
|
||||
# 子图出口 table 原样交出查询结果:cell() 按台账认出是子图里的那份快照
|
||||
[cell] = metrics["raw"]["inputs"]
|
||||
assert (cell["via"], cell["node_id"], cell["status"]) == ("tool_cell", "sub/fetch", "verified")
|
||||
assert untraced(metrics["gmv"]["inputs"] + metrics["raw"]["inputs"]) == []
|
||||
assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]["gaps"]
|
||||
|
||||
# 证据面板:转抄的那一项后面接一步 upstream(子图里的卡),一直到子图里的取数查询
|
||||
body = (await client.get(f"/api/runs/{row.id}/evidence/metrics/top/gmv")).json()
|
||||
chain = body["chain"]
|
||||
assert chain[0]["untraced"] == []
|
||||
[inp] = [s for s in chain if s["step"] == "input"]
|
||||
assert inp["subgraph"] == "sub" and inp["subgraph_label"] == "取数子流程"
|
||||
assert inp["node_id"] == "sub/card" and inp["node_label"] == "取数子流程 › card"
|
||||
[up] = [s for s in chain if s["step"] == "upstream"]
|
||||
assert (up["node_id"], up["label"], up["sealed"], up["traced"]) == ("sub/card", "取数子流程 › card", True, True)
|
||||
[read] = up["inputs"]
|
||||
assert read["node_id"] == "sub/fetch" and read["node_label"] == "取数子流程 › fetch"
|
||||
[query] = [s for s in chain if s["step"] == "query"]
|
||||
assert query["artifact"] == read["artifact"] and query["node_id"] == "sub/fetch" and query["sealed"] is True
|
||||
|
||||
|
||||
async def test_an_untraced_field_inside_the_subgraph_degrades_the_parent():
|
||||
inner = node("card", "metrics", caliber="子口径", caliber_version="v1", metrics=[metric("gmv", "vars.calc.v")])
|
||||
sub = S.chain(node("s", "input"),
|
||||
node("calc", "transform", mode="json", template='{"v": 5}', assign_to="calc"), inner,
|
||||
node("o", "output", fields=[{"name": "gmv", "value": "{{ nodes.card.metrics[0].value }}"}]))
|
||||
sub_id = await publish(sub)
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("gmv", "float(nodes.sub.output.gmv)")])
|
||||
row = await run(parent(sub_id, top))
|
||||
[copy] = (await card(row.id, "top"))["gmv"]["inputs"]
|
||||
assert copy["upstream"]["traced"] is False and not traced(copy)
|
||||
issuance = row.output["_issuance"]
|
||||
assert issuance["tier"] == "degraded"
|
||||
assert any("指标「gmv」的输入没追到原始数据:nodes.sub.output.gmv(停在「取数子流程」)" in g
|
||||
for g in issuance["gaps"]), issuance["gaps"]
|
||||
|
||||
|
||||
async def test_a_field_that_is_not_a_whole_reference_stops_at_the_subgraph():
|
||||
sub_id = await publish(fetch_card_graph(out=[{"name": "gmv", "value": "合计 {{ nodes.card.metrics[0].value }}"}]))
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("n", "len(nodes.sub.output.gmv)")])
|
||||
row = await run(parent(sub_id, top))
|
||||
[item] = (await card(row.id, "top"))["n"]["inputs"]
|
||||
assert (item["via"], item["node_id"]) == ("subgraph", "sub") and not traced(item)
|
||||
assert row.output["_issuance"]["tier"] == "degraded"
|
||||
|
||||
|
||||
async def test_the_subgraph_run_input_is_traced_back_through_the_parent_input_template():
|
||||
"""子图出口交出它的运行输入:子图的输入是父图按「输入」模板渲染的。模板整串就是父图的运行输入时追到原始数据;拼了
|
||||
别的字的停在子工作流的入口。"""
|
||||
sub = S.chain(node("s", "input", fields=[{"name": "region"}]),
|
||||
node("o", "output", fields=[{"name": "region", "value": "{{ input.region }}"}]))
|
||||
sub_id = await publish(sub)
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("r", "nodes.sub.output.region")])
|
||||
row = await run(parent(sub_id, top, sub_input={"region": "{{ input.region }}"}), {"region": "east"})
|
||||
[item] = (await card(row.id, "top"))["r"]["inputs"]
|
||||
assert (item["via"], item["path"], item["subgraph"]) == ("input", "nodes.sub.output.region", "sub") and traced(item)
|
||||
assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]["gaps"]
|
||||
|
||||
row = await run(parent(sub_id, top, sub_input={"region": "{{ input.region }}区"}), {"region": "east"})
|
||||
[item] = (await card(row.id, "top"))["r"]["inputs"]
|
||||
assert (item["via"], item["node_id"], item["field"]) == ("subgraph_input", "sub", "region") and not traced(item)
|
||||
|
||||
|
||||
async def test_two_levels_of_nesting_trace_all_the_way_down(client):
|
||||
inner_id = await publish(fetch_card_graph(), "内层")
|
||||
middle = S.chain(node("m_in", "input"),
|
||||
node("inner", "subgraph", "内层", workflow_id=inner_id, workflow_version=1),
|
||||
node("m_out", "output", fields=[{"name": "gmv", "value": "{{ nodes.inner.output.gmv }}"}]))
|
||||
middle_id = await publish(middle, "中层")
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("gmv", "float(nodes.sub.output.gmv)")])
|
||||
row = await run(parent(middle_id, top))
|
||||
evs = await events(row.id)
|
||||
assert "sub/inner/fetch" in {e.node_id for e in evs if e.type == "node.finished"}
|
||||
[copy] = (await card(row.id, "top"))["gmv"]["inputs"]
|
||||
assert (copy["via"], copy["node_id"], copy["subgraph"]) == ("metric", "sub/inner/card", "sub")
|
||||
assert copy["upstream"]["traced"] is True
|
||||
assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]["gaps"]
|
||||
chain = (await client.get(f"/api/runs/{row.id}/evidence/metrics/top/gmv")).json()["chain"]
|
||||
[up] = [s for s in chain if s["step"] == "upstream"]
|
||||
assert up["node_id"] == "sub/inner/card" and up["sealed"] is True and up["label"] == "取数子流程 › 内层 › card"
|
||||
[query] = [s for s in chain if s["step"] == "query"]
|
||||
assert query["node_id"] == "sub/inner/fetch" and query["sealed"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 静态检查:和运行时同一套认法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _static(graph: dict) -> list[str]:
|
||||
"""受管级按指标出具的发布检查里「指标追不到原始数据」的那几条(governed.metric_untraced),子工作流读好放进来。"""
|
||||
from app.engine.governance import lint_for_publish
|
||||
from app.engine.lint_process import load_subgraphs, with_subgraphs
|
||||
from app.engine.schema import GraphSpec
|
||||
|
||||
spec = GraphSpec.model_validate(graph)
|
||||
async with SessionLocal() as session:
|
||||
graphs = await load_subgraphs(session, spec)
|
||||
with with_subgraphs(graphs):
|
||||
issues = lint_for_publish(spec, level="governed").issues
|
||||
return [i.message for i in issues if i.code == "governed.metric_untraced"]
|
||||
|
||||
|
||||
async def test_the_static_check_agrees_with_the_run():
|
||||
from app.engine.governance import lint_for_publish
|
||||
from app.engine.schema import GraphSpec
|
||||
|
||||
traced_sub = await publish(fetch_card_graph())
|
||||
inner = node("card", "metrics", caliber="子口径", caliber_version="v1", metrics=[metric("gmv", "vars.calc.v")])
|
||||
loose_sub = await publish(S.chain(
|
||||
node("s", "input"), node("calc", "transform", mode="json", template='{"v": 5}', assign_to="calc"), inner,
|
||||
node("o", "output", fields=[{"name": "gmv", "value": "{{ nodes.card.metrics[0].value }}"}])), "松的")
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1",
|
||||
metrics=[metric("gmv", "float(nodes.sub.output.gmv)"), metric("raw", "cell(nodes.sub.output.table, 0, 'gmv')")])
|
||||
good = parent(traced_sub, top)
|
||||
bad = parent(loose_sub, node("top", "metrics", caliber="父口径", caliber_version="v1",
|
||||
metrics=[metric("gmv", "float(nodes.sub.output.gmv)")]))
|
||||
assert await _static(good) == []
|
||||
[said] = await _static(bad)
|
||||
assert "停在「取数子流程」" in said
|
||||
# 运行时同一个结论
|
||||
assert (await run(good)).output["_issuance"]["tier"] == "formal"
|
||||
assert (await run(bad)).output["_issuance"]["tier"] == "degraded"
|
||||
# 没把子工作流读好放进来(只看图本身的地方):看不到子图,照旧停在子工作流节点
|
||||
plain = [i for i in lint_for_publish(GraphSpec.model_validate(good), level="governed").issues
|
||||
if i.code == "governed.metric_untraced"]
|
||||
assert plain and "停在「取数子流程」" in plain[0].message
|
||||
|
||||
|
||||
async def test_the_static_check_follows_two_levels_and_the_input_template():
|
||||
inner_id = await publish(fetch_card_graph(), "内层")
|
||||
middle_id = await publish(S.chain(
|
||||
node("m_in", "input"), node("inner", "subgraph", "内层", workflow_id=inner_id, workflow_version=1),
|
||||
node("m_out", "output", fields=[{"name": "gmv", "value": "{{ nodes.inner.output.gmv }}"}])), "中层")
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("gmv", "float(nodes.sub.output.gmv)")])
|
||||
assert await _static(parent(middle_id, top)) == []
|
||||
|
||||
echo_id = await publish(S.chain(node("s", "input", fields=[{"name": "region"}]),
|
||||
node("o", "output", fields=[{"name": "region", "value": "{{ input.region }}"}])),
|
||||
"回声")
|
||||
top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("r", "nodes.sub.output.region")])
|
||||
assert await _static(parent(echo_id, top, sub_input={"region": "{{ input.region }}"})) == []
|
||||
assert await _static(parent(echo_id, top, sub_input={"region": "{{ input.region }}区"}))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 父图报告直接引用子图里的查询和口径卡
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def report_parent(sub_id: str) -> dict:
|
||||
return S.chain(
|
||||
node("start", "input"),
|
||||
node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1),
|
||||
node("write", "report", instructions="写答案"),
|
||||
node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}],
|
||||
contract={"report_from": "write"}))
|
||||
|
||||
|
||||
async def _segments(client: AsyncClient, run_id: str) -> dict[str, dict]:
|
||||
body = (await client.get(f"/api/runs/{run_id}/evidence")).json()
|
||||
assert body["mode"] == "cited", body
|
||||
return body
|
||||
|
||||
|
||||
async def test_the_parent_report_cites_the_subgraph_query_and_card(client, monkeypatch):
|
||||
sub_id = await publish(fetch_card_graph())
|
||||
S.script(monkeypatch, "子流程里查到的销售额 [[v:Q1.r0.gmv]],口径算的是 [[m:gmv]]。")
|
||||
started = await run_manager.start(graph=report_parent(sub_id), input_payload={})
|
||||
row = await S.wait(started.id)
|
||||
assert row.status == "succeeded", row.error
|
||||
body = await _segments(client, row.id)
|
||||
items = {e["alias"]: e for e in body["evidence"]}
|
||||
assert (items["Q1"]["node_id"], items["Q1"]["sealed"]) == ("sub/fetch", True)
|
||||
assert (items["m:gmv"]["node_id"], items["m:gmv"]["sealed"]) == ("sub/card", True)
|
||||
cited = {e["to"]: e["from"] for e in body["edges"] if e["rel"] == "cites"}
|
||||
# 单元格引用一路点到子图里的那一格
|
||||
cell = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cited['Q1']}")).json()["chain"]
|
||||
[query] = [s for s in cell if s["step"] == "query"]
|
||||
assert query["node_id"] == "sub/fetch" and query["sealed"] is True and query["artifact"] == items["Q1"]["artifact"]
|
||||
# 指标引用:子图里的卡、它的输入、子图里的查询
|
||||
chain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cited['m:gmv']}")).json()["chain"]
|
||||
step = chain[0]
|
||||
assert (step["step"], step["node_id"], step["sealed"], step["untraced"]) == ("metric", "sub/card", True, [])
|
||||
[inp] = [s for s in chain if s["step"] == "input"]
|
||||
assert (inp["node_id"], inp["node_label"], inp["via"]) == ("sub/fetch", "取数子流程 › fetch", "tool_cell")
|
||||
[q] = [s for s in chain if s["step"] == "query"]
|
||||
assert q["node_id"] == "sub/fetch" and q["sealed"] is True
|
||||
# 封存核对通过,可验证报告照常(事件本来就在同一个运行里)
|
||||
from app.engine.runner import verify_manifest
|
||||
|
||||
assert (await verify_manifest(row.id))["ok"] is True
|
||||
res = await client.get(f"/api/runs/{row.id}/report.html")
|
||||
assert res.status_code == 200 and "246.5" in res.text
|
||||
|
||||
|
||||
async def test_masks_inside_the_subgraph_follow_the_parent_run(client, monkeypatch, tmp_path):
|
||||
"""数据源遮了 phone:子图里查出来的号码在父图报告的证据面板里照样写「已遮罩」。"""
|
||||
await S.set_source(tmp_path / "shop.db", options={"mask_columns": "phone"})
|
||||
amount = node("card", "metrics", caliber="子口径", caliber_version="v1",
|
||||
metrics=[metric("gmv", "cell(nodes.fetch, 0, 'amount')")])
|
||||
sub_id = await publish(fetch_card_graph(card=amount, sql="SELECT phone, amount FROM orders WHERE id = 1"))
|
||||
S.script(monkeypatch, "第一单的电话 [[v:Q1.r0.phone]]。")
|
||||
started = await run_manager.start(graph=report_parent(sub_id), input_payload={})
|
||||
row = await S.wait(started.id)
|
||||
assert row.status == "succeeded", row.error
|
||||
body = await _segments(client, row.id)
|
||||
cited = {e["to"]: e["from"] for e in body["edges"] if e["rel"] == "cites"}
|
||||
chain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cited['Q1']}")).json()["chain"]
|
||||
[query] = [s for s in chain if s["step"] == "query"]
|
||||
assert query["node_id"] == "sub/fetch"
|
||||
assert "13800000001" not in str(query), "子图里的遮罩列照父图的运行遮"
|
||||
assert "已遮罩" in str(query) and query["sealed"] is True
|
||||
Reference in New Issue
Block a user