Files
AgentLab/backend/app/ledger/derive.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

650 lines
41 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.
"""从钉住的口径版本确定性地派生只读小图:全站唯一的生成器(docs/design/next-round/00-总方案.md X3)。
**为什么只有一份。** 评测的口径题要算期望(细案 01 做法 8:在同一份数据上把口径重算一遍),口径账本的对账要在同一份
数据上把「这次的取数」和口径各算一次(细案 02 做法 6),召回要把新旧两版口径各算一次(做法 7)。三处要的是同一件事:
从一个不可变的已发布版本里,把口径卡读的取数节点原样拷出来,后面接一张 caliber_from 固定到那一版的口径卡,跑一次探索
运行。各写一份,口径的取法迟早走样——评测量出来的「对」和账本说的「等价」就不是同一个口径。
**派生图长什么样**(assemble):入口(字段照抄钉住版本的入口:类型、必填、缺省值都不变)→ 各段(Block)首尾相接 →
收尾的卡(对账卡、召回卡,这一波没有)→ 出口(每张口径卡一个字段)。一段 = 那一版口径卡读的取数节点(id、配置原样
照抄;图级 defaults 也照抄,节点上没写的项按同样的规则取值)+ 一张本地口径卡,caliber_from 固定到那一版的那张卡
(名字、版本、指标式、取整、单位都按那一版,metrics 节点的 _pinned_caliber),「缺输入时」照抄原卡。节点一律首尾相接:
同一次运行里先后查,差值只反映口径(或这次的取数)和数据,不反映图的形状。
**能派生的只有可复用的卡**(load_pinned):口径卡本身是源头(不是转抄别处的 caliber_from)、每个输入都是
cell(nodes.X, 0, '列')、X 是查库的调用工具节点(look/calibers.card_inputs,和蓝图复用口径同一个认法),而且取数节点的
模板只引用入口字段({{ input.字段 }})——引用 vars.、nodes.、模板函数(file_text……)的,拷出来就拿不到同样的值,照实
拒收、给原因(NotDerivable)。工作流、版本、节点不在了也照实说。
**只算一部分指标(K08 S2)**:load_pinned / select_pinned 收可选的 metric_ids(或显式 whole_card=True)。给了选择就先算一份
依赖闭包(ledger/closure),只拷这次真正要执行的取数节点、入口字段收窄到这次要用的,并把选中的指标记进口径卡的
caliber_from.metrics(运行时按 closure.effective_metrics 只算这几个)。没给选择就照旧整卡——图、参数、身份一个字不变。
未决依赖(点名的指标不在这张卡上、式子读不出来、绑定对不上、点名的取数没有可读 SQL)一律照实拒
(NotDerivable,code=derive.selection_unresolved),**不静默退回整卡**;共享给未选中指标的那条 SQL 照样保留(它还在算谁
记在闭包的 computes_unselected 上,不做投影)。
**已实现的**:caliber_probe —— 一份口径的取数 + 口径卡(评测口径题的期望);reconcile —— 口径对账(第 3 波,
reconcile_graph);组合式问数用的 caliber_block(card_id, inputs)(第 3 波);recall —— 口径召回(第 4 波,recall_graph)。
**扩展点**(第 3 波的账本按这里扩,不另写生成器):
- 口径对账(02 做法 6):assemble("reconcile", [ours, caliber_block(pinned)], tail=[对账卡])。ours 是「这次的取数」
(执行过的 SQL 原文,节点 id ours_q1…,build 一个 Block 即可,不经 caliber_block),对账卡是本地 metrics 节点(差值、
相对差,式子引用 cell(nodes.ours_q1, 0, '列') 和口径卡的指标,on_missing=null),出口的契约写 metrics_from。
- 口径召回(02 做法 7,recall_graph):assemble("recall", [caliber_block(old, prefix="old_"), caliber_block(new,
prefix="new_")], tail=[召回卡])。同一张图里放两份口径,取数节点 id 会撞:caliber_block 带 prefix 时把拷出来的节点改名,
口径卡的 caliber_from 带上改名映射 nodes={原 id: 新 id},运行时 metrics._pinned_caliber 按语法树改写式子里的
cell(nodes.X…) 和 nodes.X(第 3 波起)。
- 组合式问数(02 做法 4,ledger/compose.py):不经 assemble(拷进的是问数据那张图),只用 caliber_block(prefix=…,
card_id=…, inputs=…):取数节点改名、模板按助手给的参数渲染成字面 SQL,口径卡带改名映射。
- 新的派生种类登记进 KINDS,入口字段、出口、首尾相接都走 assemble,不在调用方拼节点。
派生图都是探索运行(正式运行只从已发布未退役的版本发起);来源(origin_kind)由调用方定:评测 eval、对账 reconcile、
召回 recall,都只能由服务端写(engine/origin.py)。DerivedGraph.origin 是记进运行 origin 的那段派生说明。
"""
from __future__ import annotations
import copy
import re
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.ledger import params
if TYPE_CHECKING: # 运行时按需 import:closure 反过来要用这一层(entry_fields)
from app.ledger.closure import Closure
#: 派生图的种类(见模块说明)
KINDS = ("caliber_probe", "reconcile", "recall")
#: 不能派生的原因的机器码
GONE = "derive.version_gone"
NOT_CARD = "derive.not_caliber_card"
NOT_REUSABLE = "derive.not_reusable"
TEMPLATE = "derive.fetch_template"
#: 取数模板引用了入口没声明的参数(ledger/params.UNDECLARED):拷出来渲染不出那一版口径的 SQL
UNDECLARED_PARAMETER = "derive.undeclared_parameter"
#: 业务定义(统计时间口径、退款规则)和取数节点上的受控绑定对不上(K04):派生出来跑的还是旧 SQL,
#: 这一版的数不能冒充新定义(见 ledger/bindings)
BINDING_UNALIGNED = "derive.binding_unaligned"
#: 点名要算的指标解析不出依赖闭包(K08 S2):闭包的每一条未决原样写进 message,照实拒绝、不静默退回整卡
SELECTION = "derive.selection_unresolved"
#: 派生图自己的入口、出口节点 id(和拷出来的节点撞了就加后缀)
ENTRY_ID = "derived_in"
EXIT_ID = "derived_out"
#: 节点在画布上的横向间距(派生图也能在运行详情里画出来)
_STEP_X = 260
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
#: 取数节点的模板只许引用入口字段:{{ input.字段 }},可以带过滤器
_INPUT_REF = re.compile(r"^input\.([^\s.()\[\]]+)$")
TEMPLATE_REASON = ("取数节点「{node}」的模板引用了「{ref}」:只能引用入口字段({{{{ input.字段 }}}}),别的值(vars.、nodes.、"
"模板函数)在派生的图里拿不到同样的值")
class NotDerivable(Exception):
"""不能从这一版派生只读图。message 给人看(照实说哪一环不行),code 给程序认。
problems:参数完整性问题(ledger/params.Problem)的原样一份,入口据此回结构化错误(problems 数组);不是参数问题为空。"""
def __init__(self, message: str, code: str, *, problems: Sequence[Any] = ()) -> None:
super().__init__(message)
self.message = message
self.code = code
self.problems = tuple(problems)
@dataclass(frozen=True)
class Pinned:
"""读好的一版口径:那一版的图、口径卡、它读的取数节点、入口字段。只从不可变的版本快照读。
给了选择(select_pinned / load_pinned(metric_ids=…))的这份只带**这次要执行**的那几个取数节点与前缀入口字段,另带
这份选择的依赖闭包(``closure``);没给选择就是整卡,闭包是整卡的(附上供调用方读身份,不在这里判未决)。
"""
workflow_id: str
workflow_name: str
version: int
node_id: str
#: 这一版发布时的级别(governed / published),没发布过的草稿为空
level: str | None
#: 这一版是不是「已发布且未退役」(apps/versions.is_published)。派生不拦:升版、退役怎么处置由调用方按
#: engine/governance.unresolved_caliber_upgrades 和 apps/versions.subgraph_pin_problems 判(同正式运行)
published: bool
retired: bool
card: dict[str, Any]
#: 口径卡读的取数节点(图里的原始 JSON),按图里的先后;给了选择时只有这次要执行的那几个
fetches: tuple[dict[str, Any], ...]
#: card_inputs 认出的 [(取数节点, 工具, 列)];给了选择时只有留下的那几个节点
reads: tuple[tuple[str, str, tuple[str, ...]], ...]
#: 入口节点声明的字段(原始 JSON:name、type、required、default……);给了选择时收窄到这次真正会用到的
entry_fields: tuple[dict[str, Any], ...]
#: 图级 defaults(节点上没写的配置按它取,照抄才和原图取值一致)
defaults: dict[str, Any]
#: 业务绑定(K04)的判定:这一版的业务定义(时间口径、退款规则)和取数节点上的受控绑定是不是同步
#: (ledger/bindings 的 Report.as_dict())。load_pinned 已经保证只有 aligned / unbound 的卡才派得出来。
binding: dict[str, Any] = field(default_factory=dict)
#: 读这一版时留下的那张图(只读):按选择再算一次依赖闭包时用它,不重读库、也不再回内存里那份快照
graph: dict[str, Any] = field(default_factory=dict)
#: 这一版的依赖闭包(K08):没给选择时是整卡闭包;给了选择时就是这次选择的那一份,也是这次裁剪的依据
closure: "Closure | None" = None
@property
def card_title(self) -> str:
data = self.card.get("data") if isinstance(self.card.get("data"), dict) else {}
return str(data.get("label") or self.node_id)
@property
def card_config(self) -> dict[str, Any]:
data = self.card.get("data") if isinstance(self.card.get("data"), dict) else {}
return data.get("config") if isinstance(data.get("config"), dict) else {}
@property
def metrics(self) -> list[dict[str, Any]]:
"""这张卡的指标定义(id、名称、单位、小数位、式子),只读。"""
return [m for m in self.card_config.get("metrics") or [] if isinstance(m, dict) and m.get("id")]
@property
def caliber(self) -> str:
cfg = self.card_config
return f"{cfg.get('caliber') or self.card_title} @ {cfg.get('caliber_version') or 'v1'}"
def ref(self) -> dict[str, Any]:
"""caliber_from 的写法(metrics 节点认的形状)。"""
return {"workflow_id": self.workflow_id, "workflow_version": self.version, "node_id": self.node_id}
def describe(self) -> dict[str, Any]:
"""记进运行 origin 的那一份:哪个工作流哪一版的哪张卡。"""
return {**self.ref(), "workflow_name": self.workflow_name, "card": self.card_title}
@dataclass
class Block:
"""派生图里的一段:照抄(或这次执行过)的取数节点 + 一张口径卡。rename 是拷出来的节点改了名的映射(不带前缀时为空)。
card_id 为 None 的段没有口径卡(对账里「这次的取数」那一段只有一条查询)。"""
nodes: list[dict[str, Any]]
card_id: str | None
rename: dict[str, str] = field(default_factory=dict)
#: 这一段出自哪里(记进 DerivedGraph.origin)
source: dict[str, Any] | None = None
@dataclass(frozen=True)
class DerivedGraph:
kind: str
graph: dict[str, Any]
#: 各段的口径卡节点 id,按段的先后(出口按它们取指标)
card_ids: tuple[str, ...]
entry_fields: tuple[dict[str, Any], ...]
#: 记进运行 origin 的派生说明:{kind, from: [各段的出处]}
origin: dict[str, Any]
# ==========================================================================
# 读一版口径
# ==========================================================================
def _node_of(graph: Mapping[str, Any], node_id: str) -> dict[str, Any] | None:
return next((n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id), None)
def _config(node: Mapping[str, Any]) -> dict[str, Any]:
data = node.get("data") if isinstance(node.get("data"), dict) else {}
return data.get("config") if isinstance(data.get("config"), dict) else {}
def _strings(value: Any) -> Iterable[str]:
if isinstance(value, str):
yield value
elif isinstance(value, Mapping):
for v in value.values():
yield from _strings(v)
elif isinstance(value, (list, tuple)):
for v in value:
yield from _strings(v)
def template_problem(node_id: str, config: Mapping[str, Any]) -> str | None:
"""取数节点的配置里引用了入口字段以外的东西:给人看的原因;只引用入口字段(或什么都不引用)为 None。"""
for text in _strings(config):
for match in _TEMPLATE_RE.finditer(text):
expr = match.group(1).strip()
path = expr.split("|")[0].strip()
if not _INPUT_REF.match(path):
return TEMPLATE_REASON.format(node=node_id, ref=path or "{{ }}")
return None
def entry_fields(graph: Mapping[str, Any]) -> tuple[dict[str, Any], ...]:
"""图里入口节点声明的字段(原始 JSON,照抄)。几个入口节点的字段按先后合在一起,同名的取第一个。"""
out: dict[str, dict[str, Any]] = {}
for node in graph.get("nodes") or []:
if not isinstance(node, dict) or node.get("type") != "input":
continue
for f in _config(node).get("fields") or []:
if isinstance(f, dict) and isinstance(f.get("name"), str) and f["name"].strip():
out.setdefault(f["name"].strip(), copy.deepcopy(f))
return tuple(out.values())
async def read_pinned(session: AsyncSession, workflow_id: str, version: int, node_id: str) -> Pinned:
"""读 workflow_id@version 里的口径卡 node_id,**不判依赖**:工作流、版本、节点在不在、是不是口径卡(不在就抛
NotDerivable);读一版图只有这一处。交回的 Pinned 有那一版的图、卡、入口字段与图级缺省值;取数节点空着,reads 是
「整卡认得出的那一份」(card_inputs 读不出来就是空,不在这里判能不能用)。
调用方按选择裁(select_pinned)或按整卡门禁判(load_pinned)。look 的按指标可组合性(calibers.metric_options)也从
这一份出发:B 独有那条查询坏了不该牵连 A,所以它不能走整卡门禁那一条路。"""
from app.apps import versions as app_versions
from app.db.models import Workflow
from app.look.calibers import card_inputs
workflow = await session.get(Workflow, str(workflow_id or ""))
if workflow is None:
raise NotDerivable("口径所在的工作流不存在,可能已被删除", GONE)
row = await app_versions.version_row(session, workflow.id, int(version))
if row is None:
raise NotDerivable(f"「{workflow.name}」没有 v{version} 这个版本", GONE)
graph = row.graph or {}
card = _node_of(graph, node_id)
if card is None:
raise NotDerivable(f"「{workflow.name}」v{version} 里没有节点 {node_id}", GONE)
if card.get("type") != "metrics":
raise NotDerivable(f"「{workflow.name}」v{version} 里的 {node_id} 不是口径卡", NOT_CARD)
readable, _ = card_inputs(graph, node_id)
return Pinned(workflow_id=workflow.id, workflow_name=workflow.name, version=int(version), node_id=node_id,
level=row.level, published=app_versions.is_published(row, current=workflow.published_version),
retired=row.retired_at is not None, card=copy.deepcopy(card), fetches=(),
reads=tuple((i["node"], i["tool"], tuple(i["columns"])) for i in readable),
entry_fields=entry_fields(graph), defaults=copy.deepcopy(graph.get("defaults") or {}), graph=graph)
async def load_pinned(session: AsyncSession, workflow_id: str, version: int, node_id: str, *,
metric_ids: Sequence[str] | None = None, whole_card: bool = False) -> Pinned:
"""读 workflow_id@version 里的口径卡 node_id。不能派生的抛 NotDerivable(照实说哪一环不行)。
metric_ids / whole_card 给了就只按这次要算的指标读(K08 S2):取数节点、入口字段、参数与绑定判定都收窄到这次的范围
(未选中指标独有那条查询的坏 SQL、坏绑定不牵连选中的),未决依赖照实拒(code=SELECTION)——**不静默退回整卡**。
不给就是整张卡:门禁、图、身份和以前一字不差(整卡闭包照样算一份附在 Pinned 上,只是不在这里判它有没有未决)。
唯一两条路径都不放行的是**指标 id 重复**(同一张卡上两个指标用同一个 id,code=duplicate_metric_id):那是卡的指标
身份本身坏了——闭包按定义解析、运行时按指标集筛、下游按位置取 nodes.<卡>.metrics[i] 会各算一份,谁是谁说不清。
"""
from app.look.calibers import card_inputs
pinned = await read_pinned(session, workflow_id, version, node_id)
title = pinned.card_title
# 指标 id 重复(同一张卡上两个指标用了同一个 id):卡的指标身份本身坏了——闭包按定义解析、运行时按指标集筛、下游按
# 位置取 nodes.<卡>.metrics[i] 会各算一份,谁是谁说不清。这不是「某个指标的问题」,哪条路径都不放行、也不静默取一份
from app.ledger.closure import DUPLICATE_METRIC_ID, duplicate_metric_ids
if duplicates := duplicate_metric_ids(pinned.metrics):
raise NotDerivable(f"「{pinned.workflow_name}」v{pinned.version} 的口径卡「{title}」上有指标 id 重复:"
f"{'、'.join(duplicates)}——同一张卡上每个指标 id 只能有一个,先改成不重的 id",
DUPLICATE_METRIC_ID)
if metric_ids is not None:
# 点名要算哪几个:整卡的「每个输入都要是查库结果里的格」那道门禁不在这里判——B 独有那条查询再糟也不该牵连 A;
# 收窄到这次的范围、逐条判依赖,判不出来照实拒(收窄与判定都在 select_pinned 的闭包里)
return select_pinned(pinned, metric_ids=metric_ids, whole_card=whole_card)
graph, node = pinned.graph, pinned.node_id
inputs, reason = card_inputs(graph, node)
if reason is not None:
raise NotDerivable(f"「{pinned.workflow_name}」v{pinned.version} 的口径卡「{title}」不能复用:{reason}", NOT_REUSABLE)
wanted = {i["node"] for i in inputs}
fetches = tuple(copy.deepcopy(n) for n in graph.get("nodes") or []
if isinstance(n, dict) and n.get("id") in wanted)
for node_ in fetches:
if problem := template_problem(str(node_["id"]), _config(node_)):
raise NotDerivable(f"「{pinned.workflow_name}」v{pinned.version} 的{problem}", TEMPLATE)
# 模板里的参数要和入口字段对得上(ledger/params,各入口同一套判断):引用入口没声明的字段,派生出来渲染的就是空串
# ——在派生这一步就说清楚,别等发出的 SQL 少了个值
if problems := params.check_nodes(fetches, fields=pinned.entry_fields,
where=f"「{pinned.workflow_name}」v{pinned.version} 的入口声明的字段"):
first = problems[0]
raise NotDerivable(f"「{pinned.workflow_name}」v{pinned.version} 的取数节点「{first.node_id or node}」:"
f"{first.message}", UNDECLARED_PARAMETER if first.code == params.UNDECLARED else TEMPLATE,
problems=problems)
# K04:指标里的业务定义(统计时间口径、退款规则)和取数节点上的受控绑定对不上的,不许派生——派生出来跑的还是
# 那张旧 SQL,这一版的数不能冒充新定义。旧资产(没声明这两个字段)是 unbound,照旧放行
from app.ledger import bindings as caliber_bindings
alignment = caliber_bindings.analyze(graph, node)
if alignment.blocked:
raise NotDerivable(f"「{pinned.workflow_name}」v{pinned.version} 的口径卡「{title}」:{alignment.reason}",
BINDING_UNALIGNED)
pinned = replace(pinned, fetches=fetches,
reads=tuple((i["node"], i["tool"], tuple(i["columns"])) for i in inputs),
binding=alignment.as_dict())
# 显式整卡:和缺省那条路径同一道门禁,再要一份整卡闭包(闭包有未决就照实拒,不静默放宽)
return select_pinned(pinned, whole_card=True) if whole_card else select_pinned(pinned)
def select_pinned(pinned: Pinned, *, metric_ids: Sequence[str] | None = None,
whole_card: bool = False) -> Pinned:
"""按选择从**读好的一版**里裁出一份 Pinned(纯函数、无 IO):只留这次要执行的取数节点、入口字段收窄到这次会用到的,
并把这次选择的依赖闭包附在闭包字段上。别的(卡、口径名、版本、级别、图级缺省值)原样。
- 没给选择(metric_ids 是 None、whole_card 不是 True):原样交回,只把整卡闭包附上去(供调用方读身份与依赖),
不在这里判有没有未决——那几道门禁在 load_pinned 里照旧;
- 给了选择:先看这份闭包能不能用,不能用一律 NotDerivable(code=SELECTION,每一条未决原样写进 message),
**绝不静默退回整卡**;能用就只留闭包里的那些查询(共享给未选中指标的那条 SQL 照样保留,它还在算谁记在
Query.computes_unselected 上),入口字段收窄到这次真正引用的那些,绑定判定也只看这次要执行的那些节点。
pinned.graph 是读这一版时留下的那张图(只读)。同一版、同一份选择,每次得到同一份结果。
"""
from app.ledger import closure as caliber_closure
graph = pinned.graph if isinstance(pinned.graph, dict) else {}
explicit = metric_ids is not None or whole_card
try:
selection = caliber_closure.build(graph, pinned.workflow_id, pinned.version, pinned.node_id,
metric_ids=metric_ids, whole_card=whole_card)
except caliber_closure.ClosureRefused as e:
if not explicit:
return pinned # 手里没有这一版的图(手工拼的 Pinned):算不了闭包,旧路径照旧
raise NotDerivable(f"口径卡「{pinned.card_title}」算不出依赖:{e}", SELECTION) from None
if not explicit:
return replace(pinned, closure=selection)
# 每个点名的指标读的输入都要能复用(cell(nodes.X, 0, '列')、X 是查库的调用工具节点):判定与整卡那道门禁、
# look 的按指标可组合性(calibers.metric_options)同一份 metric_inputs。B 读的是别的节点,不牵连 A
from app.look.calibers import metric_inputs as caliber_metric_inputs
named = [m for m in pinned.metrics if str(m.get("id")) in set(metric_ids or [])]
for metric in named:
_, reason = caliber_metric_inputs(graph, pinned.node_id, metric)
if reason is not None:
raise NotDerivable(f"「{pinned.workflow_name}」v{pinned.version} 的口径卡「{pinned.card_title}」"
f"不能复用:{reason}", NOT_REUSABLE)
try:
selection.require()
except caliber_closure.UnresolvedClosure as e:
raise NotDerivable(
f"「{pinned.workflow_name}」v{pinned.version} 的口径卡「{pinned.card_title}」这次点名的指标算不出来:"
f"{problems_text(e.problems)}。请把依赖补齐,或按整卡再来一次", SELECTION) from None
if selection.whole_card:
return replace(pinned, closure=selection)
kept = {q.node_id for q in selection.queries}
names = {str(f.get("name")) for f in selection.required_entry_fields}
from app.ledger import bindings as caliber_bindings
selected = [m for m in pinned.metrics if str(m.get("id")) in set(selection.metric_ids)]
# 这次要执行的取数节点从**图**里取(不靠整卡那一步认出来的那份):B 独有那条查询再糟,也不影响 A 被认出来
fetches = tuple(copy.deepcopy(n) for n in graph.get("nodes") or []
if isinstance(n, Mapping) and str(n.get("id")) in kept)
return replace(pinned, fetches=fetches,
reads=tuple(r for r in pinned.reads if str(r[0]) in kept),
entry_fields=tuple(f for f in pinned.entry_fields if str(f.get("name") or "").strip() in names),
binding=caliber_bindings.analyze(graph, pinned.node_id, selected).as_dict(),
closure=selection)
def problems_text(problems: Sequence[Mapping[str, Any]]) -> str:
"""闭包的每一条未决收成一句话:前四条原样带各自的机器码,多的只报个数(不逐条铺开)。
``select_pinned`` 的拒收消息与 look 的按指标可组合性(calibers.metric_options)都读这一份,不各写一遍措辞。"""
said = ";".join(f"{p.get('message') or p.get('code')}({p.get('code')})" for p in problems[:4])
more = f";另有 {len(problems) - 4} 项" if len(problems) > 4 else ""
return said + more
def _problems_text(problems: Sequence[Mapping[str, Any]]) -> str:
"""闭包的每一条未决收成一句话:前四条原样带各自的机器码,多的只报个数(不逐条铺开)。"""
said = ";".join(f"{p.get('message') or p.get('code')}({p.get('code')})" for p in problems[:4])
more = f";另有 {len(problems) - 4} 项" if len(problems) > 4 else ""
return said + more
# ==========================================================================
# 拼图
# ==========================================================================
def _node(nid: str, ntype: str, label: str, **config: Any) -> dict[str, Any]:
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label, "config": config}}
def render_inputs(value: Any, inputs: Mapping[str, Any]) -> Any:
"""配置里每一段字的 {{ input.字段 }} 按 inputs 渲染成字面值(和运行时同一个渲染器 engine/expressions.render_template)。
load_pinned 已经保证取数节点的模板只引用入口字段。别的值原样。
渲染只能发生一次:这里渲染出来的东西交给运行时之后,运行时的模板渲染不能再碰它(caliber_block 会在节点上写一份参数
契约,engine/nodes/tools.run_tool 按它跳过渲染)。渲染过的结果被再解释一遍,值里的 {{ }} 就不是数据了(K01)。"""
from app.engine.expressions import render_template
if isinstance(value, str):
return render_template(value, {"input": dict(inputs)}) if "{{" in value else value
if isinstance(value, dict):
return {k: render_inputs(v, inputs) for k, v in value.items()}
if isinstance(value, list):
return [render_inputs(v, inputs) for v in value]
return value
def caliber_block(pinned: Pinned, *, prefix: str = "", upgrade_policy: str | None = None,
card_id: str | None = None, inputs: Mapping[str, Any] | None = None,
metrics: Sequence[str] | None = None, whole_card: bool = False) -> Block:
"""一份口径:拷出取数节点(id、配置原样;prefix 不为空时改名为 prefix + 原 id)+ 一张 caliber_from 固定到那一版的
口径卡(id 同原卡,带前缀时同样改名;「缺输入时」照抄原卡)。
metrics / whole_card 给了就先按这次要算的指标裁一份(select_pinned 的闭包):只拷这次要执行的取数节点,改名映射
(caliber_from.nodes)只列它们,拷出来的卡照旧固定到那一版。裁剪过的口径卡多一项 caliber_from.metrics(这次算哪几个
指标,按源卡顺序)——运行时按它只算这几个(closure.effective_metrics);整卡时不写这一项,行为和以前一样。
upgrade_policy:钉住的版本有了新的已发布版本时这一段怎么处置(recompute / dual / incomparable,没有缺省)。写在
本地口径卡上,判断走和正式运行同一个 engine/governance.unresolved_caliber_upgrades;不给就不写(有新版本时那个
判断报「待处置」)。带前缀时口径卡的 caliber_from 带改名映射 nodes(metrics 运行时按它改写式子,见模块说明)。
组合式问数(ledger/compose.py)另给两项:card_id 是口径卡在新图里的 id(缺省 prefix + 原卡 id);inputs 给了的话,
取数节点配置里的 {{ input.字段 }} **在这里渲染成字面值**(值只被模板替换这一次),并在节点上写一份按这份渲染结果的
哈希与节点绑定的参数契约(ledger/params 的 marker):args 里已经是字面语句,运行时的模板渲染一概不碰它;复制走、
改过 args 之后契约作废,那个节点回退成普通模板。不写契约的话,值里带 `{{ }}` 的文本会在运行时被当模板再解释一遍
(K01 的二次渲染反例);而模板留在节点上也不行——engine/variables 会把「引用了没人产出的 {{ input.x }}」判成错误,
那张图跑不起来(拷进的是问数据的图,入口没有口径的入口字段)。"""
if metrics is not None or whole_card:
pinned = select_pinned(pinned, metric_ids=metrics, whole_card=whole_card)
rename = {str(n["id"]): f"{prefix}{n['id']}" for n in pinned.fetches} if prefix else {}
nodes: list[dict[str, Any]] = []
for original in pinned.fetches:
node = copy.deepcopy(original)
node["id"] = rename.get(str(node["id"]), str(node["id"]))
node["position"] = {"x": 0, "y": 0}
if inputs is not None and isinstance(node.get("data"), dict):
config = node["data"].get("config") or {}
template = str(((config.get("args") or {}) if isinstance(config, dict) else {}).get("sql") or "")
node["data"]["config"] = render_inputs(config, inputs)
if params.input_refs(template):
# 契约与渲染好的 args 绑定(rendered):节点被复制、args 改过之后契约自动作废,运行时回退正常渲染
node["data"]["config"][params.KEY] = params.marker(
rendered=node["data"]["config"].get("args"), template=template, values=inputs,
declared_fields=declared_entry_fields(pinned),
source={**pinned.ref(), "node_id": str(original.get("id") or pinned.node_id)})
nodes.append(node)
source = pinned.ref()
if rename:
source = {**source, "nodes": dict(rename)}
selection = _selection_ids(pinned)
if selection:
source = {**source, "metrics": selection}
config: dict[str, Any] = {"caliber_from": source}
on_missing = pinned.card_config.get("on_missing")
if on_missing not in (None, ""):
config["on_missing"] = on_missing
if upgrade_policy:
config["upgrade_policy"] = upgrade_policy
card_id = card_id or f"{prefix}{pinned.node_id}"
nodes.append(_node(card_id, "metrics", pinned.card_title, **config))
return Block(nodes=nodes, card_id=card_id, rename=rename, source=pinned.describe())
def _selection_ids(pinned: Pinned) -> list[str]:
"""这一版这次选了哪些指标(按源卡顺序):整卡、或手里没有闭包(手工拼的 Pinned)时是空——那时不写这一项,
运行时按整卡算,和以前一样。"""
closure = pinned.closure
return [] if closure is None or closure.whole_card else [str(m) for m in closure.metric_ids]
def declared_entry_fields(pinned: Pinned) -> tuple[dict[str, Any], ...]:
"""写参数契约时记的入口字段:**那一版入口声明了什么**,不随这次选了哪些指标而变。裁剪过(选了指标)的 Pinned 手上
只有这次要用的那几个,就从读这一版时留下的图里取回全部;图不在(手工拼的 Pinned)就用手上这份。"""
if pinned.closure is not None and not pinned.closure.whole_card:
return entry_fields(pinned.graph) or pinned.entry_fields
return pinned.entry_fields
def _free_id(wanted: str, taken: set[str]) -> str:
nid, n = wanted, 2
while nid in taken:
nid, n = f"{wanted}_{n}", n + 1
return nid
def assemble(kind: str, blocks: Sequence[Block], *, fields: Sequence[Mapping[str, Any]] = (),
defaults: Mapping[str, Any] | None = None, tail: Sequence[dict[str, Any]] = (),
contract: Mapping[str, Any] | None = None) -> DerivedGraph:
"""入口 → 各段(首尾相接)→ tail(对账卡、召回卡)→ 出口。出口每张口径卡(含 tail 里的 metrics 节点)一个字段,
取那张卡的指标清单;contract 给了就写成出口的出具契约(对账写 metrics_from 指向对账卡,不加报告、不调模型)。几段的
节点 id 撞了直接报错:两份口径放进同一张图要带前缀(caliber_block 的 prefix)。"""
if kind not in KINDS:
raise ValueError(f"不认识的派生图种类:{kind}")
body: list[dict[str, Any]] = [n for b in blocks for n in b.nodes] + [copy.deepcopy(n) for n in tail]
ids = [str(n["id"]) for n in body]
dup = sorted({i for i in ids if ids.count(i) > 1})
if dup:
raise ValueError(f"派生图里节点 id 重复:{'、'.join(dup)}(两份口径放进同一张图要带前缀)")
taken = set(ids)
entry = _free_id(ENTRY_ID, taken)
exit_ = _free_id(EXIT_ID, taken | {entry})
cards = [b.card_id for b in blocks if b.card_id] + [str(n["id"]) for n in tail if n.get("type") == "metrics"]
titles = {str(n["id"]): str((n.get("data") or {}).get("label") or n["id"]) for n in body}
outputs, seen = [], set()
for cid in cards:
name = _free_id(titles.get(cid, cid), seen)
seen.add(name)
outputs.append({"name": name, "value": f"{{{{ nodes.{cid}.text }}}}"})
exit_config: dict[str, Any] = {"fields": outputs}
if contract:
exit_config["contract"] = copy.deepcopy(dict(contract))
chain = [_node(entry, "input", "入口", fields=[copy.deepcopy(dict(f)) for f in fields]), *body,
_node(exit_, "output", "出口", **exit_config)]
for i, node in enumerate(chain):
node["position"] = {"x": i * _STEP_X, "y": 0}
graph: dict[str, Any] = {
"nodes": chain,
"edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(chain, chain[1:])],
}
if defaults:
graph["defaults"] = copy.deepcopy(dict(defaults))
origin = {"kind": kind, "from": [b.source for b in blocks if b.source]}
return DerivedGraph(kind=kind, graph=graph, card_ids=tuple(cards),
entry_fields=tuple(copy.deepcopy(dict(f)) for f in fields), origin=origin)
#: 对账图里「这次的取数」节点、对账卡、多行时按键对齐的合并查询的 id(细案 02 做法 6)
OURS_ID = "ours_q1"
RECONCILE_CARD = "reconcile"
ALIGN_ID = "align"
def reconcile_prefix(pinned: Pinned) -> str:
"""对账图里口径那一段的前缀:口径的节点 id 和 ours_q1 / align / reconcile / 入口出口撞了才带 cal_,不撞不带。"""
reserved = {OURS_ID, ALIGN_ID, RECONCILE_CARD, ENTRY_ID, EXIT_ID}
clash = any(str(n.get("id")) in reserved for n in pinned.fetches) or pinned.node_id in reserved
return "cal_" if clash else ""
def ours_block(sql: str, tool: str, *, label: str = "这次的取数") -> Block:
"""对账里「这次的取数」:这次执行过的 SQL 原文,放进一个调用工具节点(id ours_q1)。没有口径卡。
放进节点的**是实际执行过的那条语句,不是模板**:写一份参数契约(ledger/params 的 marker,与这条语句绑定),运行时的
模板渲染一概不碰它。不然语句里带 {{ }} 的值(值里就是有这种字)会在运行时被再解释一遍,对账比的就不是这次查过的那条了。"""
return Block(nodes=[_node(OURS_ID, "tool", label, tool=tool, args={"sql": sql},
**{params.KEY: params.marker(rendered={"sql": sql},
source={"kind": "executed", "node_id": OURS_ID})})],
card_id=None, source={"kind": "executed", "node_id": OURS_ID})
def reconcile_graph(pinned: Pinned, ours: Block, tail: Sequence[dict[str, Any]], *,
inputs: Mapping[str, Any]) -> DerivedGraph:
"""口径对账(细案 02 做法 6):入口(口径的入口字段,缺省值是这次的参数)→ 这次的取数 → 口径的取数(模板里保留
{{ input.x }},运行时按入口渲染)→ 口径卡(caliber_from 固定那一版)→ tail(多行时的对齐合并查询、对账卡)→ 出口
(契约 metrics_from 指向对账卡;不加报告、不调模型)。两边在同一次运行里先后查,差值在对账卡里算。
口径的节点 id 和 ours_q1 / align / reconcile 撞了的,口径那一段带前缀 cal_(改名映射照常写进 caliber_from.nodes)。
同样的输入永远得到同一张图。"""
block = caliber_block(pinned, prefix=reconcile_prefix(pinned))
fields = []
for f in pinned.entry_fields:
field_ = copy.deepcopy(dict(f))
if field_.get("name") in inputs:
field_["default"] = inputs[field_["name"]]
fields.append(field_)
card = next((n for n in tail if n.get("type") == "metrics" and n.get("id") == RECONCILE_CARD), None)
return assemble("reconcile", [ours, block], fields=fields, defaults=pinned.defaults, tail=tail,
contract={"metrics_from": RECONCILE_CARD} if card is not None else None)
#: 召回图里的召回卡(细案 02 做法 7)
RECALL_CARD = "recall"
def recall_prefixes(i: int) -> tuple[str, str]:
"""召回图里第 i 对口径卡(旧版、新版)各自的前缀:第一对 old_ / new_,之后 old2_ / new2_……(同一版里两张卡读同一个
取数节点时,各拷一份也不撞)。"""
n = "" if i == 0 else str(i + 1)
return f"old{n}_", f"new{n}_"
def recall_fields(old: Pinned, new: Pinned, inputs: Mapping[str, Any]) -> list[dict[str, Any]]:
"""召回图的入口:新版的入口字段在前(类型、必填照新版),旧版有、新版没有的接在后面;缺省值都换成这一组的参数。"""
fields: list[dict[str, Any]] = []
seen: set[str] = set()
for f in [*new.entry_fields, *old.entry_fields]:
name = str(f.get("name") or "")
if not name or name in seen:
continue
seen.add(name)
item = copy.deepcopy(dict(f))
if name in inputs:
item["default"] = inputs[name]
fields.append(item)
return fields
def recall_graph(pairs: Sequence[tuple[Pinned, Pinned]], tail: Sequence[dict[str, Any]], *,
inputs: Mapping[str, Any]) -> DerivedGraph:
"""口径召回(细案 02 做法 7):入口(这一组的参数)→ 每一对口径卡:旧版的取数 + 旧版口径卡(caliber_from 固定旧版)、
新版的取数 + 新版口径卡(固定新版),两版的取数节点各带前缀、口径卡的 caliber_from 带改名映射(不撞 id)→ tail(召回卡:
两版之差在这里算,ledger/recall.recall_card)→ 出口(契约 metrics_from 指向召回卡;不加报告、不调模型)。
两版在同一次运行里先后查同一份数据:差值只反映口径的变化(数据随时间的变化另拿原答案当时的值对照着说)。入口字段按
第一对的新版、旧版合起来(recall_fields)。同样的输入永远得到同一张图。"""
if not pairs:
raise ValueError("召回图至少要一对口径卡")
blocks: list[Block] = []
for i, (old, new) in enumerate(pairs):
old_prefix, new_prefix = recall_prefixes(i)
blocks += [caliber_block(old, prefix=old_prefix), caliber_block(new, prefix=new_prefix)]
old, new = pairs[0]
card = next((n for n in tail if n.get("type") == "metrics" and n.get("id") == RECALL_CARD), None)
return assemble("recall", blocks, fields=recall_fields(old, new, inputs), defaults=new.defaults or old.defaults,
tail=tail, contract={"metrics_from": RECALL_CARD} if card is not None else None)
def caliber_probe(pinned: Pinned, *, upgrade_policy: str | None = None) -> DerivedGraph:
"""口径探针:一份口径的取数 + 口径卡(评测口径题的期望,细案 01 做法 8)。节点 id 和原图一样,入口字段照抄。
同样的 pinned 永远得到同一张图(确定性)。"""
return assemble("caliber_probe", [caliber_block(pinned, upgrade_policy=upgrade_policy)],
fields=pinned.entry_fields, defaults=pinned.defaults)
__all__ = ["ALIGN_ID", "Block", "DerivedGraph", "ENTRY_ID", "EXIT_ID", "GONE", "KINDS", "NOT_CARD", "NOT_REUSABLE",
"NotDerivable", "OURS_ID", "Pinned", "RECALL_CARD", "RECONCILE_CARD", "SELECTION", "TEMPLATE", "assemble",
"caliber_block", "caliber_probe", "declared_entry_fields", "entry_fields", "load_pinned", "ours_block",
"problems_text", "read_pinned", "recall_fields", "recall_graph", "recall_prefixes", "reconcile_graph",
"reconcile_prefix", "render_inputs", "select_pinned", "template_problem"]