Files
AgentLab/backend/app/data/introspect.py
T
ThearchyHelios a6ee525148 Copilot 编排期看得见数据库了,附带修一个让非流式生成整体失效的 bug
阶段②。之前 agent 是运行时才摸索表结构,每跑一次重探一遍——慢,而且同一张图
两次运行走的 SQL 可能不一样,没法钉口径。现在 Copilot 建图时就知道有哪些库、
哪些表,生成的 SQL 直接能跑。

## 先修一个更要紧的:非流式生成的 config 全是空的

GRAPH_SCHEMA 里 config 写的是 {"type": "object"},没有 properties 也没有
additionalProperties。不少 provider 的结构化输出实现遇到这种 object 会按
"不允许额外字段"处理,于是模型填好的 config 被整个过滤成 {}——生成的图每个
节点都是空壳,一提交就挂在"工具节点还没选工具"。

config 的键随节点类型千变万化(tool 有 tool/args,code 有 language/code…),
没法枚举成 properties,只能显式声明 additionalProperties=true。流式那条路径
走操作流协议、不经过 structured output,所以这个 bug 一直藏着没露头。

## 注入什么

- 工具清单补上动态的 db_query__<源> / db_schema__<源>。all_specs() 只有静态
  注册的内置工具,不补进来 Copilot 根本不知道这些工具存在,只会退回让用户自己
  写 SQL 的代码节点——接了数据库等于白接。
- 数据源摘要(含 schema 全名),外加四条写 SQL 的硬性要求。这几条都是真实
  踩过的:漏 schema 前缀在 Oracle 上直接 ORA-00942,写惯 MySQL 的模型会顺手
  写 LIMIT,分号拼接会被守卫拒掉。

## token 预算:按对象数自适应,不是按字符数

实测一个 53 对象的库,带字段 2715 token、只给对象名 956 token(35%)。
但真正的决策点不是字符数——detail 模式每库只列 40 个对象,多出来的 Copilot
根本看不见。对 53 个对象的库,"40 张表带字段"不如"53 张表只给名字":
找不到那张表,字段写得再全也没用。截断丢信息比省 token 更糟。

所以先按对象数判断(超过 40 就整体切紧凑),再用字符预算兜多库叠加的情况。
真实库实测:紧凑模式 1219 token,53 个对象全部可见。

## 实测

完整模式:一句话需求 → Copilot 出图 → 不改一个字直接跑通真实 Oracle。
生成的 SQL 三处细节全对——ANALYTICS. 前缀、FETCH FIRST(不是 LIMIT)、单条语句。

紧凑模式(看不到字段):Copilot 没有硬猜字段,而是生成了一个带
db_schema + db_query 两个工具的 agent 节点,让运行时先查结构再写 SQL——
正是 prompt 里"字段拿不准就先放一个 db_schema 节点"那条。跑通,结论正确。

测试 72 项。
2026-09-17 12:16:54 +08:00

