Files
AgentLab/backend/app/engine/expressions.py
T
ThearchyHelios fcdcc30d4e 可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
  表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
  再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号

证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
  遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
  和子工作流同一套规则;子工作流节点也补上了升版处置的配置项

Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
  模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口

二、发布前自动修复

- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
  Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽

验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

648 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import ast
import json
import math
import operator
import re
from typing import Any
# --------------------------------------------------------------------------
# 变量插值: {{ input.query }} / {{ nodes.n1.text }} / {{ vars.items | json }}
# --------------------------------------------------------------------------
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
def resolve_path(ctx: dict[str, Any], path: str) -> Any:
"""按点号路径取值,支持 a.b[0].c。取不到返回 None 而不是抛错 —— 编排里
半成品状态很常见,让它渲染成空字符串比中断整个 run 有用。"""
cur: Any = ctx
for raw in path.split("."):
raw = raw.strip()
if not raw:
continue
# 拆出 name[0][1] 形式的下标
m = re.match(r"^([^\[\]]*)((?:\[[^\[\]]+\])*)$", raw)
if not m:
return None
name, idx_part = m.group(1), m.group(2)
if name:
if isinstance(cur, dict):
cur = cur.get(name)
else:
cur = getattr(cur, name, None)
if cur is None:
return None
for idx in re.findall(r"\[([^\[\]]+)\]", idx_part):
idx = idx.strip().strip("'\"")
try:
if isinstance(cur, (list, tuple, str)):
cur = cur[int(idx)]
elif isinstance(cur, dict):
cur = cur.get(idx)
else:
return None
except (ValueError, IndexError, KeyError, TypeError):
return None
return cur
_FILTERS = {
"json": lambda v: json.dumps(v, ensure_ascii=False, indent=2, default=str),
"compact": lambda v: json.dumps(v, ensure_ascii=False, separators=(",", ":"), default=str),
"upper": lambda v: str(v).upper(),
"lower": lambda v: str(v).lower(),
"trim": lambda v: str(v).strip(),
"length": lambda v: len(v) if hasattr(v, "__len__") else 0,
"first": lambda v: v[0] if isinstance(v, (list, tuple)) and v else None,
"last": lambda v: v[-1] if isinstance(v, (list, tuple)) and v else None,
}
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, default=str)
return str(value)
def render_template(text: str, ctx: dict[str, Any]) -> str:
"""把字符串里的 {{ ... }} 全部替换掉。支持 `路径 | 过滤器` 语法。"""
if not text or "{{" not in text:
return text or ""
def _sub(match: re.Match[str]) -> str:
expr = match.group(1).strip()
if not expr:
return ""
parts = [p.strip() for p in expr.split("|")]
value = resolve_path(ctx, parts[0])
for filt in parts[1:]:
fn = _FILTERS.get(filt)
if fn:
try:
value = fn(value)
except Exception: # noqa: BLE001 - 过滤器不该让 run 挂掉
value = None
return _stringify(value)
return _TEMPLATE_RE.sub(_sub, text)
def render_deep(value: Any, ctx: dict[str, Any]) -> Any:
"""递归渲染嵌套结构里的所有模板字符串。"""
if isinstance(value, str):
return render_template(value, ctx)
if isinstance(value, dict):
return {k: render_deep(v, ctx) for k, v in value.items()}
if isinstance(value, list):
return [render_deep(v, ctx) for v in value]
return value
# --------------------------------------------------------------------------
# 安全表达式求值(分支条件用)
# --------------------------------------------------------------------------
class ExpressionError(ValueError):
pass
# 幂运算的上界。`9**9**9**9` 只有 11 个 AST 节点,轻松过掉节点数护栏,
# 却会让 CPython 无限期地算一个天文数字的大整数 —— 而求值是在事件循环里
# 同步执行的(分支条件、transform、口径卡、每个节点的 skip_if 都走这里),
# 既没有线程池隔离也没有超时,asyncio 的 cancel 打断不了同步 CPU。
# 一条画布上的分支条件就能把整个后端冻死。
_MAX_POW_EXPONENT = 1024
_MAX_POW_BASE = 1e15
def _safe_pow(base: Any, exponent: Any) -> Any:
"""带上界的幂运算:超界直接报错,而不是让进程算到天荒地老。"""
try:
if abs(exponent) > _MAX_POW_EXPONENT:
raise ExpressionError(f"指数超过上限 {_MAX_POW_EXPONENT}")
if abs(base) > _MAX_POW_BASE and exponent > 1:
raise ExpressionError(f"底数超过上限 {_MAX_POW_BASE:g}")
except TypeError as e: # 非数值类型交给 operator.pow 自己报
raise ExpressionError(f"幂运算的操作数必须是数值:{e}") from e
return operator.pow(base, exponent)
_BIN_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: _safe_pow,
}
_CMP_OPS = {
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
}
_UNARY_OPS = {ast.Not: operator.not_, ast.USub: operator.neg, ast.UAdd: operator.pos}
# --------------------------------------------------------------------------
# 查询结果里的一格:cell(x, row, column)
#
# 口径卡直接从 tool 节点的查询结果取数时,以前只能写 vars.q.rows[0][2]:列的顺序一改,
# 取到的就是另一列,而且报告点开只追得到这条路径。cell() 按列名取,来历能精确到
# 那次查询的那一格。报告里的 [[v:Q1.r0.amount]] 用的也是这里的规则,两边取到的永远是同一个值。
# --------------------------------------------------------------------------
#: 数据源把 DECIMAL 一律落成 str(Decimal)(data/engine.py 的 _jsonable):"45678.50";小数位为 0 的
#: ——MySQL 对整数列 SUM()(包括 SUM(CASE WHEN … THEN 1 ELSE 0 END) 这种计数)、Postgres 的
#: SUM(bigint)、NUMERIC(10,0)——是没有小数点的 "45678";很小的写成 "1E-7"。这些都按数算。
#: 带前导零的 "00123" 是编号,照文本。快照里没有列类型:文本列里的值恰好全是不带前导零的数字时,
#: 同样会被当成数,要文本就写 str(cell(...))
_NUMERIC_TEXT = re.compile(r"^\s*-?(?:0|[1-9]\d*)(?:\.\d+)?(?:E[+-]?\d+)?\s*$")
#: 字段声明成数时,前导零、正号、小写 e 也按数读:写 schema 的人说了它是数
_LOOSE_NUMERIC_TEXT = re.compile(r"^\s*[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\s*$")
_INTEGER_TEXT = re.compile(r"^\s*[+-]?\d+\s*$")
class CellError(ExpressionError):
"""取不到那一格。消息是人话:第几行、有几行、有哪些列。口径卡、分支条件照接住
表达式错误的路子接住它。"""
def numeric_text(raw: Any, *, loose: bool = False) -> int | float | None:
"""数字文本 → 数;不是数字文本(或者根本不是文本)返回 None。
没有小数点、没有指数的给整数:不经 float,大整数不丢位,45678 也不会变成 45678.0。
loose=True 连前导零、正号也认,只在字段声明成数时用。
"""
if not isinstance(raw, str) or not (_LOOSE_NUMERIC_TEXT if loose else _NUMERIC_TEXT).match(raw):
return None
try:
return int(raw) if _INTEGER_TEXT.match(raw) else float(raw)
except ValueError: # 超过 Python 整数位数上限(4300 位)的:照文本,不让它在渲染、核对里炸开
return None
def cell_value(raw: Any, kind: str | None = None) -> Any:
"""快照里的一格 → 参与运算、比对的值:DECIMAL 落成的数字文本换成数,其余原样。
kind 是查询时从驱动的原始值记下的列类型(query_snapshot 的 column_types):text 列原样是文本,
VARCHAR 的 "2026" 不会变成 2026。不给(老快照没记、口径卡表达式里的 cell())就按值猜。
"""
if kind == "text":
return raw
number = numeric_text(raw)
return raw if number is None else number
def column_kind(table: Any, column: Any) -> str | None:
"""查询时记下的这一列的类型:number / text / date / datetime / time / boolean。老快照没记,返回 None。"""
types = table.get("column_types") if isinstance(table, dict) else None
kind = types.get(column) if isinstance(types, dict) and isinstance(column, str) else None
return kind if isinstance(kind, str) else None
def same_value(model: Any, truth: Any) -> bool:
"""模型报的值和快照里的一格是不是一回事:数按数比(数字文本也算数,45678.0 和 "45678" 一致),
文本压成一行比。cite_fields 的核对和口径卡的来历用同一把尺子。"""
if model is None or truth is None:
return model is None and truth is None
m, t = cell_value(model), cell_value(truth)
if isinstance(m, bool) or isinstance(t, bool):
return m == t
if isinstance(m, (int, float)) and isinstance(t, (int, float)):
return math.isclose(float(m), float(t), rel_tol=1e-9, abs_tol=1e-9)
return re.sub(r"\s+", " ", str(m)).strip() == re.sub(r"\s+", " ", str(t)).strip()
def _table_of(table: Any) -> dict[str, Any]:
data = table
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
# 查库失败时工具交回的是一句话(「查询失败:…」):原话照搬,比「不是 JSON」有用
raise CellError(f"要的是查询结果,拿到的是「{data.strip()[:80]}」") from None
if not isinstance(data, dict) or not isinstance(data.get("rows"), list):
raise CellError("第一个参数要是查询结果:查询工具交回的 JSON 文本,或者 {columns, rows}")
return data
def locate_cell(table: Any, row: Any, column: Any) -> tuple[Any, str]:
"""(那一格的原值, 列名)。列号换成列名:按列名和按列号引用同一格,得到同一个 eid。"""
data = _table_of(table)
rows = data["rows"]
if isinstance(row, bool) or not isinstance(row, int):
raise CellError(f"行号要是从 0 数的整数,写的是 {row!r}")
if not rows:
raise CellError("这份查询结果是空的(0 行)")
if not 0 <= row < len(rows):
raise CellError(f"要第 {row} 行,但这份查询结果只有 {len(rows)} 行(从 0 数)")
record = rows[row]
columns = [str(c) for c in data.get("columns") or []]
if isinstance(record, dict):
columns = columns or [str(k) for k in record]
if isinstance(column, int) and not isinstance(column, bool):
if not 0 <= column < len(columns):
raise CellError(f"要第 {column} 列,但只有 {len(columns)} 列(从 0 数)")
column = columns[column]
elif not isinstance(column, str):
raise CellError(f"列要写列名,或者从 0 数的列号,写的是 {column!r}")
if isinstance(record, dict):
if column not in record:
raise CellError(f"没有列「{column}」,有:{'、'.join(columns[:12])}")
return record[column], column
if column not in columns:
raise CellError(f"没有列「{column}」,有:{'、'.join(columns[:12])}")
index = columns.index(column)
if not isinstance(record, (list, tuple)) or index >= len(record):
raise CellError(f"第 {row} 行没有「{column}」这一格")
return record[index], column
def table_cell(table: Any, row: Any, column: Any) -> Any:
"""表达式里的 cell(x, row, column):x 是查询工具交回的 JSON 文本或 {columns, rows}。
不看列类型、照旧按值猜:口径卡是在算数,数字写成文本的列(CAST 成文本、VARCHAR 存的金额)多半
就是要拿来算的。要文本写 str(cell(...))。
"""
try:
raw, _ = locate_cell(table, row, column)
except CellError as e:
raise CellError(f"cell() {e}") from None
return cell_value(raw)
_SAFE_FUNCS: dict[str, Any] = {
"len": len,
"str": str,
"int": int,
"float": float,
"bool": bool,
"abs": abs,
"min": min,
"max": max,
"sum": sum,
"round": round,
"sorted": sorted,
"any": any,
"all": all,
"lower": lambda s: str(s).lower(),
"upper": lambda s: str(s).upper(),
"contains": lambda hay, needle: needle in hay if hay is not None else False,
"startswith": lambda s, p: str(s).startswith(p),
"endswith": lambda s, p: str(s).endswith(p),
"matches": lambda s, p: bool(re.search(p, str(s))),
"get": lambda obj, key, default=None: (obj or {}).get(key, default),
"cell": table_cell,
}
_MAX_NODES = 200
#: 表达式能引用的根名字,就是 state.template_context 的那几个键(有测试钉住两边一致)。
#: 不在这里、也不是白名单函数或 true/false/null 的名字,运行时一律取到 None——
#: `gate == "ok"` 这种漏写了 vars. 的条件永远不成立,而且不报错
EXPRESSION_ROOTS = frozenset(
{"input", "nodes", "vars", "output", "loops", "usage", "messages", "last_message"}
)
_LITERAL_NAMES = frozenset({"true", "True", "false", "False", "null", "None"})
_ALLOWED_NODES: tuple[type[ast.AST], ...] = (
ast.Expression, ast.Constant, ast.Name, ast.Load, ast.Attribute, ast.Subscript,
ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.And, ast.Or, ast.Compare, ast.IfExp,
ast.Call, ast.keyword, ast.List, ast.Tuple, ast.Dict,
*_BIN_OPS, *_UNARY_OPS, *_CMP_OPS,
)
_NODE_NAMES = {
"ListComp": "列表推导式", "SetComp": "集合推导式", "DictComp": "字典推导式",
"GeneratorExp": "生成器表达式", "Lambda": "lambda", "JoinedStr": "f-string",
"NamedExpr": ":=", "Slice": "切片 a[i:j]", "Starred": "* 展开", "Await": "await",
}
class _Unbrace(ast.NodeTransformer):
"""`{{ vars.x }}` 在 Python 里是"装着一个集合的集合"。表达式不允许集合,所以
它在表达式里只可能是一种笔误:把模板写法带了进来——模型和人都这么写(开发库
里四次运行就死在"表达式里不允许出现 Set")。意思毫无歧义,按 vars.x 理解。
在语法树上剥,不在文本上剥:字符串字面量里的花括号原样保留。
"""
def __init__(self) -> None:
self.found: list[str] = []
def visit_Set(self, node: ast.Set) -> ast.AST:
self.generic_visit(node)
inner = node.elts[0] if len(node.elts) == 1 else None
if isinstance(inner, ast.Set) and len(inner.elts) == 1:
self.found.append(ast.unparse(inner.elts[0]))
return inner.elts[0]
return node
def _check_static(tree: ast.Expression) -> list[str]:
"""不求值,只看结构:违规直接抛 ExpressionError,返回不认识的名字(留给提示用)。
运行时求值会短路(and/or、三元只走一边),没走到的那一半写错了也发现不了;
画布校验和 Copilot 自查要的是"整条表达式都能跑",所以得把整棵树走一遍。
"""
unknown: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.BinOp) and type(node.op) not in _BIN_OPS:
if isinstance(node.op, ast.BitOr):
raise ExpressionError(
"表达式里没有 | 过滤器(那是模板的写法):取长度写 len(x),大小写写 upper(x) / lower(x)"
)
raise ExpressionError(f"不支持的运算符 {type(node.op).__name__}")
if not isinstance(node, _ALLOWED_NODES):
if isinstance(node, ast.Set):
raise ExpressionError("表达式里不支持集合 {…}:要判断是不是其中之一,用列表 x in ['a', 'b']")
name = type(node).__name__
raise ExpressionError(f"表达式里不允许出现 {_NODE_NAMES.get(name, name)}")
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
called = node.func.id if isinstance(node.func, ast.Name) else ast.unparse(node.func)
raise ExpressionError(f"不认识的函数 {called}(),能用的只有:{'、'.join(_SAFE_FUNCS)}")
elif isinstance(node, ast.Attribute) and node.attr.startswith("_"):
raise ExpressionError("不允许访问私有属性")
elif (isinstance(node, ast.Constant) and isinstance(node.value, str)
and _TEMPLATE_RE.search(node.value)):
raise ExpressionError(
f"字符串 {node.value!r} 里的 {{{{ }}}} 不会被渲染——表达式不过模板,直接写路径,比如 vars.x"
)
elif (isinstance(node, ast.Name) and node.id not in EXPRESSION_ROOTS
and node.id not in _SAFE_FUNCS and node.id not in _LITERAL_NAMES):
unknown.append(node.id)
return list(dict.fromkeys(unknown))
def parse_expression(expr: str) -> tuple[ast.Expression, list[str], list[str]]:
"""解析 + 静态检查:(语法树, 被剥掉的 {{ }}, 不认识的名字)。结构违规抛 ExpressionError。
运行时求值、画布校验、Copilot 自查都走这一个入口——三处各写一套规则的话,
迟早出现"校验说能跑、跑起来报错"。
"""
text = (expr or "").strip()
try:
tree = ast.parse(text, mode="eval")
except SyntaxError as e:
hint = "(判断相等要写 ==)" if re.search(r"(?<![=!<>])=(?!=)", text) else ""
raise ExpressionError(f"表达式语法错误:{e.msg}{hint}") from e
unbrace = _Unbrace()
tree = ast.fix_missing_locations(unbrace.visit(tree))
if sum(1 for _ in ast.walk(tree)) > _MAX_NODES:
raise ExpressionError("表达式太复杂")
unknown = _check_static(tree)
return tree, unbrace.found, unknown
# --------------------------------------------------------------------------
# 引用与代入:口径卡要说清「这个数是拿哪几个输入算出来的」
# --------------------------------------------------------------------------
def _chain_root(node: ast.AST) -> str | None:
"""node 是不是一条从根名字出发的取值链(vars.kpi.gmv、nodes.q.rows[0])。是就返回根。"""
cur = node
while isinstance(cur, (ast.Attribute, ast.Subscript)):
cur = cur.value
if isinstance(cur, ast.Name) and cur.id in EXPRESSION_ROOTS:
return cur.id
return None
def _cell_call(node: ast.AST) -> bool:
"""cell(<取值链>, row, column):第一个参数是从根名字出发的取值链,整个调用算一个输入。"""
return (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "cell"
and len(node.args) == 3 and not node.keywords and _chain_root(node.args[0]) is not None)
def cell_parts(path: str) -> tuple[str, str, str] | None:
"""leaf_refs 给出的 cell(...) 路径拆回三段源码:(取值链, 行, 列)。不是 cell 调用返回 None。"""
try:
body = ast.parse(path, mode="eval").body
except SyntaxError:
return None
if not _cell_call(body):
return None
return tuple(ast.unparse(a) for a in body.args) # type: ignore[return-value]
def _walk_refs(node: ast.AST, found: list[ast.AST]) -> None:
if _cell_call(node):
# 取哪一格由行、列决定,它们里面的引用是独立的输入;取值链本身只是这一格的来处
found.append(node)
for arg in node.args[1:]:
_walk_refs(arg, found)
return
if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node):
found.append(node)
# 链本身不再往里拆(vars.kpi 是 vars.kpi.gmv 的一部分),但下标里的表达式是
# 独立的输入:vars.rows[vars.i] 取哪一格由 vars.i 决定
cur = node
while isinstance(cur, (ast.Attribute, ast.Subscript)):
if isinstance(cur, ast.Subscript):
_walk_refs(cur.slice, found)
cur = cur.value
return
for child in ast.iter_child_nodes(node):
_walk_refs(child, found)
def leaf_refs(tree: ast.AST) -> list[str]:
"""表达式引用了哪些值:每条从根名字出发的最长取值链,按出现顺序去重。
parse_expression 只告诉你「有没有不认识的名字」;口径卡要记来源,得知道
round((vars.kpi.gmv - vars.kpi.gmv_prev) / …) 里用到的是 vars.kpi.gmv 和
vars.kpi.gmv_prev 这两个值,而不是 vars、kpi 这些零件。
cell(nodes.fetch, 0, 'gmv') 整个调用算一个值:它的来历是那次查询的那一格。
路径用 ast.unparse 的写法,substitute 按同样的写法认。
"""
found: list[ast.AST] = []
_walk_refs(tree, found)
return list(dict.fromkeys(ast.unparse(n) for n in found))
_SCALARS = (int, float, str, bool, type(None))
#: 字符串长过这个数就不代进去:上游模型写的一大段文本抄进代入式(和 metric_set 工件),
#: 没人看得懂,只是把工件撑大
_INLINE_STR_MAX = 80
def _inlinable(value: Any) -> bool:
"""这个值能不能以字面量的样子写进代入式。
容器不行;含 {{ 或 }} 的字符串不行——parse_expression 会把整条代入式当成
「表达式里写了模板」拒掉,复算就成了假的「对不上」;太长的字符串也不代。
"""
if not isinstance(value, _SCALARS):
return False
if isinstance(value, str):
return len(value) <= _INLINE_STR_MAX and "{{" not in value and "}}" not in value
return True
def _literal(value: Any) -> ast.expr:
"""负数写成一元负号:Constant(-3) 放在幂运算左边会被 unparse 成 -3 ** 2,
读起来和算起来都是 -(3 ** 2)。一元负号节点 unparse 时会自己加括号。"""
if isinstance(value, (int, float)) and not isinstance(value, bool) and value < 0:
return ast.UnaryOp(op=ast.USub(), operand=ast.Constant(-value))
return ast.Constant(value)
class _Substitute(ast.NodeTransformer):
def __init__(self, values: dict[str, Any]) -> None:
self.values = values
def _replace(self, node: ast.AST) -> ast.AST:
if _chain_root(node):
path = ast.unparse(node)
if path in self.values and _inlinable(self.values[path]):
return ast.copy_location(_literal(self.values[path]), node)
if path in self.values:
return node # 容器、含 {{ }} 的或太长的字符串留原路径,复算时照样取得到
return self.generic_visit(node)
visit_Attribute = visit_Subscript = visit_Name = _replace
def visit_Call(self, node: ast.Call) -> ast.AST:
if _cell_call(node):
path = ast.unparse(node)
if path in self.values and _inlinable(self.values[path]):
return ast.copy_location(_literal(self.values[path]), node)
return self.generic_visit(node)
def substitute(tree: ast.AST, values: dict[str, Any]) -> str:
"""把引用换成它当时的值,得到代入式:round((45678.5 - 42010.0) / 42010.0 * 100, 1)。
只代标量(含 {{ }} 或长过 80 字的字符串除外)。代入式本身还是一条合法表达式——拿同一个上下文再求一遍,结果必须和
原式一样,这是口径卡复算(recompute_ok)的依据。不改动传进来的语法树。
"""
import copy
replaced = _Substitute(values).visit(copy.deepcopy(tree))
return ast.unparse(ast.fix_missing_locations(replaced))
def eval_expression(expr: str, ctx: dict[str, Any]) -> Any:
"""求值一个受限的 Python 表达式。
只放行字面量、比较、布尔/算术运算、下标、属性访问和一小撮白名单函数。
没有 import、没有属性魔法、没有函数定义,所以用户在画布上写条件是安全的。
"""
expr = (expr or "").strip()
if not expr:
return None
tree, _, _ = parse_expression(expr)
def _eval(node: ast.AST) -> Any:
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
if node.id in ctx:
return ctx[node.id]
if node.id in _SAFE_FUNCS:
return _SAFE_FUNCS[node.id]
if node.id in ("true", "True"):
return True
if node.id in ("false", "False"):
return False
if node.id in ("null", "None"):
return None
return None
if isinstance(node, ast.Attribute):
base = _eval(node.value)
if isinstance(base, dict):
return base.get(node.attr)
if node.attr.startswith("_"):
raise ExpressionError("不允许访问私有属性")
return getattr(base, node.attr, None)
if isinstance(node, ast.Subscript):
base = _eval(node.value)
key = _eval(node.slice)
try:
return base[key]
except (KeyError, IndexError, TypeError):
return None
if isinstance(node, ast.BinOp):
op = _BIN_OPS.get(type(node.op))
if not op:
raise ExpressionError("不支持的运算符")
return op(_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp):
op = _UNARY_OPS.get(type(node.op))
if not op:
raise ExpressionError("不支持的一元运算符")
return op(_eval(node.operand))
if isinstance(node, ast.BoolOp):
# Python 的 and/or 返回的是操作数本身而不是布尔值,并且短路求值。
# `vars.x or '默认值'` 是模板里最常见的写法,返回 True 就全错了。
is_and = isinstance(node.op, ast.And)
result: Any = None
for i, operand in enumerate(node.values):
result = _eval(operand)
truthy = bool(result)
if i < len(node.values) - 1 and (truthy is not is_and):
return result # and 遇假、or 遇真:短路返回该操作数
return result
if isinstance(node, ast.Compare):
left = _eval(node.left)
for op_node, comparator in zip(node.ops, node.comparators):
op = _CMP_OPS.get(type(op_node))
if not op:
raise ExpressionError("不支持的比较运算符")
right = _eval(comparator)
try:
if not op(left, right):
return False
except TypeError:
return False
left = right
return True
if isinstance(node, ast.IfExp):
return _eval(node.body) if _eval(node.test) else _eval(node.orelse)
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
raise ExpressionError("只能调用白名单函数")
args = [_eval(a) for a in node.args]
kwargs = {kw.arg: _eval(kw.value) for kw in node.keywords if kw.arg}
return _SAFE_FUNCS[node.func.id](*args, **kwargs)
if isinstance(node, (ast.List, ast.Tuple)):
return [_eval(e) for e in node.elts]
if isinstance(node, ast.Dict):
return {
_eval(k): _eval(v)
for k, v in zip(node.keys, node.values)
if k is not None
}
raise ExpressionError(f"表达式里不允许出现 {type(node).__name__}")
return _eval(tree)
def eval_condition(expr: str, ctx: dict[str, Any]) -> bool:
"""求值成布尔。条件写错时返回 False 而不是炸掉整个 run。"""
try:
return bool(eval_expression(expr, ctx))
except ExpressionError:
raise
except Exception: # noqa: BLE001
return False