mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:11:58 +00:00
295 lines
15 KiB
Python
295 lines
15 KiB
Python
"""助手搭完图的自查接上基于数据目录的 SQL 检查(data/sqlcheck.py)。
|
|
|
|
- 调用工具节点里写死的 SQL 走现有的自查:error 级(一对多关联后重复计算、存量跨期求和)交回模型改,进修正轮;
|
|
warning、info 不返工,放进 final 的问题清单。
|
|
- Agent 节点的 SQL 要到运行时才由模型写出来,静态拿不到,不查(运行时由数据源查询工具检查)。
|
|
- 这一轮没碰的节点不查:用户自己写的 SQL 不替他改(和列名核对同一个规矩)。
|
|
- 没有目录的数据源什么都不查。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.data import catalog
|
|
from app.data.engine import engines
|
|
from app.data.introspect import introspect
|
|
from app.data.sqlcheck import SqlChecker
|
|
from tests.fixtures.catalog import scenic_notes
|
|
from tests.fixtures.sources import drop_source
|
|
|
|
FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id "
|
|
"WHERE o.status = 1")
|
|
#: 改好的:对明细求和;没按订单状态筛,留一条 warning
|
|
FIXED_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id"
|
|
CLEAN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1"
|
|
|
|
_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 _spec(*nodes):
|
|
from app.engine.schema import GraphSpec
|
|
|
|
return GraphSpec.model_validate({"nodes": list(nodes), "edges": [
|
|
{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]})
|
|
|
|
|
|
def _node(nid, ntype, **config):
|
|
return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}}
|
|
|
|
|
|
def _fetch(sql, source="scenic"):
|
|
return _node("fetch", "tool", tool=f"db_query__{source}", args={"sql": sql})
|
|
|
|
|
|
async def _checkers(scenic_db, status="confirmed"):
|
|
cache = await _schema(scenic_db)
|
|
source = SimpleNamespace(id="s1", name="scenic", kind="sqlite", schema_cache=cache)
|
|
checker = SqlChecker(kind="sqlite", schema_cache=cache, notes=scenic_notes.notes(status), source_name="scenic")
|
|
return source, {"scenic": checker}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# authored_issues / _blocking_issues
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_sql_check_errors_block_and_warnings_do_not(scenic_db):
|
|
from app.api.copilot import _blocking_issues, authored_issues
|
|
|
|
source, checkers = await _checkers(scenic_db)
|
|
spec = _spec(_node("start", "input"), _fetch(FANOUT_SQL), _node("out", "output", fields=[]))
|
|
[issue] = authored_issues(spec, [source], checkers=checkers)
|
|
assert issue["code"] == "fanout_sum" and issue["level"] == "error"
|
|
assert issue["node_id"] == "fetch" and issue["field"] == "args.sql"
|
|
assert issue["table"] == "orders" and issue["column"] == "total_amount" and issue["sql_excerpt"]
|
|
assert issue["message"].startswith("「订单」关联「订单明细」是一对多") and "order_items" in issue["for_model"]
|
|
nodes = {n.id: n.model_dump(mode="json") for n in spec.nodes}
|
|
assert [i["code"] for i in _blocking_issues(nodes, [], sources=[source], checkers=checkers)] == ["fanout_sum"]
|
|
|
|
# 只有 warning:不挡(不返工),但 authored_issues 照样带出来,收尾时放进 final
|
|
warn = _spec(_node("start", "input"), _fetch(FIXED_SQL), _node("out", "output", fields=[]))
|
|
[w] = authored_issues(warn, [source], checkers=checkers)
|
|
assert (w["code"], w["level"]) == ("missing_valid_filter", "warning")
|
|
nodes = {n.id: n.model_dump(mode="json") for n in warn.nodes}
|
|
assert _blocking_issues(nodes, [], sources=[source], checkers=checkers) == []
|
|
|
|
# 依据只是推断:降为 info,也不挡
|
|
_, proposed = await _checkers(scenic_db, "proposed")
|
|
assert {i["level"] for i in authored_issues(spec, [source], checkers=proposed)} == {"info"}
|
|
# 不给检查器(没有目录):和以前一样什么都不查
|
|
assert authored_issues(spec, [source]) == []
|
|
|
|
|
|
async def test_unknown_columns_come_first(scenic_db):
|
|
"""列名都对不上的 SQL 先改列名:这时不再叠加目录检查的结果,免得一轮里塞给模型两套说法。"""
|
|
from app.api.copilot import authored_issues
|
|
|
|
source, checkers = await _checkers(scenic_db)
|
|
bad = ("SELECT SUM(o.total_amt) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id "
|
|
"WHERE o.status = 1")
|
|
spec = _spec(_node("start", "input"), _fetch(bad), _node("out", "output", fields=[]))
|
|
assert [i["code"] for i in authored_issues(spec, [source], checkers=checkers)] == ["sql_unknown_column"]
|
|
|
|
|
|
async def test_agent_prompts_and_untouched_nodes_are_not_checked(scenic_db):
|
|
from app.api.copilot import authored_issues
|
|
|
|
source, checkers = await _checkers(scenic_db)
|
|
agent = _node("ask", "agent", prompt=f"用这条 SQL 查:{FANOUT_SQL}", tools=["db_query__scenic"])
|
|
spec = _spec(_node("start", "input"), agent, _node("out", "output", fields=[]))
|
|
assert authored_issues(spec, [source], checkers=checkers) == []
|
|
# 用户自己写的、这一轮没改的 SQL 不替他改
|
|
fetch = _fetch(FANOUT_SQL)
|
|
untouched = _spec(_node("start", "input"), fetch, _node("out", "output", fields=[]))
|
|
assert authored_issues(untouched, [source], baseline=[fetch], checkers=checkers) == []
|
|
# 模板占位照常检查其余部分
|
|
templated = _fetch("SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id "
|
|
"WHERE o.status = 1 AND o.ordered_at >= '{{ input.since }}'")
|
|
out = authored_issues(_spec(_node("start", "input"), templated, _node("out", "output", fields=[])), [source],
|
|
checkers=checkers)
|
|
assert [i["code"] for i in out] == ["fanout_sum"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 端到端:假模型搭图,error 进修正轮,warning 留在 final
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class _Scripted:
|
|
"""第一轮吐 first;收到自查请求(human 里有「上线前自查」)时吐 fix。"""
|
|
|
|
def __init__(self, first, fix):
|
|
self.first, self.fix = first, fix
|
|
self.calls: list[str] = []
|
|
|
|
async def astream(self, messages):
|
|
from langchain_core.messages import AIMessageChunk
|
|
|
|
human = next(text for role, text in messages if role == "human")
|
|
self.calls.append(human)
|
|
for op in (self.fix if "上线前自查" in human else self.first):
|
|
yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def _ops(source, sql):
|
|
return [
|
|
{"op": "plan", "summary": "查订单金额"},
|
|
{"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}},
|
|
{"op": "add_node", "node": {"id": "fetch", "type": "tool", "label": "取数",
|
|
"config": {"tool": f"db_query__{source}", "args": {"sql": sql}}}},
|
|
{"op": "add_edge", "edge": {"source": "start", "target": "fetch"}},
|
|
{"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果",
|
|
"config": {"fields": [{"name": "r", "value": "{{ nodes.fetch }}"}]}}},
|
|
{"op": "add_edge", "edge": {"source": "fetch", "target": "out"}},
|
|
{"op": "done", "explanation": "查一下", "run": True},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
async def scenic_source(scenic_db):
|
|
"""库里一个带目录的景区数据源:表结构真实探查,目录按 scenic_notes 写进 catalog_notes。"""
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import DataSource
|
|
|
|
name = f"scenic_{uuid.uuid4().hex[:8]}"
|
|
async with SessionLocal() as session:
|
|
row = DataSource(name=name, kind="sqlite", database=scenic_db, readonly=True)
|
|
row.schema_cache = await _schema(scenic_db)
|
|
session.add(row)
|
|
await session.commit()
|
|
for table, notes in scenic_notes.notes().items():
|
|
await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏")
|
|
source_id = row.id
|
|
yield name
|
|
await drop_source(source_id)
|
|
|
|
|
|
async def _stream(monkeypatch, model, source):
|
|
import app.api.copilot as copilot
|
|
from app.main import app
|
|
|
|
async def _model(*_a, **_k):
|
|
return model, "mock-fast"
|
|
|
|
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
|
events: list[dict] = []
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
async with client.stream("POST", "/api/copilot/generate-stream",
|
|
json={"instruction": "上周订单金额多少", "intent": "answer",
|
|
"datasource_ids": [source]}) as r:
|
|
async for line in r.aiter_lines():
|
|
if line.startswith("data: "):
|
|
events.append(json.loads(line[6:]))
|
|
return events
|
|
|
|
|
|
async def test_fanout_is_sent_back_and_fixed_before_running(scenic_source, monkeypatch):
|
|
model = _Scripted(_ops(scenic_source, FANOUT_SQL),
|
|
[{"op": "update_node", "id": "fetch", "config": {"args": {"sql": FIXED_SQL}}},
|
|
{"op": "done", "explanation": "改好了"}])
|
|
events = await _stream(monkeypatch, model, scenic_source)
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "passed"], checks
|
|
[issue] = checks[0]["issues"]
|
|
assert issue["code"] == "fanout_sum" and issue["node_id"] == "fetch"
|
|
# 交回模型的是 for_model:写 SQL 里的真名和改法
|
|
assert "orders 关联 order_items 是一对多" in model.calls[1] and "「fetch」" in model.calls[1]
|
|
final = events[-1]
|
|
assert final["op"] == "final" and final["autorun"] is True
|
|
sql = next(n for n in final["graph"]["nodes"] if n["id"] == "fetch")["data"]["config"]["args"]["sql"]
|
|
assert sql == FIXED_SQL
|
|
# 剩下的 warning 不返工,留在 final 里看得见
|
|
[warn] = [i for i in final["issues"] if i.get("code") == "missing_valid_filter"]
|
|
assert warn["level"] == "warning" and warn["node_id"] == "fetch" and "status = 1" in warn["message"]
|
|
assert not [i for i in final["issues"] if i["level"] == "error"]
|
|
|
|
|
|
async def test_a_fanout_that_is_not_fixed_stops_autorun(scenic_source, monkeypatch):
|
|
model = _Scripted(_ops(scenic_source, FANOUT_SQL),
|
|
[{"op": "update_node", "id": "fetch", "config": {"args": {"sql": FANOUT_SQL + " "}}},
|
|
{"op": "done", "explanation": "改了"}])
|
|
events = await _stream(monkeypatch, model, scenic_source)
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks
|
|
final = events[-1]
|
|
assert final["autorun"] is False
|
|
assert [i["code"] for i in final["issues"] if i["level"] == "error"] == ["fanout_sum"]
|
|
|
|
|
|
async def test_clean_sql_costs_no_extra_call(scenic_source, monkeypatch):
|
|
model = _Scripted(_ops(scenic_source, CLEAN_SQL), [])
|
|
events = await _stream(monkeypatch, model, scenic_source)
|
|
assert len(model.calls) == 1
|
|
assert [e["status"] for e in events if e["op"] == "check"] == ["passed"]
|
|
final = events[-1]
|
|
assert final["autorun"] is True
|
|
assert not [i for i in final["issues"] if i.get("code") in ("fanout_sum", "missing_valid_filter")]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 一键升级的语义层(assist_upgrade):自查和最终判断同样带 SQL 检查器,和发布门禁一个口径
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _upgrade_graph(source: str) -> dict:
|
|
"""查库 → 沙箱代码算比率 → 口径卡读代码的产出 → 报告撰写 → 出口。查库的 SQL 本身没问题。"""
|
|
def n(nid, ntype, x, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": x, "y": 80}, "data": {"label": nid, "config": config}}
|
|
|
|
nodes = [
|
|
n("start", "input", 0),
|
|
n("fetch", "tool", 280, tool=f"db_query__{source}", args={"sql": CLEAN_SQL}),
|
|
n("calc", "code", 560, language="python", assign_to="calc", code="import json\nprint(json.dumps({'ratio': 3 / 4}))"),
|
|
n("card", "metrics", 840, metrics=[{"id": "ratio", "name": "客单价", "expression": "vars.calc.ratio"}]),
|
|
n("write", "report", 1120, instructions="写一句话"),
|
|
n("out", "output", 1400, fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
|
|
]
|
|
return {"nodes": nodes, "edges": [{"id": f"e{i}", "source": a["id"], "target": b["id"]}
|
|
for i, (a, b) in enumerate(zip(nodes, nodes[1:]))]}
|
|
|
|
|
|
async def test_upgrade_assist_checks_sql_like_the_publish_gate(scenic_source, monkeypatch):
|
|
"""助手改写口径卡时顺手把查询改成了一对多关联后求和:受管级别的发布门禁会挡,升级的自查也得交回去改,
|
|
改不好就作废——以前自查和最终判断都没带 SQL 检查器,这样的改写会被采纳,交出去的图发布时才被拦。"""
|
|
import app.api.copilot as copilot
|
|
from app.db.base import SessionLocal
|
|
|
|
first = [{"op": "plan", "summary": "把纯算术的代码挪进口径卡"},
|
|
{"op": "update_node", "id": "card", "config": {"metrics": [
|
|
{"id": "ratio", "name": "客单价", "expression": "cell(nodes.fetch, 0, 'gmv') / 4"}]}},
|
|
{"op": "update_node", "id": "fetch", "config": {"args": {"sql": FANOUT_SQL}}},
|
|
{"op": "done", "explanation": "客单价改由口径卡直接算"}]
|
|
fix = [{"op": "update_node", "id": "fetch", "config": {"args": {"sql": FANOUT_SQL + " "}}},
|
|
{"op": "done", "explanation": "改了"}]
|
|
model = _Scripted(first, fix)
|
|
|
|
async def _model(*_a, **_k):
|
|
return model, "mock-fast"
|
|
|
|
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
|
async with SessionLocal() as session:
|
|
out = await copilot.assist_upgrade(session, _upgrade_graph(scenic_source), level="governed")
|
|
# 自查把一对多求和交回模型,带着给模型的改法
|
|
assert len(model.calls) >= 2 and "上线前自查" in model.calls[1]
|
|
assert "orders 关联 order_items 是一对多" in model.calls[1]
|
|
# 改不好:整个作废,原因写的是这条 SQL 检查
|
|
assert out["accepted"] is False and out["ok"] is False
|
|
assert "一对多" in (out["reason"] or ""), out["reason"]
|