mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:11:58 +00:00
440 lines
24 KiB
Python
440 lines
24 KiB
Python
"""分析卡(节点类型 analysis,docs/design/analysis-round/00-总方案.md §3.2):画图时的校验、执行、产出的变量、受管规则、
|
||
登记处(叫法、变量表、助手查看、目录影响面、升级提醒)。运行对比按项对齐见 tests/test_analysis_compare.py。
|
||
|
||
算子(app.analysis.run)换成按请求交回假结论的替身(tests/fixtures/analysis_ops.FakeOps);样例里的下钻那一项另用真算子
|
||
跑一遍(替身不核参数)。库是虚构的零售库 shop:orders(region, week, amount, channel),华东、华南、华北两周。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sqlite3
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.analysis import contract
|
||
from app.analysis.contract import CallError
|
||
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, NodeType, validate_graph
|
||
from tests.fixtures.analysis_ops import FakeOps
|
||
|
||
TOOL = "db_query__shop"
|
||
WEEKLY = "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week"
|
||
REGION = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week"
|
||
#: 各地区两周的销售额:华东 820 → 700(线上 500 → 420、线下 320 → 280),华南 700 → 666,华北 600 → 560
|
||
ROWS = [(1, "华东", "2026-W41", 500.0, "线上"), (2, "华东", "2026-W41", 320.0, "线下"), (3, "华东", "2026-W42", 420.0, "线上"),
|
||
(4, "华东", "2026-W42", 280.0, "线下"), (5, "华南", "2026-W41", 700.0, "线上"), (6, "华南", "2026-W42", 666.0, "线上"),
|
||
(7, "华北", "2026-W41", 600.0, "线下"), (8, "华北", "2026-W42", 560.0, "线下")]
|
||
#: 华东按渠道(DRILL 的输入):两个渠道之和 820 → 700,和 WHERE 里华东那一段闭合
|
||
EAST = ("SELECT channel, week, SUM(amount) AS gmv FROM orders WHERE region = '华东' GROUP BY channel, week "
|
||
"ORDER BY channel, week")
|
||
WOW = {"id": "wow", "op": "baseline", "from": "weekly", "params": {"period": "week", "measure": "gmv",
|
||
"label": "销售额"}}
|
||
WHERE = {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region",
|
||
"params": {"by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv",
|
||
"headline": {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"}, "label": "销售额"},
|
||
"hypothesis": "下降是不是集中在某个地区"}
|
||
#: 下钻:WHERE 里华东那一段按渠道再拆一层,读华东按渠道的那次查询(drill_graph 里的 east_q)
|
||
DRILL = {"id": "east", "op": "decompose", "mode": "additive", "from": "east_q",
|
||
"params": {"by": ["channel"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv",
|
||
"parent": {"of": "where", "key": ["华东"]}}}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def engine_up():
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
async def _shop(tmp_path, scratch_source, options=None):
|
||
"""虚构零售库 shop(tests/conftest.py 的 scratch_source:同名的旧源连同数据目录、现查缓存先删掉,测试结束时再删)。"""
|
||
path = tmp_path / "shop.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, week TEXT, amount REAL, channel TEXT);")
|
||
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", ROWS)
|
||
db.commit()
|
||
db.close()
|
||
return await scratch_source("shop", path, options=options)
|
||
|
||
|
||
@pytest.fixture
|
||
async def shop(tmp_path, scratch_source):
|
||
return await _shop(tmp_path, scratch_source)
|
||
|
||
|
||
def node(nid, ntype, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||
|
||
|
||
def edge(a, b, handle=None):
|
||
return {"source": a, "target": b, **({"sourceHandle": handle} if handle else {})}
|
||
|
||
|
||
def card_graph(items=(WOW, WHERE), *, extra_nodes=(), extra_edges=(), **card):
|
||
nodes = [node("start", "input"),
|
||
node("weekly", "tool", tool=TOOL, args={"sql": WEEKLY}),
|
||
node("by_region", "tool", tool=TOOL, args={"sql": REGION}),
|
||
node("card", "analysis", items=[dict(i) if isinstance(i, dict) else i for i in items], **card),
|
||
node("out", "output", fields=[{"name": "判定", "value": "{{ nodes.card.results.wow.phrase }}"}]),
|
||
*extra_nodes]
|
||
edges = [edge("start", "weekly"), edge("weekly", "by_region"), edge("by_region", "card"), edge("card", "out"),
|
||
*extra_edges]
|
||
return {"nodes": nodes, "edges": edges}
|
||
|
||
|
||
def drill_graph(items=(WOW, WHERE, DRILL), **card):
|
||
"""带下钻那一项的分析卡:华东按渠道的那次查询(east_q)接在 by_region 后面、分析卡前面。"""
|
||
return card_graph(items=items, extra_nodes=[node("east_q", "tool", tool=TOOL, args={"sql": EAST})],
|
||
extra_edges=[edge("by_region", "east_q"), edge("east_q", "card")], **card)
|
||
|
||
|
||
def card_issues(graph, level="error"):
|
||
return [i for i in validate_graph(GraphSpec.model_validate(graph)).issues
|
||
if i.node_id == "card" and (level is None or i.level == level)]
|
||
|
||
|
||
async def wait(run_id, statuses=("succeeded", "failed")):
|
||
for _ in range(400):
|
||
await asyncio.sleep(0.05)
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row.status in statuses and row.manifest_seq is not None:
|
||
return row
|
||
raise AssertionError(f"run {run_id} 没有结束:{row.status}")
|
||
|
||
|
||
async def events(run_id, etype=None, node_id=None):
|
||
async with SessionLocal() as session:
|
||
q = select(RunEvent).where(RunEvent.run_id == run_id)
|
||
if etype:
|
||
q = q.where(RunEvent.type == etype)
|
||
if node_id:
|
||
q = q.where(RunEvent.node_id == node_id)
|
||
return [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()]
|
||
|
||
|
||
async def run_graph(graph, **kw):
|
||
run = await run_manager.start(graph=graph, input_payload={}, **kw)
|
||
return await wait(run.id)
|
||
|
||
|
||
async def output_of(run_id, node_id):
|
||
[finished] = await events(run_id, "node.finished", node_id)
|
||
return finished, artifact_store.load(finished["artifact"])
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 画图时的校验
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_node_type_and_labels():
|
||
from app.engine.labels import field_label, type_label
|
||
|
||
assert NodeType.ANALYSIS == contract.NODE_TYPE == "analysis"
|
||
assert type_label("analysis") == "分析卡"
|
||
assert field_label("items", "analysis") == "分析项" and field_label("overrides", "analysis") == "阈值覆盖"
|
||
|
||
|
||
def test_a_well_formed_card_validates():
|
||
assert card_issues(drill_graph(overrides={"main_share": 0.6}), level=None) == []
|
||
|
||
|
||
@pytest.mark.parametrize("items, field, code, why", [
|
||
([], "items", "analysis.items_missing", "至少写一项"),
|
||
(["wow"], "items[0]", "analysis.item_invalid", "需要是一个对象"),
|
||
([{**WOW, "id": "1st"}], "items[0].id", "analysis.item_invalid", "字母、数字和下划线"),
|
||
([WOW, {**WHERE, "id": "wow"}], "items[1].id", "analysis.item_invalid", "唯一"),
|
||
([{**WOW, "op": "forecast"}], "items[0].op", "analysis.op_invalid", "不认识"),
|
||
([{**WHERE, "mode": None}], "items[0].mode", "analysis.mode_invalid", "要选一种模式"),
|
||
([{**WOW, "mode": "additive"}], "items[0].mode", "analysis.mode_invalid", "没有模式可选"),
|
||
([{**WOW, "from": None}], "items[0].from", "analysis.input_invalid", "还没有选择输入"),
|
||
([{**WOW, "from": "nope"}], "items[0].from", "analysis.input_invalid", "不存在"),
|
||
([{**WOW, "params": {**WOW["params"], "k_warn": 3}}], "items[0].params.k_warn", "analysis.params_invalid",
|
||
"阈值覆盖"),
|
||
([{**WOW, "params": {**WOW["params"], "query": "weekly"}}], "items[0].params.query", "analysis.params_invalid",
|
||
"输入里"),
|
||
([{**WOW, "params": {**WOW["params"], "bogus": 1}}], "items[0].params.bogus", "analysis.params_invalid",
|
||
"没有「bogus」"),
|
||
# 运行当天、目录事实、下钻层数由系统填(算子自己记),分析项里写了不收
|
||
([{**WOW, "params": {**WOW["params"], "today": "2026-10-21"}}], "items[0].params.today",
|
||
"analysis.params_invalid", "没有「today」"),
|
||
([{**WHERE, "params": {**WHERE["params"], "headline": {"base": "weekly.gmv", "target": "weekly.r1.gmv"}}}],
|
||
"items[0].params.headline.base", "analysis.params_invalid", "节点 id.r行号.列名"),
|
||
([{**WHERE, "params": {**WHERE["params"], "headline": "weekly.r0.gmv"}}], "items[0].params.headline",
|
||
"analysis.params_invalid", "两格"),
|
||
([DRILL, WHERE], "items[0].params.parent", "analysis.parent_invalid", "排在它前面"),
|
||
([WOW, {**DRILL, "params": {**DRILL["params"], "parent": {"of": "wow", "key": ["华东"]}}}],
|
||
"items[1].params.parent", "analysis.parent_invalid", "不是变化拆解"),
|
||
([WHERE, {**DRILL, "params": {**DRILL["params"], "parent": {"of": "where"}}}], "items[1].params.parent",
|
||
"analysis.parent_invalid", "写清是哪一项里的哪一段"),
|
||
([WHERE, {**WOW, "params": {**WOW["params"], "parent": {"of": "where", "key": ["华东"]}}}],
|
||
"items[1].params.parent", "analysis.params_invalid", "没有「parent」"),
|
||
])
|
||
def test_item_problems(items, field, code, why):
|
||
found = [i for i in card_issues(drill_graph(items=items)) if i.field == field]
|
||
assert found and found[0].code == code and why in found[0].message, [i.message for i in card_issues(
|
||
drill_graph(items=items))]
|
||
|
||
|
||
@pytest.mark.parametrize("extra, why", [
|
||
(node("think", "llm", prompt="写点什么"), "不产出查询结果"),
|
||
(node("asker", "agent", prompt="查一下", tools=[TOOL]), "Agent 可能查询多次"),
|
||
])
|
||
def test_inputs_must_be_query_nodes(extra, why):
|
||
graph = card_graph(items=[{**WOW, "from": extra["id"]}], extra_nodes=[extra],
|
||
extra_edges=[edge("by_region", extra["id"]), edge(extra["id"], "card")])
|
||
[issue] = card_issues(graph)
|
||
assert issue.field == "items[0].from" and why in issue.message and issue.code == "analysis.input_invalid"
|
||
|
||
|
||
def test_inputs_must_be_upstream_and_other_tools_only_warn():
|
||
lonely = node("lonely", "tool", tool=TOOL, args={"sql": WEEKLY})
|
||
[issue] = card_issues(card_graph(items=[{**WOW, "from": "lonely"}], extra_nodes=[lonely],
|
||
extra_edges=[edge("start", "lonely")]))
|
||
assert "不在分析卡的上游" in issue.message
|
||
# headline 那两格落在的节点同样要是上游的查询节点
|
||
headline = {**WHERE["params"], "headline": {"base": "lonely.r0.gmv", "target": "weekly.r1.gmv"}}
|
||
[issue] = card_issues(card_graph(items=[{**WHERE, "params": headline}], extra_nodes=[lonely],
|
||
extra_edges=[edge("start", "lonely")]))
|
||
assert issue.field == "items[0].params.headline.base" and "不在分析卡的上游" in issue.message
|
||
# 调用别的工具的调用工具节点:登记里声明了表格的端点读取也交回快照,静态看不出,只提醒
|
||
other = node("ext", "tool", tool="crm_report", args={})
|
||
graph = card_graph(items=[{**WOW, "from": "ext"}], extra_nodes=[other],
|
||
extra_edges=[edge("by_region", "ext"), edge("ext", "card")])
|
||
assert card_issues(graph) == []
|
||
[warning] = card_issues(graph, level="warning")
|
||
assert warning.code == "analysis.input_invalid" and "不是数据库查询工具" in warning.message
|
||
|
||
|
||
@pytest.mark.parametrize("overrides, why", [
|
||
({"k_warn": 3.0, "k_alert": 2.5}, "要大于"),
|
||
({"main_share": 2}, "之间"),
|
||
({"bogus": 1}, "不认识的分析设置"),
|
||
("tight", "需要是一个对象"),
|
||
])
|
||
def test_overrides_are_checked_by_the_contract(overrides, why):
|
||
[issue] = card_issues(card_graph(overrides=overrides))
|
||
assert issue.field == "overrides" and issue.code == "analysis.overrides_invalid" and why in issue.message
|
||
|
||
|
||
def test_two_snapshot_items_need_both_nodes():
|
||
two = {"id": "same", "op": "comparable", "params": {"base_query": "weekly", "target_query": "by_region"}}
|
||
assert card_issues(card_graph(items=[two])) == []
|
||
half = {"id": "same", "op": "comparable", "params": {"base_query": "weekly"}}
|
||
[issue] = card_issues(card_graph(items=[half]))
|
||
assert issue.field == "items[0].from" and "两个查询节点" in issue.message
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 执行
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_the_card_runs_each_item_and_records_it(monkeypatch, shop):
|
||
ops = FakeOps(monkeypatch, verdicts=["outside_band", "driver", "driver"], peek=["wow", "where", "east"])
|
||
row = await run_graph(drill_graph(overrides={"main_share": 0.6}), options={contract.SETTINGS_KEY: {"k_warn": 2.5}})
|
||
assert row.status == "succeeded", row.error
|
||
|
||
# 算子拿到的:请求、按上游节点 id 解析的快照(只认台账里的那几份)、阈值(缺省 ← 发起时的快照 ← 卡上的覆盖)
|
||
wow, where, east = ops.calls
|
||
assert wow["request"] == {"op": "baseline", "mode": None,
|
||
"params": {"period": "week", "measure": "gmv", "label": "销售额", "query": "weekly"}}
|
||
assert where["request"]["hypothesis"] == "下降是不是集中在某个地区" and where["request"]["mode"] == "additive"
|
||
assert where["request"]["params"]["headline"] == {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"}
|
||
weekly = wow["snapshots"]["weekly"]
|
||
[tool_end] = await events(row.id, "tool.end", "weekly")
|
||
assert weekly["ref"] == "weekly" and weekly["node_id"] == "weekly" and weekly["artifact"] == tool_end["query_artifact"]
|
||
assert weekly["columns"] == ["week", "gmv"] and weekly["rows"] == [["2026-W41", 2120.0], ["2026-W42", 1926.0]]
|
||
assert weekly["sql"] == WEEKLY and weekly["source"] == "shop" and weekly["masked"] == []
|
||
assert wow["settings"] == {**contract.DEFAULTS, "k_warn": 2.5, "main_share": 0.6}
|
||
# 下钻取到同卡里前一项的结论;只看得到排在前面、已经做完的项
|
||
assert east["parent"]["op"] == "decompose" and east["parent"]["params"]["query"] == "by_region"
|
||
assert east["visible"] == {"wow": True, "where": True, "east": False}
|
||
assert wow["visible"] == {"wow": False, "where": False, "east": False}
|
||
|
||
finished, output = await output_of(row.id, "card")
|
||
results = output["results"]
|
||
assert set(results) == {"wow", "where", "east"} # 产出工件按规范 JSON 存,键的先后看配置里项的先后
|
||
assert results["wow"]["verdict"] == "outside_band" and results["wow"]["status"] == "ok"
|
||
assert results["wow"]["phrase"].startswith("销售额本期变化") and results["wow"]["direction"] == "down"
|
||
assert results["where"]["values"]["top_share"] == "0.6186"
|
||
# 台账:每项一条 kind analysis(带 item),工件就是整份结论
|
||
entries = finished["evidence"]
|
||
assert [(e["kind"], e["item"]) for e in entries] == [("analysis", "wow"), ("analysis", "where"),
|
||
("analysis", "east")]
|
||
for entry, item in zip(entries, ("wow", "where", "east")):
|
||
stored = artifact_store.load(entry["artifact"])
|
||
assert entry == contract.ledger_entry(stored, node_id="card", exec_no=1, artifact=entry["artifact"],
|
||
item=item)
|
||
assert results[item]["artifact"] == entry["artifact"]
|
||
# 每项一条 analysis.end
|
||
ends = await events(row.id, "analysis.end", "card")
|
||
assert [(e["item"], e["op"], e["verdict"], e["analysis_artifact"]) for e in ends] == [
|
||
(e["item"], {"wow": "baseline"}.get(e["item"], "decompose"), results[e["item"]]["verdict"],
|
||
results[e["item"]]["artifact"]) for e in ends]
|
||
# 成果字段取判定语
|
||
assert row.output["判定"] == results["wow"]["phrase"]
|
||
|
||
|
||
async def test_the_drill_item_holds_up_with_the_real_operator(shop):
|
||
"""上面的 DRILL 换真算子也成立(替身不核参数):华东按渠道拆,两个渠道之和和上一项里华东那一段闭合,主要来自线上;上一层
|
||
按存下的工件认(parent 的哈希就是 where 那一项的结论工件),按封存的工件复算逐字段相同。"""
|
||
import app.analysis as analysis_ops
|
||
|
||
row = await run_graph(drill_graph(overrides={"main_share": 0.6}))
|
||
assert row.status == "succeeded", row.error
|
||
_, output = await output_of(row.id, "card")
|
||
results = output["results"]
|
||
assert results["where"]["verdict"] == "driver" and "主要来自华东" in results["where"]["phrase"]
|
||
east = artifact_store.load(results["east"]["artifact"])
|
||
assert east["status"] == "ok" and east["verdict"] == {"code": "driver", "subjects": [["线上"]], "direction": "down"}
|
||
assert east["values"]["base_total"] == "820" and east["values"]["target_total"] == "700"
|
||
[parent] = [i for i in east["inputs"] if i.get("role") == "parent"]
|
||
assert parent["artifact"] == results["where"]["artifact"]
|
||
again = analysis_ops.recompute(east, artifact_store.load, load_result=artifact_store.load)
|
||
assert contract.same(east, again)
|
||
|
||
|
||
async def test_a_refused_item_does_not_fail_the_card(monkeypatch, shop):
|
||
FakeOps(monkeypatch, statuses=["refused", "ok"])
|
||
row = await run_graph(card_graph())
|
||
assert row.status == "succeeded", row.error
|
||
_, output = await output_of(row.id, "card")
|
||
assert output["results"]["wow"]["status"] == "refused" and output["results"]["wow"]["verdict"] is None
|
||
assert output["results"]["wow"]["phrase"].endswith("历史只有 5 期,至少要 8 期")
|
||
assert output["results"]["where"]["status"] == "ok"
|
||
[warn] = [e for e in await events(row.id, "log", "card") if e.get("code") == "analysis_refused"]
|
||
assert warn["item"] == "wow" and warn["reason_code"] == "history_short" and "wow" in warn["message"]
|
||
|
||
|
||
async def test_a_call_error_fails_the_card_and_names_the_item(monkeypatch, shop):
|
||
FakeOps(monkeypatch, errors=[None, CallError("ratio_as_additive", "比率不能按段相加,改用 ratio 模式,给分子、分母两列")])
|
||
row = await run_graph(card_graph())
|
||
assert row.status == "failed"
|
||
assert "分析项「where」(变化拆解)" in row.error and "比率不能按段相加" in row.error and "改正这一项" in row.error
|
||
|
||
|
||
async def test_a_branch_can_follow_the_verdict(monkeypatch, shop):
|
||
FakeOps(monkeypatch, verdicts=["outside_band", "driver"])
|
||
graph = card_graph(extra_nodes=[
|
||
node("route", "branch", cases=[{"key": "alert", "condition": "nodes.card.results.wow.verdict == 'outside_band'"}]),
|
||
node("alert", "output", fields=[{"name": "转人工", "value": "{{ nodes.card.results.wow.phrase }}"}])])
|
||
graph["edges"] = [e for e in graph["edges"] if e["target"] != "out"] + [
|
||
edge("card", "route"), edge("route", "alert", "alert"), edge("route", "out", "default")]
|
||
row = await run_graph(graph)
|
||
assert row.status == "succeeded", row.error
|
||
assert "转人工" in row.output
|
||
|
||
|
||
async def test_inputs_that_are_not_query_snapshots_fail_clearly(monkeypatch, shop):
|
||
FakeOps(monkeypatch)
|
||
other = node("calc", "tool", tool="json_query", args={"data": '{"a": 1}', "path": "a"})
|
||
graph = card_graph(items=[{**WOW, "from": "calc"}], extra_nodes=[other],
|
||
extra_edges=[edge("by_region", "calc"), edge("calc", "card")])
|
||
row = await run_graph(graph)
|
||
assert row.status == "failed" and "不是存成快照的查询结果" in row.error
|
||
|
||
|
||
async def test_masked_columns_reach_the_operator(monkeypatch, tmp_path, scratch_source):
|
||
await _shop(tmp_path, scratch_source, {"mask_columns": ["region"]})
|
||
ops = FakeOps(monkeypatch)
|
||
row = await run_graph(card_graph(items=[WHERE]))
|
||
assert row.status == "succeeded", row.error
|
||
assert "region" in ops.calls[0]["snapshots"]["by_region"]["masked"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 受管规则、登记处
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _governed(graph):
|
||
from app.engine.governance import lint_for_publish
|
||
|
||
return lint_for_publish(GraphSpec.model_validate(graph), level="governed").issues
|
||
|
||
|
||
def test_governed_cards_must_analyze_platform_queries():
|
||
other = node("ext", "tool", tool="crm_report", args={})
|
||
graph = card_graph(items=[{**WOW, "from": "ext"}], extra_nodes=[other],
|
||
extra_edges=[edge("by_region", "ext"), edge("ext", "card")])
|
||
[issue] = [i for i in _governed(graph) if i.code == "governed.analysis_input"]
|
||
assert issue.level == "error" and issue.node_id == "card" and issue.field == "items[0].from"
|
||
assert [i for i in _governed(card_graph()) if i.code == "governed.analysis_input"] == []
|
||
# 发布时:受管级按错误挡、画布那条同义的提醒不再列;已发布级只有画布那条提醒
|
||
from app.engine.governance import publish_issues
|
||
|
||
spec = GraphSpec.model_validate(graph)
|
||
governed = [(i.code, i.level) for i in publish_issues(spec, level="governed") if i.node_id == "card"]
|
||
assert ("governed.analysis_input", "error") in governed and ("analysis.input_invalid", "warning") not in governed
|
||
published = [(i.code, i.level) for i in publish_issues(spec, level="published") if i.node_id == "card"]
|
||
assert published == [("analysis.input_invalid", "warning")]
|
||
|
||
|
||
def test_a_caliber_card_reading_analysis_results_gets_a_hint():
|
||
from app.engine.governance import _traceable
|
||
|
||
graph = card_graph(extra_nodes=[node("kpi", "metrics", metrics=[
|
||
{"id": "share", "expression": "nodes.card.results.where.values.top_share"}])],
|
||
extra_edges=[edge("card", "kpi")])
|
||
[issue] = [i for i in _governed(graph) if i.code == "governed.caliber_analysis_input"]
|
||
assert issue.level == "warning" and issue.node_id == "card" and "追不到原始数据" in issue.message
|
||
assert _traceable(GraphSpec.model_validate(graph).node_map()["card"])
|
||
|
||
|
||
def test_plain_wording_for_the_new_codes():
|
||
from app.plain import plain_of
|
||
|
||
for code in ("governed.analysis_input", "governed.caliber_analysis_input"):
|
||
assert plain_of(code) is not None, code
|
||
|
||
|
||
async def test_a_caliber_card_reading_an_analysis_card_is_untraced(monkeypatch, shop):
|
||
from app.engine.lineage import untraced
|
||
|
||
FakeOps(monkeypatch)
|
||
graph = card_graph(extra_nodes=[node("kpi", "metrics", metrics=[
|
||
{"id": "share", "expression": "float(nodes.card.results.where.values.top_share)"}])],
|
||
extra_edges=[edge("card", "kpi")])
|
||
row = await run_graph(graph)
|
||
assert row.status == "succeeded", row.error
|
||
_, card = await output_of(row.id, "kpi")
|
||
[item] = card["metrics"][0]["inputs"]
|
||
assert item["via"] == "analysis" and item["node_id"] == "card" and untraced([item]) == [item]
|
||
|
||
|
||
def test_variables_list_each_item_and_count_the_inputs_as_references():
|
||
from app.engine.variables import analyze
|
||
|
||
report = analyze(GraphSpec.model_validate(card_graph(items=(WOW, WHERE))))
|
||
found = {v.path: v for v in report.variables}
|
||
assert {"nodes.card.results.wow", "nodes.card.results.where"} <= set(found)
|
||
assert found["nodes.card.results.wow"].produced_by == "card"
|
||
assert any(r.node_id == "card" and r.field == "items[0].from" for r in found["nodes.weekly"].refs)
|
||
assert any(r.node_id == "card" and r.field == "items[1].params.headline.base" for r in found["nodes.weekly"].refs)
|
||
assert not [i for i in report.issues if i.level != "info"]
|
||
|
||
|
||
def test_describe_impact_and_upgrade_know_the_card():
|
||
from types import SimpleNamespace
|
||
|
||
from app.apps.describe import describe_version
|
||
from app.data.catalog_impact import template_refs
|
||
from app.engine.upgrade import _EVIDENCE_TYPES
|
||
|
||
graph = card_graph(items=(WOW, WHERE))
|
||
text = describe_version(SimpleNamespace(graph=graph, version=2, graph_hash="ab" * 32, level="published"),
|
||
workflow_name="区域周报")
|
||
assert "分析卡「card」(card),分析项 2 个" in text and "card.results.where 变化拆解" in text
|
||
refs = template_refs(GraphSpec.model_validate(graph), "shop", "orders", None)
|
||
[card] = [r for r in refs if r["node_id"] == "card"]
|
||
assert card["type"] == "analysis" and card["impact"] == "direct"
|
||
assert {(v["node_id"], v["alias"]) for v in card["via"]} == {("weekly", "wow"), ("by_region", "where"),
|
||
("weekly", "where")}
|
||
assert NodeType.ANALYSIS in _EVIDENCE_TYPES
|