diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index f321593d..9fa996ba 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -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: diff --git a/backend/app/ledger/descriptor.py b/backend/app/ledger/descriptor.py index e627b541..5ebca863 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") +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]]: diff --git a/backend/tests/test_ledger_descriptor.py b/backend/tests/test_ledger_descriptor.py index 8f988b60..5b9c1654 100644 --- a/backend/tests/test_ledger_descriptor.py +++ b/backend/tests/test_ledger_descriptor.py @@ -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"}] diff --git a/backend/tests/test_metric_definition.py b/backend/tests/test_metric_definition.py new file mode 100644 index 00000000..d3ef5fd2 --- /dev/null +++ b/backend/tests/test_metric_definition.py @@ -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 diff --git a/frontend/src/pages/ledger/CaliberCatalog.tsx b/frontend/src/pages/ledger/CaliberCatalog.tsx index a932dc47..338db22e 100644 --- a/frontend/src/pages/ledger/CaliberCatalog.tsx +++ b/frontend/src/pages/ledger/CaliberCatalog.tsx @@ -129,9 +129,19 @@ export function CaliberCatalog() {

指标

diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index 82f2c431..6e152424 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -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、不返回参数值) */