mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
口径卡分母为 0 时算不出来就是空值,和「缺少输入」分开说;指标清单按格式渲染。助手节点手册讲口径卡的那段写明分母可能为 0 时写「… if 分母 else None」、不要写 else 0,附示例;画布校验加一条提醒 metrics.zero_for_undefined(按语法树认:条件表达式一边有除法、另一边是常量 0,两边对调、套在 round 里、嵌套的都认)。输入都齐、计算式自己交回空值的指标多记 undefined: true(状态仍是 missing_input,按状态认的地方和老运行不受影响),清单、写作目录、引用解析不了的原因、运行对比、证据面板都说「算不出来(这组输入下计算式没有值,比如分母为 0)」;推演把它当不带值的字段。指标清单({{ nodes.卡.text }})改用卡上渲染好的 rendered:比率 0.0007 显示成 0.07%、金额带千分位,和报告里 [[m:]] 一致;有值却显示不出来的照实说,不拼原值
This commit is contained in:
@@ -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 则缺输入的指标记为空值,交给出具契约判档
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = "转抄的口径卡结果无法读取,核对不了这个值是否取自设置了遮罩的列"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -350,6 +350,8 @@ export const EVIDENCE_TEXT = {
|
||||
unmatchedPlain: '无法追溯的数字:',
|
||||
uncited: '该数字未使用引用标记,系统无法核对其来源',
|
||||
missingValue: '缺少输入:口径卡本次未计算出该指标的值',
|
||||
/** 指标没有值、但不是缺输入:输入都齐,计算式自己算不出来(比如分母为 0) */
|
||||
undefinedValue: '算不出来:这组输入下计算式没有值(比如分母为 0),不是缺少输入',
|
||||
/** 指标拿截断的查询结果整组算出来(计数、求和……):值只算到了取回的那部分,出具按缺口降档 */
|
||||
incomplete: '结果不完整',
|
||||
incompleteFallback: '基于被截断的查询结果计算,结果不完整',
|
||||
|
||||
@@ -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 && <DualCheckLine check={dualCheck} formal={runClass === 'formal'} />}
|
||||
<Integrity step={step} seal={seal} />
|
||||
{missing && <p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-missing="">{EVIDENCE_TEXT.missingValue}</p>}
|
||||
{missing && (
|
||||
<p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-missing=""
|
||||
{...(undefinedValue ? { 'data-ev-undefined': '' } : {})}>
|
||||
{undefinedValue ? EVIDENCE_TEXT.undefinedValue : EVIDENCE_TEXT.missingValue}
|
||||
</p>
|
||||
)}
|
||||
{incomplete && (
|
||||
<p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-incomplete-reason="">{incompleteReason}</p>
|
||||
)}
|
||||
|
||||
@@ -2399,6 +2399,8 @@ export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
|
||||
sql_check_failed?: boolean
|
||||
/** metric:没通过的原因(人话,「所依据的查询未通过 SQL 检查(…),结果不可靠」) */
|
||||
sql_check_reason?: string
|
||||
/** metric:没有值,不是缺输入,是输入都齐、计算式自己算不出来(分母为 0)。状态仍是 missing_input */
|
||||
undefined?: boolean
|
||||
/** metric(期 4):这个指标的双盲复算状态。没有双盲复算复核过这份指标集时没有这个键 */
|
||||
dual_check?: EvidenceDualCheck
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user