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 &&
{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