mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
数字推演复核修正(第 2 轮,后端):整个指标推演不了的不改动也照实说,完整值找不回来的数组排除行后不再报「无变化」
- 用到了遮罩列、最外层输入还原不了(容器的完整值找不回来、所在的查询快照读不出来)、卡上没记全输入的指标,以前不改动时 报 unchanged(界面写「无变化」),输入照样可改、行照样可勾,改了、勾了才说推演不了。现在不论有没有改动都交回 unsupported / masked 和原因,所有输入 editable 为假、不给 row_span,unsupported 里另有一条 input 为空的(界面据此只写 原因),之后只列卡住它的输入。同一格既遮着、快照又读不出来时说遮罩 - 下游口径卡改看重算新给的 maybe_changed(改动碰到了某个指标,它又变了或说不准):推演不了的指标 changed 一律为空, 只看 changed 分不出「什么都没改」,一打开就会把下游标成可能受影响 - agent 数组字段的完整值找不回来(节点产出不在、被改过,另存的完整值读不出来)时,排除它行段里的行以前因为值为空被忽略, 指标报「无变化」、报告引用处报「不受影响」。现在算碰到了它(excluded_rows 照记,输入 changed 为真、new 为空), 完整值找不回来的输入一律不给行段 - 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了行时,以前拿卡上记的那一格算出 「无变化」,现在说不准(unsupported) - 补测试:四类整个推演不了的指标不改动、改动后都照实说(单元和真跑);数组完整值找不回来时排除行段内一行(单元和真跑, 含报告引用处);查询当时记下、跑完又从数据源去掉的遮罩(推演和勾行接口);写输入的节点和快照在同一条事件里交回 (合并查询的边界);给了新值的输入不再说不准;数组只认行段内被排除的行;同一份快照交回两次按第一次;读得出来却不是 查询结果的快照;嵌在里面、读不出来的那一格不进代入式。逐个做过变异:复核列出的四处、第 1 轮漏掉的三处和这次新加的 判断改掉后都变红
This commit is contained in:
@@ -89,8 +89,9 @@ async def evidence_whatif(run_id: str, payload: Any = Body(None)) -> dict[str, A
|
||||
result, segments = await asyncio.to_thread(_compute, card, changes, sources, metric, docs, card_node, artifact)
|
||||
except recompute.WhatIfError as e:
|
||||
raise CodedHTTPException(422, str(e), BAD_INPUT) from None
|
||||
# 改动可能改变了某个指标——算出来变了,或者碰到了它却推演不了(还原不了、遮罩),新值不知道——下游才可能受影响
|
||||
maybe = any(m.get("changed") is not False for m in result["metrics"])
|
||||
# 改动可能改变了某个指标——算出来变了,或者碰到了它却推演不了(还原不了、遮罩),新值不知道——下游才可能受影响。
|
||||
# 不能只看 changed:整个推演不了的指标 changed 一律为空,什么都没改时也一样(recompute.whatif 的 maybe_changed)
|
||||
maybe = result["maybe_changed"]
|
||||
edges = [(e.get("source"), e.get("target")) for e in sealed.graph.get("edges") or [] if isinstance(e, dict)]
|
||||
return {
|
||||
"run_id": run_id,
|
||||
|
||||
@@ -17,10 +17,15 @@ round(和 metrics.run_metrics 一样只 round 浮点),渲染走 evidence.r
|
||||
- 遮罩列取来的输入不给值、也不拿来推演:改一个输入、排除一行,新值和原值一比就能算出遮住的那一格
|
||||
- 落在读不出来(文件不在、与哈希不一致)的查询快照上的输入同样不给值、不推演:那一格遮没遮核对不了,宁可多遮
|
||||
|
||||
有的指标不论改什么都推演不了(_Plan.blocked):卡上没记全输入、用到了遮罩列、最外层的输入还原不了(容器的完整值
|
||||
找不回来、所在的快照读不出来)。这种不改动也照实交回 unsupported / masked 和原因,输入一个都不让改、不给行段:
|
||||
界面一打开就只写原因,不写「无变化」,也不摆改了、勾了也推演不出来的输入框和勾选框。
|
||||
|
||||
同一张口径卡的指标不能互相引用(算这张卡时卡上的值还不存在),所以只有用到了被改的输入、被排除的行的指标需要重算,
|
||||
别的原样交回。排除行时有一种例外:指标的某个输入对不上任何快照(完整值找不回来的容器、加工过的结果、按位置从结果里
|
||||
取的一格、由查询结果算出来的数),它可能就是从被排除了行的那份结果来的,重算不了,只能说不知道(unsupported),
|
||||
不能报「无变化」。写它的节点在那份快照交回之前就跑完了的,影响不到它,照常不算。
|
||||
别的原样交回。排除行时有两种例外,都只能说不知道(unsupported),不能报「无变化」:
|
||||
- 指标的某个输入对不上任何快照(完整值找不回来的容器、加工过的结果、按位置从结果里取的一格、由查询结果算出来的数),
|
||||
它可能就是从被排除了行的那份结果来的,重算不了。写它的节点在那份快照交回之前就跑完了的,影响不到它,照常不算
|
||||
- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -63,6 +68,7 @@ NOT_NUMBER = "不是数值,不能修改"
|
||||
DOUBT = "排除行对 {path} 的影响无法确定:{why}"
|
||||
DOUBT_UNMATCHED = "它对不上任何查询快照,可能是由查询结果加工而来"
|
||||
DOUBT_DERIVED = "它不是直接取自查询结果的格,可能由查询结果算出"
|
||||
DOUBT_SELECTOR = "它决定取哪一行、哪一项,排除行后需要重新取数"
|
||||
NOT_FINITE = "计算结果不是有限的数值(溢出或非数),无法推演"
|
||||
|
||||
#: 卡上给容器记的大概(metrics._brief):「[12 项]」「{3 个字段}」
|
||||
@@ -161,6 +167,23 @@ class _Plan:
|
||||
inputs: dict[str, _Input] = field(default_factory=dict)
|
||||
problem: str | None = None
|
||||
|
||||
@property
|
||||
def blocked(self) -> tuple[str, str] | None:
|
||||
"""整个指标推演不了(不论改什么):(status, 原因),推演得了的是 None。
|
||||
|
||||
卡上没记全输入、表达式有误;用到了遮罩列(哪怕只是嵌在下标里的一格);最外层的输入还原不了——容器的完整值
|
||||
找不回来,或者所在的查询快照读不出来。嵌在下标里的输入还原不了不碍事:它取到的那一格的值卡上记着。
|
||||
遮罩排在还原不了前面:同一格既遮着、快照又读不出来时,说遮罩(快照修好了它照样遮着)。
|
||||
"""
|
||||
if self.problem:
|
||||
return "unsupported", self.problem
|
||||
if any(inp.masked for inp in self.inputs.values()):
|
||||
return "masked", MASKED_METRIC
|
||||
for path in self.tops:
|
||||
if problem := self.inputs[path].problem:
|
||||
return "unsupported", f"无法推演:{path}({problem})"
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 入口
|
||||
@@ -168,11 +191,16 @@ class _Plan:
|
||||
|
||||
|
||||
def whatif(card: dict[str, Any], changes: Changes, sources: Sources, *, metric: str | None = None) -> dict[str, Any]:
|
||||
"""在一张口径卡上推演:{metrics: [每个指标的旧值、新值、输入…], unsupported: [不能用于推演的输入和原因]}。
|
||||
"""在一张口径卡上推演:{metrics: [每个指标的旧值、新值、输入…], unsupported: [不能用于推演的输入和原因],
|
||||
maybe_changed: 有没有指标可能因这次改动而变}。
|
||||
|
||||
metric 给了就只推演这一个指标。改动不合法时抛 WhatIfError,一个指标都不算。没碰到改动的指标不重算,
|
||||
原值原样交回(status: unchanged);unsupported 不论有没有改动都列全(遮罩的、还原不了的、不能改也没有行可勾的),
|
||||
界面一打开就能说清哪些输入不能动。
|
||||
原值原样交回(status: unchanged);整个推演不了的指标(_Plan.blocked)不论有没有改动都交回 unsupported / masked
|
||||
和原因。unsupported 不论有没有改动都列全(遮罩的、还原不了的、不能改也没有行可勾的;整个指标推演不了的另有一条
|
||||
input 为空的),界面一打开就能说清哪些输入不能动。
|
||||
|
||||
maybe_changed 只给调用方决定要不要标下游口径卡:改动碰到了某个指标,它又算出来变了或者说不准(推演不了的指标
|
||||
新值不知道)。推演不了的指标 changed 一律为 None,只看它分不出「什么都没改」。
|
||||
"""
|
||||
store = _Store(sources)
|
||||
metrics = [m for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") is not None]
|
||||
@@ -182,12 +210,14 @@ def whatif(card: dict[str, Any], changes: Changes, sources: Sources, *, metric:
|
||||
raise WhatIfError(f"口径卡中没有指标「{metric}」")
|
||||
plans = [_plan(m, store) for m in metrics]
|
||||
_check(changes, plans, store, metric=metric)
|
||||
return {"metrics": [_outcome(p, changes, sources) for p in plans], "unsupported": _unsupported(plans)}
|
||||
outcomes = [_outcome(p, changes, sources) for p in plans]
|
||||
return {"metrics": [result for result, _ in outcomes], "unsupported": _unsupported(plans),
|
||||
"maybe_changed": any(moved for _, moved in outcomes)}
|
||||
|
||||
|
||||
def recompute(metric: dict[str, Any], changes: Changes, sources: Sources) -> dict[str, Any]:
|
||||
"""重算一个指标,不管改动碰没碰到它,也不校验改动。核对还原是否忠实时用:什么都不改,重算出来应当和卡上记的一样。"""
|
||||
return _outcome(_plan(metric, _Store(sources)), changes, sources, force=True)
|
||||
return _outcome(_plan(metric, _Store(sources)), changes, sources, force=True)[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -472,11 +502,17 @@ def _apply(inp: _Input, changes: Changes) -> tuple[Any, list[int] | None, bool]:
|
||||
return inp.value, None, False
|
||||
if inp.row is not None:
|
||||
return (None, [inp.row], False) if inp.row in excluded else (inp.value, None, False)
|
||||
if inp.rows is not None and isinstance(inp.value, list):
|
||||
if inp.rows is not None:
|
||||
# 数组字段只用到快照里的一段行:段外的行排除了也影响不到它
|
||||
first, last = inp.rows
|
||||
hit = sorted(r for r in excluded if first <= r <= last)
|
||||
kept = [v for k, v in enumerate(inp.value) if first + k not in excluded]
|
||||
return (kept, hit, False) if hit else (inp.value, None, False)
|
||||
if not hit:
|
||||
return inp.value, None, False
|
||||
if not isinstance(inp.value, list):
|
||||
# 完整值找不回来(inp.problem):用到的行少了,它一定变了,变成什么不知道。照样算碰到了它,指标因这个
|
||||
# 输入还原不了而推演不了(_Plan.blocked),不会拿原值算出一个「无变化」
|
||||
return None, hit, False
|
||||
return [v for k, v in enumerate(inp.value) if first + k not in excluded], hit, False
|
||||
if inp.table:
|
||||
rows = inp.value["rows"] if isinstance(inp.value, dict) else inp.value
|
||||
hit = sorted(r for r in excluded if r < len(rows))
|
||||
@@ -487,7 +523,9 @@ def _apply(inp: _Input, changes: Changes) -> tuple[Any, list[int] | None, bool]:
|
||||
return inp.value, None, False
|
||||
|
||||
|
||||
def _outcome(plan: _Plan, changes: Changes, sources: Sources, *, force: bool = False) -> dict[str, Any]:
|
||||
def _outcome(plan: _Plan, changes: Changes, sources: Sources, *,
|
||||
force: bool = False) -> tuple[dict[str, Any], bool]:
|
||||
"""(这个指标的推演结果, 它会不会因这次改动而变:碰到了它,又算出来变了或者说不准)。"""
|
||||
values: dict[str, Any] = {}
|
||||
notes: dict[str, list[int]] = {}
|
||||
touched = False
|
||||
@@ -497,18 +535,25 @@ def _outcome(plan: _Plan, changes: Changes, sources: Sources, *, force: bool = F
|
||||
notes[path] = excluded
|
||||
touched = touched or overridden or bool(excluded)
|
||||
doubt = _doubt(plan, changes, sources)
|
||||
# 卡上没记全输入的指标:不知道它用了什么,有任何改动都说不准它变不变
|
||||
touched = touched or (plan.problem is not None and bool(changes.inputs or changes.exclude))
|
||||
if not touched and doubt is None and not force:
|
||||
return _result(plan, "unchanged", plan.metric.get("value"), None, values, notes)
|
||||
status, new, reason = _compute(plan, values, notes, changes, doubt)
|
||||
return _result(plan, status, new, reason, values, notes)
|
||||
# 说不准受不受影响的也算碰到了;卡上没记全输入的指标不知道它用了什么,有任何改动都说不准它变不变
|
||||
touched = touched or doubt is not None or (plan.problem is not None and bool(changes.inputs or changes.exclude))
|
||||
if blocked := plan.blocked:
|
||||
# 整个推演不了:不改动也照实说,不拿原值报「无变化」。碰没碰到它只决定下游要不要标
|
||||
status, reason = blocked
|
||||
result = _result(plan, status, None, reason, values, notes)
|
||||
elif not touched and not force:
|
||||
result = _result(plan, "unchanged", plan.metric.get("value"), None, values, notes)
|
||||
else:
|
||||
status, new, reason = _compute(plan, values, notes, changes, doubt)
|
||||
result = _result(plan, status, new, reason, values, notes)
|
||||
return result, touched and result["changed"] is not False
|
||||
|
||||
|
||||
def _doubt(plan: _Plan, changes: Changes, sources: Sources) -> str | None:
|
||||
"""排除了行,这个指标却有输入说不准受不受影响:返回原因;没排除行、或者输入都对得上号的返回 None。
|
||||
|
||||
对得上号的输入(cell() 那一格、agent 的单格字段和数组字段、整份查询结果)排除行怎么影响它是确定的,_apply 照办。
|
||||
对得上号的输入(cell() 那一格、agent 的单格字段和数组字段、整份查询结果)排除行怎么影响它是确定的,_apply 照办;
|
||||
数组字段的完整值找不回来时也知道它用到哪几行,排除了那几行就算碰到了它(指标因它还原不了而推演不了)。
|
||||
别的输入——完整值找不回来的容器、对不上任何快照的容器(加工过的结果)、不是直接取自快照的数(按位置从整份结果里
|
||||
取的一格、transform 由查询结果算出来的数)——都可能来自被排除了行的那份结果,又重算不了,只能说不知道,不能拿
|
||||
原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准;写它的节点在那份快照交回之前就跑完了
|
||||
@@ -536,17 +581,16 @@ def _after(snapshot: str, inp: _Input, sources: Sources) -> bool:
|
||||
|
||||
def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], changes: Changes,
|
||||
doubt: str | None) -> tuple[str, Any, str | None]:
|
||||
"""(status, 新值, 原因)。还原不了的、遮罩的、排除后缺了的、说不准受不受影响的先说,都没有才求值。"""
|
||||
if plan.problem:
|
||||
return "unsupported", None, plan.problem
|
||||
for path in plan.tops:
|
||||
if plan.inputs[path].problem:
|
||||
return "unsupported", None, f"无法推演:{path}({plan.inputs[path].problem})"
|
||||
if any(inp.masked for inp in plan.inputs.values()):
|
||||
return "masked", None, MASKED_METRIC
|
||||
for path, inp in plan.inputs.items():
|
||||
if inp.row is not None and path in notes and path not in changes.inputs:
|
||||
"""(status, 新值, 原因)。整个推演不了的(_Plan.blocked)到不了这里。排除后缺了的、说不准受不受影响的先说,
|
||||
都没有才求值。"""
|
||||
hit = [(path, inp) for path, inp in plan.inputs.items() if path in notes and path not in changes.inputs]
|
||||
for path, inp in hit:
|
||||
if inp.row is not None:
|
||||
return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除"
|
||||
for path, inp in hit:
|
||||
if inp.selector:
|
||||
# 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数
|
||||
return "unsupported", None, DOUBT.format(path=path, why=DOUBT_SELECTOR)
|
||||
if doubt:
|
||||
return "unsupported", None, doubt
|
||||
value, reason = _evaluate(plan, values)
|
||||
@@ -617,6 +661,7 @@ def _result(plan: _Plan, status: str, new: Any, reason: str | None, values: dict
|
||||
metric = plan.metric
|
||||
old = metric.get("value")
|
||||
known = status in _KNOWN
|
||||
blocked = plan.blocked is not None
|
||||
delta, rendered_delta = _delta(metric, old, new) if known else (None, None)
|
||||
substituted = metric.get("substituted") if status == "unchanged" \
|
||||
else substitute(plan.tree, values) if status == "ok" and plan.tree is not None else None
|
||||
@@ -630,7 +675,7 @@ def _result(plan: _Plan, status: str, new: Any, reason: str | None, values: dict
|
||||
"rendered_delta": rendered_delta,
|
||||
"changed": (not _same(old, new)) if known else None,
|
||||
"status": status, "reason": reason, "substituted": substituted,
|
||||
"inputs": [_input_view(inp, values, notes) for inp in plan.inputs.values()],
|
||||
"inputs": [_input_view(inp, values, notes, blocked=blocked) for inp in plan.inputs.values()],
|
||||
}
|
||||
|
||||
|
||||
@@ -686,15 +731,18 @@ def _kind(inp: _Input) -> str:
|
||||
else "number" if is_number(value) else "text"
|
||||
|
||||
|
||||
def _editable(inp: _Input) -> bool:
|
||||
return inp.shape == "scalar" and not inp.masked and not inp.selector and inp.problem is None \
|
||||
def _editable(inp: _Input, *, blocked: bool) -> bool:
|
||||
"""推演里能改的输入:数值(或原本缺失)的标量,不是遮罩列、不决定取哪一格、还原得出。整个指标推演不了时一个都不算:
|
||||
改了也推演不出来。"""
|
||||
return not blocked and inp.shape == "scalar" and not inp.masked and not inp.selector and inp.problem is None \
|
||||
and (inp.value is None or _number(inp.value))
|
||||
|
||||
|
||||
def _row_span(inp: _Input) -> tuple[int, int] | None:
|
||||
"""这个输入实际用到的快照行(含两端):单格一行、数组字段一段、整份结果全部。排除别的行影响不到它,界面据此只让
|
||||
这几行可勾。遮罩的、快照读不出来的不给:排除它们的行什么也推演不出来。"""
|
||||
if inp.snapshot is None or inp.masked or inp.unreadable:
|
||||
这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来,都记在 problem 里)不给:排除它们的行什么也推演
|
||||
不出来。"""
|
||||
if inp.snapshot is None or inp.masked or inp.problem is not None:
|
||||
return None
|
||||
if inp.row is not None:
|
||||
return inp.row, inp.row
|
||||
@@ -704,16 +752,16 @@ def _row_span(inp: _Input) -> tuple[int, int] | None:
|
||||
return (0, len(rows) - 1) if inp.table and isinstance(rows, list) and rows else None
|
||||
|
||||
|
||||
def _input_view(inp: _Input, values: dict[str, Any], notes: dict[str, list[int]]) -> dict[str, Any]:
|
||||
def _input_view(inp: _Input, values: dict[str, Any], notes: dict[str, list[int]], *, blocked: bool) -> dict[str, Any]:
|
||||
"""一个输入在推演里的样子:原值、新值、排除了哪几行、用到快照的哪几行(row_span)。遮罩列取来的、落在读不出来的
|
||||
快照上的不给值。容器写大概(「[12 项]」)。"""
|
||||
快照上的不给值。容器写大概(「[12 项]」)。整个指标推演不了时(blocked)一个都不让改、不给行段。"""
|
||||
item = inp.item
|
||||
span = _row_span(inp)
|
||||
span = None if blocked else _row_span(inp)
|
||||
view: dict[str, Any] = {"path": inp.path, "via": item.get("via"), "node_id": item.get("node_id"),
|
||||
**({"artifact": inp.snapshot} if inp.snapshot else {}),
|
||||
**({"locator": item["locator"]} if isinstance(item.get("locator"), dict) else {}),
|
||||
**({"row_span": list(span)} if span else {}),
|
||||
"kind": _kind(inp), "editable": _editable(inp)}
|
||||
"kind": _kind(inp), "editable": _editable(inp, blocked=blocked)}
|
||||
if inp.masked:
|
||||
return {**view, "old": None, "new": None, "changed": False, "masked": True}
|
||||
if inp.unreadable and inp.shape == "scalar":
|
||||
@@ -721,7 +769,8 @@ def _input_view(inp: _Input, values: dict[str, Any], notes: dict[str, list[int]]
|
||||
if inp.shape == "container":
|
||||
old = _outline(inp.value, inp.table) if inp.problem is None else item.get("value")
|
||||
new = _outline(values.get(inp.path), inp.table) if inp.problem is None else None
|
||||
changed = inp.problem is None and not _same(old, new)
|
||||
# 完整值找不回来的数组排除了它用到的行:少了几项是确定的,只是新的样子不知道(new 为空)
|
||||
changed = not _same(old, new) if inp.problem is None else inp.path in notes
|
||||
else:
|
||||
old, new = _finite(item.get("value")), _finite(values.get(inp.path))
|
||||
changed = not _same(old, new)
|
||||
@@ -740,14 +789,20 @@ def _outline(value: Any, table: bool) -> Any:
|
||||
|
||||
def _unsupported(plans: list[_Plan]) -> list[dict[str, Any]]:
|
||||
"""不能用于推演的输入和原因:遮罩的、完整值找不回来的(含快照读不出来的)、决定取哪一格的、既不能改也没有行可勾的。
|
||||
卡本身没记输入的,整个指标一条。"""
|
||||
|
||||
整个指标推演不了的(_Plan.blocked)先有一条 input 为空的,原因和指标上的一样:界面据此只写原因,不画输入框、
|
||||
勾选框。之后只列卡住它的输入(遮罩的、还原不了的),别的输入能不能改已无从谈起;卡本身没记输入的只有这一条。
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for plan in plans:
|
||||
metric_id = plan.metric.get("id")
|
||||
if blocked := plan.blocked:
|
||||
out.append({"metric": metric_id, "input": None, "reason": blocked[1]})
|
||||
if plan.problem:
|
||||
out.append({"metric": metric_id, "input": None, "reason": plan.problem})
|
||||
continue
|
||||
for inp in plan.inputs.values():
|
||||
if blocked and not (inp.masked or inp.problem):
|
||||
continue
|
||||
reason = (MASKED_TABLE if inp.table else MASKED_INPUT) if inp.masked \
|
||||
else f"无法推演:{inp.problem}" if inp.problem \
|
||||
else SELECTOR if inp.selector else _fixed(inp)
|
||||
|
||||
+194
-38
@@ -5,10 +5,13 @@
|
||||
- 改输入、排除快照里的行,结果和手算一致;排除了 cell() 那一格所在的行,指标标「排除后无法计算」
|
||||
- 容器输入的还原:agent 数组字段(按 locator.rows 去掉被排除的行)、nodes.* 从节点产出、vars.* 只认 value_artifact
|
||||
- 遮罩列取来的输入既不给值、也不能拿来推演
|
||||
- 整个指标推演不了的(遮罩列、还原不了的输入、读不出来的快照、没记输入),不改动也照实说,输入一个都不让动
|
||||
- 排除了行却说不准受不受影响的指标,不报「无变化」
|
||||
- 不合法的请求抛 WhatIfError(接口回 422),说清哪里不对
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import pytest
|
||||
@@ -137,12 +140,20 @@ def test_an_old_run_container_cannot_be_restored():
|
||||
def test_a_baseline_lists_what_cannot_be_used_without_recomputing_anything():
|
||||
result = whatif(CARD, Changes(), sources())
|
||||
metrics = by_id(result)
|
||||
# 推演得了的指标原样交回;整个推演不了的(老运行没存完整值的容器、遮罩列)不改动也照实说,不报「无变化」
|
||||
assert all(m["status"] == "unchanged" and m["new"] == m["old"] and m["changed"] is False
|
||||
for m in result["metrics"])
|
||||
for k, m in metrics.items() if k not in ("n", "phone_len"))
|
||||
assert [(metrics[k]["status"], metrics[k]["changed"], metrics[k]["rendered_new"]) for k in ("n", "phone_len")] == [
|
||||
("unsupported", None, None), ("masked", None, None)]
|
||||
assert result["maybe_changed"] is False
|
||||
unsupported = {(u["metric"], u["input"]): u["reason"] for u in result["unsupported"]}
|
||||
assert "完整值" in unsupported[("n", "vars.kpi.items")]
|
||||
assert "遮罩" in unsupported[("phone_len", "cell(nodes.crm, 0, 'phone')")]
|
||||
assert "取哪" in unsupported[("pick", "vars.kpi.i")]
|
||||
# 整个指标推演不了的另有一条 input 为空的:界面据此只写原因
|
||||
assert unsupported[("n", None)] == metrics["n"]["reason"]
|
||||
assert unsupported[("phone_len", None)] == metrics["phone_len"]["reason"]
|
||||
assert {m for m, i in unsupported if i is None} == {"n", "phone_len"}
|
||||
# 还原得出的容器不算「完整值找不回来」,只是不能直接改、也没有行可勾
|
||||
assert "完整值" not in unsupported[("n2", "vars.kpi.items")] and "完整值" not in unsupported[("n3", "nodes.fetch.items")]
|
||||
# 遮罩列的值不给
|
||||
@@ -150,9 +161,10 @@ def test_a_baseline_lists_what_cannot_be_used_without_recomputing_anything():
|
||||
assert phone["masked"] is True and phone["old"] is None and phone["new"] is None and phone["editable"] is False
|
||||
# 代入式里代进去的就是那一格:同样不给
|
||||
assert metrics["phone_len"]["substituted"] is None and "13800000000" not in str(metrics["phone_len"])
|
||||
# 能改的只有数值标量
|
||||
# 能改的只有数值标量;整个推演不了的指标里,本来能改的数也不让改
|
||||
editable = {i["path"]: i["editable"] for i in metrics["wow"]["inputs"] + metrics["total"]["inputs"]}
|
||||
assert editable == {"vars.kpi.gmv": True, "vars.kpi.prev": True, "vars.kpi.amounts": False}
|
||||
assert [i["editable"] for i in metrics["n"]["inputs"]] == [False, False]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -170,7 +182,9 @@ def test_changing_an_input_recomputes_only_the_metrics_that_use_it():
|
||||
assert wow["substituted"] == "round((50000 - 42010.0) / 42010.0 * 100, 1)"
|
||||
gmv = next(i for i in wow["inputs"] if i["path"] == "vars.kpi.gmv")
|
||||
assert (gmv["old"], gmv["new"], gmv["changed"]) == (45678.5, 50000, True)
|
||||
assert all(m["status"] == "unchanged" for k, m in metrics.items() if k != "wow")
|
||||
# 别的指标没碰到:原样交回(整个推演不了的照旧说推演不了)
|
||||
assert {k: m["status"] for k, m in metrics.items() if k != "wow" and m["status"] != "unchanged"} == {
|
||||
"n": "unsupported", "phone_len": "masked"}
|
||||
|
||||
|
||||
def test_excluding_rows_drops_them_from_an_agent_array_field():
|
||||
@@ -222,8 +236,6 @@ def test_division_by_zero_is_explained():
|
||||
|
||||
def test_an_overflow_is_an_error_not_a_crash():
|
||||
"""算出无穷大:说清算不出来;老卡上记着无穷大的也不往外送(JSON 放不下,接口会 500)。"""
|
||||
import json
|
||||
|
||||
result = whatif(CARD, Changes(inputs={"vars.kpi.refunds": 1e308, "vars.kpi.orders": 1e-10}), sources(),
|
||||
metric="ratio")
|
||||
[ratio] = result["metrics"]
|
||||
@@ -291,7 +303,10 @@ def test_a_query_result_with_a_masked_column_is_masked_as_a_whole():
|
||||
scalar("vars.kpi.k", 2)])]}
|
||||
masked = sources(masked={S1: frozenset({"phone"})})
|
||||
baseline = whatif(card, Changes(), masked)
|
||||
assert [(u["input"], "遮罩" in u["reason"]) for u in baseline["unsupported"]] == [("vars.res.rows", True)]
|
||||
# 整个指标一条,再列出卡住它的那个输入;本来能改的 vars.kpi.k 不再单列
|
||||
assert [(u["input"], "遮罩" in u["reason"]) for u in baseline["unsupported"]] == [
|
||||
(None, True), ("vars.res.rows", True)]
|
||||
assert baseline["metrics"][0]["status"] == "masked"
|
||||
cnt = whatif(card, Changes(inputs={"vars.kpi.k": 3}), masked)["metrics"][0]
|
||||
assert cnt["status"] == "masked" and cnt["new"] is None
|
||||
|
||||
@@ -421,17 +436,25 @@ def test_excluding_rows_never_reports_an_unrestorable_metric_as_unchanged():
|
||||
[mix] = whatif(OLD_MIX, Changes(exclude={S1: frozenset({2})}), sources())["metrics"]
|
||||
assert mix["status"] == "unsupported" and mix["changed"] is None and mix["rendered_new"] is None
|
||||
assert mix["new"] is None and "vars.res.rows" in mix["reason"] and "完整值" in mix["reason"]
|
||||
# 只改输入值、不排除行时照旧:容器没被改,也就不用重算
|
||||
# 不改动也一样:完整值找不回来,这个指标本来就推演不了,不报「无变化」
|
||||
[mix] = whatif(OLD_MIX, Changes(), sources())["metrics"]
|
||||
assert mix["status"] == "unchanged" and mix["changed"] is False
|
||||
assert mix["status"] == "unsupported" and mix["changed"] is None and "完整值" in mix["reason"]
|
||||
|
||||
|
||||
#: cell() 一格加一个还原得出、却对不上任何快照的容器(加工过的结果,写它的是 fetch 节点)
|
||||
MIXED = lone("mixed", "cell(nodes.pull, 0, 'amount') + len(vars.kpi.items)", "120.5 + len(vars.kpi.items)", 123.5,
|
||||
[cell("pull", 0, "amount", 120.5), {**scalar("vars.kpi.items", "[3 项]"), "value_artifact": ITEMS_VALUE}],
|
||||
decimals=1)
|
||||
#: 按位置取自整份结果的一格,除以整份结果的行数(都是 parse 节点写的)
|
||||
FIRST_SHARE = lone("first_share", "round(vars.res.rows[0][1] / len(vars.res.rows), 2)",
|
||||
"round(120.5 / len(vars.res.rows), 2)", 30.12,
|
||||
[scalar("vars.res.rows[0][1]", 120.5, node="parse"),
|
||||
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=2)
|
||||
|
||||
|
||||
def test_a_container_matching_no_snapshot_is_doubted_when_rows_are_excluded():
|
||||
"""能还原、却对不上任何快照的容器(加工过的结果):排除行后它会不会变说不准。"""
|
||||
card = lone("mixed", "cell(nodes.pull, 0, 'amount') + len(vars.kpi.items)", "120.5 + len(vars.kpi.items)", 123.5,
|
||||
[cell("pull", 0, "amount", 120.5),
|
||||
{**scalar("vars.kpi.items", "[3 项]"), "value_artifact": ITEMS_VALUE}], decimals=1)
|
||||
[mixed] = whatif(card, Changes(exclude={S1: frozenset({3})}), sources())["metrics"]
|
||||
[mixed] = whatif(MIXED, Changes(exclude={S1: frozenset({3})}), sources())["metrics"]
|
||||
assert mixed["status"] == "unsupported" and mixed["changed"] is None
|
||||
assert "排除行对 vars.kpi.items 的影响无法确定" in mixed["reason"] and "对不上任何查询快照" in mixed["reason"]
|
||||
|
||||
@@ -439,36 +462,133 @@ def test_a_container_matching_no_snapshot_is_doubted_when_rows_are_excluded():
|
||||
def test_a_scalar_taken_by_position_from_the_excluded_table_is_doubted():
|
||||
"""vars.res.rows[0][1] 按位置取自整份结果:排除第 0 行后,len 变了,分子却还是原来第 0 行的值——不能算出一个
|
||||
不对应任何一份数据的数。"""
|
||||
card = lone("first_share", "round(vars.res.rows[0][1] / len(vars.res.rows), 2)",
|
||||
"round(120.5 / len(vars.res.rows), 2)", 30.12,
|
||||
[scalar("vars.res.rows[0][1]", 120.5, node="parse"),
|
||||
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=2)
|
||||
assert recompute(card["metrics"][0], Changes(), sources())["new"] == round(120.5 / 4, 2) == 30.12
|
||||
[share] = whatif(card, Changes(exclude={S1: frozenset({0})}), sources())["metrics"]
|
||||
assert recompute(FIRST_SHARE["metrics"][0], Changes(), sources())["new"] == round(120.5 / 4, 2) == 30.12
|
||||
[share] = whatif(FIRST_SHARE, Changes(exclude={S1: frozenset({0})}), sources())["metrics"]
|
||||
assert share["status"] == "unsupported" and share["new"] is None and share["changed"] is None
|
||||
assert "vars.res.rows[0][1]" in share["reason"]
|
||||
|
||||
|
||||
def test_inputs_written_before_the_snapshot_came_back_are_not_doubted():
|
||||
"""写这个输入的节点在快照交回之前就跑完了:排除这份快照里的行影响不到它。"""
|
||||
early = sources(returned={S1: 10}, finished={"parse": 5, "fetch": 5})
|
||||
[mix] = whatif(OLD_MIX, Changes(exclude={S1: frozenset({2})}), early)["metrics"]
|
||||
assert mix["status"] == "unchanged" and mix["changed"] is False
|
||||
late = sources(returned={S1: 10}, finished={"parse": 12})
|
||||
[mix] = whatif(OLD_MIX, Changes(exclude={S1: frozenset({2})}), late)["metrics"]
|
||||
assert mix["status"] == "unsupported"
|
||||
def test_an_explicit_value_settles_an_input_that_would_be_doubted():
|
||||
"""按位置取的那一格给了新值:它的值以新值为准,排除行影响不到它,指标照常按剩下的行重算。"""
|
||||
changes = Changes(inputs={"vars.res.rows[0][1]": 100}, exclude={S1: frozenset({0})})
|
||||
[share] = whatif(FIRST_SHARE, changes, sources())["metrics"]
|
||||
assert share["status"] == "ok" and share["new"] == round(100 / 3, 2) == 33.33
|
||||
|
||||
|
||||
def test_a_metric_without_lineage_is_unsupported_once_anything_changes():
|
||||
@pytest.mark.parametrize("finished, doubted", [(5, False), (10, True), (12, True)])
|
||||
def test_only_inputs_written_after_the_snapshot_came_back_are_doubted(finished, doubted):
|
||||
"""写这个输入的节点在快照交回之前就跑完了:排除这份快照里的行影响不到它。跑完的那条事件就是交回快照的那一条
|
||||
(合并查询的快照记在它自己跑完的事件里),或者更晚:说不准。"""
|
||||
timed = sources(returned={S1: 10}, finished={"fetch": finished})
|
||||
[mixed] = whatif(MIXED, Changes(exclude={S1: frozenset({2})}), timed)["metrics"]
|
||||
assert (mixed["status"], mixed["changed"]) == (("unsupported", None) if doubted else ("unchanged", False))
|
||||
|
||||
|
||||
def test_a_container_that_picks_the_cell_is_doubted_when_its_rows_are_excluded():
|
||||
"""cell() 的行号由整份结果算出(取最后一行):排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。"""
|
||||
path = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')"
|
||||
card = lone("last", path, "40.0", 40.0, [
|
||||
{**cell("pull", 3, "amount", 40.0), "path": path},
|
||||
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1)
|
||||
assert recompute(card["metrics"][0], Changes(), sources())["new"] == 40.0
|
||||
[last] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"]
|
||||
assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"]
|
||||
|
||||
|
||||
def test_a_metric_without_lineage_is_unsupported_whatever_changes():
|
||||
legacy = {"id": "old", "name": "旧指标", "unit": "", "value": 3, "expression": "vars.x + 1"}
|
||||
card = {**CARD, "metrics": [legacy, CARD["metrics"][0]]}
|
||||
metrics = by_id(whatif(card, Changes(), sources()))
|
||||
assert metrics["old"]["status"] == "unchanged"
|
||||
for changes in (Changes(inputs={"vars.kpi.gmv": 50000}), Changes(exclude={S1: frozenset({1})})):
|
||||
for changes in (Changes(), Changes(inputs={"vars.kpi.gmv": 50000}), Changes(exclude={S1: frozenset({1})})):
|
||||
old = by_id(whatif(card, changes, sources()))["old"]
|
||||
assert old["status"] == "unsupported" and old["changed"] is None and "没有记录输入" in old["reason"]
|
||||
|
||||
|
||||
#: 整个指标推演不了的几种:(卡, Sources 的改动, 改点什么, 状态, 原因里该有的字)。每种都另有一个本来能改的输入
|
||||
BLOCKED = {
|
||||
# 老运行:容器型 vars 输入没存完整值
|
||||
"old_container": (lone("n", "len(vars.kpi.items) * vars.kpi.k", "len(vars.kpi.items) * 2", 6,
|
||||
[scalar("vars.kpi.items", "[3 项]"), scalar("vars.kpi.k", 2)]),
|
||||
{}, Changes(inputs={"vars.kpi.k": 5}), "unsupported", ["vars.kpi.items", "完整值"]),
|
||||
# 用到了遮罩列:那一格不给、代入式不给
|
||||
"masked": (lone("mix", "len(cell(nodes.crm, 0, 'phone')) + vars.kpi.k", "len('13800000000') + 2", 13,
|
||||
[cell("crm", 0, "phone", "13800000000", artifact=S2), scalar("vars.kpi.k", 2)]),
|
||||
{}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]),
|
||||
# 那一格所在的查询快照读不出来:遮没遮核对不了
|
||||
"unreadable": (lone("mix2", "cell(nodes.pull, 0, 'amount') + vars.kpi.k", "120.5 + 2", 122.5,
|
||||
[cell("pull", 0, "amount", 120.5), scalar("vars.kpi.k", 2)], decimals=1),
|
||||
{"load": lambda a: None if a == S1 else load(a)}, Changes(inputs={"vars.kpi.k": 5}), "unsupported",
|
||||
["cell(nodes.pull, 0, 'amount')", "无法读取"]),
|
||||
# agent 的数组字段:节点产出不在了,完整值找不回来
|
||||
"lost_array": (lone("share", "round(cell(nodes.pull, 0, 'amount') / sum(vars.kpi.amounts) * 100, 1)",
|
||||
"round(120.5 / sum(vars.kpi.amounts) * 100, 1)", 35.4,
|
||||
[cell("pull", 0, "amount", 120.5), AMOUNTS], decimals=1, fmt="plain", unit="%"),
|
||||
{"load": lambda a: None if a == BOT_OUT else load(a)}, Changes(exclude={S1: frozenset({1})}),
|
||||
"unsupported", ["vars.kpi.amounts", "无法读取节点「bot」当时的产出"]),
|
||||
# 升级前的卡:没记输入
|
||||
"no_lineage": ({**CARD, "metrics": [{"id": "old", "name": "旧指标", "unit": "", "value": 3,
|
||||
"expression": "vars.x + 1"}]},
|
||||
{}, Changes(exclude={S1: frozenset({1})}), "unsupported", ["没有记录输入"]),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(BLOCKED))
|
||||
def test_a_metric_that_cannot_be_recomputed_says_so_before_anything_changes(name):
|
||||
"""遮罩列、还原不了的最外层输入、读不出来的快照、没记输入:不论改什么都推演不了。一打开就照实说,不报「无变化」;
|
||||
输入一个都不让改、不让勾(改了、勾了也推演不出来);unsupported 里另有一条 input 为空的,界面据此只写原因。"""
|
||||
card, kw, change, status, words = BLOCKED[name]
|
||||
result = whatif(card, Changes(), sources(**kw))
|
||||
[m] = result["metrics"]
|
||||
assert m["status"] == status and all(w in m["reason"] for w in words), m
|
||||
assert [m[k] for k in ("new", "rendered_new", "changed", "delta", "rendered_delta")] == [None] * 5
|
||||
assert all(i["editable"] is False and "row_span" not in i for i in m["inputs"]), m["inputs"]
|
||||
assert {"metric": m["id"], "input": None, "reason": m["reason"]} in result["unsupported"]
|
||||
# 什么都没改:它不会变,下游也不会受影响
|
||||
assert result["maybe_changed"] is False
|
||||
# 改了它的输入、排除了它用到的行:照样推演不了,原因不变;它可能变了,下游可能受影响
|
||||
after = whatif(card, change, sources(**kw))
|
||||
assert [(x["status"], x["reason"], x["changed"]) for x in after["metrics"]] == [(status, m["reason"], None)]
|
||||
assert after["maybe_changed"] is True
|
||||
assert "13800000000" not in json.dumps([result, after], ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lost", ["missing", "tampered", "value_artifact"])
|
||||
def test_excluding_rows_of_an_array_whose_full_value_is_lost_is_never_unchanged(lost):
|
||||
"""agent 的数组字段完整值找不回来(节点产出不在、被改过,或者另存的完整值读不出来):排除它用到的行,它一定少了
|
||||
一项,少的是多少不知道。指标不能报「无变化」,报告里引用它的地方也不能报「不受影响」;它也不给行段,不让人勾。"""
|
||||
item = {**AMOUNTS, "value_artifact": "99" * 32} if lost == "value_artifact" else AMOUNTS
|
||||
card = lone("total", "sum(vars.kpi.amounts)", "sum(vars.kpi.amounts)", 340.0, [item], decimals=1, unit="元")
|
||||
broken = {"missing": lambda a: None if a == BOT_OUT else load(a),
|
||||
"tampered": lambda a: (_ for _ in ()).throw(ValueError("篡改")) if a == BOT_OUT else load(a),
|
||||
"value_artifact": load}[lost]
|
||||
result = whatif(card, Changes(exclude={S1: frozenset({1})}), sources(load=broken))
|
||||
[total] = result["metrics"]
|
||||
assert total["status"] == "unsupported" and total["changed"] is None and total["new"] is None
|
||||
assert total["rendered_new"] is None and "vars.kpi.amounts" in total["reason"]
|
||||
[amounts] = total["inputs"]
|
||||
assert "row_span" not in amounts and amounts["excluded_rows"] == [1] and amounts["editable"] is False
|
||||
# 少了一项是确定的,少了之后什么样不知道
|
||||
assert (amounts["old"], amounts["new"], amounts["changed"]) == ("[4 项]", None, True)
|
||||
assert result["maybe_changed"] is True
|
||||
[found] = report_segments([("write", doc_with(seg("s2", "340.0元", "m:total")))], card_node="card",
|
||||
card_artifact="CARD", results=result["metrics"])
|
||||
assert found["rendered_new"] is None and found["changed"] is None
|
||||
|
||||
|
||||
def test_only_excluded_rows_inside_an_array_span_reach_it():
|
||||
"""数组字段只用到快照的第 1–2 行(从 0 数):排除第 0 行影响不到它;同时排除第 0、2 行,起作用的只有第 2 行。"""
|
||||
out = "c9" * 32
|
||||
mid = {"path": "vars.kpi.mid", "value": "[2 项]", "node_id": "bot2", "via": "agent_field", "field": "mid",
|
||||
"artifact": S1, "locator": {"rows": [1, 2], "column": "amount"}, "eid": "ev:rows:y", "status": "verified"}
|
||||
card = lone("mid", "sum(vars.kpi.mid)", "sum(vars.kpi.mid)", 179.5, [mid], decimals=1, unit="元")
|
||||
span = sources(load=lambda a: {"data": {"mid": [80.0, 99.5]}} if a == out else load(a),
|
||||
outputs={"bot": BOT_OUT, "fetch": FETCH_OUT, "bot2": out})
|
||||
assert whatif(card, Changes(), span)["metrics"][0]["inputs"][0]["row_span"] == [1, 2]
|
||||
[m] = whatif(card, Changes(exclude={S1: frozenset({0})}), span)["metrics"]
|
||||
assert m["status"] == "unchanged" and "excluded_rows" not in m["inputs"][0]
|
||||
[m] = whatif(card, Changes(exclude={S1: frozenset({0, 2})}), span)["metrics"]
|
||||
assert m["status"] == "ok" and m["new"] == 80.0 and m["inputs"][0]["excluded_rows"] == [2]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 查询快照读不出来:值不给、不推演
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -477,28 +597,47 @@ def test_a_metric_without_lineage_is_unsupported_once_anything_changes():
|
||||
@pytest.mark.parametrize("broken, words", [
|
||||
(lambda a: None if a == S1 else load(a), "无法读取"),
|
||||
(lambda a: (_ for _ in ()).throw(ValueError("篡改")) if a == S1 else load(a), "哈希"),
|
||||
# 读得出来,却不是一份查询结果(没有 rows):同样核对不了
|
||||
(lambda a: {"columns": ["region", "amount", "phone"], "source": "shop"} if a == S1 else load(a), "无法读取"),
|
||||
])
|
||||
def test_an_unreadable_snapshot_withholds_its_cells(broken, words):
|
||||
"""快照读不出来,遮没遮核对不了:卡上记着的那一格也不给、不能改,用到它的指标推演不了。"""
|
||||
"""快照读不出来,遮没遮核对不了:卡上记着的那一格也不给、不能改、没有行可勾,用到它的指标推演不了。"""
|
||||
path = "cell(nodes.pull, 0, 'amount')"
|
||||
result = whatif(CARD, Changes(), sources(load=broken), metric="east")
|
||||
[east] = result["metrics"]
|
||||
[inp] = east["inputs"]
|
||||
assert inp["old"] is None and inp["new"] is None and inp["editable"] is False and inp["withheld"] is True
|
||||
assert "row_span" not in inp and east["status"] == "unsupported" and words in east["reason"]
|
||||
assert east["substituted"] is None and "120.5" not in str(east["inputs"]) + str(east["substituted"])
|
||||
[(input_path, reason)] = [(u["input"], u["reason"]) for u in result["unsupported"]]
|
||||
assert input_path == path and words in reason
|
||||
assert [(u["input"], words in u["reason"]) for u in result["unsupported"]] == [(None, True), (path, True)]
|
||||
with pytest.raises(WhatIfError, match="快照"):
|
||||
whatif(CARD, Changes(inputs={path: 100}), sources(load=broken), metric="east")
|
||||
# 别的指标照样推演,只是用到这份快照的推演不了
|
||||
total = by_id(whatif(CARD, Changes(inputs={"vars.kpi.gmv": 50000}), sources(load=broken)))["total"]
|
||||
assert total["status"] == "unchanged" and "120.5" not in str(total["inputs"])
|
||||
# 别的指标照样推演,只是用到这份快照的推演不了(数组字段的出处也在这份快照上)
|
||||
metrics = by_id(whatif(CARD, Changes(inputs={"vars.kpi.gmv": 50000}), sources(load=broken)))
|
||||
assert metrics["wow"]["status"] == "ok" and metrics["wow"]["new"] == 19.0
|
||||
assert metrics["total"]["status"] == "unsupported" and "120.5" not in str(metrics["total"]["inputs"])
|
||||
|
||||
|
||||
def test_a_masked_column_stays_masked_when_its_snapshot_is_unreadable():
|
||||
phone = by_id(whatif(CARD, Changes(), sources(load=lambda a: None if a == S2 else load(a))))["phone_len"]
|
||||
[inp] = phone["inputs"]
|
||||
assert inp["masked"] is True and inp["old"] is None and phone["substituted"] is None
|
||||
# 既遮着、快照又读不出来:说遮罩,快照修好了它照样遮着
|
||||
assert phone["status"] == "masked" and "遮罩" in phone["reason"]
|
||||
|
||||
|
||||
def test_a_withheld_cell_inside_another_reference_stays_out_of_the_substituted_expression():
|
||||
"""取第几行由一格决定,这一格落在读不出来的快照上;取到的又是长文本(代入式不代长文本,就把行号代了进去)。
|
||||
嵌在里面不碍事,指标照样推演得了,但代入式里有那一格的值,同样不给。"""
|
||||
s3, note = "d3" * 32, "备注" * 50
|
||||
store = {**STORE, s3: {"columns": ["note"], "source": "crm", "rows": [["短"], ["短"], [note]]}}
|
||||
path = "cell(nodes.memo, vars.kpi.r, 'note')"
|
||||
r = {**scalar("vars.kpi.r", 2), "via": "agent_field", "artifact": S1, "locator": {"row": 0, "column": "amount"}}
|
||||
card = lone("memo_len", f"len({path})", "len(cell(nodes.memo, 2, 'note'))", 100,
|
||||
[{**cell("memo", 2, "note", note, artifact=s3), "path": path}, r])
|
||||
[m] = whatif(card, Changes(), sources(load=lambda a: None if a == S1 else store.get(a)))["metrics"]
|
||||
assert m["status"] == "unchanged" and m["substituted"] is None
|
||||
assert [i.get("withheld") for i in m["inputs"]] == [None, True]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -512,7 +651,6 @@ def test_numbers_too_large_to_compute_are_rejected_not_crashed():
|
||||
# 1e308 本身放得下,乘出来的整数放不下:说清算不出来
|
||||
[n2] = whatif(CARD, Changes(inputs={"vars.kpi.k": 10 ** 308}), sources(), metric="n2")["metrics"]
|
||||
assert n2["status"] == "error" and n2["new"] is None and "有限" in n2["reason"]
|
||||
import json
|
||||
json.dumps(n2, allow_nan=False)
|
||||
|
||||
|
||||
@@ -545,7 +683,10 @@ def test_inputs_that_cannot_be_adjusted_say_why():
|
||||
*CARD["metrics"],
|
||||
metric("label", "len(vars.kpi.name) + vars.kpi.k", "len('华东区') + 2", 5,
|
||||
[scalar("vars.kpi.name", "华东区"), scalar("vars.kpi.k", 2)]),
|
||||
metric("flag", "vars.kpi.on * 3", "True * 3", 3, [scalar("vars.kpi.on", True)])]}
|
||||
metric("flag", "vars.kpi.on * 3", "True * 3", 3, [scalar("vars.kpi.on", True)]),
|
||||
# 老运行:容器没存完整值,整个推演不了
|
||||
metric("label_n", "len(vars.kpi.name) + len(vars.kpi.items)", "len('华东区') + len(vars.kpi.items)", 6,
|
||||
[scalar("vars.kpi.name", "华东区"), scalar("vars.kpi.items", "[3 项]")])]}
|
||||
reasons = {(u["metric"], u["input"]): u["reason"] for u in whatif(card, Changes(), sources())["unsupported"]}
|
||||
assert "列表或对象不能直接修改" in reasons[("n2", "vars.kpi.items")]
|
||||
assert "列表或对象不能直接修改" in reasons[("n3", "nodes.fetch.items")]
|
||||
@@ -553,6 +694,21 @@ def test_inputs_that_cannot_be_adjusted_say_why():
|
||||
# 能排除行的容器、能改的数不在其中
|
||||
assert ("total", "vars.kpi.amounts") not in reasons and ("rows", "vars.res.rows") not in reasons
|
||||
assert ("label", "vars.kpi.k") not in reasons and ("wow", "vars.kpi.gmv") not in reasons
|
||||
# 整个推演不了的指标:一条整个指标的,再加卡住它的那个输入;别的输入能不能改已无从谈起,不再单列
|
||||
assert [i for m, i in reasons if m == "label_n"] == [None, "vars.kpi.items"]
|
||||
|
||||
|
||||
def test_a_lost_array_inside_a_subscript_offers_no_rows():
|
||||
"""cell() 的行号由 agent 的数组字段算出,这个数组另存的完整值又读不出来:嵌在里面不碍事(取到的那一格卡上记着),
|
||||
指标照样推演得了,那一格能改、能勾它那一行;数组本身不给行段。排除了数组用到的行,取到哪一格说不准。"""
|
||||
path = "cell(nodes.pull, len(vars.kpi.amounts) - 1, 'amount')"
|
||||
lost = {**AMOUNTS, "value_artifact": "99" * 32}
|
||||
card = lone("last2", path, "40.0", 40.0, [{**cell("pull", 3, "amount", 40.0), "path": path}, lost], decimals=1)
|
||||
[m] = whatif(card, Changes(), sources())["metrics"]
|
||||
assert m["status"] == "unchanged" and [i.get("row_span") for i in m["inputs"]] == [[3, 3], None]
|
||||
assert [i["editable"] for i in m["inputs"]] == [True, False]
|
||||
[m] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"]
|
||||
assert m["status"] == "unsupported" and m["changed"] is None and "vars.kpi.amounts" in m["reason"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -361,13 +361,54 @@ async def test_masked_columns_are_neither_shown_nor_used(client, monkeypatch, sh
|
||||
assert metrics["amount5"]["substituted"] is None and metrics["per"]["substituted"] is None
|
||||
reasons = {(u["metric"], u["input"]): u["reason"] for u in body["unsupported"]}
|
||||
assert "遮罩" in reasons[("amount5", AMOUNT5)] and "遮罩" in reasons[("cnt", "vars.res.rows")]
|
||||
# 三个指标都用到了遮罩列:不改动也照实说推演不了,不报「无变化」;什么都没改,下游不受影响
|
||||
assert {k: (m["status"], m["changed"], m["rendered_new"]) for k, m in metrics.items()} == {
|
||||
k: ("masked", None, None) for k in ("amount5", "cnt", "per")}
|
||||
assert all(reasons[(k, None)] == metrics[k]["reason"] for k in metrics) and body["affected_cards"] == []
|
||||
|
||||
resp = await client.post(f"/api/runs/{row.id}/evidence/whatif",
|
||||
json={"card": "card", "overrides": {"inputs": {AMOUNT5: 1}}})
|
||||
assert resp.status_code == 422 and "遮罩" in resp.json()["detail"]
|
||||
metrics = by_id(await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [1]}}}))
|
||||
assert metrics["cnt"]["status"] == "masked" and metrics["cnt"]["new"] is None
|
||||
assert metrics["per"]["status"] == "masked" and metrics["amount5"]["status"] == "unchanged"
|
||||
assert metrics["per"]["status"] == "masked" and metrics["amount5"]["status"] == "masked"
|
||||
|
||||
|
||||
async def test_a_metric_using_a_masked_column_offers_nothing_to_adjust(client, monkeypatch, shop):
|
||||
"""指标的一个输入取自遮罩列,另一个是能改的格:整个指标推演不了,那一格也不让改、它那一行也不让勾。
|
||||
改了也只回「用到了遮罩列」,不给新值。"""
|
||||
await set_source(shop, {"mask_columns": ["region"]})
|
||||
region = "cell(nodes.fetch, 5, 'region')"
|
||||
metrics = [{"id": "mix", "name": "混合", "decimals": 1, "expression": f"len({region}) + {AMOUNT5}"}]
|
||||
row = await run_graph(monkeypatch, query_graph(metrics=metrics), text="混合 [[m:mix]]。")
|
||||
body = await whatif(client, row.id, {"card": "card", "metric": "mix", "report": "write"})
|
||||
[mix] = body["metrics"]
|
||||
assert (mix["status"], mix["changed"], mix["rendered_new"]) == ("masked", None, None) and "遮罩" in mix["reason"]
|
||||
inputs = {i["path"]: i for i in mix["inputs"]}
|
||||
assert inputs[region]["masked"] is True and inputs[AMOUNT5]["old"] == 600.5
|
||||
assert all(i["editable"] is False and "row_span" not in i for i in inputs.values())
|
||||
assert {"metric": "mix", "input": None, "reason": mix["reason"]} in body["unsupported"]
|
||||
[seg] = body["segments"]
|
||||
assert seg["rendered_new"] is None and seg["changed"] is None and body["affected_cards"] == []
|
||||
[mix] = (await whatif(client, row.id, {"card": "card", "metric": "mix",
|
||||
"overrides": {"inputs": {AMOUNT5: 1}}}))["metrics"]
|
||||
assert (mix["status"], mix["new"], mix["changed"]) == ("masked", None, None)
|
||||
|
||||
|
||||
async def test_masks_recorded_at_query_time_apply_after_the_source_drops_them(client, monkeypatch, shop):
|
||||
"""查询当时设了遮罩、跑完之后数据源去掉了:快照里记下的遮罩照样算,推演和勾行接口都不给那一列。"""
|
||||
await set_source(shop, {"mask_columns": ["amount"]})
|
||||
row = await run_graph(monkeypatch, query_graph())
|
||||
snapshot = await snapshot_of(row.id)
|
||||
await set_source(shop, {})
|
||||
body = await whatif(client, row.id, {"card": "card"})
|
||||
[cell] = by_id(body)["amount5"]["inputs"]
|
||||
assert cell["masked"] is True and cell["old"] is None and cell["editable"] is False
|
||||
assert by_id(body)["amount5"]["status"] == "masked"
|
||||
assert "600.5" not in json.dumps([m["inputs"] for m in body["metrics"]])
|
||||
url = f"/api/runs/{row.id}/evidence/whatif/rows"
|
||||
page = (await client.get(url, params={"artifact": snapshot, "limit": 2})).json()
|
||||
assert page["masked"] == ["amount"] and page["rows"] == [[1, "华南", "已遮罩"], [2, "华北", "已遮罩"]]
|
||||
|
||||
|
||||
async def test_a_run_waiting_for_approval_can_be_explored_but_is_not_sealed(client, monkeypatch):
|
||||
@@ -471,6 +512,35 @@ async def test_agent_array_fields_follow_the_excluded_rows(client, monkeypatch):
|
||||
assert amounts["excluded_rows"] == [0] and (amounts["old"], amounts["new"]) == ("[2 项]", "[1 项]")
|
||||
|
||||
|
||||
async def test_an_agent_array_whose_node_output_is_gone_is_never_unchanged(client, monkeypatch, damage):
|
||||
"""agent 节点封存的产出文件不在了:nodes.bot.data.amounts 的完整值找不回来。排除它用到的一行,它一定少了一项,
|
||||
少的是多少不知道——不能报「无变化」,也不给行段让人勾。vars.kpi.amounts 另存过完整值,照常推演。"""
|
||||
AgentScript(monkeypatch)
|
||||
schema = {"type": "object", "properties": {"amounts": {"type": "array", "items": {"type": "number"}}}}
|
||||
row = await wait((await run_manager.start(graph=chain(
|
||||
node("start", "input"),
|
||||
node("bot", "agent", prompt="按周汇总金额", tools=["db_query__shop"], approval="never",
|
||||
output_schema=schema, cite_fields=True, assign_to="kpi"),
|
||||
node("card", "metrics", caliber="推演口径", metrics=[
|
||||
{"id": "via_node", "name": "合计", "decimals": 1, "expression": "sum(nodes.bot.data.amounts)"},
|
||||
{"id": "via_vars", "name": "合计", "decimals": 1, "expression": "sum(vars.kpi.amounts)"}]),
|
||||
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])), input_payload={})).id)
|
||||
assert row.status == "succeeded", row.error
|
||||
snapshot = await snapshot_of(row.id, "bot")
|
||||
async with SessionLocal() as session:
|
||||
[finished] = (await session.execute(select(RunEvent).where(
|
||||
RunEvent.run_id == row.id, RunEvent.type == "node.finished", RunEvent.node_id == "bot"))).scalars()
|
||||
damage(finished.data["artifact"])
|
||||
|
||||
baseline = by_id(await whatif(client, row.id, {"card": "card"}))
|
||||
assert baseline["via_node"]["status"] == "unsupported" and "bot" in baseline["via_node"]["reason"]
|
||||
assert all(i["editable"] is False and "row_span" not in i for i in baseline["via_node"]["inputs"])
|
||||
body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [0]}}})
|
||||
via_node, via_vars = by_id(body)["via_node"], by_id(body)["via_vars"]
|
||||
assert via_node["status"] == "unsupported" and via_node["changed"] is None and via_node["rendered_new"] is None
|
||||
assert via_vars["status"] == "ok" and via_vars["new"] == round(sum(a for i, _, a in ROWS if i > 3), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 下游口径卡
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -806,6 +876,15 @@ async def test_downstream_cards_are_flagged_when_the_change_cannot_be_followed(c
|
||||
body = await whatif(client, row.id, {"card": "card", "metric": "per", "overrides": {"inputs": {AMOUNT5: 1000}}})
|
||||
assert body["metrics"][0]["status"] == "unsupported"
|
||||
assert [c["node_id"] for c in body["affected_cards"]] == ["yearly"]
|
||||
# 什么都不改:平均每单本来就推演不了(vars.res.rows 没存完整值),一打开就照实说;那一格也不让改、它那一行
|
||||
# 也不让勾,报告里引用它的地方不说「不受影响」。什么都没改,下游不会受影响
|
||||
body = await whatif(client, row.id, {"card": "card", "metric": "per", "report": "write"})
|
||||
[per] = body["metrics"]
|
||||
assert (per["status"], per["changed"], per["rendered_new"]) == ("unsupported", None, None)
|
||||
assert "vars.res.rows" in per["reason"] and "完整值" in per["reason"]
|
||||
assert all(i["editable"] is False and "row_span" not in i for i in per["inputs"])
|
||||
assert [(s["metric"], s["rendered_new"], s["changed"]) for s in body["segments"]] == [("per", None, None)]
|
||||
assert body["affected_cards"] == []
|
||||
|
||||
|
||||
TRICKY = [(2.675, 2), (0.125, 2), (-1.5, 0), (1234.5, -1), (-1250.0, -2), (0.0005, 3), (99.995, 2), (-0.045, 2)]
|
||||
|
||||
@@ -85,3 +85,6 @@ def test_a_snapshot_counts_as_returned_the_first_time_it_shows_up():
|
||||
assert _returned(sealed(LOOP)) == {Q1: 2}
|
||||
merged_only = [(3, "node.finished", "merged", {"artifact": OUT1, "evidence": [{"kind": "query", "artifact": Q1}]})]
|
||||
assert _returned(sealed(merged_only)) == {Q1: 3}
|
||||
# 同一条查询又跑了一次(结果一样,工件 id 也一样):仍按第一次交回算,第一次之后算出来的值都可能受影响
|
||||
again = [(2, "tool.end", "fill", {"query_artifact": Q1}), (6, "tool.end", "fill", {"query_artifact": Q1})]
|
||||
assert _returned(sealed(again)) == {Q1: 2}
|
||||
|
||||
Reference in New Issue
Block a user