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

156 lines
9.2 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.
"""口径可调用描述(descriptor):给目录页、应用表单、外部集成一份机器可读的稳定描述(next-round 02 的后续)。
口径已经能按固定版本实例化(look/calibers 的查看结果、ledger/compose 的 use_caliber),但入口字段、依赖、能不能出具这些
契约只写在给模型看的文字里。这里把它们写成结构化的数据:
- 只列每个工作流**推荐发布版**(Workflow.published_version,K07:默认调用读推荐版,不机械取最高已发布版)里的
**源头口径卡**(metrics 节点、caliber_from 为空),和 look/calibers、ledger/derive 的认法一致:转抄卡不列,
源头才能复用;
- 入口字段用 ledger/derive.entry_fields,按 apps/forms.input_schema 转成 JSON Schema(和表单、use_caliber 校验同一个);
- 依赖只从图里的取数节点认数据源和表名,**不返回 SQL、不返回参数值**(SQL 里可能写着遮罩值,组合时由服务端照抄);
- 能不能组合按**每个指标**判(K08 S4,look/calibers.metric_options):B 独有那条查询坏了、绑定对不上,只让 B 不可以选,
A 照旧可组合;一个可选指标也没有才不能组合。另给这一版的两份闭包摘要——``closure``(不点名、整卡那份)与
``selectable_metrics`` / ``unavailable_metrics``(每个指标的摘要与原因),以及 ``computes_unselected``(共享 SQL 还在
算哪些没选中的指标)。摘要与描述里只有 query_id、字段、工具、指纹这些,**没有 SQL 正文、没有参数值**;
- 出具只描述这一版的策略:级别、图里有没有人工节点或写外部系统的节点。全局的自动出具开关、每日上限是运行时条件,这里
不查——descriptor 说的是「这一版允许什么」,不代表这次运行一定能出具。
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.apps import versions as app_versions
from app.apps.backfill import graph_side_effect
from app.apps.forms import input_schema
from app.apps.tree import node_map
from app.db.models import Workflow
from app.ledger.derive import entry_fields
from app.look.calibers import metric_options, option_reason
#: 指标只写口径卡配置里真有的这几个键
METRIC_KEYS = ("id", "name", "unit", "decimals", "format", "kind", "definition", "params", "external", "rule",
"assessment", "synonyms", "grain", "time_field", "numerator", "denominator", "dimensions", "filters", "contract")
async def list_calibers(session: AsyncSession, *, workflow_id: str | None = None) -> list[dict[str, Any]]:
"""每个工作流**推荐发布版**(Workflow.published_version)里的源头口径卡,一张卡一条描述。给了 workflow_id 只列那个工作流。
K07:默认调用读的是**推荐版**(重新推上一版旧版之后,默认就跟着它走),不再机械地取「最高已发布版」——最高版
只在升级发现里用(app/apps/publications.upgrade_of)。
同一版图里的 graph_side_effect 按 (workflow_id, version) 只算一次:它要读子图、跑发布检查,比其余字段慢得多。"""
from app.apps import publications
query = select(Workflow).order_by(Workflow.name, Workflow.id)
if workflow_id:
query = query.where(Workflow.id == workflow_id)
side_cache: dict[tuple[str, int], str | None] = {}
out: list[dict[str, Any]] = []
for workflow in (await session.execute(query)).scalars().all():
row = await publications.recommended(session, workflow)
if row is None:
continue
for node_id, node in node_map(row.graph or {}).items():
if node["type"] != "metrics" or node["config"].get("caliber_from") not in (None, "", {}):
continue
made = await _describe(session, workflow, row, node_id, side_cache)
if made is not None:
out.append(made)
return out
async def caliber_descriptor(session: AsyncSession, workflow_id: str, version: int,
node_id: str) -> dict[str, Any] | None:
"""一个工作流版本里一张源头口径卡的描述;工作流不存在、版本没发布过或已退役、节点不是源头口径卡时返回 None。"""
workflow = await session.get(Workflow, str(workflow_id or ""))
if workflow is None:
return None
row = await app_versions.version_row(session, workflow.id, int(version))
if row is None or not app_versions.is_published(row, current=workflow.published_version):
return None
return await _describe(session, workflow, row, str(node_id), {})
async def _describe(session: AsyncSession, workflow: Workflow, row: Any, node_id: str,
side_cache: dict[tuple[str, int], str | None]) -> dict[str, Any] | None:
"""一张卡的描述;节点不是源头口径卡时返回 None。"""
graph = row.graph or {}
nodes = node_map(graph)
card = nodes.get(node_id)
if card is None or card["type"] != "metrics" or card["config"].get("caliber_from") not in (None, "", {}):
return None
cfg = card["config"]
level = str(row.level or "")
made = metric_options(graph, node_id, workflow_id=workflow.id, version=int(row.version))
from app.apps import publications
from app.data.caliber_identity import semantic_hash
key = (workflow.id, int(row.version))
if key not in side_cache:
side_cache[key] = await graph_side_effect(session, graph, level=level or None)
side_effect = side_cache[key]
sources, tables = publications.fetch_dependencies(graph)
publication = await publications.latest(session, workflow.id, int(row.version))
reasons: list[str] = []
if level != "governed":
reasons.append("这一版是已发布级别,只支持一键出具")
if side_effect is not None:
reasons.append(f"这一版{side_effect},不能替人再次执行")
if not sources:
reasons.append("这一版没有取数节点,账本不能按口径证明等价")
return {
"workflow_id": workflow.id,
"workflow_name": workflow.name,
"version": int(row.version),
"level": level,
"graph_hash": row.graph_hash,
"semantic_hash": semantic_hash(graph, node_id),
# K07:这一版的发布依据是哪一份清单(没有清单的是加这一机制之前发布的老资产,值为 null)
"publication": ({"id": publication.id, "manifest": publication.manifest,
"proven": bool(publication.proven)}
if publication is not None else None),
"card": {"node_id": node_id, "title": card["title"], "caliber": cfg.get("caliber"),
"caliber_version": cfg.get("caliber_version")},
"metrics": [{k: m[k] for k in METRIC_KEYS if k in m}
for m in cfg.get("metrics") or []
if isinstance(m, dict) and m.get("id")],
"entry_schema": input_schema(list(entry_fields(graph))),
# 能不能组合按**每个指标**判(K08 S4):B 独有那条查询坏了、绑定对不上,A 照旧可选;一个指标也选不了才不能组合。
# 摘要里只有 query_id、字段、工具、指纹与共享声明,没有 SQL 正文、没有参数值
"composable": made.composable,
"compose_reason": option_reason(made),
"closure": made.default,
"selectable_metrics": [dict(m) for m in made.selectable],
"unavailable_metrics": [dict(m) for m in made.unavailable],
"computes_unselected": shared_queries(made),
"dependencies": {"sources": sources, "tables": tables,
"catalog_versions": row.catalog_versions, "schema_versions": row.schema_versions},
"issuance": {"level": level, "auto_issue": level == "governed" and side_effect is None and bool(sources),
"requires_independent_proof": True, "side_effects": side_effect, "reasons": reasons},
}
def shared_queries(made: Any) -> list[dict[str, Any]]:
"""这几份摘要里「还在算没选中的指标」的查询:{query_id, node_id, metric_ids, computes_unselected}。
按 (query_id, 这次选中的指标) 去重、按出现先后排:一张卡上几个指标共用一条 SQL 时,选谁、还在算谁一目了然(K08 S3
不做投影,共享 SQL 原样保留)。整卡那一份加上每个可选指标那一份,只从闭包的摘要里收,不另解析一遍依赖。"""
out: list[dict[str, Any]] = []
seen: set[tuple[str, tuple[str, ...]]] = set()
for summary in [made.default, *made.selectable]:
for query in (summary or {}).get("queries") or []:
others = [str(m) for m in query.get("computes_unselected") or []]
read_by = [str(m) for m in query.get("metric_ids") or []]
if not others or (str(query["query_id"]), tuple(read_by)) in seen:
continue
seen.add((str(query["query_id"]), tuple(read_by)))
out.append({"query_id": query["query_id"], "node_id": query["node_id"],
"metric_ids": read_by, "computes_unselected": others})
return out
__all__ = ["caliber_descriptor", "list_calibers", "shared_queries"]