Files
AgentLab/backend/app/data/caliber_identity.py
T
ThearchyHelios 8d9a712eae 业务本体与标准实体:跨源键值对齐、对账共变算子、健康证据链与实体助手
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手
- 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具
- 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测
- 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
2026-11-12 12:57:53 +08:00

113 lines
6.5 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.
"""口径的内容指纹:同一位置的口径定义变没变,按定义本身的哈希认,不看运行时算出来的值。
口径的身份原先是「工作流 id@版本」(ledger/labels.caliber_key)。同一版不会变(版本快照不可变),但复制、改名、迁移之后
就认不回是同一份口径了;后续做「定义变更影响分析」也需要一份内容指纹。这里给的就是这份指纹:
- definition_hash:一张口径卡的指标定义(caliber、caliber_version 和每个指标的语义字段)的 sha256;
- version_hash:一份口径版本里各卡的内容哈希排序后再取 sha256,也就是整份版本的定义指纹;
- semantic_hash:口径卡及上游执行依赖的指纹,含 SQL、数据源与绑定,排除布局;
- card_hash_of:从 {node_id, metrics, caliber?, caliber_version?} 这种卡形状取哈希(运行时指标卡、账本里源头口径卡的
两种写法共用)。
**定义指纹只取声明字段。** 指标只留 SEMANTIC_FIELDS 中存在的键(含类型配置、粒度、过滤和可执行约束),
按 id 排序(顺序不影响哈希);空字符串、None、空的列表和字典不写进规范 JSON。值、渲染后的字、输入出处、状态这些都是
运行时产物,不进哈希——同一份定义重跑一次,或者补了无关的字段,哈希不变。
**这是内容指纹,不是跨图稳定的 ID。** 表达式里写的是节点 id(cell(nodes.fetch, 0, 'gmv')),取数节点改了名,式子跟着改,
哈希就会变;复制到另一张图、指标定义一字不差时哈希相同,正是用来认回同一份定义。要跨图认同一份口径,仍靠版本引用
(caliber_from)和「工作流@版本」。
**口径名、版本进不进哈希。** 两个函数的入参可以是空串:运行时指标卡和账本两侧算指标集指纹时都不查库、不拿名字,
只按 metrics 算(空串时规范 JSON 里省略这两个键),这样两边的哈希才对得上。名字、版本要不要算进去由调用方决定。
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
#: 指标的语义字段:只有这几样算定义,运行时值(value、rendered、inputs……)不参与哈希。
#: time_role / refund_rule 是 K04 的业务定义字段(统计时间口径、退款处理):改了定义身份就变,绑定同步状态跟着重判。
SEMANTIC_FIELDS = ("id", "name", "expression", "unit", "decimals", "format", "kind", "definition", "params", "external",
"rule", "assessment", "grain", "time_field", "numerator", "denominator", "dimensions", "filters",
"contract", "time_role", "refund_rule", "semantic", "links")
def _blank(value: Any) -> bool:
"""空值、缺省键:None、空字符串、空的列表和字典不写进规范 JSON。0、False 是有意义的取值,照写。"""
return value is None or value == "" or (isinstance(value, (list, dict, tuple)) and not value)
def _metric_content(metric: Any) -> dict[str, Any]:
"""一个指标的语义内容:只留 SEMANTIC_FIELDS 里存在的键,空值略去。"""
if not isinstance(metric, dict):
return {}
return {field: metric[field] for field in SEMANTIC_FIELDS if field in metric and not _blank(metric[field])}
def definition_hash(caliber: str, caliber_version: str, metrics: Iterable[dict[str, Any]]) -> str:
"""一张口径卡的指标定义 → 内容指纹。指标按 id 排序,顺序不影响哈希;caliber / caliber_version 为空时不进规范 JSON
(运行时和账本两侧只按 metrics 算,见模块说明)。"""
from app.core.artifact_store import canonical_json, content_hash
content: dict[str, Any] = {}
if not _blank(caliber):
content["caliber"] = str(caliber)
if not _blank(caliber_version):
content["caliber_version"] = str(caliber_version)
kept = [_metric_content(metric) for metric in metrics or []]
content["metrics"] = sorted(kept, key=lambda metric: str(metric.get("id") or ""))
return content_hash(canonical_json(content))
def card_hash_of(card: dict[str, Any]) -> str:
"""一张口径卡(card)的内容指纹:{node_id, metrics, caliber?, caliber_version?}。没有 caliber 时按空串算
(即只按 metrics)。"""
if not isinstance(card, dict):
return definition_hash("", "", [])
return definition_hash(str(card.get("caliber") or ""), str(card.get("caliber_version") or ""),
card.get("metrics") or [])
def version_hash(card_hashes: Iterable[str]) -> str:
"""一份口径版本的内容指纹:各卡的内容哈希(card_hash_of 的结果)排序后取 sha256,和卡片的先后无关。"""
from app.core.artifact_store import content_hash
return content_hash("\n".join(sorted(str(h) for h in card_hashes or () if h)))
def semantic_hash(graph: dict[str, Any], node_id: str) -> str:
"""口径及其上游执行依赖的指纹:包含 SQL、数据源、入口绑定和固定版本引用。
definition_hash 继续表示指标定义自身;此指纹才覆盖取数语义。保守保留 SQL 原文,
不把未证明等价的改写判为相同;画布坐标和无关的下游写作节点不参与。
"""
import re
from app.core.artifact_store import canonical_json, content_hash, execution_content
content = execution_content(graph)
nodes = {n["id"]: n for n in content["nodes"]}
wanted, pending = set(), [node_id]
while pending:
current = pending.pop()
if current in wanted:
continue
wanted.add(current)
pending.extend(e["source"] for e in content["edges"] if e["target"] == current)
node = nodes.get(current)
if node:
# 非相邻节点也能被表达式引用。保守扫描配置,包含节点参数中的模板引用。
config = canonical_json(node["data"].get("config") or {})
pending.extend(re.findall(r"\bnodes\.([\w-]+)", config))
pending.extend(re.findall(r"\bnodes\[['\"]([\w-]+)['\"]\]", config.replace('\\"', '"')))
inputs = {n["id"] for n in nodes.values() if n["type"] == "input"}
wanted |= inputs
return content_hash(canonical_json({
"schema_version": 1, "defaults": content["defaults"],
"nodes": [n for n in content["nodes"] if n["id"] in wanted],
"edges": [e for e in content["edges"] if e["source"] in wanted and e["target"] in wanted],
}))
__all__ = ["SEMANTIC_FIELDS", "card_hash_of", "definition_hash", "version_hash"]