mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
eddeded2a4
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费 - 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定 - 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
694 lines
40 KiB
Python
694 lines
40 KiB
Python
"""SQL 模板参数完整性:一处认模板里的参数,各入口同一套语义(开工任务书 v3 · K01,§4.4 参数完整性)。
|
||
|
||
**一条规则,几个入口。** 口径的取数 SQL 模板里参数写成 `{{ input.字段 }}`。试跑(api/ledger 的 trial,含草稿试跑)、
|
||
组合式问数(ledger/compose)、派生图(ledger/derive 的 probe / reconcile / recall)和运行时的数据源查询
|
||
(engine/nodes/tools.run_tool)都按这里的一套判断:**缺参数、未声明参数、认不出的写法,在渲染之前就拒,绝不发出一条
|
||
少了个值的 SQL**(以前 `{{ input.store }}` 取不到值时渲染成空串,`AND region = ''` 照样发出去)。
|
||
|
||
正式出具(ledger/issue)与召回计划(ledger/recall)都接了这里的参数完整性(check_nodes:issue 在代填之前拒、
|
||
recall 在计划这一步判「这一组重算得了吗」),并且各留了一把自己的分寸(issue 跳过认不出写法的取数模板、
|
||
recall 两版各自判)。
|
||
|
||
| 情况 | 行为 |
|
||
| --- | --- |
|
||
| 必需参数没给值、也没有缺省值 | missing_parameter |
|
||
| 可选参数没给值、也没声明的缺省值 | missing_parameter(不能当空串) |
|
||
| 显式给了空串、字段也没声明的缺省值 | missing_parameter——空串不是值,是省略(入口收值 provided 与运行时的存在性判断同一套) |
|
||
| SQL 引用了入口没声明的字段 | undeclared_parameter |
|
||
| 参数写在表名、列名里,或用了倒推不出值的写法(vars.、模板函数、别的过滤器) | unsupported_parameter |
|
||
| 引号里的值带引号 / 反斜杠 / 换行,裸写的不是数,注释里的不能带换行或 `*/` | literal_unsupported |
|
||
| 可选参数声明了缺省值(含 `""`:作者写的就是「空值不过滤」) | 按缺省值渲染,不算缺参数 |
|
||
|
||
(还有一层在图那边:engine/variables 会拦「引用了没人产出的 `{{ input.x }}`」的节点,那一条在运行发起前就红——组合式问数
|
||
拷进来的节点因此**保留不了模板**,只能渲染成字面语句 + 这里的参数契约;那条校验按同一份契约跳过 args / sql,见下。)
|
||
|
||
**运行时的兜底认哪些写法。** 取数 SQL 在发出之前按 `input` 引用过一遍参数完整性,认法和
|
||
engine/expressions.resolve_path 一致:`input.字段`、`input['字段']`、`input["字段"]`、`input[0]`、
|
||
`input.rows[0].name` 都算,取的是第一层的那个字段(下标里的字是字面量,resolve_path 取的就是字面量)。认不出取哪个
|
||
字段的写法——光秃秃的 `{{ input }}`、`input['a.b']`、`input[a[b]]`、`input x`——resolve_path 取不到值、会渲染成空串,
|
||
SQL 执行路径按 unsupported_parameter 直接拒,不当「没引用参数」放过去。vars. / nodes. 这些别的命名空间不在这趟里
|
||
(照旧由画布的变量校验管);口径模板那边的严格解析(slots_of,只认 `{{ input.字段 }}`)没有变。
|
||
|
||
**这次的值怎么定(resolve)。** 显式给的(不是 None、不是空串)优先,否则用声明的缺省值——`default` 给了就用,**含 `0`
|
||
与空串**(`resolve`);检查、渲染、快照材料里的输入哈希用的都是这一份。已经自己补过缺省值的入口(试跑、组合式问数)传进来
|
||
的就是补好的 values,结果不变;`plan()` 这条共享层接口按这一份定值:字段声明了 `default="east"` 而调用方没给值时,渲染出来
|
||
的是 `east`,不是空串(`default=0` 同理,渲染出来是 `0`)。
|
||
|
||
**值只当数据,只经历一次模板替换。** 本仓库的数据源适配器是「内联文本替换」:值渲染进语句文本,没有驱动级参数绑定
|
||
(ADAPTER = "inline_text",快照里照实写,不假装是驱动绑定)。所以值必须**只被模板替换一次**——渲染进模板的那一次;渲染
|
||
出来的结果不会再被当模板解释(`{{ vars.x }}` 这样的文本落在值里就是字面量)。为此:
|
||
|
||
- 组合式问数(ledger/compose)在拷节点时把值渲染进 SQL(只有这一次),同时在节点上写一份参数契约(见 marker):
|
||
args 里已经是字面语句,运行时(engine/nodes/tools.run_tool)的模板渲染一概不碰它——不然值里的 `{{ vars.question }}`
|
||
会在运行时被当成模板替换成那个变量的值,SQL 被数据改掉;
|
||
- 对账图里「这次的取数」放进去的是**执行过的 SQL 原文**;按维度值对齐的合并语句(ledger/reconcile.align_sql)是服务端
|
||
按查询结果拼的字面 SQL:两处都写 marker,运行时的模板渲染一概不碰它们(tools.run_tool / merge.run_merge);
|
||
- 画布校验(engine/variables)按 literal_keys 跳过带契约节点的 args / sql:里面是数据,不按变量引用判——否则值里恰好
|
||
有几个花括号,就等于那张图跑不起来。
|
||
|
||
**参数契约(marker)与它注解的那条语句绑定。** 写进节点 `data.config[KEY]` 的那一份记:承载方式(内联)、**rendered_hash**
|
||
(它注解的那一项——tool 的 `args`、merge 的 `sql`——的确定哈希)、源模板哈希、规范化输入哈希、参数契约(字段、类型、
|
||
缺省值)与每个参数写在哪儿(quoted / bare / comment)、出处。绑定值不另存一份:它就是内联进实际执行语句里的那些字,实际
|
||
语句(payload["sql"])本身落进快照,展示用 SQL 不作为执行证明。
|
||
|
||
**哈希对不上,契约就作废。** 节点会被复制、会被改:改过 args / sql 之后,契约说的已经不是这一条语句了。所以
|
||
`literal_keys()` 一律先核对 rendered_hash——对得上才跳过渲染、才跳过画布校验的模板扫描、才把契约材料记进快照;对不上
|
||
(复制后改了、改回普通模板、只是改了个别的参数)就当**没写契约**:正常渲染、正常判参数,画布的变量校验也照常扫。
|
||
绝不能因为节点上留着一份旧契约,就把改过的、带 `{{ }}` 的语句原样发出去。
|
||
|
||
**快照材料**里的源模板正文不写:engine/variables 会把节点配置里每一段带 {{ }} 的字都当活引用扫,正文留在节点上会把取数
|
||
模板的占位符报成「没有任何节点产出」。出处(source)记的是那一版取数节点的固定引用,**本切片只把它当地址写下来**——没有
|
||
实现「按引用自动还原模板并核对哈希」,要复核得照着这个引用去读那一版。
|
||
|
||
**还没做的**(照实说,不当成已完成):驱动级参数绑定(占位符交给驱动、绑定值单独传)只对试点方言有意义,本切片没有
|
||
实现,所以含引号、反斜杠、换行的值仍然明确拒绝(literal_unsupported),不能因此宣布特殊字符与各类型都已支持;类型契约
|
||
只做到规范化声明类型和值类别(R02 那类「同名字段声明成不同数字类型怎么合并」的规则在 ledger/draftplan._merged_type,
|
||
不在这里)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import re
|
||
from collections.abc import Iterable, Mapping, Sequence
|
||
from dataclasses import dataclass, field
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
#: 参数问题的机器码(§4.4 的三条 + 值承载不了的一条)
|
||
MISSING = "missing_parameter"
|
||
UNDECLARED = "undeclared_parameter"
|
||
UNSUPPORTED = "unsupported_parameter"
|
||
LITERAL = "literal_unsupported"
|
||
|
||
CODES = (MISSING, UNDECLARED, UNSUPPORTED, LITERAL)
|
||
|
||
#: 承载参数的方式:驱动不提供参数绑定,值一律内联进语句文本(旧适配器)。快照按它照实写
|
||
ADAPTER = "inline_text"
|
||
|
||
#: 节点配置里写参数契约的键(ledger/compose、ledger/derive、ledger/reconcile 写,engine 侧按它认)
|
||
KEY = "parameters"
|
||
#: args / sql 里已经是渲染好的字面语句(值渲染进去的那一次已经发生过):运行时的模板渲染不再碰它
|
||
LITERAL_ARGS = "literal"
|
||
#: 契约里记的、它注解的那一项(tool 的 args / merge 的 sql)的确定哈希:对不上就说明契约过时了
|
||
HASH_KEY = "rendered_hash"
|
||
#: 带参数契约的节点上,「已经渲染成字面值」的那一项在哪个键上(按节点类型):调用工具的 args、合并查询的 sql。
|
||
#: engine/variables 扫模板引用、engine/nodes/* 渲染参数时都要跳过它们——里面是数据,不是活模板
|
||
LITERAL_KEY = {"tool": "args", "merge": "sql"}
|
||
|
||
#: 运行时查询快照记参数材料时,ToolContext.extra 里用的键(tools/datasource 读)
|
||
SNAPSHOT_KEY = "sql_parameters"
|
||
|
||
#: 值写在哪儿(data/sql_semantics._template_slots 的 context):引号里的字 / 裸写 / 注释里 / 表名列名
|
||
CONTEXTS = ("quoted", "bare", "comment", "name")
|
||
#: 参数能带的过滤器(和 data/sql_semantics._SLOT_FILTERS 同一份):别的过滤器倒推不回值
|
||
FILTERS = frozenset({"trim", "compact"})
|
||
|
||
#: 写给人和模型的几句话(各入口同一句,改就一处改)
|
||
MISSING_TEXT = "参数「{name}」这次没有值:{where}没给值、也没有缺省值,不能当空串用(missing_parameter)"
|
||
UNDECLARED_TEXT = "SQL 模板引用了没有声明的参数「{name}」:{where}是 {fields}(undeclared_parameter)"
|
||
UNSUPPORTED_TEXT = "SQL 模板里的参数认不出来:{reason}(unsupported_parameter)"
|
||
#: 引用了入口、但看不出取的是哪个字段的写法:resolve_path 取不到值 → 会渲染成空串,SQL 执行路径照实拒
|
||
DYNAMIC_TEXT = ("SQL 模板里的入口引用「{{{{ {expr} }}}}」看不出取的是哪个字段(这样写会渲染成空串):"
|
||
"请写成 {{{{ input.字段 }}}},点号与 {{{{ input['字段'] }}}} 都行,下标里要是字面量(unsupported_parameter)")
|
||
QUOTED_TEXT = "参数「{name}」写在 SQL 的引号里,值里不能有引号、反斜杠或换行(literal_unsupported)"
|
||
BARE_TEXT = "参数「{name}」直接写在 SQL 里(没有引号),只能是数,当前为「{value}」(literal_unsupported)"
|
||
COMMENT_TEXT = "参数「{name}」写在 SQL 的注释里,值里不能有换行或注释结束符(literal_unsupported)"
|
||
#: 取数模板根本读不出来(模板语法、引号没配对上)时套在原因外面的那一句
|
||
UNREADABLE_TEXT = "取数节点的 SQL 模板认不出参数写在哪:{reason}"
|
||
|
||
_NUMBER = re.compile(r"^\s*-?\d+(?:\.\d+)?\s*$")
|
||
#: 运行时按它挑出模板里的 input 引用(宽松:只认 {{ input.字段 }},别的模板写法不归参数完整性管)
|
||
|
||
|
||
class ParameterRefused(Exception):
|
||
"""参数不合规:不渲染、不发 SQL。problems 给程序认(机器码),message 给人看。"""
|
||
|
||
def __init__(self, problems: Sequence[Problem]) -> None:
|
||
self.problems = tuple(problems)
|
||
super().__init__(describe(self.problems))
|
||
|
||
@property
|
||
def code(self) -> str:
|
||
return self.problems[0].code if self.problems else UNSUPPORTED
|
||
|
||
@property
|
||
def message(self) -> str:
|
||
return str(self)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Slot:
|
||
"""模板里的一处 `{{ input.字段 }}`:第几处、字段、过滤器、写在哪儿。"""
|
||
|
||
index: int
|
||
field: str
|
||
filters: tuple[str, ...] = ()
|
||
context: str = "bare"
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {"name": self.field, "filters": list(self.filters), "slot": self.context}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Problem:
|
||
"""一处参数问题:机器码 + 给人看的一句话(含节点,便于在画布上定位)。"""
|
||
|
||
code: str
|
||
message: str
|
||
name: str | None = None
|
||
node_id: str | None = None
|
||
where: str | None = None
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {"code": self.code, "name": self.name, "node_id": self.node_id, "message": self.message}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Statement:
|
||
"""一次取数的结果:渲染好的字面语句 + 进查询快照的参数材料(源模板、两种哈希、参数类型与写在哪儿)。"""
|
||
|
||
statement: str
|
||
material: dict[str, Any] = field(default_factory=dict)
|
||
|
||
def __str__(self) -> str: # pragma: no cover - 只为人读
|
||
return self.statement
|
||
|
||
|
||
def describe(problems: Iterable[Problem]) -> str:
|
||
"""几处问题合成一句话(给人和模型看的那句)。"""
|
||
return ";".join(p.message for p in problems)
|
||
|
||
|
||
# ==========================================================================
|
||
# 认得模板里的参数
|
||
# ==========================================================================
|
||
|
||
|
||
def slots_of(template: str, *, dialect: str | None = None) -> tuple[Slot, ...]:
|
||
"""模板里每一处 `{{ input.字段 }}`(含写在哪儿、带了什么过滤器)。认不出写法、写在表名列名里的抛
|
||
ParameterRefused(unsupported_parameter)——判断和带值比对那一套(data/sql_semantics._template_slots)是同一份。"""
|
||
from app.data.sql_semantics import _NotComparable, _template_slots
|
||
|
||
try:
|
||
spans, _ = _template_slots(template, dialect=dialect)
|
||
except _NotComparable as e:
|
||
raise ParameterRefused([Problem(UNSUPPORTED, str(e))]) from None
|
||
return tuple(Slot(index=s.index, field=s.field, filters=tuple(s.filters), context=s.context) for _, _, s in spans)
|
||
|
||
|
||
def input_refs(template: str) -> tuple[str, ...]:
|
||
"""模板里按 input 取值、而且**认得出取的是哪个字段**的那些引用,按出现先后去重。
|
||
|
||
认法和 engine/expressions.resolve_path 一个走法:`input.字段`、`input['字段']`、`input["字段"]`、`input[0]`、
|
||
`input.rows[0].name` 都算(下标里的字是字面量——resolve_path 取的就是字面量,不做表达式求值),取的是第一层的
|
||
那个字段。非 input 的模板写法(vars.、nodes.、模板函数的具体实现)不归这里管:普通画布上的取数 SQL 照样能引用
|
||
运行时的值。
|
||
|
||
认不出的写法(`{{ input }}`、`input['a.b']`、`input[a[b]]`……)**不在这里**——它们取不到值、会渲染成空串,由
|
||
dynamic_input_writings 交出来,SQL 执行路径按 unsupported_parameter 拒。"""
|
||
return tuple(dict.fromkeys(field for field in _input_fields(template) if field))
|
||
|
||
|
||
def dynamic_input_writings(template: str) -> tuple[str, ...]:
|
||
"""模板里引用了 input、但**认不出取的是哪个字段**的写法,原样一份(按出现先后去重)。
|
||
|
||
resolve_path 对这些写法取不到值(返回 None),模板渲染出来是空串:SQL 执行路径不能把它当「没引用参数」放过去。"""
|
||
return tuple(dict.fromkeys(writing for _, writing in _scanned_inputs(template) if writing))
|
||
|
||
|
||
def mentions_input(template: str) -> bool:
|
||
"""模板里有没有按 input 取值的地方(认得出的与认不出的都算):SQL 执行路径按它决定要不要过参数完整性。"""
|
||
return bool(input_refs(template) or dynamic_input_writings(template))
|
||
|
||
|
||
#: 模板里一处 {{ … }}(和 engine/expressions 的 _TEMPLATE_RE 同一个写法)
|
||
_TEMPLATE_EXPR = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
|
||
#: 路径的一段:名字 + 若干 [下标](和 engine/expressions.resolve_path 同一个写法)
|
||
_SEGMENT = re.compile(r"^([^\[\]]*)((?:\[[^\[\]]+\])*)$")
|
||
_INDEX = re.compile(r"\[([^\[\]]+)\]")
|
||
#: 表达式开头是不是 input 这个命名空间(`input.x` / `input['x']` / `input[0]` / 光秃秃的 `input`)。
|
||
#: 用词边界:`inputs`、`input_value` 这类别的名字不算。写成 `input x` 这种(以 input 起头但合不上路径走法)
|
||
#: 也归到这里,由 _scanned_inputs 判成「认不出取哪个字段」——resolve_path 对它同样取不到值。
|
||
_INPUT_HEAD = re.compile(r"^input\b")
|
||
|
||
|
||
def _lookups(path: str) -> list[str] | None:
|
||
"""按 resolve_path 的走法把一条路径拆成一层层的取法(名字 / 下标里的字),写不进那套写法返回 None。
|
||
|
||
resolve_path 是「按点切段、每段再拆名字与若干下标」,认不出的一段就整个取不到值——这里逐字照搬那一套,
|
||
所以「这里认得出」= 「resolve_path 取得到值」。"""
|
||
out: list[str] = []
|
||
for raw in path.split("."):
|
||
raw = raw.strip()
|
||
if not raw:
|
||
continue
|
||
m = _SEGMENT.match(raw)
|
||
if m is None:
|
||
return None
|
||
name, idx_part = m.group(1).strip(), m.group(2)
|
||
if name:
|
||
out.append(name)
|
||
out += [idx.strip().strip("'\"") for idx in _INDEX.findall(idx_part)]
|
||
return out
|
||
|
||
|
||
def _template_path(expr: str) -> str:
|
||
"""一处 `{{ … }}` 里的路径部分(去掉过滤器);模板函数按它的第一个参数认(同 engine/variables._root_of)。"""
|
||
from app.engine.expressions import template_call
|
||
|
||
path = expr.split("|")[0].strip()
|
||
if "(" in path and (call := template_call(path)) is not None:
|
||
return call[1][0] if call[1] else ""
|
||
return path
|
||
|
||
|
||
def _scanned_inputs(template: str) -> list[tuple[str, str]]:
|
||
"""模板里每一处按 input 取值的地方:[(字段名, 认不出的原样写法)],两者只有一个非空。
|
||
|
||
认得出字段的给 (字段名, "");认不出取哪个字段的(会渲染成空串)给 ("", 那处表达式原文)。**范围是「以 input 起头的
|
||
那几条路径」**:不是 input 的引用(vars.、nodes.、别的模板函数、别的根名字)两边都空,不在这趟里——那些由画布的
|
||
变量校验管,取数 SQL 照旧能引用它们。"""
|
||
out: list[tuple[str, str]] = []
|
||
for m in _TEMPLATE_EXPR.finditer(template or ""):
|
||
expr = m.group(1).strip()
|
||
path = _template_path(expr)
|
||
if not path or not _INPUT_HEAD.match(path):
|
||
continue
|
||
lookups = _lookups(path)
|
||
if lookups is None or lookups[0] != "input" or len(lookups) < 2:
|
||
# 认不出取的是哪个字段(写法不合 resolve_path、或者就是光秃秃的 {{ input }}):会渲染成空串
|
||
out.append(("", expr or path))
|
||
continue
|
||
out.append((lookups[1], ""))
|
||
return out
|
||
|
||
|
||
def _input_fields(template: str) -> list[str]:
|
||
return [field for field, _ in _scanned_inputs(template)]
|
||
|
||
|
||
def declared(fields: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, Any]]:
|
||
"""入口字段 → {字段名: 字段(原样的一份)}。没名字的不要;同名的取第一个(和 derive.entry_fields 一个认法)。"""
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for f in fields:
|
||
name = f.get("name")
|
||
if isinstance(name, str) and name.strip():
|
||
out.setdefault(name.strip(), dict(f))
|
||
return out
|
||
|
||
|
||
#: 声明类型 → 规范类型。认不出的一律 string(各入口同一套:类型判定不依赖字段先后,R02 的一半)
|
||
_TYPES = {"string": "string", "text": "string", "integer": "integer", "int": "integer", "number": "number",
|
||
"float": "number", "decimal": "number", "boolean": "boolean", "bool": "boolean", "date": "date",
|
||
"datetime": "datetime", "time": "time", "file": "file", "array": "array", "object": "object"}
|
||
|
||
|
||
def field_type(field: Mapping[str, Any]) -> str:
|
||
"""字段声明的参数类型,规范成一个词。没写、认不出的按 string(不按值反推:同一个值在不同字段上含义不同)。"""
|
||
return _TYPES.get(str(field.get("type") or "").strip().lower(), "string")
|
||
|
||
|
||
def value_kind(value: Any) -> str:
|
||
"""这次的实际值是什么类别:text / number / boolean / null / list / object。"""
|
||
if value is None:
|
||
return "null"
|
||
if isinstance(value, bool):
|
||
return "boolean"
|
||
if isinstance(value, (int, float, Decimal)):
|
||
return "number"
|
||
if isinstance(value, str):
|
||
return "text"
|
||
if isinstance(value, (list, tuple)):
|
||
return "list"
|
||
if isinstance(value, Mapping):
|
||
return "object"
|
||
return type(value).__name__
|
||
|
||
|
||
def _names(names: Iterable[str]) -> str:
|
||
items = list(names)
|
||
return "、".join(items) if items else "(没有入口字段)"
|
||
|
||
|
||
def _given(value: Any) -> bool:
|
||
"""这个值算不算「给了」:None 与空串都不算——空串不是值,是省略。三处按同一份判:入口收值(provided)、
|
||
缺参数判断(_unresolved / runtime_problems)、渲染前定值(resolve)。"""
|
||
return value is not None and value != ""
|
||
|
||
|
||
def provided(values: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""用户这次「给了值」的那些键:None 和空串都算没给——空串不是值,是省略。
|
||
|
||
各入口收值用同一份(试跑 api/ledger.trial_caliber、草稿试跑、组合式问数 compose.check_inputs):显式传了 `""`、
|
||
字段又没声明缺省值时,接着判就是 missing_parameter(不能当空串用);字段声明了 `default=""`(作者写的就是「空值不过滤」)
|
||
的照缺省值渲染,不算缺(见 _unresolved)。"""
|
||
return {str(k): v for k, v in values.items() if _given(v)}
|
||
|
||
|
||
def resolve(fields: Iterable[Mapping[str, Any]], values: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""这次要渲染进去的那一份值:**显式给的(不是 None、不是空串)优先,否则用声明的缺省值**(`default` 给了就用——
|
||
含 `0` 与空串)。
|
||
|
||
共享层替入口把这一份定下来:检查、渲染、快照的输入哈希都用它。字段声明了 `default="east"` 而调用方没给值时,渲染出来
|
||
的必须是 `east`——以前 plan() 直接拿原来的 values 渲染,声明了却渲染成空串。已经自己补过缺省值的入口(组合式问数、
|
||
试跑)传进来的就是补好的 values,这里不改变它们的结果;入口没声明的键(开放入口:值由发起时传)原样留着。
|
||
"""
|
||
out = provided(values)
|
||
for name, field in declared(fields).items():
|
||
if name in out:
|
||
continue # 显式给了非空值:以它为准
|
||
default = field.get("default")
|
||
if default is not None:
|
||
out[name] = default # 否则用声明的缺省值(0 / 空串也算声明过的)
|
||
return out
|
||
|
||
|
||
def _written(value: Any) -> str:
|
||
if isinstance(value, str):
|
||
return value
|
||
if isinstance(value, Decimal):
|
||
from app.engine.expressions import _format_decimal
|
||
|
||
return _format_decimal(value)
|
||
if isinstance(value, (dict, list)):
|
||
return json.dumps(value, ensure_ascii=False, default=str)
|
||
return str(value)
|
||
|
||
|
||
def _bare_scalar_ok(value: Any) -> bool:
|
||
if isinstance(value, bool):
|
||
return True
|
||
if isinstance(value, int):
|
||
return True
|
||
if isinstance(value, float):
|
||
return math.isfinite(value)
|
||
if isinstance(value, Decimal):
|
||
return value.is_finite()
|
||
if isinstance(value, str):
|
||
return bool(_NUMBER.match(value))
|
||
return False
|
||
|
||
|
||
def _unresolved(name: str, field: Mapping[str, Any] | None, values: Mapping[str, Any]) -> bool:
|
||
"""这个参数这次没有可用的值:没给、给了 None、或者给了空串(空串不是值,是省略——和入口收值 params.provided 同一套),
|
||
而且声明的缺省值也是 None。
|
||
|
||
声明了缺省值的算「按声明省略」,不算缺——含 `""`:作者写的就是「空值不过滤」(`AND ('{{ input.x }}' = '' OR …)`),
|
||
那是声明的省略规则,不是没给值。"""
|
||
if _given(values.get(name)):
|
||
return False
|
||
return (field or {}).get("default") is None
|
||
|
||
|
||
# ==========================================================================
|
||
# 判断:模板、值
|
||
# ==========================================================================
|
||
|
||
|
||
def literal_problem(name: str, value: Any, contexts: Iterable[str]) -> str | None:
|
||
"""值写在 SQL 的哪个位置、旧适配器(内联文本替换)承载得了吗:承载不了给一句原因,承载得了为 None。
|
||
|
||
引号里的字里再出现引号 / 反斜杠 / 换行、裸写的位置上不是有限数值或布尔值、注释里带换行或注释结束符——这些都会让渲染出来的语句
|
||
不再等于「那一版口径的 SQL」。真支持特殊字符要等驱动级参数绑定(见模块说明),那之前明确拒绝,不硬塞。"""
|
||
text = _written(value)
|
||
where = set(contexts)
|
||
if "quoted" in where and any(ch in text for ch in ("'", "\\", "\n", "\r")):
|
||
return QUOTED_TEXT.format(name=name)
|
||
if "bare" in where and not _bare_scalar_ok(value):
|
||
return BARE_TEXT.format(name=name, value=text[:40])
|
||
if "comment" in where and ("\n" in text or "\r" in text or "*/" in text):
|
||
return COMMENT_TEXT.format(name=name)
|
||
return None
|
||
|
||
|
||
def check_template(template: str, *, fields: Mapping[str, Mapping[str, Any]], values: Mapping[str, Any] | None = None,
|
||
node_id: str | None = None, where: str = "入口字段",
|
||
dialect: str | None = None) -> list[Problem]:
|
||
"""一条取数模板按声明的入口字段过一遍:未声明、认不出的写法;给了 values 的,再判缺值与值承载不了。
|
||
|
||
values 为 None 是「此刻还没有这次调用的值」(读一版口径时就是这样):只判模板和声明对不对得上。
|
||
认不出写法(unsupported)时不接着往下判——值写在哪儿都还没定,别的结论不可靠。"""
|
||
try:
|
||
slots = slots_of(template, dialect=dialect)
|
||
except ParameterRefused as e:
|
||
return [Problem(UNSUPPORTED, UNREADABLE_TEXT.format(reason=p.message), node_id=node_id) for p in e.problems]
|
||
problems: list[Problem] = []
|
||
seen: set[str] = set()
|
||
for slot in slots:
|
||
name = slot.field
|
||
if name in seen:
|
||
continue
|
||
seen.add(name)
|
||
field = fields.get(name)
|
||
if field is None:
|
||
problems.append(Problem(UNDECLARED, UNDECLARED_TEXT.format(name=name, where=where,
|
||
fields=_names(fields)),
|
||
name=name, node_id=node_id, where=where))
|
||
elif values is not None and _unresolved(name, field, values):
|
||
problems.append(Problem(MISSING, MISSING_TEXT.format(name=name, where=where),
|
||
name=name, node_id=node_id, where=where))
|
||
if values is None:
|
||
return problems
|
||
seen_literal: set[str] = set()
|
||
for name in (s.field for s in slots):
|
||
field = fields.get(name)
|
||
if field is None or name not in values or name in seen_literal:
|
||
continue
|
||
seen_literal.add(name)
|
||
# 同一个字段的每一处槽位一起判(写在引号里 / 裸写 / 注释里各算一处),一个字段只报一条
|
||
if problem := literal_problem(name, values[name], (s.context for s in slots if s.field == name)):
|
||
problems.append(Problem(LITERAL, problem, name=name, node_id=node_id, where=where))
|
||
return problems
|
||
|
||
|
||
def _sql_of(node: Mapping[str, Any]) -> str | None:
|
||
args = ((node.get("data") or {}).get("config") or {}).get("args") if isinstance(node, Mapping) else None
|
||
sql = args.get("sql") if isinstance(args, Mapping) else None
|
||
return sql if isinstance(sql, str) and sql else None
|
||
|
||
|
||
def check_nodes(nodes: Iterable[Mapping[str, Any]], *, fields: Iterable[Mapping[str, Any]],
|
||
values: Mapping[str, Any] | None = None, where: str = "入口字段",
|
||
dialect: str | None = None) -> list[Problem]:
|
||
"""几个调用工具节点(原始 JSON)的 args.sql 各过一遍,问题按节点先后合起来。values 不给就只判模板与声明。"""
|
||
declared_fields = declared(fields)
|
||
out: list[Problem] = []
|
||
for node in nodes:
|
||
if not isinstance(node, Mapping):
|
||
continue
|
||
sql = _sql_of(node)
|
||
if sql is None:
|
||
continue
|
||
out += check_template(sql, fields=declared_fields, values=values, node_id=str(node.get("id") or "") or None,
|
||
where=where, dialect=dialect)
|
||
return out
|
||
|
||
|
||
def runtime_problems(template: str, *, values: Mapping[str, Any],
|
||
declared: Mapping[str, Mapping[str, Any]]) -> list[Problem]:
|
||
"""运行时的同一条判断(宽松认法):模板里按 input 取值的地方这次取不到值就拒。
|
||
|
||
认法和 engine/expressions.resolve_path 一致(`input.字段`、`input['字段']`、`input[0]`、`input.a.b[0].c` 都算,
|
||
取的是第一层的字段);认不出取哪个字段的写法(`{{ input }}`、`input['a.b']`、`input[a[b]]`)同样拒——它们渲染成
|
||
空串,不能当「没引用参数」放过去。
|
||
|
||
「有值」和入口收值(params.provided)同一套:**None 和空串都算没给**(空串不是值,是省略),取到别的值才放行。
|
||
开放入口(入口节点没声明字段、键由发起时传)也一样——否则 `{{ input['store'] }}` 传了个空串就会渲染成 `''`,
|
||
发出一条 `AND region = ''`。
|
||
|
||
- 没给值、入口也没声明过它 → undeclared_parameter;
|
||
- 没给值、声明过、声明的缺省值也是 None(含只声明成可选的)→ missing_parameter;声明了缺省值(含 `""`:作者写的
|
||
就是「空值不过滤」)的按声明保留,照缺省值渲染。
|
||
只看会不会渲染成空串:普通画布上的取数 SQL 引用 vars./nodes. 的不归它管。
|
||
"""
|
||
problems: list[Problem] = [
|
||
Problem(UNSUPPORTED, DYNAMIC_TEXT.format(expr=writing)) for writing in dynamic_input_writings(template)]
|
||
for name in input_refs(template):
|
||
if _given(values.get(name)):
|
||
continue
|
||
field = declared.get(name)
|
||
# 这次运行的输入里有这个键(只是值空着)就不算「没声明」——那是这次没给值;连键都没有、入口也没声明才是没声明
|
||
if field is None and name not in values:
|
||
problems.append(Problem(UNDECLARED, UNDECLARED_TEXT.format(
|
||
name=name, where="这次运行的入口", fields=_names(sorted(declared))), name=name))
|
||
elif _unresolved(name, field, values):
|
||
problems.append(Problem(MISSING, MISSING_TEXT.format(name=name, where="这次运行的入口"), name=name))
|
||
return problems
|
||
|
||
|
||
# ==========================================================================
|
||
# 渲染与快照材料
|
||
# ==========================================================================
|
||
|
||
|
||
def render(template: str, values: Mapping[str, Any]) -> str:
|
||
"""模板按值渲染成字面语句。走的是运行时同一个渲染器(engine/expressions.render_template):值替换进来就不再被
|
||
模板解释一遍。这里只负责「值是我的、模板只过一次」,缺值缺声明的判断在 check_* / plan。"""
|
||
from app.engine.expressions import render_template
|
||
|
||
return render_template(template, {"input": dict(values)}) if "{{" in template else template
|
||
|
||
|
||
def _hash(text: str) -> str:
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def material(template: str, *, values: Mapping[str, Any], declared_fields: Mapping[str, Mapping[str, Any]],
|
||
source: Mapping[str, Any] | None = None, with_template: bool = True,
|
||
dialect: str | None = None) -> dict[str, Any]:
|
||
"""查询快照要记的那一份:源模板、源模板哈希、规范化输入哈希、参数契约、每个参数的类型与写在哪儿、承载方式。
|
||
|
||
绑定值不在这里再存一份——内联方式下它就写在实际执行语句里,而实际语句本身在快照里(payload["sql"])。
|
||
|
||
with_template=False 时不写源模板正文,只留哈希(写进节点配置的那一份就是这样:engine/variables 会把节点配置里
|
||
每一段带 {{ }} 的字都当成活引用扫一遍,正文留在节点上会把取数模板的占位符报成「没人产出」)。这一版口径的模板由
|
||
source 里的固定引用(工作流 + 版本 + 节点)还原,哈希用来核对还原出来的还是不是当时那一份。"""
|
||
names = input_refs(template)
|
||
bound: list[dict[str, Any]] = []
|
||
try:
|
||
by_field = {s.field: s.context for s in slots_of(template, dialect=dialect)}
|
||
except ParameterRefused:
|
||
by_field = {}
|
||
for name in names:
|
||
field = declared_fields.get(name) or {}
|
||
item: dict[str, Any] = {"name": name, "kind": value_kind(values.get(name)),
|
||
"declared": field_type(field) if field else None,
|
||
"slot": by_field.get(name)}
|
||
bound.append(item)
|
||
payload = {k: values.get(k) for k in names}
|
||
out: dict[str, Any] = {
|
||
"adapter": ADAPTER,
|
||
"binding": "values_in_statement",
|
||
"template_hash": _hash(template),
|
||
"inputs_hash": _hash(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)),
|
||
"declared": contract(declared_fields.values()),
|
||
"parameters": bound,
|
||
}
|
||
if with_template:
|
||
out["template"] = template
|
||
if source:
|
||
out["source"] = dict(source)
|
||
return out
|
||
|
||
|
||
def plan(template: str, *, fields: Iterable[Mapping[str, Any]], values: Mapping[str, Any],
|
||
source: Mapping[str, Any] | None = None, node_id: str | None = None,
|
||
where: str = "入口字段", dialect: str | None = None) -> Statement:
|
||
"""「现在就发一条 SQL」的入口用:先按声明的字段把这次的值定下来(resolve:显式非空值优先、否则声明缺省值),
|
||
再判参数完整性,过了才渲染成字面语句,附快照材料——检查、渲染、材料里的输入哈希用的是**同一份 resolved values**。
|
||
|
||
不合规抛 ParameterRefused(缺参数、未声明、认不出的写法、值承载不了),调用方据此回 4xx,别发 SQL。
|
||
|
||
values 按同一套存在性语义收(None 与空串都算没给):没过滤就传进来的调用方,等于把「显式空串」当值渲染出去——
|
||
resolve 会先把它按「没给」处理(有声明缺省值就用缺省值,没有就判缺参数)。已经自己补过缺省值的入口传进来的是
|
||
补好的 values,结果不变。
|
||
|
||
注意:现在**没有正式入口调它**(试跑、组合式问数、派生图、正式出具各走自己的入口,都在发起/派生之前用
|
||
check_nodes / check_template 判过)——它是给下一个入口(QueryCore 的固定执行)准备的共享层接口,行为由测试锁住。"""
|
||
declared_fields = declared(fields)
|
||
resolved = resolve(declared_fields.values(), values)
|
||
problems = check_template(template, fields=declared_fields, values=resolved, node_id=node_id, where=where,
|
||
dialect=dialect)
|
||
if problems:
|
||
raise ParameterRefused(problems)
|
||
return Statement(statement=render(template, resolved),
|
||
material=material(template, values=resolved, declared_fields=declared_fields, source=source,
|
||
dialect=dialect))
|
||
|
||
|
||
# ==========================================================================
|
||
# 节点的参数契约(组合式问数、对账图写,运行时认)
|
||
# ==========================================================================
|
||
|
||
|
||
def marker(*, rendered: Any, template: str | None = None, values: Mapping[str, Any] | None = None,
|
||
declared_fields: Iterable[Mapping[str, Any]] = (), source: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||
"""写进节点 data.config[KEY] 的参数契约:**rendered 那一项(tool 的 args / merge 的 sql)已经是渲染好的字面语句**,
|
||
运行时的模板渲染一概不碰它——不然值里的 `{{ }}` 会被再解释一遍(K01 的二次渲染反例)。
|
||
|
||
rendered 必给:契约按它的哈希与节点上那一项绑定,节点被复制、被改过之后契约自动作废(见 literal_keys)。写契约的人
|
||
必须把它要注解的那份内容原样交进来(和写进节点的必须是同一个对象)。
|
||
|
||
template/values 给了就再把材料写上(源模板哈希、规范化输入哈希、参数契约、每个参数写在哪儿、出处);**不写源模板
|
||
正文**:engine/variables 会把节点配置里每一段带 {{ }} 的字都当活引用扫,正文留在节点上会把取数模板的占位符报成
|
||
「没有任何节点产出」,那张图就跑不起来了。"""
|
||
out: dict[str, Any] = {"mode": LITERAL_ARGS, "adapter": ADAPTER, HASH_KEY: rendered_hash(rendered)}
|
||
if template is not None:
|
||
out.update(material(template, values=values or {}, declared_fields=declared(declared_fields),
|
||
source=source, with_template=False))
|
||
elif source:
|
||
out["source"] = dict(source)
|
||
return out
|
||
|
||
|
||
def contract(fields: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||
"""参数契约:入口字段按声明顺序写成 {name, type, required, default}(快照与审核看得懂的那一份)。"""
|
||
out: list[dict[str, Any]] = []
|
||
for field in declared(fields).values():
|
||
out.append({"name": field["name"], "type": field_type(field), "required": bool(field.get("required")),
|
||
**({"default": field["default"]} if field.get("default") is not None else {})})
|
||
return out
|
||
|
||
|
||
def mode_of(config: Mapping[str, Any] | None) -> str | None:
|
||
"""契约里写的模式(data.config[KEY])。**只看写了什么,不核对哈希**——判断要不要跳过渲染、要不要跳过画布校验,
|
||
一律用 literal_keys / is_literal;这个只用来断言「服务端写下了契约」。"""
|
||
marker_ = (config or {}).get(KEY)
|
||
mode = marker_.get("mode") if isinstance(marker_, Mapping) else None
|
||
return LITERAL_ARGS if mode == LITERAL_ARGS else None
|
||
|
||
|
||
def rendered_hash(value: Any) -> str:
|
||
"""契约注解的那一项(tool 的 args / merge 的 sql)的确定哈希。字典按键排序(canonical_json),字符串按原文。"""
|
||
from app.core.artifact_store import canonical_json
|
||
|
||
return _hash(value if isinstance(value, str) else canonical_json(value))
|
||
|
||
|
||
def literal_keys(config: Mapping[str, Any] | None, node_type: str | None) -> tuple[str, ...]:
|
||
"""这个节点上「服务端渲染好的字面语句」在哪个键上(tool 的 args / merge 的 sql)——**核对过哈希**才算,其余为空。
|
||
|
||
契约是服务端写在节点上的,但节点会被复制、会被改:改过 args / sql 之后,契约说的已经不是这一条语句了。只有契约里的
|
||
rendered_hash 与当前那一项对得上,才按 literal 处理(跳过渲染、跳过模板扫描、快照用契约里的材料);对不上(复制后
|
||
改了、改回普通模板、改了别的参数)一律当没写契约——正常渲染、正常判参数,画布的变量校验也照常扫。"""
|
||
keys = LITERAL_KEY.get(str(node_type or ""))
|
||
if not keys or mode_of(config) != LITERAL_ARGS:
|
||
return ()
|
||
marker_ = (config or {}).get(KEY)
|
||
if not isinstance(marker_, Mapping):
|
||
return ()
|
||
if marker_.get(HASH_KEY) != rendered_hash((config or {}).get(keys)):
|
||
return ()
|
||
return (keys,)
|
||
|
||
|
||
def is_literal(config: Mapping[str, Any] | None, node_type: str | None) -> bool:
|
||
"""这个节点的 args / sql 是不是「服务端渲染好的字面语句,而且没被改过」:是就跳过渲染、跳过模板扫描。"""
|
||
return bool(literal_keys(config, node_type))
|
||
|
||
|
||
def marker_material(config: Mapping[str, Any] | None, node_type: str | None) -> dict[str, Any] | None:
|
||
"""契约里要进查询快照的部分(去掉 mode 与 rendered_hash):源模板哈希、规范化输入哈希、参数契约、参数类型与写在哪儿、
|
||
出处。**只有核对过哈希才给**——过时的契约说的不是这一条语句,拿它当证据就是失真。"""
|
||
if not is_literal(config, node_type):
|
||
return None
|
||
marker_ = (config or {}).get(KEY)
|
||
return {k: v for k, v in marker_.items() if k not in ("mode", HASH_KEY)}
|
||
|
||
|
||
def contract_of(config: Mapping[str, Any] | None) -> list[dict[str, Any]]:
|
||
"""节点上那份参数契约声明的参数(name / type / required / default),原样的一份。没写契约的空数组。"""
|
||
marker_ = (config or {}).get(KEY)
|
||
items = marker_.get("declared") if isinstance(marker_, Mapping) else None
|
||
return [dict(d) for d in items or [] if isinstance(d, Mapping) and d.get("name")]
|
||
|
||
|
||
__all__ = ["ADAPTER", "CODES", "CONTEXTS", "FILTERS", "HASH_KEY", "KEY", "LITERAL", "LITERAL_ARGS", "LITERAL_KEY",
|
||
"MISSING", "ParameterRefused", "Problem", "SNAPSHOT_KEY", "Slot", "Statement", "UNDECLARED", "UNSUPPORTED",
|
||
"check_nodes", "check_template", "contract", "contract_of", "declared", "describe", "dynamic_input_writings",
|
||
"field_type", "input_refs", "is_literal", "literal_keys", "literal_problem", "marker", "marker_material",
|
||
"material", "mentions_input", "mode_of", "plan", "provided", "render", "rendered_hash", "resolve",
|
||
"runtime_problems", "slots_of", "value_kind"]
|