支持外部常量指标:来源、生效日期、过期降档与 external 标签

This commit is contained in:
ThearchyHelios
2026-11-03 20:32:55 +08:00
parent 214a3367ff
commit 37657e24c3
12 changed files with 173 additions and 15 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ from collections.abc import Iterable
from typing import Any
#: 指标的语义字段:只有这几样算定义,运行时值(value、rendered、inputs……)不参与哈希
SEMANTIC_FIELDS = ("id", "name", "expression", "unit", "decimals", "format", "kind", "definition", "params")
SEMANTIC_FIELDS = ("id", "name", "expression", "unit", "decimals", "format", "kind", "definition", "params", "external")
def _blank(value: Any) -> bool:
+11
View File
@@ -475,6 +475,17 @@ def incomplete_gaps(metrics: list[dict[str, Any]]) -> list[str]:
for m in metrics if isinstance(m, dict) and m.get("incomplete")]
def external_stale_gaps(metrics: list[dict[str, Any]]) -> list[str]:
"""外部常量过期:过了 stale_after_days 天,值还是那个值,但它已经不能代表现在。"""
out: list[str] = []
for m in metrics:
external = m.get("external") if isinstance(m, dict) else None
if isinstance(external, dict) and external.get("stale"):
out.append(f"指标「{m.get('name') or m.get('id')}」的外部常量已过期"
f"(来源:{external.get('source') or '未写'},生效日期 {external.get('as_of') or '未写'})")
return out
def no_data_gaps(metrics: list[dict[str, Any]]) -> list[str]:
"""契约收来的指标里,输入取自「查询区间里这张表没有数据」的列的(口径卡标了 no_data,data/coverage.py),每个一句缺口。
+5 -1
View File
@@ -151,7 +151,9 @@ def _apply_contract(
from datetime import datetime, timezone
from app.engine.governance import board_only
from app.engine.issuance import decide_tier, incomplete_gaps, no_data_gaps, sql_check_findings, trace_numbers
from app.engine.issuance import (
decide_tier, external_stale_gaps, incomplete_gaps, no_data_gaps, sql_check_findings, trace_numbers,
)
nodes = state.get("nodes") or {}
# 写了 report_from 就是引用模式:按报告文档逐段复核,不再按数值回指叙述
@@ -194,6 +196,8 @@ def _apply_contract(
gaps.extend(incomplete_gaps(metrics))
# 输入取自「查询区间里这张表没有数据」的列的指标:数回指得上、复算一致,但那个 0 不代表真的是 0,不能完整出具
gaps.extend(no_data_gaps(metrics))
# 外部常量过了有效期:值还是那个值,但已经不能代表现在
gaps.extend(external_stale_gaps(metrics))
# 来源查询没通过 SQL 检查(error 级:重复计算、存量跨期加总):口径卡复算得一致,算的却是错数。
# 报告直接引用的查询要等引用核对之后才知道,缺口放在这个位置,下面一起算
sql_at = len(gaps)
+57 -3
View File
@@ -5,6 +5,7 @@ import asyncio
import json
import math
import re
from datetime import date, timedelta
from typing import Any
from app.core import artifact_store
@@ -97,14 +98,40 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
comparing = _Comparing()
for definition in definitions:
metric_id = str(definition.get("id") or "").strip()
expr = str(definition.get("expression") or "").strip()
if not metric_id or not expr:
errors.append(f"指标定义不完整(缺少 ID 或计算表达式):{definition}")
if not metric_id:
errors.append(f"指标定义不完整(缺少 ID):{definition}")
continue
display, problem = _display_of(definition)
if problem:
errors.append(f"指标「{metric_id}」:{problem}")
continue
if str(definition.get("kind") or "formula") == "external":
value, external, problem = _external_of(definition)
if problem:
errors.append(f"指标「{metric_id}」:{problem}")
continue
decimals = display["decimals"]
value = round_metric(value, decimals)
metrics.append({
"id": metric_id,
"name": definition.get("name") or metric_id,
"unit": definition.get("unit") or "",
"value": value,
"expression": "",
"decimals": decimals,
"format": display["format"],
"rendered": _rendered(value, definition, display),
"inputs": [],
"substituted": str(value),
"recompute_ok": True,
"status": "ok",
"external": external,
})
continue
expr = str(definition.get("expression") or "").strip()
if not expr:
errors.append(f"指标定义不完整(缺少计算表达式):{definition}")
continue
try:
tree, _, _ = parse_expression(expr)
except ExpressionError as e:
@@ -414,6 +441,33 @@ def source_expression(expression: str, source: Any) -> str:
_DECIMALS_RANGE = 15
def _external_of(definition: dict[str, Any]) -> tuple[Any, dict[str, Any], str | None]:
"""外部常量指标:{value, external: {source, as_of, stale_after_days, stale}},以及写错时的一句话。
外部常量不来自查询,但也不能没有出处:来源和生效日期必填;过了 stale_after_days 天标 stale,出具契约据此降档。
值只收数(布尔不算),单位的换算、取整仍交给 decimals/format。
"""
external = definition.get("external")
if not isinstance(external, dict):
return None, {}, "外部常量指标需要 external 对象(value / source / as_of / stale_after_days)"
value = external.get("value")
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None, {}, "external.value 需要是数"
source = str(external.get("source") or "").strip()
if not source:
return None, {}, "external.source 需要写明来源"
raw_as_of = external.get("as_of")
try:
as_of = date.fromisoformat(str(raw_as_of))
except (TypeError, ValueError):
return None, {}, "external.as_of 需要是 YYYY-MM-DD"
days = external.get("stale_after_days")
if days is not None and (isinstance(days, bool) or not isinstance(days, int) or days < 0):
return None, {}, "external.stale_after_days 需要是非负整数"
stale = days is not None and date.today() > as_of + timedelta(days=days)
return value, {"source": source, "as_of": as_of.isoformat(), "stale_after_days": days, "stale": stale}, None
def _display_of(definition: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
"""指标的显示格式:{decimals, format, bad_decimals},以及写错时的一句话。
+26 -4
View File
@@ -1088,7 +1088,7 @@ def _check_challenge(node: GraphNode, spec: GraphSpec, result: ValidationResult)
#: 指标定义的类型。现在只有公式(表达式)这一种在运行时能算;规则、评估、外部常量还没实现,
#: 先在校验里挡住,免得写进图里以后被当成能算的指标
METRIC_KINDS = ("formula",)
METRIC_KINDS = ("formula", "external")
#: 定义卡参数的标量类型(和入口表单的字段类型同一套子集)
METRIC_PARAM_TYPES = ("string", "number", "integer", "boolean")
@@ -1108,10 +1108,32 @@ def metric_definition_issues(node: GraphNode, metric: Any, index: int) -> list[V
add(f"指标定义缺少 ID:{field}", code="metrics.id_missing")
kind = str(metric.get("kind") or "formula")
if kind not in METRIC_KINDS:
add(f"指标「{metric.get('id') or index + 1}」的类型「{kind}」还没有实现:现在只支持「公式」(formula)。"
"规则、评估、外部常量请先在定义卡里写成说明,等对应的执行方式上线后再发布",
add(f"指标「{metric.get('id') or index + 1}」的类型「{kind}」还没有实现:现在支持「公式」(formula)和"
"「外部常量」(external)。规则、评估请先在定义卡里写成说明,等对应的执行方式上线后再发布",
f"{field}.kind", code="metrics.kind_unsupported")
if not metric.get("expression"):
if kind == "external":
external = metric.get("external")
if not isinstance(external, dict):
add("外部常量指标需要 external 对象(value / source / as_of / stale_after_days)",
f"{field}.external", code="metrics.external_invalid")
else:
value = external.get("value")
if isinstance(value, bool) or not isinstance(value, (int, float)):
add("external.value 需要是数", f"{field}.external.value", code="metrics.external_invalid")
if not isinstance(external.get("source"), str) or not external["source"].strip():
add("external.source 需要写明来源", f"{field}.external.source", code="metrics.external_invalid")
raw = external.get("as_of")
try:
from datetime import date
date.fromisoformat(str(raw))
except (TypeError, ValueError):
add("external.as_of 需要是 YYYY-MM-DD", f"{field}.external.as_of", code="metrics.external_invalid")
days = external.get("stale_after_days")
if days is not None and (isinstance(days, bool) or not isinstance(days, int) or days < 0):
add("external.stale_after_days 需要是非负整数", f"{field}.external.stale_after_days",
code="metrics.external_invalid")
elif not metric.get("expression"):
add(f"指标定义缺少表达式:{metric.get('id') or '(空)'}", code="metrics.expression_missing")
definition = metric.get("definition")
+1 -1
View File
@@ -28,7 +28,7 @@ from app.look.calibers import card_inputs
from app.tools.datasource import QUERY_PREFIX
#: 指标只写口径卡配置里真有的这几个键
METRIC_KEYS = ("id", "name", "unit", "decimals", "format", "kind", "definition", "params")
METRIC_KEYS = ("id", "name", "unit", "decimals", "format", "kind", "definition", "params", "external")
async def list_calibers(session: AsyncSession, *, workflow_id: str | None = None) -> list[dict[str, Any]]:
+7 -1
View File
@@ -34,7 +34,7 @@ from typing import Any
from app.analysis.contract import LEDGER_KIND
#: 标签的四种、认法的两种
KINDS = ("caliber", "related", "reasoning", "unknown")
KINDS = ("caliber", "external", "related", "reasoning", "unknown")
HOWS = ("independent", "composed")
#: 界面上标签要写明的两句:比对在封存之后做,不在封存范围内;事后补比的是按现在的口径回看
POST_SEAL_NOTE = "封存后比对,不在封存范围内"
@@ -300,6 +300,12 @@ def metric_label(card: Mapping[str, Any] | None, metric_id: Any, queries: Mappin
metric = next((m for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") == metric_id), None)
if metric is None:
return _unknown(METRIC_GONE)
external = metric.get("external")
if isinstance(external, dict):
# 外部常量不来自查询:没有等价证明可做,标签单独一类;带来源和生效日期,过期照实标
return {"kind": "external", "source": external.get("source"), "as_of": external.get("as_of"),
"stale": bool(external.get("stale")),
**({"definition_hash": card.get("definition_hash")} if card.get("definition_hash") else {})}
inputs = [i for i in metric.get("inputs") or [] if isinstance(i, dict)]
from_queries = [i for i in inputs if i.get("artifact") and i.get("via") in _SNAPSHOT_VIAS]
if not from_queries:
+1 -1
View File
@@ -88,7 +88,7 @@ def test_analysis_numbers_get_no_caliber_label_and_are_not_counted():
labelled = {s["id"]: s.get("ref") for s in iter_segments(doc) if s["id"] in labels}
assert list(labelled.values()) == ["v:Q1.r0.gmv"]
whole = ledger_labels.whole_of(labels, {})
assert whole["counts"] == {"caliber": 0, "related": 0, "reasoning": 1, "unknown": 0}
assert whole["counts"] == {"caliber": 0, "external": 0, "related": 0, "reasoning": 1, "unknown": 0}
assert ledger_labels.referenced_columns([doc], lambda _: None) == {q: {"gmv"}}
+2 -2
View File
@@ -273,8 +273,8 @@ async def test_last_weeks_sales_labels_only_the_sales_number_as_from_the_caliber
assert cols["销售额"]["binding"] == {"start": "2026-10-05", "end": "2026-10-11"}
assert {c: cols[c]["kind"] for c in COLUMNS[1:]} == {c: "related" for c in COLUMNS[1:]}
whole = content["whole"]
assert whole["equivalent"] is False and whole["counts"] == {"caliber": 1, "related": 3, "reasoning": 0,
"unknown": 0}
assert whole["equivalent"] is False and whole["counts"] == {"caliber": 1, "external": 0, "related": 3,
"reasoning": 0, "unknown": 0}
# 工件 meta:逐格证明了的口径也记上(召回按旧版本反查只读它)
from sqlalchemy import select
+48
View File
@@ -44,3 +44,51 @@ def test_params_must_be_named_and_typed_once_each():
def test_definition_must_be_an_object_with_text_fields():
errors = _errors(_graph(definition={"text": 1, "exclusions": "新客"}))
assert [i.code for i in errors].count("metrics.definition_invalid") >= 2
async def test_an_external_constant_metric_runs_without_a_query():
from app.engine.runner import run_manager
from tests import ledger_samples as S
await run_manager.setup()
try:
graph = S.chain(
S.node("start", "input"),
S.node("card", "metrics", caliber="年度目标", caliber_version="v1", metrics=[{
"id": "target", "name": "年度目标", "unit": "元", "decimals": 0, "kind": "external",
"definition": {"text": "董事会批准的年度目标", "owner": "财务"},
"external": {"value": 120000000, "source": "2026 年度预算决议", "as_of": "2026-01-01",
"stale_after_days": 365},
}]),
S.node("out", "output", fields=[{"name": "v", "value": "{{ nodes.card.metrics[0].rendered }}"}]))
started = await run_manager.start(graph=graph, input_payload={})
row = await S.wait(started.id)
assert row.status == "succeeded", row.error
assert row.output["v"] == "120,000,000元"
from app.api.evidence import _sealed, _sealed_cards
sealed = await _sealed(row.id)
[card] = [c for _, _, c in _sealed_cards(sealed)]
metric = card["metrics"][0]
assert metric["external"]["source"] == "2026 年度预算决议"
assert metric["external"]["stale"] is False
finally:
await run_manager.shutdown()
def test_an_external_constant_needs_a_source_and_an_effective_date():
errors = _errors(_graph(kind="external", external={"value": 10, "as_of": "not-a-date"}))
codes = [i.code for i in errors]
assert codes.count("metrics.external_invalid") >= 2
assert _errors(_graph(kind="external", external={
"value": 10, "source": "预算", "as_of": "2026-01-01", "stale_after_days": 30})) == []
def test_an_external_metric_is_labelled_external_not_reasoning():
from app.ledger.labels import metric_label
card = {"definition_hash": "abc", "metrics": [{"id": "target", "external": {
"source": "预算", "as_of": "2026-01-01", "stale": True}}]}
label = metric_label(card, "target", {}, {})
assert label == {"kind": "external", "source": "预算", "as_of": "2026-01-01", "stale": True,
"definition_hash": "abc"}
+10
View File
@@ -348,6 +348,16 @@ export function LedgerPart({ runId, runClass, report, segId, segText, segLedger,
i.workflow_id === issue.workflow_id && i.version === issue.version) }} />}
</div>
)}
{label.kind === 'external' && (
<div className="space-y-1" data-ledger-external="">
<p className="font-medium">外部常量</p>
<p className="text-2xs text-dim">
来源:{label.source || '未写'} · 生效日期:{label.as_of || '未写'}
{label.stale && <span className="ml-1.5 text-[var(--warn)]">已过期</span>}
</p>
<p className="text-2xs text-faint">不来自查询,没有等价证明;正式出具前请核对来源和有效期。</p>
</div>
)}
{label.kind === 'related' && (
<div className="space-y-2">
<p className="text-2xs text-dim">{LEDGER_UI.step.relatedSub}{label.reason ? `:${label.reason}` : ''}</p>
+4 -1
View File
@@ -53,8 +53,11 @@ export interface LedgerRelated {
* - unknown:没比对(reason 写为什么)
*/
export interface LedgerLabel {
kind: 'caliber' | 'related' | 'reasoning' | 'unknown'
kind: 'caliber' | 'external' | 'related' | 'reasoning' | 'unknown'
how?: 'independent' | 'composed'
source?: string
as_of?: string
stale?: boolean
caliber?: string
workflow_id?: string
version?: number