mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:21:58 +00:00
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 通过。
650 lines
41 KiB
Python
650 lines
41 KiB
Python
"""从钉住的口径版本确定性地派生只读小图:全站唯一的生成器(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"]
|