Files
AgentLab/backend/tests/test_catalog_impact.py

224 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""目录的影响面:改了一张表的目录,哪些已发布、受管的模板会受影响(阶段 4A)。
GET /api/datasources/{id}/catalog/{table}/impact,每个模板按它当前的已发布版本算(不是画布上的草稿):
- 调用工具节点调用这个源的 db_query__,SQL 里用到了这张表:直接引用。表名的取法和证据台账同一个口径
(evidence.sql_tables,按 name_key 对到表结构上)。
- Agent 节点(协作成员也算)绑定了这个源的查询工具:可能涉及——SQL 是运行时写的,静态看不出用不用这张表。
- 合并查询节点按它的输入追溯:输入里有直接引用的,它也算直接引用,并写明经由哪个输入。
- 没发布过的草稿、别的源的同名表、只在未发布草稿里用到的,都不算。
"""
from __future__ import annotations
import uuid
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from app.data.engine import engines
from app.data.introspect import introspect
# 模型要在建表之前注册进 metadata:conftest 的建表只导入了 app.db.base,这个文件单独跑时没有别的模块先导入它们
from app.db import models # noqa: F401
from tests.fixtures.sources import drop_source
_CACHE: dict[str, dict] = {}
async def _schema(path: str) -> dict:
if path not in _CACHE:
probe = SimpleNamespace(id=f"probe-{uuid.uuid4().hex[:6]}", name="scenic", kind="sqlite", database=path,
host=None, port=None, username=None, password=None, options={}, readonly=True,
description="", schema_cache={}, origin="manual")
try:
_CACHE[path] = await introspect(probe)
finally:
await engines.invalidate(probe.id)
return _CACHE[path]
def _node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "data": {"label": label or nid, "config": config}}
def _graph(*nodes):
nodes = [_node("start", "input"), *nodes, _node("out", "output", fields=[])]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def _query(nid, source, sql, label=None):
return _node(nid, "tool", label, tool=f"db_query__{source}", args={"sql": sql})
@pytest.fixture
async def world(scenic_db):
"""一个景区数据源、另一个同结构的源,和几份模板;用完全部删掉。"""
from app.db.base import SessionLocal
from app.db.models import DataSource, Workflow, WorkflowVersion
tag = uuid.uuid4().hex[:8]
src, other = f"scenic_{tag}", f"mirror_{tag}"
created: list[str] = []
async with SessionLocal() as session:
rows = []
for name in (src, other):
row = DataSource(name=name, kind="sqlite", database=scenic_db, readonly=True)
row.schema_cache = await _schema(scenic_db)
session.add(row)
rows.append(row)
await session.commit()
async def template(name, versions, *, published=None, level="published"):
wf = Workflow(name=f"{name}-{tag}", graph=versions[-1], version=len(versions),
status=level if published else "draft", published_version=published)
session.add(wf)
await session.flush()
for i, g in enumerate(versions, start=1):
session.add(WorkflowVersion(workflow_id=wf.id, version=i, graph=g,
level=level if published == i else None))
created.append(wf.id)
return wf
counted = _query("q", src, "SELECT COUNT(*) FROM main.VISITS v WHERE v.status = 1", "查询入园人数")
direct = await template("入园日报", [_graph(counted)], published=1)
agent = await template("入园分析", [_graph(_node("ask", "agent", "分析入园", prompt="分析",
tools=[f"db_query__{src}", f"db_schema__{src}"]))],
published=1, level="governed")
team = await template("协作分析", [_graph(_node("team", "supervisor", "协作", agents=[
{"name": "analyst", "tools": [f"db_query__{src}"]}]))], published=1)
merged = await template("渠道合并", [_graph(
_query("q1", src, "SELECT id, park_id FROM visits", "入园"),
_query("q2", src, "SELECT id, channel_id FROM orders", "订单"),
_node("m", "merge", "合并入园和订单", inputs={"a": "q1", "b": "q2"}, sql="SELECT * FROM a JOIN b ON a.id = b.id"),
_node("m2", "merge", "再合并", inputs={"x": "m"}, sql="SELECT * FROM x"),
)], published=1)
# 不算的:草稿、别的源的同名表、只在未发布的新版本里用到
await template("草稿", [_graph(_query("q", src, "SELECT * FROM visits"))])
await template("镜像", [_graph(_query("q", other, "SELECT * FROM visits"))], published=1)
await template("后来才用", [_graph(_query("q", src, "SELECT * FROM orders")),
_graph(_query("q", src, "SELECT * FROM visits"))], published=1)
# 已发布的是旧版本:画布上后来删掉了这张表,影响面仍按已发布的那一版算
stale = await template("改过画布", [_graph(_query("q", src, "SELECT * FROM visits")),
_graph(_query("q", src, "SELECT * FROM orders"))], published=1)
await session.commit()
ids = SimpleNamespace(source=rows[0].id, other=rows[1].id, src=src, direct=direct.id, agent=agent.id,
team=team.id, merged=merged.id, stale=stale.id)
yield ids
async with SessionLocal() as session:
for wid in created:
if (wf := await session.get(Workflow, wid)) is not None:
await session.delete(wf)
await session.commit()
for sid in (ids.source, ids.other):
await drop_source(sid)
async def _impact(source_id: str, table: str) -> dict:
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.get(f"/api/datasources/{source_id}/catalog/{table}/impact")
assert r.status_code == 200, r.text
return r.json()
async def test_impact_classifies_direct_possible_and_merge(world):
out = await _impact(world.source, "visits")
assert out["table"] == "visits"
by_id = {t["workflow_id"]: t for t in out["templates"]}
assert set(by_id) == {world.direct, world.agent, world.team, world.merged, world.stale}
direct = by_id[world.direct]
assert direct["impact"] == "direct" and direct["version"] == 1 and direct["level"] == "published"
assert [(n["node_id"], n["label"], n["impact"]) for n in direct["nodes"]] == [("q", "查询入园人数", "direct")]
agent = by_id[world.agent]
assert agent["impact"] == "possible" and agent["level"] == "governed"
assert [(n["node_id"], n["impact"]) for n in agent["nodes"]] == [("ask", "possible")]
assert by_id[world.team]["impact"] == "possible"
assert by_id[world.team]["nodes"][0]["member"] == "analyst"
merged = by_id[world.merged]
assert merged["impact"] == "direct"
nodes = {n["node_id"]: n for n in merged["nodes"]}
assert set(nodes) == {"q1", "m", "m2"} # 查订单的 q2 不涉及这张表
assert nodes["m"]["impact"] == "direct" and nodes["m"]["via"] == [{"node_id": "q1", "label": "入园", "alias": "a"}]
# 合并的合并:一路追到底
assert nodes["m2"]["impact"] == "direct" and nodes["m2"]["via"] == [{"node_id": "m", "label": "合并入园和订单",
"alias": "x"}]
# 直接引用的排在前面
impacts = [t["impact"] for t in out["templates"]]
assert impacts == sorted(impacts, key=lambda i: i != "direct")
async def test_impact_of_another_table(world):
out = await _impact(world.source, "orders")
by_id = {t["workflow_id"]: t for t in out["templates"]}
# 已发布的旧版本只查了入园记录:画布上后来改成查订单,不算
assert world.stale not in by_id
assert by_id[world.merged]["impact"] == "direct"
assert {n["node_id"] for n in by_id[world.merged]["nodes"]} == {"q2", "m", "m2"}
# 只绑了查询工具的 Agent 对每张表都是「可能涉及」
assert by_id[world.agent]["impact"] == "possible"
assert world.direct not in by_id
async def test_impact_unknown_table_is_404(world):
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.get(f"/api/datasources/{world.source}/catalog/ghost_table/impact")
assert r.status_code == 404
def test_graph_sql_tables_uses_the_evidence_rules():
"""发布时记目录版本和影响面共用这一份:只看调用工具节点写死的 SQL,表名按 name_key 对到表结构上。"""
from app.data.catalog_impact import graph_sql_tables
from app.engine.schema import GraphSpec
spec = GraphSpec.model_validate(_graph(
_query("q1", "shop", "SELECT * FROM main.Orders o JOIN order_items i ON i.order_id = o.id -- FROM ghost"),
_query("q2", "shop", "WITH t AS (SELECT 1) SELECT * FROM t, customers"),
_query("q3", "other", "SELECT * FROM orders"),
_node("ask", "agent", tools=["db_query__shop"]),
))
tables = {"shop": {"orders": {}, "order_items": {}, "customers": {}}, "other": {"orders": {}}}
assert graph_sql_tables(spec, tables) == {"shop": {"orders", "order_items", "customers"}, "other": {"orders"}}
def test_assistant_seeds_read_tool_nodes_the_same_way():
"""助手概览里的「现有工作流用到的表」、发布前修复和升级证据由系统代为查看的表(look 文档 D5),和发布记录、SQL
检查认的是同一种节点(sqlcheck.tool_query)。
画布上把调用工具节点换成别的类型,配置里的 tool、args 还留着:那已经不是一条会执行的查询,不能当成用到的表。
"""
from app.look.tables import graph_tables
shop = SimpleNamespace(name="shop", schema_cache={"tables": {
"orders": {"qualified": "orders"}, "customers": {"qualified": "customers"}, "stores": {"qualified": "stores"}}})
graph = _graph(
_query("q1", "shop", "SELECT * FROM customers c JOIN orders o ON o.customer_id = c.id"),
# 换成了 Agent 的节点:残留的写死查询不算
_node("ask", "agent", tool="db_query__shop", args={"sql": "SELECT * FROM stores"}, tools=["db_query__shop"]),
# GraphSpec 的写法(config 在节点上)照样认
{"id": "q2", "type": "tool", "config": {"tool": "db_query__shop", "args": {"sql": "SELECT * FROM stores"}}},
_query("q3", "shop", " "),
)
assert graph_tables(graph, [shop]) == {"shop": ["customers", "orders", "stores"]}
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != "q2"]
assert graph_tables(graph, [shop]) == {"shop": ["customers", "orders"]}
def test_recorded_versions_reads_both_shapes():
"""发布记录两种写法:现在的 {direct, possible},和可能涉及上线之前只记直接引用的 {源名: {表名: 版本}}。
源名可以就叫 direct / possible,靠第二层是不是版本号分开。"""
from app.data.catalog_impact import recorded_versions
now = {"direct": {"shop": {"orders": 2}}, "possible": {"shop": {"customers": 1}, "crm": {}}}
assert recorded_versions(now) == now
assert recorded_versions({"direct": {}, "possible": {}}) == {"direct": {}, "possible": {}}
assert recorded_versions({"shop": {"orders": 2}}) == {"direct": {"shop": {"orders": 2}}, "possible": {}}
# 旧写法里一个叫 direct 的源
assert recorded_versions({"direct": {"orders": 2}}) == {"direct": {"direct": {"orders": 2}}, "possible": {}}
assert recorded_versions({}) == {"direct": {}, "possible": {}}
assert recorded_versions(None) is None and recorded_versions("x") is None and recorded_versions({"shop": 3}) is None