mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
220 lines
11 KiB
Python
220 lines
11 KiB
Python
"""报告文档里系统写的「分析范围」(engine/analysis_scope.py,00-总方案 §3.6,契约 contract.ScopeBlock)。
|
||
|
||
- 现象、拆过(按下钻链)、排除(分散、差异可能只是样本波动)、算不了、检验次数
|
||
- 没拆:候选维度确定地从数据目录算——拆解所用查询的事实表上标成属性、状态、不遮罩、取值不多的列,加上它关联的维度表;
|
||
拆解按的列对不上目录、读不到目录、认不出事实表时只说核对不了,不乱列
|
||
- agent 写的假设不进这一段
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
import uuid
|
||
|
||
import pytest
|
||
|
||
from app.analysis.testing import fake_result
|
||
from app.data import catalog as data_catalog
|
||
from app.data.catalog import make_item
|
||
from app.data.catalog_view import CatalogView
|
||
from app.data.introspect import introspect
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource
|
||
from app.engine import analysis_scope as scope
|
||
from app.engine.evidence import make_eid
|
||
from tests.fixtures.sources import drop_source
|
||
|
||
SQL = "SELECT o.region, o.week, SUM(o.amount) AS gmv FROM orders o JOIN stores s ON s.id = o.store_id GROUP BY 1, 2"
|
||
SNAP = "1" * 64
|
||
SNAPSHOTS = {SNAP: {"source": "shop", "sql": SQL, "columns": ["region", "week", "gmv"], "rows": []}}
|
||
|
||
|
||
def codes(*values: str, complete: bool = False) -> dict:
|
||
item = make_item({v: "" for v in values}, "profile", "verified")
|
||
return {**item, "complete": True} if complete else item
|
||
|
||
|
||
def column(label: str, measure: str | None = None, codes_item: dict | None = None) -> dict:
|
||
out = {"label": make_item(label, "human")}
|
||
if measure:
|
||
out["measure"] = make_item(measure, "human")
|
||
if codes_item:
|
||
out["codes"] = codes_item
|
||
return out
|
||
|
||
|
||
NOTES = {
|
||
"orders": {
|
||
"kind": make_item("fact", "human"),
|
||
"columns": {
|
||
"region": column("地区", "attribute", codes("华东", "华南", "华北", "西南")),
|
||
"channel": column("渠道", "attribute", codes("线上", "线下")),
|
||
"status": column("订单状态", "status", codes("1", "2", "9", complete=True)),
|
||
"member_level": column("会员等级", "attribute"), # 没有码值:取值多不多说不清
|
||
"customer_id": column("客户编号", "identifier", codes("c1", "c2")),
|
||
"phone": column("手机号", "attribute", codes("1", "2")), # 遮罩列
|
||
"amount": column("实收金额", "flow"),
|
||
"week": column("周", "attribute", codes(*(f"W{i}" for i in range(60)))), # 取值太多
|
||
},
|
||
"relations": [{"id": "r1", "columns": ["store_id"], "to_table": "stores", "to_columns": ["id"],
|
||
"cardinality": "many_to_one", "source": "fk", "status": "verified"}],
|
||
},
|
||
"stores": {
|
||
"kind": make_item("dimension", "human"),
|
||
"columns": {"store_type": column("门店类型", "attribute", codes("旗舰", "社区")),
|
||
"city": column("城市", "attribute", codes("上海", "杭州"))},
|
||
},
|
||
}
|
||
SOURCES = {"shop": {"tables": NOTES, "masked": ["phone"]}}
|
||
|
||
|
||
def entry(alias: str, result: dict) -> dict:
|
||
artifact = "a" * 63 + alias[-1]
|
||
return {"alias": alias, "kind": "analysis", "artifact": artifact, "eid": make_eid("analysis", artifact, {}),
|
||
"locator": {}, "result": result}
|
||
|
||
|
||
def data(snapshot: str = SNAP) -> list[dict]:
|
||
return [{"ref": "Q1", "artifact": snapshot, "role": "data", "columns": ["region", "week", "gmv"], "rows": "all"}]
|
||
|
||
|
||
def build(catalog: dict, *, sources=SOURCES, snapshots=SNAPSHOTS):
|
||
def load(artifact):
|
||
if artifact not in snapshots:
|
||
raise ValueError("取不回来")
|
||
return snapshots[artifact]
|
||
|
||
return scope.build(catalog, sources=sources, load=load)
|
||
|
||
|
||
def test_no_conclusions_no_scope():
|
||
assert scope.build({"m:gmv": {"kind": "metric"}}, sources=SOURCES) is None
|
||
|
||
|
||
def test_the_sections_follow_the_verdicts():
|
||
region = fake_result(inputs=data())
|
||
channel = fake_result(verdict="dispersed", inputs=[*data(), {"ref": "A1", "artifact": "a" * 63 + "1",
|
||
"role": "parent"}],
|
||
params={"by": ["channel"], "label": "销售额"}, hypothesis="华东是不是线上拖累的")
|
||
channel["table"]["columns"][0] = "channel"
|
||
channel["table"]["labels"] = {"channel": "渠道"}
|
||
catalog = {
|
||
"A1": entry("A1", region),
|
||
"A2": entry("A2", fake_result("baseline", None, "outside_band")),
|
||
"A3": entry("A3", channel),
|
||
"A4": entry("A4", fake_result("differ", "rate", "no_evidence")),
|
||
"A5": entry("A5", fake_result("trend", None, None, status="refused", reason_code="history_short")),
|
||
"A6": entry("A6", fake_result("differ", "rate", "differs")),
|
||
}
|
||
block = build(catalog)
|
||
assert [i["alias"] for i in block["phenomena"]] == ["A2"]
|
||
assert [(i["alias"], i["parent"], i["dims"], i["verdict"]) for i in block["decomposed"]] == [
|
||
("A1", None, ["地区"], "driver"), ("A3", "A1", ["渠道"], "dispersed")]
|
||
assert [i["alias"] for i in block["excluded"]] == ["A3", "A4"]
|
||
assert [(i["alias"], i["reason"]) for i in block["refused"]] == [("A5", "历史只有 5 期,至少要 8 期")]
|
||
assert block["tests"] == 2 and block["corrected"] is False
|
||
assert block["decomposed"][0]["phrase"] == region["phrase"]
|
||
assert "华东是不是线上拖累的" not in str(block), "agent 写的假设不进分析范围"
|
||
|
||
|
||
def test_drill_chains_keep_each_parent_ahead_of_its_children():
|
||
def child(of: str) -> dict:
|
||
return fake_result(inputs=[*data(), {"ref": of, "artifact": "a" * 63 + of[-1], "role": "parent"}])
|
||
|
||
catalog = {"A1": entry("A1", fake_result(inputs=data())), "A2": entry("A2", fake_result(inputs=data())),
|
||
"A3": entry("A3", child("A1")), "A4": entry("A4", child("A3"))}
|
||
assert [i["alias"] for i in build(catalog)["decomposed"]] == ["A1", "A3", "A4", "A2"]
|
||
|
||
|
||
def test_candidate_dimensions_come_from_the_fact_table_and_its_dimensions():
|
||
block = build({"A1": entry("A1", fake_result(inputs=data()))})
|
||
# 地区拆过了;会员等级没有码值、客户编号是标识、手机号遮罩、金额是流量、周取值太多:都不是候选
|
||
assert block["not_decomposed"] == ["渠道", "订单状态", "门店类型", "城市"] and block["note"] is None
|
||
assert block["tests"] == 0 and block["corrected"] is None
|
||
|
||
|
||
def test_a_dimension_that_does_not_match_the_catalog_is_reported_not_guessed():
|
||
result = fake_result(inputs=data(), params={"by": ["地区名"], "label": "销售额"})
|
||
result["table"]["columns"][0] = "地区名"
|
||
result["table"]["labels"] = {"地区名": "地区名"}
|
||
block = build({"A1": entry("A1", result)})
|
||
assert block["not_decomposed"] is None and block["note"] == "候选维度核对不了:拆解按的「地区名」对不上数据目录里的列"
|
||
|
||
|
||
@pytest.mark.parametrize("kwargs, note", [
|
||
({"sources": None}, scope.NOTE_NO_CATALOG),
|
||
({"sources": {"shop": {"tables": {}, "masked": []}}}, scope.NOTE_NO_NOTES),
|
||
({"sources": {}}, scope.NOTE_NO_CATALOG),
|
||
({"snapshots": {}}, scope.NOTE_NO_QUERY),
|
||
({"sources": {"shop": {"tables": {"orders": {"columns": NOTES["orders"]["columns"]},
|
||
"stores": NOTES["stores"]}, "masked": []}}}, scope.NOTE_NO_FACT),
|
||
])
|
||
def test_what_cannot_be_checked_is_said_plainly(kwargs, note):
|
||
block = build({"A1": entry("A1", fake_result(inputs=data()))}, **kwargs)
|
||
assert block["not_decomposed"] is None and block["note"] == note
|
||
assert [i["alias"] for i in block["decomposed"]] == ["A1"], "核对不了的只是候选维度,别的照写"
|
||
|
||
|
||
def test_a_single_unlabelled_table_counts_as_the_fact_table():
|
||
notes = {"orders": {k: v for k, v in NOTES["orders"].items() if k != "kind"}}
|
||
sql = "SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region"
|
||
block = build({"A1": entry("A1", fake_result(inputs=data()))}, sources={"shop": {"tables": notes, "masked": []}},
|
||
snapshots={SNAP: {**SNAPSHOTS[SNAP], "sql": sql}})
|
||
assert block["not_decomposed"] == ["渠道", "订单状态", "手机号"]
|
||
|
||
|
||
def test_without_a_dimension_decomposition_the_candidates_are_not_checked():
|
||
block = build({"A1": entry("A1", fake_result("baseline", None, "within_band", inputs=data()))})
|
||
assert block["not_decomposed"] is None and block["note"] == scope.NOTE_NO_DECOMPOSE
|
||
|
||
|
||
@pytest.fixture
|
||
async def shop(tmp_path):
|
||
path = tmp_path / "scope_shop.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, channel TEXT, store_id INTEGER, "
|
||
"phone TEXT, amount REAL, week TEXT);"
|
||
"CREATE TABLE stores (id INTEGER PRIMARY KEY, store_type TEXT, city TEXT);")
|
||
db.commit()
|
||
db.close()
|
||
async with SessionLocal() as session:
|
||
row = DataSource(name=f"scope_{uuid.uuid4().hex[:6]}", kind="sqlite", database=str(path),
|
||
options={"mask_columns": "phone"})
|
||
session.add(row)
|
||
await session.commit()
|
||
row.schema_cache = await introspect(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
for table, notes in NOTES.items():
|
||
await data_catalog.write_entry(session, row.id, table, notes, if_version=0, actor=None)
|
||
session.expunge(row)
|
||
yield row
|
||
await drop_source(row.id)
|
||
|
||
|
||
async def test_the_report_reads_the_catalog_through_the_run_view(shop):
|
||
facts = await scope.source_facts([shop.name, "nope"])
|
||
assert set(facts) == {shop.name} and facts[shop.name]["masked"] == ["phone"]
|
||
assert set(facts[shop.name]["tables"]) == {"orders", "stores"}
|
||
assert await scope.source_facts([shop.name], CatalogView(mode="off")) is None
|
||
|
||
snapshot = {**SNAPSHOTS[SNAP], "source": shop.name}
|
||
catalog = {"Q1": {"alias": "Q1", "kind": "query", "artifact": SNAP, "source": shop.name},
|
||
"A1": entry("A1", fake_result(inputs=data()))}
|
||
block = await scope.scope_of(catalog, None, load=lambda a: snapshot)
|
||
assert block["not_decomposed"] == ["渠道", "订单状态", "门店类型", "城市"]
|
||
block = await scope.scope_of(catalog, {"mode": "off"}, load=lambda a: snapshot)
|
||
assert block["not_decomposed"] is None and block["note"] == scope.NOTE_NO_CATALOG
|
||
|
||
|
||
def test_an_unmaintained_single_table_catalog_says_so_instead_of_a_column_mismatch():
|
||
"""V15(验收复核):只读一张表、目录还没维护时,_fact_tables 会兜底认出那张表,于是「对不上数据目录里的列」这条
|
||
把「目录没维护」说成了数据对不上。目录一列说明都没有时照实说目录没维护。"""
|
||
from app.engine.analysis_scope import NOTE_NOT_MAINTAINED, _undecomposed
|
||
|
||
result = {"op": "decompose", "mode": "additive", "inputs": [{"role": "data", "artifact": "a" * 64}],
|
||
"params": {"by": ["channel"]}, "table": {"columns": ["channel", "base"], "labels": {"channel": "渠道"}}}
|
||
sources = {"源": {"tables": {"orders": {}}, "masked": []}}
|
||
snapshots = {"a" * 64: {"source": "源", "sql": "SELECT channel, SUM(amount) AS amt FROM orders GROUP BY channel",
|
||
"columns": ["channel", "amt"]}}
|
||
assert _undecomposed([result], {}, sources, snapshots.get) == (None, NOTE_NOT_MAINTAINED)
|