diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 91be55cf..85c0a489 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -207,6 +207,9 @@ NODE_REFERENCE = """\ config: {caliber, caliber_version, metrics:[{id, name, unit, decimals, format, expression}], on_missing, assign_to} expression 是受限表达式(不是模板):vars.kpi.gmv、cell(nodes.fetch, 0, 'gmv')、 round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1) + **分母可能为 0**(上期为 0、这段时间没有数据)时写「… if 分母 else None」: + round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1) if vars.kpi.gmv_prev else None。 + 算不出来就是空值(报告里显示「—」,写作者照实说算不出来);不要写 else 0——0 会被读成持平、0% format:thousands(默认,千分位)/ plain(不分组,年份编号这类)/ percent_of_ratio(值是 0.0235 这样的比率,显示成 2.35%); decimals 写 -15 到 15 的整数;percent_of_ratio 的 decimals 按比率算:要显示两位百分比(2.35%)写 4, 写 2 只剩「2%」,0.004 这样的小比率还会显示不出来、报告引用不了。on_missing 默认 fail(缺输入整张卡失败);写 null 则缺输入的指标记为空值,交给出具契约判档 diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 8acb580d..08f89a9c 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -992,6 +992,8 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s # 来源查询没通过 SQL 检查:面板标出来并写明原因(问题和改法在查询步骤的 checks 里)。没问题的没有这两个键 **({"sql_check_failed": True, "sql_check_reason": source.get("sql_check_reason") or ""} if source.get("sql_check_failed") else {}), + # 输入都齐、计算式自己算不出来(分母为 0):面板说「算不出来」,不说「缺少输入」。有值的没有这个键 + **({"undefined": True} if source.get("undefined") and source.get("value") is None else {}), "node_id": entry.get("node_id"), "artifact": artifact or None, "eid": cite.get("eid"), "eid_ok": cite.get("eid") == entry.get("eid") == make_eid("metric", artifact or None, {"metric": metric_id}), "hash_ok": hash_ok and metric is not None, @@ -1091,6 +1093,7 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask if found.get("incomplete") else {}), **({"sql_check_failed": True, "sql_check_reason": found.get("sql_check_reason") or ""} if found.get("sql_check_failed") else {}), + **({"undefined": True} if found.get("undefined") and found.get("value") is None else {}), "untraced": _untraced_steps(found, sealed, node), }) if withheld := {path: _held_word(h) for path, h in held.items() if h.kind != "table"}: diff --git a/backend/app/engine/compare.py b/backend/app/engine/compare.py index bbc278ae..5481382e 100644 --- a/backend/app/engine/compare.py +++ b/backend/app/engine/compare.py @@ -201,6 +201,16 @@ class Side: inputs: dict[str, Any] = field(default_factory=dict) +def missing_reason(side: str, metric: dict[str, Any]) -> str | None: + """一边的指标没有值时的那句原因:输入都齐、计算式自己算不出来的(口径卡记了 undefined,分母为 0)说「算不出来」, + 别的说「缺少输入」。有值的为 None。""" + if metric.get("status") != "missing_input": + return None + if metric.get("undefined"): + return f"{side}这个指标算不出来(这组输入下计算式没有值,比如分母为 0)" + return f"{side}这个指标缺少输入,没有值" + + def compare(base: Side, target: Side) -> dict[str, Any]: """对比两次运行(base 是基准,target 是这次),只读,不碰两次运行的任何东西。返回: @@ -1499,8 +1509,8 @@ class _Compare: out.update(classification=DATA_ONLY, reasons=[ DATA_FROM_UPSTREAM if self.template["same_graph"] else DATA_FROM_UPSTREAM_SCOPED]) for side, metric in (("基准运行", bm), ("这次运行", tm)): - if metric.get("status") == "missing_input": - out["reasons"].append(f"{side}这个指标缺少输入,没有值") + if said := missing_reason(side, metric): + out["reasons"].append(said) if out["classification"] in _ATTRIBUTED and bp is not None and tp is not None: out["attribution"] = self._attribution(bp, tp, card_id) if any(inp.masked for plan in (bp, tp) if plan is not None for inp in plan.inputs.values()): diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 66dcea28..1a2afc5c 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -926,6 +926,8 @@ def build_catalog( # 来源查询没通过 SQL 检查(data/sqlcheck.py 的 error 级问题):同样只在有问题时有这两个键 **({"sql_check_failed": True, "sql_check_reason": metric.get("sql_check_reason") or ""} if metric.get("sql_check_failed") else {}), + # 输入都齐、计算式自己算不出来(分母为 0):写作目录、引用解析不了的原因说「算不出来」,不说「缺少输入」 + **({"undefined": True} if metric.get("undefined") and metric.get("value") is None else {}), } queries = retrievals = 0 @@ -1653,7 +1655,8 @@ def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_all # 换算了只会解析不了、原因说成「不是数值」。引用它本身由报告自查另记一条违规(masked_metrics) rendered = str(entry.get("rendered") or "") elif entry.get("value") is None: - return _unresolved(marker, alias, ev_kind, f"指标「{entry.get('name') or ref}」本次没有值(缺少输入)," + why = UNDEFINED_REASON if entry.get("undefined") else "缺少输入" + return _unresolved(marker, alias, ev_kind, f"指标「{entry.get('name') or ref}」本次没有值({why})," "无法写入报告", rendered=MISSING) else: try: @@ -3450,6 +3453,9 @@ SEE_AND_CODE_RULES = ( "(「状态为 9」「类型 1」):码值是没有出处的数字,系统会当成裸数字打回重写。" ) +#: 指标「算不出来」的那句(口径卡记了 undefined:输入都齐、计算式自己交回空值):引用解析不了的原因、写作目录里用 +UNDEFINED_REASON = "算不出来:这组输入下计算式没有值,比如分母为 0" + #: [[m:]]、[[v:]] 渲染出来自带单位(部署上撞过「覆盖 8条 条产线」:口径卡的单位是「条」,写作者照着助手的指令又写了一个)。 #: 渲染时紧跟着的同一个单位会去掉(_tidy),这里先说给写作者听 UNIT_RULE = ("数字标记渲染出来自带单位([[m:…]] 是口径卡里的单位,比如「8条」「12,345人次」),后面不要再写一遍单位:" @@ -3838,6 +3844,10 @@ def _metric_prompt(catalog: dict[str, Any]) -> list[str]: if e.get("masked"): # 值取自遮罩列的一格(withhold_metrics):写作者看到的就是「已遮罩」,引用了也只会交回重写 lines.append(f"- [[{e['alias']}]] {e.get('name')} = {e.get('rendered')}(取自设置了遮罩的列,不要引用)") + elif e.get("value") is None and e.get("undefined"): + # 输入都齐、计算式自己算不出来(分母为 0):照实写算不出来,不写成 0、持平 + lines.append(f"- [[{e['alias']}]] {e.get('name')}:这次算不出来({UNDEFINED_REASON}),不要引用;" + "照实写算不出来,不要写成 0 或持平") elif e.get("value") is None: lines.append(f"- [[{e['alias']}]] {e.get('name')}:这次没有值,不要引用") elif e.get("rendered") == MISSING: diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 85a9aa6d..c6f4fe7f 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -175,6 +175,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 值是空的就是缺输入,不论 on_missing 是哪一种:fail 模式下直接取到空值的 # 指标(原式就是 vars.x.y 而 y 不存在)以前也是照常产出一个 None "status": "ok" if value is not None else "missing_input", + # 输入都齐、是计算式自己交回的空值(分母为 0 时写的 … if 分母 else None):算不出来,不是缺输入。状态照旧是 + # missing_input(证据面板、推演、运行对比按状态认的地方不受影响),只多这一个键;有值的指标和以前一字不差 + **({"undefined": True} if value is None and not missing else {}), # 只在不完整时出现:完整的指标和以前一字不差,老运行复核时目录也对得上 **incomplete, # 只在来源查询没通过 SQL 检查时出现,同上 @@ -188,8 +191,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # text 是给叙述节点 prompt 用的清单——它是叙述层唯一的数字来源 lines = [ - (f"- {m['id']}({m['name']})= {m['value']}{m['unit']}" if m["value"] is not None - else f"- {m['id']}({m['name']})= {MISSING}(缺少输入,无值)") + f"- {m['id']}({m['name']})= {_listed(m)}" # 不写行数:叙述节点照抄进正文就是一个没有出处的数 + ("(不完整:基于被截断的查询结果计算)" if m.get("incomplete") else "") + ("(存疑:所依据的查询未通过 SQL 检查)" if m.get("sql_check_failed") else "") @@ -443,6 +445,24 @@ def _rendered(value: Any, definition: dict[str, Any], display: dict[str, Any]) - return MISSING +#: 清单里没有值的两种说法:缺输入,和输入都齐、计算式自己算不出来(分母为 0) +LISTED_MISSING = "(缺少输入,无值)" +LISTED_UNDEFINED = "(算不出来:这组输入下计算式没有值,比如分母为 0)" +#: 有值、按口径卡的小数位和格式显示不出来(太小、太大、不是有限的数):不把原值拼出来,照实说 +LISTED_UNSHOWABLE = "(有值,但按设定的小数位和格式显示不出来)" + + +def _listed(metric: dict[str, Any]) -> str: + """清单里这个指标的值:卡上按单位、小数位、格式渲染好的字(rendered,和报告里经 [[m:]] 渲染出来的一样)。以前拼原值 + ({value}{unit}),比率 0.0007 配 % 写成「0.0007%」,直接读清单的模型节点差 100 倍。""" + if metric["value"] is None: + return MISSING + (LISTED_UNDEFINED if metric.get("undefined") else LISTED_MISSING) + rendered = str(metric.get("rendered") or "") + if not rendered or rendered == MISSING: + return MISSING + LISTED_UNSHOWABLE + return rendered + + def _value_of(path: str, tctx: dict[str, Any]) -> Any: """单独取一个引用的值。取不到(或取值本身出错)一律当作没有值。""" try: diff --git a/backend/app/engine/recompute.py b/backend/app/engine/recompute.py index 78cd0e4b..690e6a48 100644 --- a/backend/app/engine/recompute.py +++ b/backend/app/engine/recompute.py @@ -609,7 +609,8 @@ _ANY = object() _CARD_PLAIN = frozenset({"kind", "caliber", "caliber_version", "artifact", "source"}) #: 一个指标里不带值的字段(名称、单位、表达式、显示格式、状态这类)。值、渲染、输入、代入式都带着值 _METRIC_PLAIN = frozenset({"id", "name", "unit", "expression", "decimals", "format", "status", "recompute_ok", - "incomplete", "incomplete_reason", "sql_check_failed", "sql_check_reason"}) + "incomplete", "incomplete_reason", "sql_check_failed", "sql_check_reason", "undefined", + "no_data", "no_data_reason", "no_data_sources"}) #: 转抄的上游口径卡当时的产出读不出来(文件不在、与哈希不一致、不知道是谁写的):转抄来的是不是遮罩值核对不了 CARD_UNREADABLE = "转抄的口径卡结果无法读取,核对不了这个值是否取自设置了遮罩的列" diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 5f37fa06..9adf1440 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -1464,6 +1464,40 @@ def _caliber_inputs(card: GraphNode, spec: GraphSpec) -> list[tuple[int, GraphNo return out +def _is_zero(node: Any) -> bool: + """常量 0(0、0.0、-0、+0);布尔不算。""" + import ast + + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + node = node.operand + return isinstance(node, ast.Constant) and type(node.value) in (int, float) and node.value == 0 + + +def _divides(node: Any) -> bool: + import ast + + return any(isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Div, ast.FloorDiv, ast.Mod)) for n in ast.walk(node)) + + +def zero_for_undefined(expression: str) -> bool: + """口径卡的表达式在算不出来时给了 0:某个条件表达式(x if c else y,嵌套的也算)一边有除法、另一边是常量 0—— + `round((a - b) / b, 4) if b else 0`、`0 if not b else a / b`。分母为 0、没有数据时给 0,报告里写成「0%」「持平」, + 读的人当成真的没变;应给 None(显示「—」)。按语法树认,解析不了的不算(另有表达式错误的报法)。""" + import ast + + from app.engine.expressions import ExpressionError, parse_expression + + try: + tree, _, _ = parse_expression(expression) + except (ExpressionError, ValueError, TypeError): + return False + for n in ast.walk(tree): + if isinstance(n, ast.IfExp) and ((_divides(n.body) and _is_zero(n.orelse)) + or (_divides(n.orelse) and _is_zero(n.body))): + return True + return False + + def evidence_issues(spec: GraphSpec) -> list[ValidationIssue]: from app.engine.governance import UPGRADE_POLICIES, UPGRADE_POLICY_LABELS @@ -1488,6 +1522,13 @@ def evidence_issues(spec: GraphSpec) -> list[ValidationIssue]: elif node.type == NodeType.METRICS: if cfg.get("caliber_from") not in (None, "", {}): out.extend(_caliber_from_issues(node, UPGRADE_POLICIES, UPGRADE_POLICY_LABELS, spec)) + else: + for i, d in enumerate(cfg.get("metrics") or []): + if isinstance(d, dict) and zero_for_undefined(str(d.get("expression") or "")): + add(f"指标「{d.get('name') or d.get('id') or i + 1}」在算不出来时给了 0(条件表达式的一边是除法," + "另一边是 0):分母为 0、没有数据时应给 None(报告里显示「—」,写作者照实说算不出来)," + "写成 0 会被当成持平 / 0%。请改成「… if 分母 else None」", + node, f"metrics[{i}].expression", code="metrics.zero_for_undefined") warned: set[str] = set() for i, producer in _caliber_inputs(node, spec): role = str(producer.config.get("evidence_role") or "compute") diff --git a/backend/tests/test_metric_lineage.py b/backend/tests/test_metric_lineage.py index 2ae24905..6338bd3c 100644 --- a/backend/tests/test_metric_lineage.py +++ b/backend/tests/test_metric_lineage.py @@ -179,7 +179,8 @@ async def test_each_metric_records_its_inputs_substitution_and_rendering(engine_ # 旧字段一个不少,text 照旧——模板 ⑧ 的叙述 prompt 靠它 assert {"id", "name", "unit", "value", "expression"} <= set(wow) output = row.output["清单"] - assert "- wow(环比增幅)= 8.7%" in output and "- orders(订单数)= 1234单" in output + # 清单按卡上渲染好的字写(和报告里 [[m:]] 一样按单位、小数位、千分位):叙述节点抄的就是报告上会出现的写法 + assert "- wow(环比增幅)= 8.7%" in output and "- orders(订单数)= 1,234单" in output async def test_metric_set_artifact_is_in_the_ledger_and_node_finished(engine_up): diff --git a/backend/tests/test_metrics_undefined.py b/backend/tests/test_metrics_undefined.py new file mode 100644 index 00000000..2b5500c7 --- /dev/null +++ b/backend/tests/test_metrics_undefined.py @@ -0,0 +1,183 @@ +"""口径卡:分母为 0 时算不出来就是空值,和「缺少输入」分开说;指标清单按格式渲染。 + +部署上撞过:一份周报的口径卡全写成 `round((a - b) / b, 4) if b else 0`,两周都没有数据时周环比、复访率写成 0 / 0%, +读起来像「持平」。现在: + +- 助手的节点手册写明分母可能为 0 时写 `… if 分母 else None`,不要写 else 0;画布校验对「条件表达式一边有除法、另一边是 + 常量 0」给提醒(metrics.zero_for_undefined,按语法树认,嵌套的也认); +- 输入都齐、是计算式自己交回空值的指标多记 undefined: true(状态仍是 missing_input,老运行、按状态认的地方不受影响), + 清单、写作目录、引用解析不了的原因、运行对比都说「算不出来」,不说「缺少输入」; +- 指标清单({{ nodes.卡.text }})按卡上渲染好的字写:比率 0.0007 显示成 0.07%,不再拼原值写成「0.0007%」;值不为空 + 却显示不出来的照实说。 + +数据、口径都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.core import artifact_store +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, validate_graph + +KPI = "{'cur': 0, 'prev': 0, 'visits': 1200, 'repeat': 9, 'gmv': 45678.5, 'tiny': 0.000000001}" +METRICS = [ + {"id": "wow", "name": "周环比", "decimals": 4, "format": "percent_of_ratio", + "expression": "round((vars.kpi.cur - vars.kpi.prev) / vars.kpi.prev, 4) if vars.kpi.prev else None"}, + {"id": "lost", "name": "缺一项", "expression": "vars.kpi.nothing"}, + {"id": "rate", "name": "复访率", "unit": "%", "decimals": 4, "format": "percent_of_ratio", + "expression": "round(vars.kpi.repeat / vars.kpi.visits, 4)"}, + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}, + {"id": "tiny", "name": "很小的数", "decimals": 2, "expression": "vars.kpi.tiny"}, +] + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def graph(metrics=METRICS, kpi=KPI): + nodes = [node("start", "input"), node("fetch", "transform", mode="expression", expression=kpi, assign_to="kpi"), + node("card", "metrics", caliber="周报口径", caliber_version="v1", metrics=metrics), + node("out", "output", fields=[{"name": "清单", "value": "{{ nodes.card.text }}"}])] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def _card() -> tuple[Run, dict]: + run = await run_manager.start(graph=graph(), input_payload={}) + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + break + assert row.status == "succeeded", row.error + async with SessionLocal() as session: + [event] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run.id, RunEvent.type == "node.finished", RunEvent.node_id == "card"))).scalars() + [entry] = event.data["evidence"] + return row, artifact_store.load(entry["artifact"]) + + +async def test_undefined_is_told_apart_from_a_missing_input(): + row, card = await _card() + by_id = {m["id"]: m for m in card["metrics"]} + wow, lost = by_id["wow"], by_id["lost"] + # 状态都是 missing_input(按状态认的地方照旧);算不出来的多一个 undefined + assert wow["value"] is None and wow["status"] == "missing_input" and wow["undefined"] is True + assert lost["value"] is None and lost["status"] == "missing_input" and "undefined" not in lost + # 有值的指标没有这个键:卡和以前一字不差 + assert all("undefined" not in by_id[k] for k in ("rate", "gmv", "tiny")) + lines = row.output["清单"].split("\n") + assert "- wow(周环比)= —(算不出来:这组输入下计算式没有值,比如分母为 0)" in lines + assert "- lost(缺一项)= —(缺少输入,无值)" in lines + + +async def test_the_list_shows_the_rendered_value(): + row, _ = await _card() + lines = row.output["清单"].split("\n") + assert "- rate(复访率)= 0.75%" in lines, lines # 不是「0.0075%」 + assert "- gmv(销售额)= 45,678.5元" in lines + # 卡先按小数位取整,再按格式渲染:很小的数取整成 0 就显示 0.00(和报告里一样),不拼原值 + assert "- tiny(很小的数)= 0.00" in lines and "0.000000001" not in row.output["清单"] + + +def test_a_value_that_cannot_be_shown_is_said_so(): + """有值、按口径卡的格式显示不出来(不是有限的数这类,卡上 rendered 记成「—」):照实说,不把原值拼出来。""" + from app.engine.nodes.metrics import _listed + + assert _listed({"value": float("inf"), "rendered": "—"}) == "—(有值,但按设定的小数位和格式显示不出来)" + assert _listed({"value": None, "rendered": "—", "undefined": True}).startswith("—(算不出来") + + +# -------------------------------------------------------------------------- +# 画布校验:「分母为 0 时给 0」 +# -------------------------------------------------------------------------- + + +def _zero_warnings(expression: str) -> list: + g = graph(metrics=[{"id": "x", "name": "x", "expression": expression}]) + issues = validate_graph(GraphSpec.model_validate(g)).issues + return [i for i in issues if i.code == "metrics.zero_for_undefined"] + + +@pytest.mark.parametrize("expression", [ + "round((vars.kpi.cur - vars.kpi.prev) / vars.kpi.prev, 4) if vars.kpi.prev else 0", + "vars.kpi.repeat / vars.kpi.visits if vars.kpi.visits else 0.0", + "0 if not vars.kpi.visits else vars.kpi.repeat / vars.kpi.visits", # 两边对调 + "round(vars.kpi.repeat / vars.kpi.visits if vars.kpi.visits > 0 else 0, 4)", # 套在 round 里 + "vars.kpi.gmv if vars.kpi.cur else (vars.kpi.repeat / vars.kpi.visits if vars.kpi.visits else 0)", # 嵌套 +]) +def test_zero_for_undefined_is_warned(expression): + [issue] = _zero_warnings(expression) + assert issue.level == "warning" and issue.field == "metrics[0].expression" + assert "None" in issue.message and "持平" in issue.message + + +@pytest.mark.parametrize("expression", [ + "round((vars.kpi.cur - vars.kpi.prev) / vars.kpi.prev, 4) if vars.kpi.prev else None", + "vars.kpi.cur if vars.kpi.prev else 0", # 没有除法:0 可能就是想要的值 + "vars.kpi.repeat / vars.kpi.visits", + "vars.kpi.repeat / vars.kpi.visits if vars.kpi.visits else 1", +]) +def test_other_expressions_are_left_alone(expression): + assert _zero_warnings(expression) == [] + + +def test_the_copilot_is_told_to_write_none(): + from app.api.copilot import NODE_REFERENCE + + start = NODE_REFERENCE.index("- metrics:口径卡") + section = NODE_REFERENCE[start:NODE_REFERENCE.index("\n- dual_check:", start)] + assert "if vars.kpi.gmv_prev else None" in section and "不要写 else 0" in section + + +# -------------------------------------------------------------------------- +# 写作目录、引用、对比的说法 +# -------------------------------------------------------------------------- + + +def _catalog(undefined: bool): + from app.engine.evidence import build_catalog + + card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "artifact": "c" * 64, "metrics": [ + {"id": "wow", "name": "周环比", "unit": "", "value": None, "decimals": 4, "format": "percent_of_ratio", + "status": "missing_input", **({"undefined": True} if undefined else {}), "expression": "…"}]} + ledger = [{"kind": "metric_set", "node_id": "card", "exec": 1, "artifact": "c" * 64, "caliber": "周报口径", + "version": "v1", "metrics": ["wow"]}] + return build_catalog(nodes={"card": card}, ledger=ledger, inputs={}) + + +@pytest.mark.parametrize("undefined, said", [(True, "算不出来"), (False, "缺少输入")]) +def test_the_writer_and_the_reason_say_which(undefined, said): + from app.engine.evidence import compose_doc, writer_catalog + + catalog = _catalog(undefined) + assert catalog["m:wow"].get("undefined", False) is undefined + text = writer_catalog(catalog).text + line = next(ln for ln in text.splitlines() if "[[m:wow]]" in ln) + assert ("算不出来" in line) is undefined and "不要引用" in line + doc = compose_doc("周环比 [[m:wow]]。", catalog) + [violation] = [v for v in doc["violations"] if v["code"] == "unresolved_ref"] + assert said in violation["message"], violation + + +def test_compare_says_which(): + from app.engine import compare + + assert compare.missing_reason("这次运行", {"status": "missing_input", "undefined": True}) \ + == "这次运行这个指标算不出来(这组输入下计算式没有值,比如分母为 0)" + assert compare.missing_reason("基准运行", {"status": "missing_input"}) == "基准运行这个指标缺少输入,没有值" + assert compare.missing_reason("基准运行", {"status": "ok"}) is None diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 070cc150..b234c07a 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -350,6 +350,8 @@ export const EVIDENCE_TEXT = { unmatchedPlain: '无法追溯的数字:', uncited: '该数字未使用引用标记,系统无法核对其来源', missingValue: '缺少输入:口径卡本次未计算出该指标的值', + /** 指标没有值、但不是缺输入:输入都齐,计算式自己算不出来(比如分母为 0) */ + undefinedValue: '算不出来:这组输入下计算式没有值(比如分母为 0),不是缺少输入', /** 指标拿截断的查询结果整组算出来(计数、求和……):值只算到了取回的那部分,出具按缺口降档 */ incomplete: '结果不完整', incompleteFallback: '基于被截断的查询结果计算,结果不完整', diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index bb33cc52..5ec45a31 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -1361,6 +1361,8 @@ function MetricPart({ step, entry, inputs, runId, runClass, seal, pending, faile // 口径卡里 rendered 为「—」有两种:value 为 null 是缺输入,不为 null 是有值但按格式显示不出来 const dash = rendered === NONE || entry?.rendered === NONE const missing = entry?.status === 'missing_input' || (value == null && (dash || !resolved)) + // 没有值的两种:缺输入,和输入都齐、计算式自己算不出来(分母为 0,口径卡记了 undefined) + const undefinedValue = missing && (step?.undefined === true || entry?.undefined === true) const unshowable = !missing && dash && value != null // 拿截断的查询结果整组算出来的:值是真的算出来的,但只算到了取回的那部分,出具已按缺口降档 const incomplete = step?.incomplete === true || entry?.incomplete === true @@ -1416,7 +1418,12 @@ function MetricPart({ step, entry, inputs, runId, runClass, seal, pending, faile )} {dualCheck && } - {missing &&

{EVIDENCE_TEXT.missingValue}

} + {missing && ( +

+ {undefinedValue ? EVIDENCE_TEXT.undefinedValue : EVIDENCE_TEXT.missingValue} +

+ )} {incomplete && (

{incompleteReason}

)} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 98a04f08..fe27bde8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2399,6 +2399,8 @@ export interface EvidenceStep extends Omit { sql_check_failed?: boolean /** metric:没通过的原因(人话,「所依据的查询未通过 SQL 检查(…),结果不可靠」) */ sql_check_reason?: string + /** metric:没有值,不是缺输入,是输入都齐、计算式自己算不出来(分母为 0)。状态仍是 missing_input */ + undefined?: boolean /** metric(期 4):这个指标的双盲复算状态。没有双盲复算复核过这份指标集时没有这个键 */ dual_check?: EvidenceDualCheck /**