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

359 lines
22 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.
"""受限的字段变更服务(K03):一批字段操作打在**原图的深拷贝**上,交回新图和这次到底改了什么。
一条操作只说三件事:op(set / remove)、metric_id(口径卡上的稳定身份)、path(白名单里的字段路径)。指标身份、
证据归属、治理归属、任意路径和按数组位置定位都不在能改的范围内(4.2)。改在深拷贝上做:没提到的字段、别的指标、
别人加的扩展字段和其他节点原样带着走——不重建一份定义,也不做通用 JSON Patch。
白名单(FIELDS)覆盖当前指标上真有的业务字段和 definition 里给人看的那几项。**明确不能改**的写在 FORBIDDEN 里,
落进这里会得到一句具体的话,而不是笼统的「不认识这个字段」:id/kind/rule/assessment/external/params/contract(身份、
指标类型和取值契约)、definition.citations(证据归属)、definition.owner / definition.effective_from(治理归属)。
四种写法的语义(4.2 要求各自不同且可测):
| 写法 | 语义 |
| --- | --- |
| 省略 | 这条操作没提这个字段(整批都没提)就是**不动**它;反过来,set 省略 value 是错的(不知道要设成什么) |
| set + 值 | 把这个字段设成这个值 |
| set + ''(数组是 []) | 字段在、值是明确的空值——「声明成空」,和 remove 的「没声明」不是一回事 |
| set + null | 拒(operation_value):「清空」有 remove、「声明为空」有 '',null 只能是这两者之一,不替客户端猜 |
| remove | 把字段从指标里删掉,回到「没声明」。不接受 value |
`definition` 整个对象不在(没这个键,或者就是 null)时:`set /definition/text|scope|exclusions` 会把它建出来,
`remove` 是同一条的「本来就没有」——无变化,不是路径非法。键在、但不是对象(被人写成了字符串或数组)照旧拒:
数据坏了就说清楚,不替人猜,也不把原来的值丢掉。
数组字段(同义词、维度、过滤条件、不包含)按**整份设置**处理,没有按元素的写法,也没有按位置定位的路径。
一批操作要么全部成功(落成一个新版本),要么一条都不落:先在原图的深拷贝上把整批跑完,中途任何一条不合规、
或者指标指不到,整批都不写(4.2)。整批跑完发现内容其实没变(比如先设后删),也不落版本。
变更分类(classification)按字段本身的类别算,见 FIELDS 的 kind:只改了展示类字段时报 display,动了定义类字段
报 definition。**分类不代表可以跳过验证**:图是唯一真相,口径卡的内容一变,图的执行指纹就变,旧试跑与旧验证
一律保守失效(4.8)——分类只是给页面说清「这次动的是哪一类」,回执里另给定义指纹的前后值(definition_hash)
作准。
**业务字段怎么落到 SQL 上(K04)。** `time_role`(统计时间口径)和 `refund_rule`(退款处理)是受控的业务字段:
它们同时在**定义**(指标里声明)和**实现**(取数节点上的受控绑定,ledger/bindings)里。一次变更里两边一起改:
能按受控模板确定性展开的,就在同一个事务里把取数节点的 args.sql 和 binding 一起改掉(synced);无绑定、映射不
支持、SQL 被手工改过、同一条 SQL 上的指标说法打架的,**定义照常保存、SQL 一个字不动**,回执里照实报
needs_update / unsupported + 原因 + 影响对象 + 该做什么。执行期绝不按业务语义重推(A09)。
"""
from __future__ import annotations
import copy
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from app.ledger import bindings
from app.ledger.repository import GraphBackedCaliberRepository, MetricGone
#: 一次请求最多几条操作:再多不是「改一处」该有的样子(要整份重写就走整份换指标那条路)
MAX_OPERATIONS = 50
#: 拒绝的机器码(接口一律回 422)
OPERATION_INVALID = "operation_invalid" # op 不认识、一批是空的、太多条
OPERATION_PATH = "operation_path" # 路径不在白名单、按数组位置定位、明确不能改的那几项
OPERATION_VALUE = "operation_value" # 值不对:类型不符、null、set 没有 value、remove 带了 value
OPERATION_METRIC = "operation_metric" # 卡上没有这个 metric_id
class ChangeRefused(Exception):
"""这次变更不合规(接口回 422):code 是机器码,operation 是第几条(从 0 数),path 是那一条的字段路径。"""
def __init__(self, message: str, *, code: str = OPERATION_INVALID, operation: int | None = None,
path: str | None = None) -> None:
super().__init__(message)
self.message = message
self.code = code
self.operation = operation
self.path = path
@dataclass(frozen=True)
class Field:
"""白名单里的一个字段:kind 决定它算展示还是定义(见模块说明),value_type 是值类型,nonempty 是「写了就不能空」。"""
kind: str
value_type: str
label: str
nonempty: bool = False
#: 值类型是 choice 时的允许值(业务字段的取值域,见 ledger/bindings.FIELDS)
choices: tuple[str, ...] = ()
#: 能按字段改的路径。定义类的判定和 data/caliber_identity.SEMANTIC_FIELDS 对齐:语义字段算定义,其余(同义词)算展示。
FIELDS: dict[str, Field] = {
"/name": Field("definition", "text", "指标名称", nonempty=True),
"/synonyms": Field("display", "texts", "同义词"),
"/unit": Field("definition", "text", "单位"),
"/decimals": Field("definition", "int", "小数位"),
"/format": Field("definition", "text", "展示格式"),
"/expression": Field("definition", "text", "取值表达式", nonempty=True),
"/grain": Field("definition", "text", "粒度"),
"/time_field": Field("definition", "text", "时间字段"),
"/numerator": Field("definition", "text", "分子"),
"/denominator": Field("definition", "text", "分母"),
"/dimensions": Field("definition", "texts", "维度"),
"/filters": Field("definition", "texts", "过滤条件"),
"/definition/text": Field("definition", "text", "口径说明"),
"/definition/scope": Field("definition", "text", "适用范围"),
"/definition/exclusions": Field("definition", "texts", "不包含"),
}
#: 业务字段(K04):统计时间口径、退款处理。词汇表和允许值在 ledger/bindings(读接口的能力描述共用同一份),
#: 这里只是把它们接进按字段改的白名单。它们进了 data/caliber_identity.SEMANTIC_FIELDS:改了定义身份就变,
#: 绑定同步状态跟着重判(见 ledger/bindings 与 docs 的 4.5)。
FIELDS.update({f"/{name}": Field("definition", "choice", spec.label, choices=tuple(spec.values()))
for name, spec in bindings.FIELDS.items()})
#: 明确不能改的路径:身份、指标类型、取值契约、证据归属和治理归属(4.2)。写清楚为什么,不混进「不认识这个字段」。
FORBIDDEN: dict[str, str] = {
"/id": "指标身份不能改:改名、重排都不换 ID,删掉之后也不把这个 ID 分给别的业务指标(4.1)",
"/kind": "指标类型(算式 / 规则 / 评估 / 外部常量)决定读哪一份输入,不按字段改",
"/rule": "规则型指标的规则要整份换,不按字段改",
"/assessment": "评估型指标的评分协议要整份换,不按字段改",
"/external": "外部常量自带来源和生效时间,不按字段改",
"/params": "取值参数要整份换,不按字段改",
"/contract": "取值契约是结构声明(MetricContract):改它要过校验,不按字段改",
"/open_questions": "未决问题走「回答并移除」那条路(PATCH 的 resolved)",
"/definition/citations": "引用原文是证据归属:只能由核对过证据的流程写,字段操作改不了它",
"/definition/owner": "负责人是治理信息,不在字段变更的范围内",
"/definition/effective_from": "生效日期是治理信息,不在字段变更的范围内",
}
#: 路径上「没有这个键」的标记(和值就是 None 分开:设成 null 是被拒的,读到的 None 只可能来自数据本身)
_MISSING = object()
@dataclass(frozen=True)
class Change:
"""一次字段变更的结果:新图(原图的深拷贝 + 这几处)、改了什么、算哪一类、定义指纹前后值、绑定的处置。
applied 是「内容真的变了」:整批最后和前一份内容一致(先设后删)时是 False,调用方据此不落新版本。
binding 是 ledger/bindings 的判定:定义和取数节点上的受控绑定对不对得上、能不能同步、影响哪些对象;
synced 表示这一次在同一事务里把取数 SQL 也按受控模板改过来了。
"""
graph: dict[str, Any]
card: str
applied: bool
classification: str
metrics: tuple[str, ...]
operations: tuple[dict[str, Any], ...]
definition_hash: tuple[str, str]
binding: bindings.Report
synced: bool = False
def field_of(path: str, *, operation: int | None = None) -> Field:
"""路径 → 白名单里的字段;不在白名单、按数组位置定、或者属于明确不能改的那几项时 ChangeRefused。"""
text = str(path or "")
if not text.startswith("/"):
raise ChangeRefused(f"字段路径要以 / 开头(现在是「{text}」)", code=OPERATION_PATH, operation=operation, path=text)
if text in FORBIDDEN:
raise ChangeRefused(f"{text} 不能按字段改:{FORBIDDEN[text]}", code=OPERATION_PATH, operation=operation, path=text)
segments = [segment for segment in text.split("/") if segment]
if any(segment.isdigit() for segment in segments):
raise ChangeRefused(f"不能按数组位置定位({text}):数组字段整份设置,指标身份用 metric_id,不用位置",
code=OPERATION_PATH, operation=operation, path=text)
field = FIELDS.get(text)
if field is None:
allowed = "、".join(sorted(FIELDS))
raise ChangeRefused(f"不能改这个字段:{text}。能按字段改的是:{allowed}",
code=OPERATION_PATH, operation=operation, path=text)
return field
def _read(index: int, raw: Any) -> tuple[str, str, str, Any]:
"""读一条操作并校验形状:(op, metric_id, path, value)。「有没有写 value」在这一步就判完(省略和 null 是两件事)。"""
if not isinstance(raw, Mapping):
raise ChangeRefused(f"第 {index + 1} 条操作不是一个对象", operation=index)
op = str(raw.get("op") or "").strip()
if op not in ("set", "remove"):
raise ChangeRefused(f"不认识的 op:{op or '(空)'}。只支持 set(按字段设值)和 remove(删掉这个字段)",
operation=index)
metric_id = raw.get("metric_id")
if not isinstance(metric_id, str) or not metric_id.strip():
raise ChangeRefused(f"第 {index + 1} 条操作缺少 metric_id(指标在口径里的稳定身份)", operation=index)
path = raw.get("path")
if not isinstance(path, str) or not path.strip():
raise ChangeRefused(f"第 {index + 1} 条操作缺少 path(字段路径)", operation=index)
has_value = "value" in raw
if op == "remove" and has_value:
raise ChangeRefused(f"remove 不接受 value({path.strip()}):要「声明成空」用 set + ''(数组用 []),"
"要「没声明」才用 remove", code=OPERATION_VALUE, operation=index, path=path.strip())
if op == "set" and not has_value:
raise ChangeRefused(f"set 要带 value({path.strip()}):字符串写 '',数组写 [],清空字段用 remove",
code=OPERATION_VALUE, operation=index, path=path.strip())
return op, metric_id.strip(), path.strip(), raw.get("value")
def _checked(index: int, path: str, field: Field, value: Any) -> Any:
"""按字段的值类型校验这个值。null 单独拒:它是「清空」还是「声明为空」说不清,不替客户端猜。"""
def refuse(message: str) -> None:
raise ChangeRefused(message, code=OPERATION_VALUE, operation=index, path=path)
if value is None:
refuse(f"{path} 不能设成 null:「清空」用 remove、「声明成空」用 ''(数组用 []),两种都不是就写具体值")
if field.value_type == "choice":
if not isinstance(value, str):
refuse(f"{field.label}({path})要是字符串")
if value not in field.choices:
spec = bindings.FIELDS.get(path[1:])
allowed = "、".join(f"{choice}({spec.label_of(choice) if spec else choice})" for choice in field.choices)
refuse(f"{field.label}({path})只能是:{allowed}")
return value
if field.value_type == "text":
if not isinstance(value, str):
refuse(f"{field.label}({path})要是字符串")
if field.nonempty and not value.strip():
refuse(f"{field.label}({path})不能是空串")
return value
if field.value_type == "texts":
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
refuse(f"{field.label}({path})要是字符串数组,而且是整份设置(不能按位置改其中一项)")
return copy.deepcopy(value)
if isinstance(value, bool) or not isinstance(value, int):
refuse(f"{field.label}({path})要是整数")
if value < 0:
refuse(f"{field.label}({path})不能是负数")
return value
def _container(metric: dict[str, Any], path: str, *, index: int, op: str) -> tuple[dict[str, Any], str]:
"""值所在的容器和键名:/definition/text 装在 metric["definition"] 里,别的都在指标自己身上。
装子字段的对象还没建(没这个键,或者就是 null)时:set 顺手建一个——字段本来就该装在里面(好几条指标就只有
definition 之外的内容);remove 当「本来就没有这个字段」处理,是无变化,不是路径非法,也不往指标上补一个空对象。
键在、但不是对象(比如被人写成了字符串或数组)才拒:那是数据坏了,不替人猜该改成什么,更不把原来的值丢掉。
"""
head, _, tail = path[1:].partition("/")
if not tail:
return metric, head
block = metric.get(head)
if block is None:
if op == "remove":
return {}, tail
block = metric[head] = {}
return block, tail
if not isinstance(block, dict):
raise ChangeRefused(f"指标上的 {head} 不是对象,不能按字段改它({path})",
code=OPERATION_PATH, operation=index, path=path)
return block, tail
def _same(before: Any, after: Any) -> bool:
if before is _MISSING or after is _MISSING:
return before is _MISSING and after is _MISSING
return bool(before == after)
def apply_operations(graph: Mapping[str, Any] | None, *, card_id: str,
operations: Sequence[Mapping[str, Any]]) -> Change:
"""把整批字段操作打到原图的深拷贝上,交回新图和这次改了什么。
整批原子:所有操作都先在拷贝上跑,任何一条不合规、或者指标指不到就 ChangeRefused,图一个字节都不动(4.2)。
同一条指标上的多条操作按给的顺序依次生效(后面的看得见前面的结果)。
"""
repo = GraphBackedCaliberRepository(graph, card_id=card_id)
if len(operations) > MAX_OPERATIONS:
raise ChangeRefused(f"一次最多改 {MAX_OPERATIONS} 条:再多请走整份换指标那条路")
if not operations:
raise ChangeRefused("operations 是空的:要改哪个字段就写哪几条")
before_hash = repo.definition_hash()
working: dict[str, dict[str, Any]] = {}
kinds: set[str] = set()
report: list[dict[str, Any]] = []
for index, raw in enumerate(operations):
op, metric_id, path, value = _read(index, raw)
field = field_of(path, operation=index)
if metric_id not in working:
try:
working[metric_id] = repo.metric(metric_id)
except MetricGone:
raise ChangeRefused(
f"这张口径卡上没有指标「{metric_id}」:首版不支持用字段操作新增或删除指标(改名、重排都不换 ID)",
code=OPERATION_METRIC, operation=index, path=path) from None
container, key = _container(working[metric_id], path, index=index, op=op)
was = container.get(key, _MISSING)
if op == "remove":
container.pop(key, None)
else:
container[key] = _checked(index, path, field, value)
now = container.get(key, _MISSING)
moved = not _same(was, now)
if moved:
kinds.add(field.kind)
report.append({"op": op, "metric_id": metric_id, "path": path, "changed": moved})
changed = {metric_id: metric for metric_id, metric in working.items() if metric != repo.metric(metric_id)}
if not changed:
# 内容没变:交回原图的一份深拷贝(调用方照常走保存,save_graph 认出没变就不新增版本)
return Change(graph=repo.graph_copy(), card=repo.card_id,
applied=False, classification="none", metrics=(), operations=tuple(report),
definition_hash=(before_hash, before_hash),
binding=bindings.analyze(graph or {}, repo.card_id))
new_graph = repo.replace_metrics(changed)
final, binding_report, synced = _sync_bindings(new_graph, repo.card_id)
after = GraphBackedCaliberRepository(final, card_id=repo.card_id)
return Change(graph=final, card=repo.card_id, applied=True,
classification="definition" if "definition" in kinds else "display",
metrics=tuple(metric_id for metric_id in repo.ids() if metric_id in changed),
operations=tuple(report), definition_hash=(before_hash, after.definition_hash()),
binding=binding_report, synced=synced)
def _sync_bindings(graph: dict[str, Any], card_id: str) -> tuple[dict[str, Any], bindings.Report, bool]:
"""定义改完之后,取数节点上的受控绑定跟着改(K04,4.5):能确定性同步的把它一起改掉,改不了的原样留着。
只改这几处:取数节点 args.sql(受控模板按新业务值展开)和 binding 的 values / rendered_hash / fingerprint。
有一条同步不了就**一条也不改**(定义照常保存,读的时候照实说 needs_update / unsupported + 原因和影响对象):
一半改一半不改比都不改更糟——那才是「旧 SQL 的结果冒充新定义」。执行期不重推任何业务语义(A09)。
"""
planned = bindings.plan(graph, card_id)
if not planned.configs:
return graph, planned, False
synced = GraphBackedCaliberRepository(graph, card_id=card_id).replace_node_configs(planned.configs)
return synced, bindings.analyze(synced, card_id), True
def summary(change: Change, *, applied: bool, revision: int | None) -> dict[str, Any]:
"""给接口的变更回执(K03 的「变更分类、影响摘要、绑定状态、任务引用」,K04 接上真实的绑定状态)。
只说这次真发生了什么:定义指纹前后的值、这次动到的指标、旧验证怎么失效、取数 SQL 同步到哪一步。绑定状态
来自 ledger/bindings 的实际判定(aligned / needs_update / unsupported / unbound),**不再恒为 unknown,也不
再把「改过定义」说成对齐**;aligned 只说定义和取数实现同步,不等于业务验证通过,也不改质量结论。验证记录
K05 已经落库(caliber_validation_records,读法见 app/ledger/validation 与
GET /api/ledger/drafts/{id}/validations):哪条记录还算覆盖当前定义/绑定、有没有独立期望、有没有认证审阅,
都在那边算——所以这里的「下一步」仍是一句动作,不编一个任务号。
"""
before, after = change.definition_hash
metrics = list(change.metrics) if applied else []
if change.synced:
binding_reason = ("取数 SQL 已按受控模板在同一次变更里改过来:定义和实现是同步的"
"(aligned 只说明这两边对得上,不等于业务验证通过)")
else:
binding_reason = change.binding.reason
binding = {**change.binding.as_dict(), "reason": binding_reason, "synced": change.synced}
return {
"applied": applied,
"classification": change.classification if applied else "none",
"card": change.card,
"metrics": metrics,
"operations": [dict(item) for item in change.operations],
"revision": revision,
"definition_hash": {"before": before, "after": after, "changed": before != after},
"validation": {
"invalidated": applied, "scope": "card", "metrics": metrics,
"reason": "口径卡的内容变了(图的执行指纹跟着变):旧的试运行记录和验证不再算覆盖这一版,"
"按卡片保守失效(4.8)"
if applied else "这次操作没有改动任何内容:图没换版本,原来的验证仍然对着这一版",
"next": "重新试运行,按新定义取一次独立样例" if applied else "不用重新试运行",
},
"binding": binding,
}
__all__ = ["FIELDS", "FORBIDDEN", "MAX_OPERATIONS", "OPERATION_INVALID", "OPERATION_METRIC", "OPERATION_PATH",
"OPERATION_VALUE", "Change", "ChangeRefused", "Field", "apply_operations", "field_of", "summary"]