Files
AgentLab/backend/app/ledger/compose.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

370 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""组合式问数:口径做骨架,Agent 填缺口(docs/design/next-round/02-口径账本.md 做法 4,拍板 L3)。
问数据的助手看到已发布口径(look 的 caliber,问数据里另给入口字段和「能不能组合」,不给 SQL)之后,可以输出一行
{"op":"use_caliber","id":"c1","workflow_id":"…","version":3,"card":"card","metrics":["gmv"],"inputs":{"start":"…"}}
选用一份口径。**取数节点不让模型写**:由服务端按那一版确定性地内联实例化(ledger/derive.caliber_block,全站唯一的
派生图生成器)——口径卡读的取数节点照抄,节点 id 加前缀「c1_」,模板里的 {{ input.字段 }} 按 inputs 渲染成字面 SQL
(和运行时同一个渲染器);口径卡 id 就是 c1,caliber_from 固定到那一版的那张卡、带改名映射 {原 id: c1_原 id}
(metrics 运行时按它改写式子)。取数节点首尾相接、最后一个接口径卡;入口连到第一个取数节点(GraphApplier 接上)。
模型只管把口径卡 c1 接到报告、报告里引用 [[m:指标 id]]。
**为什么不用子图节点。** 子图的证据不交回父图(报告引用不到子图里的数),使用者也用不了子工作流(auth/chat_policy)。
内联实例化只用到 tool 和 metrics 两种已放行的节点,证据照常封存,比对也照常复证(ledger/match 认出这些查询是助手
选用的,标 composed「助手选用」,不标「等价已证明」:选哪份口径是助手凭问法判断的)。
**只算选中的指标(K08 S2/S4)。** 模型写的 metrics 不只是「报告里引用哪几个数」:它决定这次真正要执行哪些取数——服务端按它
算一份依赖闭包(ledger/closure),只拷选中的指标要用的取数节点、入口字段收窄到这次会用到的,并把选中的指标记在口径卡的
caliber_from.metrics 上(运行时按 closure.effective_metrics 只算这几个)。一次选用里的指标一律按**源卡顺序**归一:
Composed.metrics、口径卡上记的 metrics、selection_hash 与执行数组都按这一份,调用方传的顺序与重复项都不影响结果。只选 A 时
不再要求 B 的参数;A 和 B 共用一条 SQL 时那条 SQL 保留、并在结果里声明它还在算未选中的 B(不做投影)。点名的指标解析不出
依赖(不在这张卡上、式子读不出来、绑定对不上)一律拒收,不静默退回整卡。
**选项是预筛,不是放行**(K08 S4)。look 读好的每一张卡带 ``Option``:``selectable_metrics``(能一个一个点名的指标,各自
要哪些入口字段)与 ``unavailable_metrics``(用不了的与原因)按每个指标判(B 独有那条查询坏了、绑定对不上,只让 B 不可选);
至少有一个可选才给组合写法,一个也没有才标不能组合。``default_closure`` 是不点名(整卡)那一份闭包的摘要。这些摘要只含
query_id、字段、工具、指纹与共享声明,**不含 SQL 正文、不含参数值**。真正选用(``instantiate``)时仍按这次要算的指标逐项
硬校验,不拿预筛的结论放宽。
**参数校验**(不合规拒收,原因交回模型改,GraphApplier.refusals;判断都在 ledger/params,一处规则各入口同一套):
- 只能选这一轮 look 能看到的那一版(每个工作流的**推荐发布版本**,K07:Workflow.published_version,默认调用读它),而且
那张卡能组合(derive.load_pinned:卡是
源头、每个输入都是取数节点第一行的格、取数模板只引用入口字段、模板里的参数都在入口字段里声明过);
- inputs 只认口径入口声明的字段,没写的补缺省值,再过 apps/forms 同一套表单校验(类型、可选值、必填、格式);
- 取数模板里的每个参数这次都要有值:没给、也没声明缺省值的(含声明成可选的)报 missing_parameter,不按空串发 SQL;
- 参数要渲染进 SQL:写在引号里的不能带引号、反斜杠、换行;裸写的只能是数;写在注释里的不能带换行和注释结束符——
否则渲染出来的就不是那一版口径的 SQL 了(旧适配器承载不了的值明确拒绝,见 ledger/params);
- metrics 只能是这张卡上的指标 id;id 不能和图里已有的节点撞。
**值只渲染一次。** 拷出来的取数节点里,`{{ input.字段 }}` 在这一步渲染成字面值(值只被模板替换这一次),同时把参数
契约写在节点的 `data.config["parameters"]` 里(ledger/params 的 marker):args 里已经是字面语句,运行时的模板渲染一概
不碰它——不然值里带 `{{ vars.question }}` 这种文本会被运行时替换成那个变量的值,SQL 被数据改掉(K01 的二次渲染反例)。
契约按**这次渲染出来的 args** 的哈希与节点绑定:复制走、改过 SQL 之后契约作废,那个节点回退成普通模板(正常渲染、
正常判参数、画布照常扫变量)。契约里另记源模板哈希、规范化输入哈希、参数类型与写在哪儿、那一版取数节点的固定引用
(出处只是个地址:本切片没有实现按它自动还原模板并核对)。
实例化是确定的:同样的版本、同样的操作得到一模一样的节点(测试锁住)。这里只是纯计算:版本快照由 look/calibers 的
CaliberKind 在开流之前用会话读好(GraphApplier.apply 是同步的,流里不碰库)。
"""
from __future__ import annotations
import copy
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
from app.ledger import params
from app.ledger.derive import Pinned, caliber_block, declared_entry_fields
#: 操作名
OP = "use_caliber"
#: 拒收时的问题编号(自查交回模型、过程里照实写一行)
REFUSED = "ledger.use_caliber_refused"
#: 操作 id:口径卡的节点 id,取数节点加它做前缀。简短英文小写下划线(同节点 id 的约定)
_OP_ID = re.compile(r"^[a-z][a-z0-9_]{0,23}$")
#: 写给模型的拒收说明(也上界面:过程里那一行)
NOT_LISTED = "只能选用这一轮查看到的已发布口径:「{workflow}」v{version} 不在里面(口径要先 look,版本以查看结果为准)"
WRONG_VERSION = "「{workflow}」这一轮能选用的是 v{latest}(这一轮的推荐发布版本),不是 v{version}"
#: card 写错时交回的原因要能一次改对:说清 card 填什么、可填哪几个(节点 id);写成了自己起的 id 的另说一句
CARD_UNKNOWN = ("「{workflow}」v{version} 里没有口径卡「{card}」:card 要填那一版工作流图里口径卡的节点 id,可填的有 {cards}"
"(照抄查看结果里 use_caliber 写法中的 card)")
CARD_IS_OP_ID = ";「{card}」是你给这次选用起的 id,不是口径卡的节点 id"
CARD_AMBIGUOUS = "「{workflow}」v{version} 有几张口径卡(节点 id:{cards}),请在 card 里写选用哪一张的节点 id"
NOT_COMPOSABLE = "「{workflow}」v{version} 的口径卡「{card}」不能组合:{reason}"
BAD_ID = "use_caliber 的 id 要写成简短的英文小写加下划线(例如 c1),它就是口径卡的节点 id,当前为「{id}」"
ID_TAKEN = "use_caliber 的 id「{id}」和图里已有的节点「{node}」撞了,请换一个"
NO_METRICS = "use_caliber 的 metrics 要写这张口径卡上要用的指标 id(有:{metrics})"
METRIC_UNKNOWN = "口径卡「{card}」上没有指标「{metric}」(有:{metrics})"
FIELD_UNKNOWN = "口径「{workflow}」的入口没有字段「{name}」(有:{fields})"
#: 这个字段这一版入口声明过,只是不在这次选中的指标的范围里(K08 复核):说清是范围问题,不是口径没这个字段
FIELD_OUT_OF_SELECTION = ("「{name}」这一版入口声明过,但不在这次选用的范围里:这次算 {metrics},只要 {fields};"
"口径「{workflow}」的入口还有 {others},那是别的指标要的——要用它就把那些指标一起选上")
INPUTS_NOT_OBJECT = "use_caliber 的 inputs 要写成字段和值的对照(JSON 对象)"
TEMPLATE_UNREADABLE = params.UNREADABLE_TEXT
class ComposeRefused(Exception):
"""不实例化这次选用:message 给模型看、也写进过程里那一行。"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
@dataclass(frozen=True)
class Option:
"""这一轮能选用的一张口径卡:读好的那一版(pinned),以及**按指标**的可组合性(K08 S4)。
- ``selectable_metrics``:能一个一个点名的指标(按源卡顺序)。每项是这一份依赖的摘要(``closure.summary()`` 加
``metric_id`` / ``name``):这次要用的入口字段、要执行的查询(query_id、节点、工具、各自算谁、共享声明)、绑定指纹
与两份身份哈希——**没有 SQL 正文、没有参数值**。
- ``unavailable_metrics``:用不了的指标与原因(按源卡顺序)。
- ``default_closure``:不点名(整卡)那一份闭包的摘要;整卡算不出来时是 None,原因在 ``reason``。
``composable`` 只看「至少有一个指标可选」:B 独有那条查询坏了、绑定对不上,A 照旧能选。这一层只是预筛,不改任何
判断——真正选用时 ledger/compose.instantiate 按这次要算的指标逐项硬校验(未知指标、参数、绑定),不靠预筛放宽。
"""
workflow_id: str
workflow_name: str
version: int
level: str
node_id: str
card: str
pinned: Pinned | None = None
reason: str | None = None
selectable_metrics: tuple[dict[str, Any], ...] = ()
unavailable_metrics: tuple[dict[str, Any], ...] = ()
default_closure: dict[str, Any] | None = None
@property
def composable(self) -> bool:
"""这一轮能不能选用:至少有一个指标可选。没做过分指标分析的老调用方(直接给了 pinned)按能不能读出这一版算。"""
if self.pinned is None:
return False
return bool(self.selectable_metrics) or self.reason is None
@dataclass
class Composed:
"""一次选用实例化出来的东西:要加的节点、连线(取数首尾相接、最后接口径卡),交给界面和模型的结果。"""
op_id: str
option: Option
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
metrics: list[dict[str, Any]]
inputs: dict[str, Any] = field(default_factory=dict)
#: 这次选择的依赖闭包(K08 S2):身份、这次要执行的查询、共享查询还在算谁(结果里照实带出去)
closure: Any = None
@property
def fetch_ids(self) -> list[str]:
return [str(n["id"]) for n in self.nodes if n.get("type") != "metrics"]
def result(self) -> dict[str, Any]:
"""交给界面(过程里那一行)、记进这一轮的操作流的结果。不放 SQL、不放参数值(参数值助手自己写的,界面不必再给)。"""
from app.ledger.labels import LEVEL_TEXT
out = {"status": "ok", "workflow_id": self.option.workflow_id, "workflow_name": self.option.workflow_name,
"version": self.option.version, "level": self.option.level,
"level_text": LEVEL_TEXT.get(self.option.level, ""), "card": self.option.card, "card_id": self.op_id,
"fetches": self.fetch_ids, "metrics": [{"id": m["id"], "name": m["name"]} for m in self.metrics],
"nodes": len(self.nodes)}
if self.closure is not None:
# 这次选择的身份(selection_hash:同一批指标同一批查询)与整份闭包的身份(closure_hash:绑定变了它就变)
out["selection"] = {"mode": self.closure.mode, "metric_ids": list(self.closure.metric_ids),
"query_ids": list(self.closure.query_ids),
"excluded_query_ids": list(self.closure.excluded_query_ids),
"selection_hash": self.closure.selection_hash,
"closure_hash": self.closure.closure_hash,
"resolver_version": self.closure.resolver_version,
"source_card_hash": self.closure.source_card_hash}
# 共享查询照实声明:这条 SQL 这次也还在算这些没选中的指标(不做投影)
shared = [{"query_id": q.query_id, "node_id": q.node_id, "computes_unselected": list(q.computes_unselected)}
for q in self.closure.queries if q.computes_unselected]
if shared:
out["shared_queries"] = shared
return out
def signature(op: Mapping[str, Any]) -> str:
"""一次选用的身份(接着写时重发的同一个操作认得出来):去掉服务端写上的结果。"""
return json.dumps({k: v for k, v in op.items() if k != "result"}, ensure_ascii=False, sort_keys=True, default=str)
def _names(items: list[str]) -> str:
return "、".join(items[:12]) or "无"
def _selected_ids(pinned: Pinned) -> list[str]:
"""这一份(可能裁过的)口径这次算哪些指标:裁过就是选择里的那几个(按源卡顺序),没裁就是卡上的全部。"""
closure = pinned.closure
if closure is not None and not closure.whole_card:
return [str(m) for m in closure.metric_ids]
return [str(m["id"]) for m in pinned.metrics]
def slot_contexts(nodes: Any, *, strict: bool = True) -> dict[str, set[str]]:
"""几个调用工具节点的 args.sql 里每个入口字段写在哪:{字段: {quoted / bare / comment}}。
判断在 ledger/params(各入口同一套参数语义):这里只把 ``params.slots_of`` 的结果收成 {字段: {写在哪儿}},给要看
承载方式的地方(``params.literal_problem`` 的那一套)读;按指标的可组合性(look/calibers.metric_options)走闭包,
不从这里拿。strict=False 时认不出写法的那条跳过、不抛。"""
out: dict[str, set[str]] = {}
for node in nodes:
if not isinstance(node, dict):
continue
args = ((node.get("data") or {}).get("config") or {}).get("args")
sql = args.get("sql") if isinstance(args, dict) else None
if not isinstance(sql, str):
continue
try:
slots = params.slots_of(sql)
except params.ParameterRefused as e:
if not strict:
continue
raise ComposeRefused(TEMPLATE_UNREADABLE.format(reason=e.problems[0].message)) from None
for slot in slots:
out.setdefault(slot.field, set()).add(slot.context)
return out
def _value_problem(name: str, value: Any, contexts: set[str]) -> str | None:
"""值写在 SQL 的哪个位置、旧适配器(内联文本替换)承载得了吗(ledger/params.literal_problem)。"""
return params.literal_problem(name, value, contexts)
#: 点名要算的指标解析不出依赖(derive.select_pinned 的未决项):照实交回,拒收这次选用
SELECTION_REFUSED = "口径卡「{card}」这次点名的指标算不出依赖:{reason}"
def _selected(pinned: Pinned, metric_ids: "Sequence[str] | None" = None, whole_card: bool = False) -> Pinned:
"""按这次要算的指标裁一份口径(derive.select_pinned:只留这次要执行的取数、入口字段收窄、带上选择的闭包)。
算不出依赖(点名的指标不在这张卡上、式子读不出来、绑定对不上、点名的取数没有可读 SQL)抛 ComposeRefused 交回模型改
——**不静默退回整卡**。"""
from app.ledger import derive
try:
return derive.select_pinned(pinned, metric_ids=metric_ids, whole_card=whole_card)
except derive.NotDerivable as e:
raise ComposeRefused(SELECTION_REFUSED.format(card=pinned.card_title, reason=e.message)) from None
def check_inputs(pinned: Pinned, inputs: Any, *, metric_ids: "Sequence[str] | None" = None,
whole_card: bool = False) -> dict[str, Any]:
"""按口径入口的字段校验助手给的参数,补缺省值,交回这次要渲染进取数节点的值(拷节点时用它渲染一次)。
metric_ids / whole_card 给了就先按这次要算的指标裁一份(_selected):只按**这次真正要执行的取数**判参数与入口字段——
只选 A 时不再要求 B 那条 SQL 的参数,B 独有的入口字段也不在这次范围里(照实拒)。不给就是整张卡的入口(和以前一样)。
字段这一栏分两种说法(K08 复核):这一版入口**声明过**、只是不在这次选中的指标的范围里 → FIELD_OUT_OF_SELECTION
(说清这次算哪些指标、只要哪些字段、剩下的字段是谁要的);入口**根本没声明** → FIELD_UNKNOWN。两种都拒,不改行为。
不合规抛 ComposeRefused(一句一处):字段不认识、必填没给、类型/可选值不对、模板里的参数这次没有值(missing)、
模板引用了没声明的字段(undeclared)、值承载不了(literal)。"""
if metric_ids is not None or whole_card:
pinned = _selected(pinned, metric_ids=metric_ids, whole_card=whole_card)
from app.apps.forms import input_schema, validate_form
if inputs is None:
inputs = {}
if not isinstance(inputs, dict):
raise ComposeRefused(INPUTS_NOT_OBJECT)
fields = {str(f.get("name")).strip(): f for f in pinned.entry_fields if isinstance(f.get("name"), str)}
if unknown := [k for k in inputs if k not in fields]:
# 字段「不在本次选择范围」与「口径没有这个字段」是两回事(K08 复核):前者说清这次算哪些指标、只要哪些字段
declared = declared_entry_fields(pinned)
names = {str(f.get("name") or "").strip() for f in declared}
if later := [k for k in unknown if k in names]:
raise ComposeRefused(FIELD_OUT_OF_SELECTION.format(
name=later[0], workflow=pinned.workflow_name, metrics=_names(_selected_ids(pinned)),
fields=_names(list(fields)),
others=_names([str(f.get("name")) for f in declared
if str(f.get("name") or "").strip() not in fields])))
raise ComposeRefused(FIELD_UNKNOWN.format(workflow=pinned.workflow_name, name=unknown[0],
fields=_names(list(fields))))
values = {name: copy.deepcopy(f.get("default")) for name, f in fields.items()
if f.get("default") not in (None, "")}
# 收值和试跑同一份(params.provided):显式传的空串算没给——字段没声明缺省值时就是缺参数(上面判),
# 声明了 default="" 的照缺省值走(作者写的就是「空值不过滤」)
values.update(params.provided(inputs))
if errors := validate_form(input_schema(list(pinned.entry_fields)), values):
raise ComposeRefused(";".join(e["message"] for e in errors))
problems = params.check_nodes(pinned.fetches, fields=pinned.entry_fields, values=values,
where=f"口径「{pinned.workflow_name}」的入口")
if problems:
raise ComposeRefused(params.describe(problems))
return values
def instantiate(option: Option, op: Mapping[str, Any], *, taken: set[str]) -> Composed:
"""按选用的那一版确定性地实例化(见模块说明)。taken 是图里已有的节点 id。不合规抛 ComposeRefused。
op.metrics 贯穿参数校验与拷贝:先按它裁一份(_selected 的依赖闭包),check_inputs 按**这次真正要执行的取数**判参数
(只选 A 时不再要求 B 的参数),caliber_block 也只拷 A 那条查询、把这次的选择记在口径卡上。选中的指标解析不出依赖
——拒收,不静默退回整卡。
一次选用里的指标按**源卡顺序**归一(同一批指标换个写法——调用方传的顺序、重复项——得到同一份结果:Composed.metrics、
口径卡上记的 metrics、selection_hash 与执行数组都按这一份,见 K08 S4)。"""
pinned = option.pinned
if pinned is None or not option.composable:
raise ComposeRefused(NOT_COMPOSABLE.format(workflow=option.workflow_name, version=option.version,
card=option.card, reason=option.reason or "读不到这一版"))
op_id = str(op.get("id") or "").strip()
if not _OP_ID.match(op_id):
raise ComposeRefused(BAD_ID.format(id=op_id))
wanted = [str(m) for m in op.get("metrics") or [] if isinstance(m, (str, int))] \
if isinstance(op.get("metrics"), list) else []
on_card = {str(m["id"]): m for m in pinned.metrics}
if not wanted:
raise ComposeRefused(NO_METRICS.format(metrics=_names(list(on_card))))
if missing := [m for m in wanted if m not in on_card]:
raise ComposeRefused(METRIC_UNKNOWN.format(card=option.card, metric=missing[0], metrics=_names(list(on_card))))
selected = _selected(pinned, wanted)
values = check_inputs(selected, op.get("inputs"))
block = caliber_block(selected, prefix=f"{op_id}_", card_id=op_id, inputs=values)
if clash := next((str(n["id"]) for n in block.nodes if str(n["id"]) in taken), None):
raise ComposeRefused(ID_TAKEN.format(id=op_id, node=clash))
nodes = block.nodes
edges = [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]
metrics = [{"id": m, "name": str(on_card[m].get("name") or m)} for m in _ordered(pinned, selected, wanted)]
return Composed(op_id=op_id, option=option, nodes=nodes, edges=edges, metrics=metrics, inputs=values,
closure=selected.closure)
def _ordered(pinned: Pinned, selected: Pinned, wanted: list[str]) -> list[str]:
"""这次算的指标 id,按**源卡顺序**归一(和 caliber_from.metrics、运行时、selection_hash 同一份)。
裁剪过的那一份带着闭包,直接读它;没有闭包(理论上到不了)就按卡上指标的顺序取。"""
closure = selected.closure
if closure is not None:
return [str(m) for m in closure.metric_ids]
return [str(m["id"]) for m in pinned.metrics if str(m["id"]) in set(wanted)]
class Composer:
"""GraphApplier 用的:这一轮能选用的口径卡(CaliberKind 在开流之前读好),按 use_caliber 实例化。"""
def __init__(self, options: list[Option]) -> None:
self.options = options
def pick(self, op: Mapping[str, Any]) -> Option:
workflow_id = str(op.get("workflow_id") or "").strip()
version = op.get("version")
cards = [o for o in self.options if o.workflow_id == workflow_id]
if not cards:
named = str(op.get("workflow_name") or workflow_id or "(没写工作流)")
raise ComposeRefused(NOT_LISTED.format(workflow=named, version=version))
name = cards[0].workflow_name
latest = cards[0].version
if str(version) != str(latest):
raise ComposeRefused(WRONG_VERSION.format(workflow=name, latest=latest, version=version))
card = str(op.get("card") or "").strip()
listed = [f"「{o.node_id}」(口径卡「{o.card}」)" for o in cards]
if card:
hit = next((o for o in cards if card in (o.node_id, o.card)), None)
if hit is None:
why = CARD_UNKNOWN.format(workflow=name, version=latest, card=card, cards=_names(listed))
if card == str(op.get("id") or "").strip():
why += CARD_IS_OP_ID.format(card=card)
raise ComposeRefused(why)
return hit
if len(cards) > 1:
raise ComposeRefused(CARD_AMBIGUOUS.format(workflow=name, version=latest, cards=_names(listed)))
return cards[0]
def compose(self, op: Mapping[str, Any], *, taken: set[str]) -> Composed:
return instantiate(self.pick(op), op, taken=taken)
__all__ = ["OP", "REFUSED", "ComposeRefused", "Composed", "Composer", "Option", "check_inputs", "instantiate",
"signature", "slot_contexts", "value_problem"]
value_problem = _value_problem