285 lines
12 KiB
Python
Raw 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.
"""探查数据库结构。
产出两种粒度,对应两种消费者:
- `summary()`:表名 + 一句话,几百 token。常驻 Copilot 的 system prompt,
让它知道"有哪些库、每个库大概有什么"。
- `describe_table()`:某张表的完整字段。按需取,Copilot 决定要查某张表时才拉。
分两种粒度是因为 token 预算:一个中等业务库几百张表、每表几十个字段,
全量塞进 prompt 轻松几万 token,既贵又会把真正的需求淹掉。
用 SQLAlchemy 的 Inspector 而不是手写各家的 information_schema 查询——
方言差异它已经处理过了,我们不必再趟一遍。
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import inspect as sa_inspect
from app.data.engine import engines
# 一次探查最多带回多少张表。生产库动辄上千张,全拉回来既慢又没人看得完。
_MAX_TABLES = 200
# summary 里每张表列几个代表字段。够 Copilot 判断"这表是不是我要的"即可。
_PREVIEW_COLUMNS = 6
# 各家的系统 schema,发现可用 schema 时要排掉——否则用户面对的是一屏
# MDSYS / CTXSYS / XDB 这类内部对象,真正的业务库反而埋在里面。
_SYSTEM_SCHEMAS = frozenset({
# Oracle
"sys", "system", "sysaux", "mdsys", "ctxsys", "xdb", "olapsys", "wmsys",
"lbacsys", "ordsys", "orddata", "outln", "dbsnmp", "appqossys", "audsys",
"gsmadmin_internal", "dvsys", "ojvmsys", "dbsfwuser", "remote_scheduler_agent",
# PostgreSQL / MySQL
"information_schema", "pg_catalog", "pg_toast", "performance_schema", "mysql", "sys",
})
# 统计每个 schema 下有多少对象。两条路径覆盖全部支持的库:
# information_schema 是 SQL 标准(MySQL / PostgreSQL / SQL Server 都有),
# Oracle 不遵循这条标准,用它自己的 all_tables / all_views 数据字典。
#
# 不用"逐个 schema 调 get_table_names"的通用写法:这个库有 94 个 schema,
# 那样是 94 次往返,几十秒起步。
_SCHEMA_STATS_SQL = {
"oracle": """
SELECT owner AS s, COUNT(*) AS n FROM (
SELECT owner FROM all_tables
UNION ALL SELECT owner FROM all_views
) GROUP BY owner ORDER BY COUNT(*) DESC
""",
"_standard": """
SELECT table_schema AS s, COUNT(*) AS n
FROM information_schema.tables
GROUP BY table_schema ORDER BY COUNT(*) DESC
""",
}
async def schema_stats(source: Any) -> list[tuple[str, int]]:
"""每个 schema 有多少表/视图,多的在前。拿不到就返回空表让调用方降级。"""
from sqlalchemy import text as _text
kind = (source.kind or "").lower()
if kind == "sqlite":
return [] # SQLite 只有 main,没有可选项
sql = _SCHEMA_STATS_SQL.get(kind, _SCHEMA_STATS_SQL["_standard"])
engine = await engines.get(source)
try:
async with engine.connect() as conn:
rows = (await conn.execute(_text(sql))).fetchall()
return [(str(r[0]), int(r[1])) for r in rows if r[0]]
except Exception: # noqa: BLE001 - 方言不认就算了,降级到纯名字列表
return []
async def list_schemas(source: Any) -> list[str]:
"""列出**可能装着业务数据**的 schema,最有可能的排在最前。
企业环境里只读账号名下往往一张表都没有——数据在别的 schema,靠跨库授权或
synonym 访问。直接返回"0 张表"等于把人晾在那儿。
但光列出名字也不够用:实测一个 Oracle 实例有 94 个用户,靠黑名单过滤完
还剩 75 个(anonymous、C##OGGADMIN、xs$null…),人在里面找不到那两个
业务 schema。所以主要依据不是"猜哪些是系统的",而是"哪些里面真有东西"——
黑名单只用来排掉已知的系统 schema(它们对象数往往还很多,光靠排序压不下去)。
"""
engine = await engines.get(source)
def _names(sync_conn: Any) -> list[str]:
try:
return list(sa_inspect(sync_conn).get_schema_names())
except Exception: # noqa: BLE001 - 有些方言不支持
return []
stats = await schema_stats(source)
if stats:
# 有统计就只留真有对象的,并按对象数降序——业务库自然浮到前面
return [s for s, n in stats if n > 0 and s.lower() not in _SYSTEM_SCHEMAS]
async with engine.connect() as conn:
names = await conn.run_sync(_names)
return [n for n in names if n.lower() not in _SYSTEM_SCHEMAS]
def _target_schema(source: Any, explicit: str | None) -> str | None:
"""探查哪个 schema:显式参数 > 数据源配置 > 当前用户默认。"""
if explicit:
return explicit
configured = (source.options or {}).get("schema")
return str(configured) if configured else None
async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any]:
"""探查整个库的结构,结果直接存进 DataSource.schema_cache。"""
engine = await engines.get(source)
target = _target_schema(source, schema)
def _collect(sync_conn: Any) -> dict[str, Any]:
inspector = sa_inspect(sync_conn)
target_schema = target
names = inspector.get_table_names(schema=target_schema)
views = inspector.get_view_names(schema=target_schema)
truncated = len(names) + len(views) > _MAX_TABLES
picked = (names + views)[:_MAX_TABLES]
tables: dict[str, Any] = {}
for name in picked:
try:
columns = inspector.get_columns(name, schema=target_schema)
except Exception: # noqa: BLE001 - 单表失败不该让整次探查失败
continue
try:
pk = (inspector.get_pk_constraint(name, schema=target_schema) or {}).get(
"constrained_columns"
) or []
except Exception: # noqa: BLE001
pk = []
try:
comment = (inspector.get_table_comment(name, schema=target_schema) or {}).get("text")
except Exception: # noqa: BLE001 - 不是所有方言都支持表注释
comment = None
tables[name] = {
# 写 SQL 要用的全名。没有 schema 前缀时 Oracle 直接 ORA-00942——
# 只读账号名下没有这些对象,全靠跨 schema 授权
"qualified": f"{target_schema}.{name}" if target_schema else name,
"schema": target_schema,
"columns": [
{
"name": c["name"],
"type": str(c.get("type") or ""),
"nullable": bool(c.get("nullable", True)),
"comment": c.get("comment") or None,
}
for c in columns
],
"primary_key": pk,
"comment": comment,
"is_view": name in views,
}
return {"tables": tables, "truncated": truncated, "total": len(names) + len(views)}
try:
async with engine.connect() as conn:
payload = await conn.run_sync(_collect)
except Exception as exc: # noqa: BLE001
# schema 名填错是高频事故(大小写、拼写、或者根本不知道该填什么),
# 直接把驱动异常抛出去等于让人自己去猜。先看看连接本身好不好:
# 能列出候选说明只是 schema 不对,这种情况给出可选项比报错有用得多。
candidates = await list_schemas(source)
if not candidates:
raise
payload = {"tables": {}, "truncated": False, "total": 0,
"error": f"{type(exc).__name__}: {exc}"}
payload["synced_at"] = datetime.now(timezone.utc).isoformat()
payload["schema"] = target
# 一张表都没探到时,别只回一个空壳——多半是数据在别的 schema 里,
# 把候选列出来,用户才知道下一步该填什么
if not payload["tables"]:
payload.setdefault("available_schemas", await list_schemas(source))
return payload
def summary(source: Any, *, max_tables: int = 40, detail: bool = True) -> str:
"""结构摘要。detail=False 只给对象名,不列字段——库多了要靠它压 token。
实测一个 53 对象的库:带字段 2715 token,只给名字 956 token(35%)。
接三五个库的差别就是 15k 和 4.5k,后者才塞得进 system prompt。
"""
if not detail:
cache = source.schema_cache or {}
names = table_names(source)
if not names:
return _empty_summary(source, cache)
head = (
f"- {source.name}({source.kind},{'只读' if source.readonly else '可写'}):"
f"{source.description or '未填说明'}"
)
return head + "\n 对象:" + "、".join(names)
return _detailed_summary(source, max_tables=max_tables)
def _empty_summary(source: Any, cache: dict[str, Any]) -> str:
hint = ""
candidates = cache.get("available_schemas") or []
if candidates:
hint = f"|该账号名下没有对象,数据可能在这些 schema:{'、'.join(candidates[:8])}"
return (
f"- {source.name}({source.kind}):{source.description or '未填说明'}"
f"|尚未探查结构{hint}"
)
def _detailed_summary(source: Any, *, max_tables: int = 40) -> str:
"""给 Copilot 的紧凑摘要。没探查过就明说,别让它对着空气编表名。"""
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
if not tables:
return _empty_summary(source, cache)
schema = cache.get("schema")
lines = [
f"- {source.name}({source.kind},{'只读' if source.readonly else '可写'}):"
f"{source.description or '未填说明'}"
+ (f"|schema:{schema}" if schema else "")
]
for name, meta in list(tables.items())[:max_tables]:
cols = meta.get("columns") or []
preview = "、".join(c["name"] for c in cols[:_PREVIEW_COLUMNS])
more = f" 等 {len(cols)} 字段" if len(cols) > _PREVIEW_COLUMNS else ""
note = f"({meta['comment']})" if meta.get("comment") else ""
kind = "视图" if meta.get("is_view") else "表"
# 用全名:模型照着写 SQL 时少了 schema 前缀在 Oracle 上直接报 ORA-00942
lines.append(f" · {meta.get('qualified', name)}[{kind}]{note}: {preview}{more}")
if len(tables) > max_tables:
lines.append(f" · …另有 {len(tables) - max_tables} 个对象,用 db_schema 工具查看")
return "\n".join(lines)
def describe_table(source: Any, table: str) -> str:
"""单表的完整字段说明。agent 调 db_schema 工具时返回这个。"""
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
# 模型可能传全名(ANALYTICS.V_TRIP_FACT),也可能只传表名,两种都认
meta = tables.get(table)
if meta is None and "." in table:
meta = tables.get(table.rsplit(".", 1)[-1])
if meta is None:
lowered = table.rsplit(".", 1)[-1].lower()
meta = next((m for n, m in tables.items() if n.lower() == lowered), None)
if not meta:
available = "、".join(
m.get("qualified", n) for n, m in list(tables.items())[:30]
) or "(还没探查过结构)"
return f"数据源「{source.name}」里没有 {table}。现有的对象:{available}"
head = f"{'视图' if meta.get('is_view') else '表'} {meta.get('qualified', table)}"
if meta.get("comment"):
head += f"({meta['comment']})"
lines = [head]
pk = set(meta.get("primary_key") or [])
for col in meta.get("columns") or []:
marks = []
if col["name"] in pk:
marks.append("主键")
if not col.get("nullable", True):
marks.append("非空")
if col.get("comment"):
marks.append(col["comment"])
suffix = f" {'、'.join(marks)}" if marks else ""
lines.append(f" {col['name']} {col['type']}{suffix}")
return "\n".join(lines)
def table_names(source: Any) -> list[str]:
"""带 schema 前缀的对象全名。模型照着这个写 SQL 才不会漏掉前缀。"""
tables = (source.schema_cache or {}).get("tables") or {}
return [meta.get("qualified", name) for name, meta in tables.items()]