mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
指标定义卡与参数进入校验、descriptor 和目录页
This commit is contained in:
@@ -1086,6 +1086,73 @@ def _check_challenge(node: GraphNode, spec: GraphSpec, result: ValidationResult)
|
||||
field="model", code="challenge.same_model")
|
||||
|
||||
|
||||
#: 指标定义的类型。现在只有公式(表达式)这一种在运行时能算;规则、评估、外部常量还没实现,
|
||||
#: 先在校验里挡住,免得写进图里以后被当成能算的指标
|
||||
METRIC_KINDS = ("formula",)
|
||||
#: 定义卡参数的标量类型(和入口表单的字段类型同一套子集)
|
||||
METRIC_PARAM_TYPES = ("string", "number", "integer", "boolean")
|
||||
|
||||
|
||||
def metric_definition_issues(node: GraphNode, metric: Any, index: int) -> list[ValidationIssue]:
|
||||
"""一条指标定义卡的结构:ID / 表达式、kind、定义文本、参数。形状错是 error,运行时没法按它算。"""
|
||||
out: list[ValidationIssue] = []
|
||||
field = f"metrics[{index}]"
|
||||
|
||||
def add(message: str, key: str = field, level: str = "error", code: str | None = None) -> None:
|
||||
out.append(ValidationIssue(level=level, message=message, node_id=node.id, field=key, code=code)) # type: ignore[arg-type]
|
||||
|
||||
if not isinstance(metric, dict):
|
||||
add("指标定义需要是一个对象")
|
||||
return out
|
||||
if not metric.get("id") or not str(metric.get("id")).strip():
|
||||
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)。"
|
||||
"规则、评估、外部常量请先在定义卡里写成说明,等对应的执行方式上线后再发布",
|
||||
f"{field}.kind", code="metrics.kind_unsupported")
|
||||
if not metric.get("expression"):
|
||||
add(f"指标定义缺少表达式:{metric.get('id') or '(空)'}", code="metrics.expression_missing")
|
||||
|
||||
definition = metric.get("definition")
|
||||
if definition is not None:
|
||||
if not isinstance(definition, dict):
|
||||
add("指标的定义说明需要是一个对象(text / scope / exclusions / owner / effective_from / citations)",
|
||||
f"{field}.definition", code="metrics.definition_invalid")
|
||||
else:
|
||||
for key in ("text", "scope", "owner", "effective_from"):
|
||||
if definition.get(key) is not None and not isinstance(definition[key], str):
|
||||
add(f"指标定义说明的「{key}」需要是文字", f"{field}.definition.{key}",
|
||||
code="metrics.definition_invalid")
|
||||
if definition.get("exclusions") is not None and not isinstance(definition["exclusions"], list):
|
||||
add("指标定义说明的「exclusions」需要是列表", f"{field}.definition.exclusions",
|
||||
code="metrics.definition_invalid")
|
||||
if definition.get("citations") is not None and not isinstance(definition["citations"], list):
|
||||
add("指标定义说明的「citations」需要是列表", f"{field}.definition.citations",
|
||||
code="metrics.definition_invalid")
|
||||
|
||||
params = metric.get("params")
|
||||
if params is not None:
|
||||
if not isinstance(params, list):
|
||||
add("指标参数需要是一个列表", f"{field}.params", code="metrics.params_invalid")
|
||||
else:
|
||||
seen: set[str] = set()
|
||||
for j, param in enumerate(params):
|
||||
key = f"{field}.params[{j}]"
|
||||
if not isinstance(param, dict) or not isinstance(param.get("name"), str) \
|
||||
or not param["name"].strip():
|
||||
add("参数需要是一个带 name 的对象", key, code="metrics.params_invalid")
|
||||
continue
|
||||
name = param["name"].strip()
|
||||
if name in seen:
|
||||
add(f"参数「{name}」重复了", f"{key}.name", code="metrics.params_invalid")
|
||||
seen.add(name)
|
||||
if param.get("type") not in METRIC_PARAM_TYPES:
|
||||
add(f"参数「{name}」的类型只能是 {'、'.join(METRIC_PARAM_TYPES)},当前为「{param.get('type')}」",
|
||||
f"{key}.type", code="metrics.params_invalid")
|
||||
return out
|
||||
|
||||
|
||||
def validate_graph(spec: GraphSpec) -> ValidationResult:
|
||||
"""编译前的静态检查。错误会挡住运行,警告只在画布上提示。"""
|
||||
result = ValidationResult()
|
||||
@@ -1173,9 +1240,9 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
|
||||
if not defs and not cfg.get("caliber_from"):
|
||||
result.add("口径卡还没有定义指标", node_id=node.id, field="metrics")
|
||||
for i, d in enumerate(defs):
|
||||
if not d.get("id") or not d.get("expression"):
|
||||
result.add(f"指标定义缺少 ID 或表达式:{d.get('id') or '(空)'}",
|
||||
node_id=node.id, field=f"metrics[{i}]")
|
||||
for issue in metric_definition_issues(node, d, i):
|
||||
result.add(issue.message, level=issue.level, node_id=issue.node_id, field=issue.field,
|
||||
code=issue.code)
|
||||
elif node.type == NodeType.DUAL_CHECK:
|
||||
_check_dual_check(node, spec, result)
|
||||
elif node.type == NodeType.REPORT:
|
||||
|
||||
@@ -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")
|
||||
METRIC_KEYS = ("id", "name", "unit", "decimals", "format", "kind", "definition", "params")
|
||||
|
||||
|
||||
async def list_calibers(session: AsyncSession, *, workflow_id: str | None = None) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -105,3 +105,15 @@ async def test_a_card_that_copies_another_caliber_is_not_listed(client):
|
||||
listed = await descriptors(client, wf)
|
||||
assert [d["card"]["node_id"] for d in listed] == ["card"]
|
||||
assert (await client.get(f"/api/ledger/calibers/{wf}/1/copy")).status_code == 404
|
||||
|
||||
|
||||
async def test_metric_definition_and_params_are_described(client):
|
||||
graph = S.caliber_graph()
|
||||
metric = graph["nodes"][2]["data"]["config"]["metrics"][0]
|
||||
metric["definition"] = {"text": "已支付订单的销售额", "scope": "全部门店", "owner": "运营"}
|
||||
metric["params"] = [{"name": "store", "type": "string", "default": "all"}]
|
||||
wf = await S.publish(graph=graph)
|
||||
[made] = await descriptors(client, wf)
|
||||
[m] = made["metrics"]
|
||||
assert m["definition"]["text"] == "已支付订单的销售额" and m["definition"]["owner"] == "运营"
|
||||
assert m["params"] == [{"name": "store", "type": "string", "default": "all"}]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""指标定义卡(definition)与参数(params)的最小结构校验。
|
||||
|
||||
执行还是公式这一种;定义文本、参数先作为语义元数据校验、进内容指纹、进 descriptor。规则/评估/外部常量没实现前,
|
||||
写进图里要按错误挡住,免得被当成能算的指标。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine.schema import GraphSpec, validate_graph
|
||||
from tests import ledger_samples as S
|
||||
|
||||
|
||||
def _graph(**metric) -> dict:
|
||||
graph = S.caliber_graph()
|
||||
graph["nodes"][2]["data"]["config"]["metrics"][0].update(metric)
|
||||
return graph
|
||||
|
||||
|
||||
def _errors(graph: dict) -> list:
|
||||
spec = GraphSpec.model_validate(graph)
|
||||
return [i for i in validate_graph(spec).issues if i.level == "error"]
|
||||
|
||||
|
||||
def test_a_formula_metric_can_carry_a_definition_and_params():
|
||||
graph = _graph(kind="formula", definition={"text": "已支付订单的销售额", "scope": "全部门店",
|
||||
"owner": "运营", "effective_from": "2026-01-01"},
|
||||
params=[{"name": "store", "type": "string", "default": "all"},
|
||||
{"name": "min_amount", "type": "number"}])
|
||||
assert _errors(graph) == []
|
||||
|
||||
|
||||
def test_an_unimplemented_kind_is_refused():
|
||||
errors = _errors(_graph(kind="rule"))
|
||||
assert any(i.code == "metrics.kind_unsupported" and "还没有实现" in i.message for i in errors)
|
||||
|
||||
|
||||
def test_params_must_be_named_and_typed_once_each():
|
||||
errors = _errors(_graph(params=[{"name": "", "type": "string"},
|
||||
{"name": "store", "type": "string"},
|
||||
{"name": "store", "type": "date"}]))
|
||||
codes = [i.code for i in errors]
|
||||
assert codes.count("metrics.params_invalid") >= 3
|
||||
|
||||
|
||||
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
|
||||
@@ -129,9 +129,19 @@ export function CaliberCatalog() {
|
||||
<h3 className="mb-1 text-2xs font-semibold text-dim">指标</h3>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{d.metrics.map((m) => (
|
||||
<li key={m.id} className="flex items-baseline justify-between gap-2 border-b border-hairline pb-1 last:border-0">
|
||||
<span>{m.name ?? m.id}</span>
|
||||
<span className="text-2xs text-faint">{m.unit || '—'}</span>
|
||||
<li key={m.id} className="border-b border-hairline pb-1 last:border-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span>{m.name ?? m.id}</span>
|
||||
<span className="text-2xs text-faint">{m.unit || '—'}</span>
|
||||
</div>
|
||||
{m.definition?.text && (
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{m.definition.text}</p>
|
||||
)}
|
||||
{!!m.params?.length && (
|
||||
<p className="mt-0.5 text-2xs text-faint">
|
||||
参数:{m.params.map((p) => `${p.name}(${p.type})`).join('、')}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -380,6 +380,11 @@ export interface CaliberMetric {
|
||||
unit?: string
|
||||
decimals?: number
|
||||
format?: string
|
||||
/** 公式(formula);规则、评估、外部常量还没实现 */
|
||||
kind?: string
|
||||
/** 定义卡:文字定义、范围、排除项、负责人、生效日期、引用 */
|
||||
definition?: { text?: string; scope?: string; exclusions?: string[]; owner?: string; effective_from?: string; citations?: unknown[] }
|
||||
params?: { name: string; type: string; default?: unknown; min?: number; max?: number }[]
|
||||
}
|
||||
|
||||
/** GET /api/ledger/calibers:一份已发布口径卡的可调用描述(不返回 SQL、不返回参数值) */
|
||||
|
||||
Reference in New Issue
Block a user