Files
AgentLab/backend/tests/test_datasource_preview.py
T
ThearchyHelios 587bf7e27c 第三波前两批:六个「看着成功其实没干活」的平台漏洞堵上,记录页能回放,管理页与画布收尾
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」,
校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮,
轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。

平台可信度(先写失败测试)
- Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则,
  工具集合变小时自查报警、改图回执列出变化
- 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修
- 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量
- 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因
- 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员
- 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行

后端
- 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、
  SQLite progress handler),可按数据源配置;超时报错与实际耗时一致
- 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存;
  自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存;
  检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors

前端
- 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时,
  一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子
- 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚;
  回放用量取那一刻的值
- 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口
- 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对
- 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条
- 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问

验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿;
check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
2026-09-27 10:16:32 +08:00

137 lines
6.2 KiB
Python

"""换个 schema 看看,不擅自改缓存;表结构给结构化的列;卡片知道缓存是按哪个 schema 探的。
「换个 schema 看看」以前一探就把缓存换成那个 schema 的结构——哪怕用户随后选了
「只看看,不改」,助手此刻看到的已经是另一套表了。前端只好再探一次配置里的
schema 把缓存换回来,而那一次要是失败,缓存和配置就对不上,只剩一条会消失的 toast。
表结构以前只有一段给模型看的文本,前端照着文本格式去解析列,格式一改就全乱。
"""
from __future__ import annotations
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def source(client, tmp_path):
path = tmp_path / "preview.db"
db = sqlite3.connect(path)
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, store TEXT NOT NULL, amount REAL)")
db.execute("CREATE TABLE sales_daily (day TEXT, total REAL)")
db.execute("CREATE VIEW v_big_orders AS SELECT * FROM orders WHERE amount > 100")
db.commit()
db.close()
r = await client.post("/api/datasources", json={"name": "preview_src", "kind": "sqlite",
"database": str(path)})
assert r.status_code == 201, r.text
yield r.json()
await client.delete(f"/api/datasources/{r.json()['id']}")
async def _cache(source_id: str) -> dict:
from app.db.base import SessionLocal
from app.db.models import DataSource
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
return {"cache": dict(row.schema_cache or {}), "synced": row.schema_synced_at}
# --------------------------------------------------------------------------
# REQ-7:缓存是按哪个 schema 探的
# --------------------------------------------------------------------------
async def test_cached_schema_says_which_schema_the_cache_came_from(client, source):
assert source["cached_schema"] is None, "没探过就是没有缓存"
r = await client.post(f"/api/datasources/{source['id']}/introspect")
assert r.json()["cached_schema"] == "", "按默认 schema 探的写空串,和「没探过」分开"
r = await client.post(f"/api/datasources/{source['id']}/introspect", params={"schema": "main"})
assert r.json()["cached_schema"] == "main"
listed = next(s for s in (await client.get("/api/datasources")).json() if s["id"] == source["id"])
assert listed["cached_schema"] == "main"
# --------------------------------------------------------------------------
# REQ-6 ①:只看看,不改缓存
# --------------------------------------------------------------------------
async def test_a_dry_run_shows_what_is_there_without_touching_the_cache(client, source):
await client.post(f"/api/datasources/{source['id']}/introspect")
before = await _cache(source["id"])
r = await client.post(f"/api/datasources/{source['id']}/introspect",
params={"schema": "main", "dry_run": "true"})
assert r.status_code == 200, r.text
preview = r.json()
assert preview["dry_run"] is True
assert preview["schema"] == "main"
assert preview["table_count"] == 3
assert set(preview["tables"]) == {"main.orders", "main.sales_daily", "main.v_big_orders"}
assert preview["schema_error"] == ""
after = await _cache(source["id"])
assert after == before, "只看看也把缓存换掉了——助手此刻看到的就不是配置里那个 schema 了"
async def test_a_dry_run_that_finds_nothing_says_why_and_still_leaves_the_cache(client, source):
await client.post(f"/api/datasources/{source['id']}/introspect")
before = await _cache(source["id"])
r = await client.post(f"/api/datasources/{source['id']}/introspect",
params={"schema": "no_such_schema", "dry_run": "true"})
preview = r.json()
assert preview["dry_run"] is True and preview["table_count"] == 0
assert preview["schema_error"], "探失败了要说出来,不能看起来像「这个 schema 是空的」"
assert "main" in preview["available_schemas"]
assert await _cache(source["id"]) == before
async def test_without_dry_run_the_cache_is_still_replaced(client, source):
r = await client.post(f"/api/datasources/{source['id']}/introspect", params={"schema": "main"})
assert r.json()["table_count"] == 3
assert (await _cache(source["id"]))["cache"]["schema"] == "main"
# --------------------------------------------------------------------------
# REQ-6 ②:表结构的结构化列
# --------------------------------------------------------------------------
async def test_table_schema_comes_with_structured_columns(client, source):
await client.post(f"/api/datasources/{source['id']}/introspect")
body = (await client.get(f"/api/datasources/{source['id']}/schema",
params={"table": "orders"})).json()
assert body["found"] is True
assert body["kind"] == "table" and body["qualified"] == "orders"
assert body["detail"].startswith("表 orders"), "给模型看的那段文本照旧"
assert body["columns"] == [
{"name": "id", "type": "INTEGER", "pk": True, "not_null": False, "comment": None},
{"name": "store", "type": "TEXT", "pk": False, "not_null": True, "comment": None},
{"name": "amount", "type": "REAL", "pk": False, "not_null": False, "comment": None},
]
view = (await client.get(f"/api/datasources/{source['id']}/schema",
params={"table": "V_BIG_ORDERS"})).json()
assert view["found"] is True and view["kind"] == "view", "和给模型的那份一样,大小写不敏感"
async def test_an_unknown_table_has_no_columns_but_keeps_the_explanation(client, source):
await client.post(f"/api/datasources/{source['id']}/introspect")
body = (await client.get(f"/api/datasources/{source['id']}/schema",
params={"table": "nope"})).json()
assert body["found"] is False and body["columns"] == []
assert "没有 nope" in body["detail"]