mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:51:59 +00:00
740 lines
38 KiB
Python
740 lines
38 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.core import artifact_store
|
||
from app.core.errors import describe_exception
|
||
from app.core.events import EventType
|
||
from app.engine.context import NodeContext, NodeError
|
||
from app.engine.evidence import (
|
||
DEFAULT_FORMAT,
|
||
FORMATS,
|
||
MISSING,
|
||
RenderError,
|
||
cell_eid,
|
||
is_number,
|
||
next_exec,
|
||
render_number,
|
||
)
|
||
from app.engine.expressions import (
|
||
CellError,
|
||
ExpressionError,
|
||
TruncatedUse,
|
||
cell_parts,
|
||
cell_value,
|
||
eval_expression,
|
||
leaf_refs,
|
||
locate_cell,
|
||
parse_expression,
|
||
same_value,
|
||
substitute,
|
||
)
|
||
from app.engine.labels import field_label
|
||
from app.engine.sql_problems import CHECK_FIELDS, QueryProblem, merge_problems, node_of, query_problems, reason_text
|
||
from app.engine.state import GraphState, template_context
|
||
|
||
|
||
async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""口径卡:把数值计算收进固定层,产出受控的指标集(MetricSet)。
|
||
|
||
表达式走 AST 白名单求值器——确定性、无模型参与、可复算。
|
||
下游叙述节点只许引用这里登记过的数,出具校验器按这份清单抽数字回指。
|
||
这就是"叙述层无算术权限"的机制载体:不是求模型别算,而是它算了也过不了校验。
|
||
|
||
每个指标还记下它是怎么来的:用到了哪些输入、各取自哪个节点、代入后的算式、
|
||
拿代入式复算是否一致。整张卡落成 metric_set 工件,id 进证据台账——报告里的
|
||
数字点开,一路能追到这里。
|
||
|
||
表达式把截断的查询结果当成整组用了(len、sum、max……,见 expressions.TruncatedUse)的指标,
|
||
值照算,但标 incomplete 并写明原因:它算的只是取回的那一截。出具契约据此降档(issuance.incomplete_gaps)。
|
||
|
||
取数的那条 SQL 对照数据目录查出 error 级问题的(查询快照的 checks,见 data/sqlcheck.py:一对多关联后重复计算、
|
||
存量跨期加总),同样值照算,标 sql_check_failed 并写明原因,出具契约据此降档(issuance.sql_check_gaps)。
|
||
"""
|
||
definitions = ctx.cfg("metrics", []) or []
|
||
caliber_raw, caliber_version = ctx.cfg("caliber", "") or ctx.node.title, str(ctx.cfg("caliber_version", "") or "v1")
|
||
source: dict[str, Any] | None = None
|
||
if ctx.config.get("caliber_from") not in (None, "", {}):
|
||
# 钉住别的工作流某一版里的口径卡:名字、版本、指标定义都按那一版,本地写的不算
|
||
pinned, source = await _pinned_caliber(ctx)
|
||
definitions = pinned["metrics"]
|
||
caliber_raw, caliber_version = pinned["caliber"], pinned["caliber_version"]
|
||
if not definitions:
|
||
raise NodeError(ctx.node.id, "口径卡没有定义任何指标")
|
||
|
||
tctx = template_context(state)
|
||
caliber = ctx.render_str(caliber_raw, state)
|
||
# 缺输入时怎么办。默认 fail:和以前一样整张卡失败;null 则这个指标记为空值、
|
||
# 交给出具契约的 required / expected 去判档
|
||
on_missing = str(ctx.cfg("on_missing", "fail") or "fail")
|
||
if on_missing not in ("fail", "null"):
|
||
raise NodeError(ctx.node.id, f"「缺输入时」只能是「整个节点失败」或「记为空值」,当前为「{on_missing}」")
|
||
|
||
metrics: list[dict[str, Any]] = []
|
||
errors: list[str] = []
|
||
#: 查询快照工件 id → 它(含合并查询的输入)没通过 SQL 检查的那几条查询。一张卡里多个指标常常取自同一次查询,
|
||
#: 快照只读一次
|
||
checked: dict[str, list[QueryProblem]] = {}
|
||
#: 指标 id → 它没通过 SQL 检查的那几条来源查询。运行日志按查询归并(_emit_sql_check_logs)
|
||
failed_by: dict[str, list[QueryProblem]] = {}
|
||
for definition in definitions:
|
||
metric_id = str(definition.get("id") or "").strip()
|
||
expr = str(definition.get("expression") or "").strip()
|
||
if not metric_id or not expr:
|
||
errors.append(f"指标定义不完整(缺少 ID 或计算表达式):{definition}")
|
||
continue
|
||
display, problem = _display_of(definition)
|
||
if problem:
|
||
errors.append(f"指标「{metric_id}」:{problem}")
|
||
continue
|
||
try:
|
||
tree, _, _ = parse_expression(expr)
|
||
except ExpressionError as e:
|
||
errors.append(f"指标「{metric_id}」:表达式有误({e})")
|
||
continue
|
||
paths = leaf_refs(tree)
|
||
values = {path: _value_of(path, tctx) for path in paths}
|
||
missing = [path for path in paths if values[path] is None]
|
||
|
||
value: Any = None
|
||
use = TruncatedUse(edge=lambda path: _truncated_field(path, state, ctx))
|
||
try:
|
||
value = eval_expression(expr, tctx, track=use)
|
||
except ExpressionError as e:
|
||
errors.append(f"指标「{metric_id}」:表达式有误({e})")
|
||
continue
|
||
except ZeroDivisionError:
|
||
errors.append(f"指标「{metric_id}」:除以零")
|
||
continue
|
||
except Exception as e: # noqa: BLE001 - 求值器之外的运算错误(None 参与算术、类型不对)要说人话
|
||
if missing or "NoneType" in str(e):
|
||
if on_missing != "null":
|
||
errors.append(f"指标「{metric_id}」:" + _missing_reason(missing))
|
||
continue
|
||
value = None
|
||
elif isinstance(e, TypeError):
|
||
errors.append(f"指标「{metric_id}」:输入的类型不正确,无法计算——" + _type_hint(paths, values))
|
||
continue
|
||
else:
|
||
errors.append(f"指标「{metric_id}」:无法计算({describe_exception(e)})")
|
||
continue
|
||
|
||
if display["bad_decimals"] and isinstance(value, float):
|
||
# 旧写法里 int() 认不得的 decimals:以前走到 round(value, int(decimals)) 就崩,
|
||
# 现在照样算失败,只是说人话。值不是小数时以前根本用不到它,照旧忽略
|
||
errors.append(f"指标「{metric_id}」:小数位(decimals)需要填写整数,当前为「{definition.get('decimals')}」")
|
||
continue
|
||
decimals = display["decimals"]
|
||
if isinstance(value, float) and decimals is not None:
|
||
value = round(value, decimals)
|
||
substituted = substitute(tree, values)
|
||
incomplete: dict[str, Any] = {}
|
||
if use.sources:
|
||
sources = list(use.sources.values())
|
||
incomplete = {"incomplete": True, "incomplete_reason": _incomplete_reason(sources),
|
||
"truncated_sources": sources}
|
||
inputs = [_input_of(path, values[path], state, ctx, tctx) for path in paths]
|
||
sql_check: dict[str, Any] = {}
|
||
if failed := _failed_queries(inputs, list(use.sources.values()), paths, tctx, checked):
|
||
sql_check = {"sql_check_failed": True, "sql_check_reason": _sql_check_reason(failed),
|
||
"sql_check_sources": [{"artifact": artifact, "codes": list(dict.fromkeys(
|
||
code for p in found for code in p.codes))} for artifact, found in failed]}
|
||
failed_by[metric_id] = [p for _, found in failed for p in found]
|
||
metrics.append(
|
||
{
|
||
"id": metric_id,
|
||
"name": definition.get("name") or metric_id,
|
||
"unit": definition.get("unit") or "",
|
||
"value": value,
|
||
"expression": expr,
|
||
"decimals": decimals,
|
||
"format": display["format"],
|
||
"rendered": _rendered(value, definition, display),
|
||
"inputs": inputs,
|
||
"substituted": substituted,
|
||
"recompute_ok": _recompute(substituted, tctx, value, decimals),
|
||
# 值是空的就是缺输入,不论 on_missing 是哪一种:fail 模式下直接取到空值的
|
||
# 指标(原式就是 vars.x.y 而 y 不存在)以前也是照常产出一个 None
|
||
"status": "ok" if value is not None else "missing_input",
|
||
# 只在不完整时出现:完整的指标和以前一字不差,老运行复核时目录也对得上
|
||
**incomplete,
|
||
# 只在来源查询没通过 SQL 检查时出现,同上
|
||
**sql_check,
|
||
}
|
||
)
|
||
|
||
if errors:
|
||
# 口径卡是固定层:任何一个指标算不出来都是模板缺陷,不能带病出具
|
||
raise NodeError(ctx.node.id, "口径卡计算失败:" + ";".join(errors))
|
||
|
||
# text 是给叙述节点 prompt 用的清单——它是叙述层唯一的数字来源
|
||
lines = [
|
||
(f"- {m['id']}({m['name']})= {m['value']}{m['unit']}" if m["value"] is not None
|
||
else f"- {m['id']}({m['name']})= {MISSING}(缺少输入,无值)")
|
||
# 不写行数:叙述节点照抄进正文就是一个没有出处的数
|
||
+ ("(不完整:基于被截断的查询结果计算)" if m.get("incomplete") else "")
|
||
+ ("(存疑:所依据的查询未通过 SQL 检查)" if m.get("sql_check_failed") else "")
|
||
for m in metrics
|
||
]
|
||
for m in metrics:
|
||
if m.get("incomplete"):
|
||
# 结构化字段(metric_name、reason、rows)给界面读,不让它从 message 里解析中文
|
||
ctx.emit(EventType.LOG, level="warn", code="metric_incomplete", metric=m["id"], metric_name=m["name"],
|
||
reason=m["incomplete_reason"], rows=[s.get("rows") for s in m.get("truncated_sources") or []],
|
||
message=f"指标「{m['name']}」{m['incomplete_reason']}")
|
||
_emit_sql_check_logs(metrics, failed_by, state, ctx)
|
||
card = {
|
||
"kind": "metric_set",
|
||
"caliber": caliber,
|
||
"caliber_version": caliber_version,
|
||
"metrics": metrics,
|
||
}
|
||
if source:
|
||
# 指标定义来自哪个工作流的哪一版:证据面板的指标步骤据此写出处,升版处置按它比版本
|
||
card["source"] = source
|
||
artifact = await _store(card, caliber, caliber_version, ctx)
|
||
result = {
|
||
**card,
|
||
"text": f"指标清单(口径 {caliber} @ {caliber_version}):\n" + "\n".join(lines),
|
||
}
|
||
ctx.emit(
|
||
EventType.LOG,
|
||
level="info",
|
||
message=f"口径卡 {caliber}@{caliber_version}:产出 {len(metrics)} 个受控指标",
|
||
)
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
|
||
if artifact:
|
||
result["artifact"] = artifact
|
||
updates["evidence"] = [{
|
||
"kind": "metric_set",
|
||
"node_id": ctx.node.id,
|
||
"exec": next_exec(state, ctx.node.id),
|
||
"artifact": artifact,
|
||
"caliber": caliber,
|
||
"version": caliber_version,
|
||
"metrics": [m["id"] for m in metrics],
|
||
**({"source": source} if source else {}),
|
||
}]
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: result}
|
||
return updates
|
||
|
||
|
||
async def _pinned_caliber(ctx: NodeContext) -> tuple[dict[str, Any], dict[str, Any]]:
|
||
"""caliber_from 指向的那张口径卡:({caliber, caliber_version, metrics}, 来源)。
|
||
|
||
从那个工作流的 WorkflowVersion 快照里取——版本快照不可变,同一份引用每次取到的定义都一样,
|
||
审批恢复后节点重放也还是这一份。取不到就让节点失败并说清是哪一环断了:悄悄退回本地
|
||
定义,等于换了口径还照常出具。
|
||
"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Workflow, WorkflowVersion
|
||
from app.engine.schema import type_label
|
||
|
||
ref = ctx.config.get("caliber_from")
|
||
wf_id = str(ref.get("workflow_id") or "").strip() if isinstance(ref, dict) else ""
|
||
node_id = str(ref.get("node_id") or "").strip() if isinstance(ref, dict) else ""
|
||
version = _integer(ref.get("workflow_version")) if isinstance(ref, dict) else None
|
||
if not wf_id or not node_id or version is None or version < 1:
|
||
raise NodeError(ctx.node.id, f"「{field_label('caliber_from')}」格式有误:需要指定工作流、版本和其中的口径卡,"
|
||
f"当前为「{ref}」")
|
||
async with SessionLocal() as session:
|
||
workflow = await session.get(Workflow, wf_id)
|
||
if workflow is None:
|
||
raise NodeError(ctx.node.id, f"「{field_label('caliber_from')}」固定的工作流({wf_id})不存在,可能已被删除,"
|
||
"请在节点中重新选择口径卡")
|
||
snapshot = (await session.execute(select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == wf_id, WorkflowVersion.version == version))).scalar_one_or_none()
|
||
name = workflow.name
|
||
if snapshot is None:
|
||
raise NodeError(ctx.node.id, f"「{field_label('caliber_from')}」固定的「{name}」不存在 v{version} 版本,请在节点中重新选择版本")
|
||
found = next((n for n in (snapshot.graph or {}).get("nodes") or []
|
||
if isinstance(n, dict) and n.get("id") == node_id), None)
|
||
if found is None:
|
||
raise NodeError(ctx.node.id, f"「{name}」v{version} 中没有节点 {node_id},请在节点中重新选择口径卡")
|
||
data = found.get("data") if isinstance(found.get("data"), dict) else {}
|
||
title = data.get("label") or node_id
|
||
if found.get("type") != "metrics":
|
||
raise NodeError(ctx.node.id, f"「{field_label('caliber_from')}」指向的「{name}」v{version} 中的「{title}」不是口径卡,"
|
||
f"而是「{type_label(found.get('type'))}」")
|
||
cfg = data.get("config") if isinstance(data.get("config"), dict) else {}
|
||
origin = cfg.get("caliber_from")
|
||
if origin not in (None, "", {}):
|
||
root = origin.get("workflow_id") if isinstance(origin, dict) else None
|
||
async with SessionLocal() as session:
|
||
upstream = await session.get(Workflow, str(root)) if root else None
|
||
where = (f"「{upstream.name}」v{origin.get('workflow_version')} 的 {origin.get('node_id')}"
|
||
if upstream is not None else str(origin))
|
||
raise NodeError(ctx.node.id, f"「{name}」v{version} 中的「{title}」本身也引用自其他工作流的口径卡(源头是 {where})。"
|
||
"请直接固定到源头的口径卡,否则升版时无法正确比对版本")
|
||
source = {"workflow_id": wf_id, "workflow_version": version, "node_id": node_id}
|
||
return {"caliber": cfg.get("caliber") or title, "caliber_version": str(cfg.get("caliber_version") or "v1"),
|
||
"metrics": cfg.get("metrics") or []}, source
|
||
|
||
|
||
#: 新写法的小数位范围。负数是取整到十位、百位(round(x, -2))
|
||
_DECIMALS_RANGE = 15
|
||
|
||
|
||
def _display_of(definition: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
|
||
"""指标的显示格式:{decimals, format, bad_decimals},以及写错时的一句话。
|
||
|
||
写了 format 的是新写法,decimals 严格校验:-15 到 15 的整数。
|
||
没写 format 的是这一期之前的口径卡,旧代码能接受的照旧接受:以前只在值是小数时
|
||
round(value, int(decimals)),所以 int() 认得的都算数("2"、-2、12);int() 认不得的
|
||
记成 bad_decimals,由调用方按值的类型决定——值是小数才报错(以前在这里崩),
|
||
不是小数就忽略(以前根本用不到它)。
|
||
"""
|
||
raw = definition.get("decimals")
|
||
strict = definition.get("format") not in (None, "")
|
||
fmt = definition.get("format") or DEFAULT_FORMAT
|
||
if fmt not in FORMATS:
|
||
return {}, f"显示格式(format)只能是 {' / '.join(FORMATS)},当前为「{fmt}」"
|
||
display: dict[str, Any] = {"decimals": None, "format": fmt, "bad_decimals": False}
|
||
if raw in (None, ""):
|
||
return display, None
|
||
if strict:
|
||
decimals = _integer(raw)
|
||
if decimals is None or not -_DECIMALS_RANGE <= decimals <= _DECIMALS_RANGE:
|
||
return {}, (f"小数位(decimals)需要填写 -{_DECIMALS_RANGE} 到 {_DECIMALS_RANGE} 的整数"
|
||
f"(负数表示取整到十位、百位),当前为「{raw}」")
|
||
display["decimals"] = decimals
|
||
return display, None
|
||
try:
|
||
display["decimals"] = int(raw)
|
||
except (TypeError, ValueError, OverflowError):
|
||
display["bad_decimals"] = True
|
||
return display, None
|
||
|
||
|
||
def _integer(raw: Any) -> int | None:
|
||
"""严格的整数:2、2.0、"2"、"-2" 算;1.5、"两位"、true 不算。"""
|
||
if isinstance(raw, bool):
|
||
return None
|
||
if isinstance(raw, int):
|
||
return raw
|
||
if isinstance(raw, float):
|
||
return int(raw) if raw.is_integer() else None
|
||
if isinstance(raw, str) and re.fullmatch(r"\s*[+-]?\d+\s*", raw):
|
||
return int(raw)
|
||
return None
|
||
|
||
|
||
def _rendered(value: Any, definition: dict[str, Any], display: dict[str, Any]) -> str:
|
||
try:
|
||
return render_number(value, unit=str(definition.get("unit") or ""),
|
||
decimals=display["decimals"], fmt=display["format"])
|
||
except RenderError:
|
||
# 按规则渲染不出来的值显示「—」:inf / nan、不是 0 却会显示成 0 的(小数位不够)、
|
||
# 位数大到不像话的。值本身照旧记着,报告里引用它会被判成解析不了,原因写清楚
|
||
return MISSING
|
||
|
||
|
||
def _value_of(path: str, tctx: dict[str, Any]) -> Any:
|
||
"""单独取一个引用的值。取不到(或取值本身出错)一律当作没有值。"""
|
||
try:
|
||
return eval_expression(path, tctx)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
def _incomplete_reason(sources: list[dict[str, Any]]) -> str:
|
||
"""「基于被截断的查询结果计算(只取回了前 1000 行),结果不完整」:出具声明、运行日志、证据面板都用这句。"""
|
||
fetched = [f"前 {s['rows']} 行" for s in sources]
|
||
if len(fetched) == 1:
|
||
return f"基于被截断的查询结果计算(只取回了{fetched[0]}),结果不完整"
|
||
return f"基于 {len(fetched)} 份被截断的查询结果计算(分别只取回了{'、'.join(fetched)}),结果不完整"
|
||
|
||
|
||
#: 取值链的最后一段:.字段 或 [下标]
|
||
_LAST_STEP = re.compile(r"(?:\.[^.\[\]]+|\[[^\[\]]*\])$")
|
||
|
||
|
||
def _failed_queries(inputs: list[dict[str, Any]], truncated: list[dict[str, Any]], paths: list[str],
|
||
tctx: dict[str, Any], checked: dict[str, list[QueryProblem]],
|
||
) -> list[tuple[str, list[QueryProblem]]]:
|
||
"""这个指标用到的查询里,SQL 检查有 error 的:[(查询快照工件 id, 没通过检查的查询)]。合并查询的快照对应它出问题的
|
||
那几个输入(sql_problems.query_problems)。
|
||
|
||
用到了哪些查询,三处来历合起来认:输入的出处(cell() 取的格、Agent 经 cite_fields 交来的字段都带着快照 id)、
|
||
截断跟踪记下的整组、取值链上的查询结果本身(数据整形解析过的 vars.res.rows[0][0],往上找到 vars.res 那份
|
||
带 artifact 的结果)。检查结果从快照里读,不信内存里那份:快照按哈希寻址,就是查询当时存下的那份。
|
||
读不出来的快照不算(另有别的检查报快照的问题)。Agent 没开 cite_fields 时说不清数出自哪次查询,不算。
|
||
"""
|
||
artifacts = [item.get("artifact") for item in inputs] + [s.get("artifact") for s in truncated]
|
||
artifacts += [_result_artifact(path, tctx) for path in paths if cell_parts(path) is None]
|
||
out = []
|
||
for artifact in dict.fromkeys(a for a in artifacts if isinstance(a, str) and a):
|
||
if artifact not in checked:
|
||
checked[artifact] = query_problems(artifact)
|
||
if checked[artifact]:
|
||
out.append((artifact, checked[artifact]))
|
||
return out
|
||
|
||
|
||
def _result_artifact(path: str, tctx: dict[str, Any]) -> str | None:
|
||
"""顺着取值链往上找(vars.res.rows[0][0] → vars.res.rows[0] → vars.res.rows → vars.res),第一个查询结果的快照 id。
|
||
数据源工具交回的是 JSON 文本(调用工具节点的输出),解析过的是对象(数据整形之后),两种都认。"""
|
||
current = path
|
||
for _ in range(8):
|
||
value = _value_of(current, tctx)
|
||
if isinstance(value, str) and value.lstrip().startswith("{") and '"artifact"' in value:
|
||
try:
|
||
value = json.loads(value)
|
||
except ValueError:
|
||
value = None
|
||
if isinstance(value, dict) and isinstance(value.get("artifact"), str) and isinstance(value.get("rows"), list):
|
||
return value["artifact"]
|
||
shorter = _LAST_STEP.sub("", current)
|
||
if not shorter or shorter == current:
|
||
return None
|
||
current = shorter
|
||
return None
|
||
|
||
|
||
def _error_checks(artifact: str) -> list[dict[str, Any]]:
|
||
"""这份查询快照对照数据目录查出的 error 级问题(合并查询顺着 inputs 往下找,见 sql_problems.query_problems)。"""
|
||
return [c for p in query_problems(artifact) for c in p.checks]
|
||
|
||
|
||
def _sql_check_reason(failed: list[tuple[str, list[QueryProblem]]]) -> str:
|
||
"""「所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算),
|
||
结果不可靠」:出具声明、运行日志、证据面板都用这句。只摘问题那半句,改法留在查询步骤里看。"""
|
||
return reason_text(list(dict.fromkeys(text for _, found in failed for p in found for text in p.problems)))
|
||
|
||
|
||
def _emit_sql_check_logs(metrics: list[dict[str, Any]], failed_by: dict[str, list[QueryProblem]],
|
||
state: GraphState, ctx: NodeContext) -> None:
|
||
"""没通过 SQL 检查的来源查询,一条一行运行日志(metric_sql_check),列出受它影响的指标。
|
||
|
||
以前一个指标一行:同一条查询算出三个指标,时间线上同一句话重复三遍。现在按出问题的那条查询归并。
|
||
结构化字段给界面读,不让它从 message 里解析中文:metrics / metric_names(受影响的指标)、reason、problems、
|
||
checks(不带 for_model)、query_artifact(出问题的查询快照)、source_node(跑这条查询的节点)、
|
||
source_field(要改的那一栏:调用工具节点写死的 SQL 在 args.sql;Agent 自己写的 SQL 没有可定位的栏,不给)。
|
||
界面据 source_node / source_field 把「打开设置」落到来源查询节点的 SQL 上,而不是口径卡。
|
||
"""
|
||
names = {m["id"]: m["name"] for m in metrics}
|
||
groups: dict[str, tuple[QueryProblem, list[str]]] = {}
|
||
for metric_id, found in failed_by.items():
|
||
for problem in merge_problems(found):
|
||
held = groups.setdefault(problem.artifact, (problem, []))
|
||
if metric_id not in held[1]:
|
||
held[1].append(metric_id)
|
||
nodes = ctx.run.spec.node_map()
|
||
for artifact, (problem, ids) in groups.items():
|
||
source = problem.node_id or node_of(artifact, state.get("evidence"))
|
||
producer = nodes.get(source) if source else None
|
||
sql_in_args = producer is not None and str(producer.type) == "tool" \
|
||
and isinstance((producer.config.get("args") or {}), dict) and "sql" in (producer.config.get("args") or {})
|
||
reason = reason_text(problem.problems)
|
||
ctx.emit(EventType.LOG, level="warn", code="metric_sql_check",
|
||
metric=ids[0], metrics=ids, metric_names=[names.get(i, i) for i in ids],
|
||
reason=reason, problems=problem.problems,
|
||
checks=[{k: c[k] for k in CHECK_FIELDS if k in c} for c in problem.checks],
|
||
query_artifact=artifact,
|
||
**({"source_node": source} if source else {}),
|
||
**({"source_field": "args.sql"} if sql_in_args else {}),
|
||
message="指标" + "".join(f"「{names.get(i, i)}」" for i in ids) + reason)
|
||
|
||
|
||
def _truncated_field(path: str, state: GraphState, ctx: NodeContext) -> dict[str, Any] | None:
|
||
"""agent 经 cite_fields 交来、被截断切开的数组字段:{rows, artifact}。别的取值链返回 None。
|
||
|
||
数组字段的出处是快照里的一段行(data_evidence 的 locator.rows = [a, b])。cite_fields 核对时,这段行
|
||
到了截断快照的末行就记 truncated(llm._verify_rows):快照后面还有没取回的行,可能也属于这个字段,
|
||
对它求和、计数算的就只是取回的那一截。停在末行之前的不记——引用的每一行都在快照里,截断拿不走其中
|
||
任何一行。标量字段是一格,不是聚合,也不记。取回的行数就是末行的行号加一。
|
||
|
||
路径怎么对到字段和 _input_of 一样:vars.X 找最后写它的节点,nodes.<agent> 下多走一层 data。
|
||
求值器只对取到列表的取值链问这里,一个指标问不了几次。
|
||
"""
|
||
head = _REF_HEAD.match(path)
|
||
if not head or head.group(1) == "input":
|
||
return None
|
||
root, name, rest = head.groups()
|
||
field = rest.lstrip(".")
|
||
node_id = name if root == "nodes" else _writer_of(name, state, ctx)
|
||
producer = ctx.run.spec.node_map().get(node_id) if node_id and field else None
|
||
if producer is None or str(producer.type) != "agent":
|
||
return None
|
||
output = (state.get("nodes") or {}).get(producer.id)
|
||
evidence = output.get("data_evidence") if isinstance(output, dict) else None
|
||
key = _field_key(field)
|
||
if root == "nodes":
|
||
key = key[len("data."):] if key.startswith("data.") else ""
|
||
cited = evidence.get(key) if isinstance(evidence, dict) and key else None
|
||
rows = (cited.get("locator") or {}).get("rows") if isinstance(cited, dict) and cited.get("truncated") else None
|
||
if not (isinstance(rows, list) and len(rows) == 2 and isinstance(rows[1], int)):
|
||
return None
|
||
return {"rows": rows[1] + 1, "artifact": cited.get("artifact")}
|
||
|
||
|
||
def _missing_reason(missing: list[str]) -> str:
|
||
if not missing:
|
||
return "有输入为空值,无法计算(请检查上游是否产出了这些字段)"
|
||
return f"输入 {'、'.join(missing[:4])} 没有值,无法计算(上游没有产出该字段,或路径有误)"
|
||
|
||
|
||
def _type_hint(paths: list[str], values: dict[str, Any]) -> str:
|
||
kinds = {bool: "布尔值", str: "文本", list: "列表", dict: "对象"}
|
||
parts = []
|
||
for path in paths:
|
||
value = values[path]
|
||
if value is None or is_number(value):
|
||
continue
|
||
label = next((name for t, name in kinds.items() if isinstance(value, t)), type(value).__name__)
|
||
sample = f"「{str(value)[:20]}」" if isinstance(value, str) else ""
|
||
parts.append(f"{path} 是{label}{sample}")
|
||
if not parts:
|
||
return "参与运算的值不是数值"
|
||
return "、".join(parts[:4]) + ",不能参与数值运算"
|
||
|
||
|
||
_REF_HEAD = re.compile(r"^(vars|nodes|input)\.([A-Za-z_一-鿿][\w\-一-鿿]*)(.*)$", re.S)
|
||
_VIA = {"input": "input", "code": "code", "agent": "agent", "llm": "llm", "tool": "tool",
|
||
"transform": "transform", "metrics": "metric", "retrieve": "retrieval", "loop": "loop",
|
||
"human": "human", "validate": "validate", "supervisor": "team", "subgraph": "subgraph",
|
||
"memory": "memory", "merge": "merge"}
|
||
|
||
|
||
def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext,
|
||
tctx: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""一个输入的来历:{path, value, node_id, via, field?, role?, status}。
|
||
|
||
vars.X 要反查是谁写的:assign_to 为 X 的节点里,按执行顺序最后跑的那一个——
|
||
和状态里 vars 的覆盖顺序一致。入口节点把输入字段也写进 vars。
|
||
|
||
两种来历能追到查询快照里的那一格:
|
||
- cell(nodes.fetch, 0, 'gmv'):via tool_cell,带 locator、artifact、eid,按快照核对
|
||
- agent 开了 cite_fields 的字段:via agent_field,接上它的 data_evidence(eid、核对状态)
|
||
"""
|
||
if (parts := cell_parts(path)) is not None:
|
||
return _cell_input(path, value, parts, state, ctx, tctx or template_context(state))
|
||
spec = ctx.run.spec
|
||
nodes = spec.node_map()
|
||
node_id: str | None = None
|
||
field = ""
|
||
head = _REF_HEAD.match(path)
|
||
if head:
|
||
root, name, rest = head.groups()
|
||
field = rest.lstrip(".")
|
||
if root == "nodes":
|
||
node_id = name if name in nodes else None
|
||
elif root == "input":
|
||
node_id = next((n.id for n in spec.entry_nodes() if str(n.type) == "input"), None)
|
||
else:
|
||
node_id = _writer_of(name, state, ctx)
|
||
producer = nodes.get(node_id) if node_id else None
|
||
entry: dict[str, Any] = {
|
||
"path": path,
|
||
"value": value if value is None or isinstance(value, (int, float, str, bool)) else _brief(value),
|
||
"node_id": node_id,
|
||
"via": _VIA.get(str(producer.type), str(producer.type)) if producer else "state",
|
||
}
|
||
if field:
|
||
entry["field"] = field
|
||
if producer is not None and str(producer.type) == "code":
|
||
# 沙箱算出来的数喂给口径卡:记下它自认是「取数」还是「计算」,治理规则按它判
|
||
entry["role"] = str(producer.config.get("evidence_role") or "compute")
|
||
entry["status"] = "ok" if value is not None else "missing"
|
||
if producer is not None and str(producer.type) == "agent" and field:
|
||
cited = _agent_field(state, producer.id, root if head else "", field)
|
||
if cited is not None:
|
||
entry.update(cited)
|
||
return entry
|
||
|
||
|
||
def _field_key(field: str) -> str:
|
||
"""vars.kpi['gmv'] 和 vars.kpi.gmv 是同一个字段:下标写法换成点号。"""
|
||
return re.sub(r"\[\s*['\"]([^'\"\]]+)['\"]\s*\]", r".\1", field).strip(".")
|
||
|
||
|
||
def _agent_field(state: GraphState, node_id: str, root: str, field: str) -> dict[str, Any] | None:
|
||
"""agent 字段的出处:{via: agent_field, field, ref, artifact, locator, eid, status, model_value?, reason?}。
|
||
|
||
assign_to 写进 vars 的是 data 本身,nodes.<agent> 下要多走一层 data。没开 cite_fields
|
||
(或者是老运行)的 agent 没有 data_evidence,返回 None,来历照旧记成 agent。
|
||
"""
|
||
output = (state.get("nodes") or {}).get(node_id)
|
||
evidence = output.get("data_evidence") if isinstance(output, dict) else None
|
||
if not isinstance(evidence, dict):
|
||
return None
|
||
key = _field_key(field)
|
||
if root == "nodes":
|
||
if not key.startswith("data."):
|
||
return None
|
||
key = key[len("data."):]
|
||
cited = evidence.get(key)
|
||
if not isinstance(cited, dict):
|
||
return _agent_element(evidence, key)
|
||
out: dict[str, Any] = {"via": "agent_field", "field": key, "status": cited.get("status") or "unresolved"}
|
||
for k in ("ref", "artifact", "locator", "eid", "model_value", "reason", "truncated"):
|
||
if k in cited:
|
||
out[k] = cited[k]
|
||
return out
|
||
|
||
|
||
_ELEMENT = re.compile(r"^(?P<base>[^\[\]]+)\[(?P<index>\d+)\](?:\.(?P<sub>[^\[\]]+))?$")
|
||
|
||
|
||
def _agent_element(evidence: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||
"""数组字段里的一个元素(vars.kpi.weeks[1].amount):按行映射落到快照里的一格。
|
||
|
||
数组的出处是一段行 rows=[a, b] 加列;第 k 个元素就是第 a+k 行。整组的核对状态说的是整组:
|
||
别的元素对不上,不等于这一个也对不上。所以这一个元素按它自己那一格再核一遍——模型报的是
|
||
model_value 里的第 k 个(整组 verified 时模型报的就是快照里的值),和快照里那一格比。
|
||
"""
|
||
m = _ELEMENT.match(key)
|
||
cited = evidence.get(m.group("base")) if m else None
|
||
locator = cited.get("locator") if isinstance(cited, dict) else None
|
||
if not (isinstance(locator, dict) and isinstance(locator.get("rows"), list) and len(locator["rows"]) == 2):
|
||
return None
|
||
first, last = locator["rows"]
|
||
index, sub = int(m.group("index")), m.group("sub")
|
||
row = first + index
|
||
column = (locator.get("columns") or {}).get(sub) if sub else locator.get("column")
|
||
if row > last or not isinstance(column, str):
|
||
return None
|
||
call, artifact = str(cited.get("call") or ""), cited.get("artifact")
|
||
out: dict[str, Any] = {"via": "agent_field", "field": key, "ref": f"{call}.r{row}.{column}", "artifact": artifact,
|
||
"locator": {"row": row, "column": column}, "eid": cell_eid(artifact, row, column)}
|
||
try:
|
||
truth, _ = locate_cell(artifact_store.load(artifact), row, column)
|
||
except (ValueError, CellError, OSError) as e:
|
||
out.update(status="unresolved", reason=f"查询快照无法读取或与哈希不一致:{e}")
|
||
return out
|
||
if cited.get("status") == "verified":
|
||
out["status"] = "verified"
|
||
return out
|
||
told = cited.get("model_value")
|
||
item = told[index] if isinstance(told, list) and index < len(told) else None
|
||
reported = isinstance(told, list) and index < len(told) and (not sub or (isinstance(item, dict) and sub in item))
|
||
element = (item[sub] if sub else item) if reported else None
|
||
if reported and same_value(element, truth):
|
||
out["status"] = "verified"
|
||
else:
|
||
# 模型没报这个元素(给的数组短了)就没有 model_value 可留
|
||
out.update(status="mismatch", **({"model_value": element} if reported else {}))
|
||
return out
|
||
|
||
|
||
def _cell_input(path: str, value: Any, parts: tuple[str, str, str], state: GraphState, ctx: NodeContext,
|
||
tctx: dict[str, Any]) -> dict[str, Any]:
|
||
"""cell() 取的那一格:它来自哪次查询(台账里的 query 条目)、第几行哪一列、快照里是不是这个值。
|
||
|
||
查询结果经 transform 解析过也认得出来:数据源工具交回的 JSON 里带着快照的工件 id。
|
||
取不到快照、或者快照里那一格和算的时候用的值不一样,status 如实写,不替它圆。
|
||
"""
|
||
source, row_src, column_src = parts
|
||
entry: dict[str, Any] = {"path": path, "value": value if value is None or isinstance(value, (int, float, str, bool))
|
||
else _brief(value), "node_id": None, "via": "tool_cell"}
|
||
table = _value_of(source, tctx)
|
||
row, column = _value_of(row_src, tctx), _value_of(column_src, tctx)
|
||
data = table
|
||
if isinstance(data, str):
|
||
try:
|
||
data = json.loads(data)
|
||
except ValueError:
|
||
data = None
|
||
artifact = data.get("artifact") if isinstance(data, dict) else None
|
||
queries = [e for e in state.get("evidence") or [] if isinstance(e, dict) and e.get("kind") == "query"]
|
||
ledger = next((e for e in reversed(queries) if artifact and e.get("artifact") == artifact), None)
|
||
head = _REF_HEAD.match(source)
|
||
if ledger is None and head and head.group(1) == "nodes":
|
||
# 台账是按工件认的;工具交回的不是带工件 id 的 JSON 时,按取值链上的节点找它最后一次查询
|
||
ledger = next((e for e in reversed(queries) if e.get("node_id") == head.group(2)), None)
|
||
if ledger is None:
|
||
entry.update(node_id=head.group(2) if head and head.group(1) == "nodes" else None,
|
||
status="unresolved", reason="找不到这份数据对应的查询快照:cell() 需要读取查询工具返回的结果")
|
||
return entry
|
||
entry["node_id"] = ledger.get("node_id")
|
||
artifact = ledger.get("artifact")
|
||
try:
|
||
raw, name = locate_cell(data if isinstance(data, dict) else table, row, column)
|
||
except CellError as e:
|
||
entry.update(status="unresolved", reason=f"cell() {e}", artifact=artifact)
|
||
return entry
|
||
locator = {"row": row, "column": name}
|
||
entry.update(locator=locator, artifact=artifact, eid=cell_eid(artifact, row, name))
|
||
try:
|
||
snapshot = artifact_store.load(artifact)
|
||
truth, _ = locate_cell(snapshot, row, name)
|
||
except (ValueError, CellError, OSError) as e:
|
||
entry.update(status="unresolved", reason=f"查询快照无法读取或与哈希不一致:{e}")
|
||
return entry
|
||
if cell_value(truth) != cell_value(raw):
|
||
# 算的时候用的值和快照不一样(上游改写过这份结果):照实记下快照里的值
|
||
entry.update(status="mismatch", snapshot_value=cell_value(truth))
|
||
return entry
|
||
entry["status"] = "verified"
|
||
return entry
|
||
|
||
|
||
def _writer_of(var: str, state: GraphState, ctx: NodeContext) -> str | None:
|
||
nodes = ctx.run.spec.node_map()
|
||
|
||
def writes(node_id: str) -> bool:
|
||
node = nodes.get(node_id)
|
||
if node is None:
|
||
return False
|
||
cfg = node.config
|
||
if str(cfg.get("assign_to") or "").strip() == var:
|
||
return True
|
||
if str(node.type) == "input":
|
||
return var in (state.get("input") or {})
|
||
if str(node.type) == "loop":
|
||
item = str(cfg.get("item_var") or "item").strip()
|
||
return var in (item, f"{item}_index")
|
||
return False
|
||
|
||
for step in reversed(state.get("trail") or []):
|
||
node_id = step.get("node_id")
|
||
if node_id and "error" not in step and writes(node_id):
|
||
return node_id
|
||
# 没有执行记录可查(比如直接调用执行器):按图里声明的写入者兜底
|
||
return next((n.id for n in ctx.run.spec.nodes if writes(n.id)), None)
|
||
|
||
|
||
def _brief(value: Any) -> str:
|
||
"""容器型的输入不整份抄进指标:记个大概,完整的在上游节点的产出工件里。"""
|
||
if isinstance(value, list):
|
||
return f"[{len(value)} 项]"
|
||
if isinstance(value, dict):
|
||
return f"{{{len(value)} 个字段}}"
|
||
return str(value)[:200]
|
||
|
||
|
||
def _recompute(substituted: str, tctx: dict[str, Any], value: Any, decimals: int | None) -> bool | None:
|
||
"""拿代入式再算一遍,和结果比对。值是空的就没有可比的,返回 None。"""
|
||
if value is None:
|
||
return None
|
||
try:
|
||
again = eval_expression(substituted, tctx)
|
||
except Exception: # noqa: BLE001 - 代入式算不出来本身就是「对不上」
|
||
return False
|
||
if isinstance(again, float) and decimals is not None:
|
||
again = round(again, decimals)
|
||
if is_number(again) and is_number(value):
|
||
return math.isclose(again, value, rel_tol=1e-12, abs_tol=0.0) or again == value
|
||
return again == value
|
||
|
||
|
||
async def _store(card: dict[str, Any], caliber: str, version: str, ctx: NodeContext) -> str | None:
|
||
"""整张卡落成 metric_set 工件。写失败不毁掉运行,只是这张卡没法被引用。"""
|
||
from app.core.artifact_store import put_json
|
||
|
||
try:
|
||
return await put_json(card, kind="metric_set", run_id=ctx.run.run_id, node_id=ctx.node.id,
|
||
meta={"caliber": caliber, "version": version})
|
||
except Exception as e: # noqa: BLE001
|
||
ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed",
|
||
message=f"口径卡的指标保存失败({describe_exception(e)}),报告将无法引用这些指标")
|
||
return None
|