mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:42:00 +00:00
1a6082f033
为什么:几千张表的库以前用不了——探查每个源最多取 200 个对象、按数据库列出的顺序截断,同步请求、整份替换、不排队; 落在窗口外的表在目录、look、剖析里被说成「表已不在」。 做了什么: - db/models_sources.py:source_objects(对象索引一行一个对象:范围、原因、结构指纹与 struct_version、first_seen / last_seen / removed_at、reflected_at、按需加入的留痕)、introspect_jobs / introspect_tasks(后台探查任务与逐表状态)。 - data/source_objects.py:每种方言一条字典查询建对象索引(失败退回 Inspector,上限 5 万,截断时不判删除);探查范围规则 (options.introspect_scope:通配、按 name_key、排除优先、显式列出 / 移出;有目录的表、已发布模板写死 SQL 用到的表 pinned; 命中超上限拒绝并说出命中数);唯一的 rebuild_schema_cache 从范围内已反射的行物化 schema_cache(形状不变,另加 scope 摘要); 三种「不在」分开说(removed / missing / out_of_scope / reflect_failed);按需加入(当场只读反射、落盘、留痕、进程内按源加锁)。 - data/introspect_jobs.py:探查改成后台任务(core/job_runtime,lane kind introspect,和同源起草、剖析串行;按批反射、逐表记状态、 可停止、读完的照样物化;重启标中断不续跑)。 - api/datasources.py:POST /introspect 返回 202 和任务,dry_run 同步只刷新索引、返回命中数;/introspect-jobs 读任务、停止; /introspect-scope 读、预览、保存规则;/schema?table= 带剖析事实、说清三种「不在」;DataSourceOut 加 schema_scope、introspect_job。 - schema_view:search_tables(BM25 倒排,遮罩列只用列名,范围外只按名字和注释、权重低一档)、profile_lines(剖析事实一节, 附「只供写查询参考」,遮罩按渲染那一刻重滤,上传表格快照不一致时不给)、absent_detail。 - look:候选超过 PREFILTER_LIMIT 时只给预筛结果 + 已用到的表 + 一跳关联;点名或挑中范围外的表按需加入(每次查看最多 4 张), look 记录加 added_to_scope、prefiltered;表详情带剖析事实。 - tools/datasource:db_schema__ 加 keyword、不传 table 时只列前 50 张;点名范围外的表按需加入(每次运行最多 8 张);查询结果 照实列「未按目录检查的表」(unchecked_tables,不新增检查规则);建工具时读剖析事实。 - 剖析:_relation_ends 分清「范围外,可以加入」和「表结构里没有」;任务记上传表格的 snapshot_id;last_facts。表助手、/schema 用同一段剖析事实。 - 目录接口:清单只列范围内,absent_reason 分开说;GET /catalog/search 查对象索引,POST /catalog/scope/add 加入范围。 - 第 1 波留下的一处:check_valid_filter 和 stored_valid_filter 拼有效记录条件时按 quote_name 强制加引号。 - 工具参数纠错:空着的可选参数不止一个时,只认拼写明显接近的那一个(db_schema 多了 keyword 以后 tabel 仍能纠成 table)。 - 测试:3,000 张表的合成库(对象索引、缺省范围超上限拒绝、规则圈 400 张后台读完、停止、重启标中断、和剖析排队、同步起草 409)、删除与结构版本、Inspector 回退、截断不判删除、范围规则、pinned、方言金样;改了同步探查的老测试改为等任务做完。
153 lines
7.0 KiB
Python
153 lines
7.0 KiB
Python
"""换个 schema 看看,不擅自改缓存;表结构给结构化的列;卡片知道缓存是按哪个 schema 探的。
|
|
|
|
「换个 schema 看看」以前一探就把缓存换成那个 schema 的结构——哪怕用户随后选了
|
|
「只看看,不改」,助手此刻看到的已经是另一套表了。前端只好再探一次配置里的
|
|
schema 把缓存换回来,而那一次要是失败,缓存和配置就对不上,只剩一条会消失的 toast。
|
|
|
|
表结构以前只有一段给模型看的文本,前端照着文本格式去解析列,格式一改就全乱。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
from tests.fixtures.sources import introspect_now
|
|
|
|
|
|
@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, "没探过就是没有缓存"
|
|
out = await introspect_now(client, source["id"])
|
|
assert out["cached_schema"] == "", "按默认 schema 探的写空串,和「没探过」分开"
|
|
# 一个数据源一个 schema(目录-D2):真探查只按配置里的 schema,要换先改配置
|
|
r = await client.patch(f"/api/datasources/{source['id']}", json={"options": {"schema": "main"}})
|
|
assert r.status_code == 200, r.text
|
|
out = await introspect_now(client, source["id"])
|
|
assert out["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 introspect_now(client, source["id"])
|
|
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 introspect_now(client, source["id"])
|
|
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 是空的」"
|
|
# 这句话原样画在数据源卡片上:不带驱动的异常类名(OperationalError: …)
|
|
assert not re.search(r"\b[A-Z]\w*(Error|Exception)\b", preview["schema_error"]), preview["schema_error"]
|
|
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.patch(f"/api/datasources/{source['id']}", json={"options": {"schema": "main"}})
|
|
assert r.status_code == 200, r.text
|
|
out = await introspect_now(client, source["id"])
|
|
assert out["table_count"] == 3
|
|
assert (await _cache(source["id"]))["cache"]["schema"] == "main"
|
|
|
|
|
|
async def test_a_real_introspect_with_another_schema_is_refused(client, source):
|
|
"""真探查不接受临时换 schema:一个数据源一个 schema,对象索引、探查范围都按配置里的那个记。"""
|
|
r = await client.post(f"/api/datasources/{source['id']}/introspect", params={"schema": "other"})
|
|
assert r.status_code == 422 and "schema" in r.json()["detail"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# REQ-6 ②:表结构的结构化列
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_table_schema_comes_with_structured_columns(client, source):
|
|
await introspect_now(client, source["id"])
|
|
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 introspect_now(client, source["id"])
|
|
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"]
|