mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:42:00 +00:00
8d9a712eae
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手 - 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具 - 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测 - 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
113 lines
6.5 KiB
Python
113 lines
6.5 KiB
Python
"""口径的内容指纹:同一位置的口径定义变没变,按定义本身的哈希认,不看运行时算出来的值。
|
||
|
||
口径的身份原先是「工作流 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"]
|