mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
支持外部常量指标:来源、生效日期、过期降档与 external 标签
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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),每个一句缺口。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},以及写错时的一句话。
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user