diff --git a/backend/app/data/caliber_identity.py b/backend/app/data/caliber_identity.py index ece0af6c..c496bc9f 100644 --- a/backend/app/data/caliber_identity.py +++ b/backend/app/data/caliber_identity.py @@ -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: diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py index bd87cfc8..32a60a56 100644 --- a/backend/app/engine/issuance.py +++ b/backend/app/engine/issuance.py @@ -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),每个一句缺口。 diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 948ff862..03b84196 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -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) diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 57ec6784..9b86ea4e 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -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},以及写错时的一句话。 diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 9fa996ba..47d137f4 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -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") diff --git a/backend/app/ledger/descriptor.py b/backend/app/ledger/descriptor.py index 5ebca863..983a7b9d 100644 --- a/backend/app/ledger/descriptor.py +++ b/backend/app/ledger/descriptor.py @@ -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]]: diff --git a/backend/app/ledger/labels.py b/backend/app/ledger/labels.py index 32a70125..f1b15f45 100644 --- a/backend/app/ledger/labels.py +++ b/backend/app/ledger/labels.py @@ -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: diff --git a/backend/tests/test_analysis_judge_ledger.py b/backend/tests/test_analysis_judge_ledger.py index c7fb4360..5f549d30 100644 --- a/backend/tests/test_analysis_judge_ledger.py +++ b/backend/tests/test_analysis_judge_ledger.py @@ -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"}} diff --git a/backend/tests/test_ledger_cells.py b/backend/tests/test_ledger_cells.py index a2d18a31..46fc0451 100644 --- a/backend/tests/test_ledger_cells.py +++ b/backend/tests/test_ledger_cells.py @@ -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 diff --git a/backend/tests/test_metric_definition.py b/backend/tests/test_metric_definition.py index d3ef5fd2..a798439c 100644 --- a/backend/tests/test_metric_definition.py +++ b/backend/tests/test_metric_definition.py @@ -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"} diff --git a/frontend/src/run/LedgerStep.tsx b/frontend/src/run/LedgerStep.tsx index 4d247e7d..a80a1eed 100644 --- a/frontend/src/run/LedgerStep.tsx +++ b/frontend/src/run/LedgerStep.tsx @@ -348,6 +348,16 @@ export function LedgerPart({ runId, runClass, report, segId, segText, segLedger, i.workflow_id === issue.workflow_id && i.version === issue.version) }} />} )} + {label.kind === 'external' && ( +
+

外部常量

+

+ 来源:{label.source || '未写'} · 生效日期:{label.as_of || '未写'} + {label.stale && 已过期} +

+

不来自查询,没有等价证明;正式出具前请核对来源和有效期。

+
+ )} {label.kind === 'related' && (

{LEDGER_UI.step.relatedSub}{label.reason ? `:${label.reason}` : ''}

diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index 6e152424..29ac215e 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -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