Files
AgentLab/backend/app/data/introspect.py
T
ThearchyHelios ee7b8b2f97 剖析与探查的方言修正(C1)和没有真库时的方言验证(C2)
为什么:剖析是全站唯一主动查业务库的地方,以前只在守卫放行和字符串片段两层上测过,从没在真库上跑过。
本机四个容器(PG 16、MySQL 8.4、MariaDB 11、Oracle Free 23)实测,旧的 IN 列表写法在 PostgreSQL 上整数子列
对文字主键直接报 operator does not exist: character varying = integer,关系一条都核对不了。

C1 方言修正:
- IN 列表的字面量按被指向列的类型族写(key_literals):父列文字写 '12',父列数字只收整数写法的值,父列是时间、
  对不上的不拼并在备注里写「两端类型不同」;子表是 CHAR(n) 时去掉补齐的尾随空格。
- 列类型改存按方言编译的完整写法(VARCHAR2(20 CHAR)、NUMBER(10, 0)、ENUM('a','b')、TIMESTAMP WITH TIME ZONE),
  编译不了退回 str()。SQLAlchemy 把 Oracle 的 VARCHAR2 反射成通用 VARCHAR(容器实测),照数据字典写成 VARCHAR2。
  ENUM / SET 另记 enum_values;Oracle 小写带引号的表和列记 quote_name;各索引(含主键)的首列记进
  index_first_columns(下一波取值观察判断能不能对大表发窗口查询)。新探查的快照哈希会变,旧运行封存的不受影响。
- catalog._type_family 认 ENUM / SET 是文字、INTERVAL 不是数字,去掉引号里的取值再认;新增 column_family
  (有 enum_values 的是文字,PostgreSQL 枚举编译出来只有类型名);is_date_column 不再把 ENUM('date') 认成日期列。
- 码值候选:ENUM / SET 不看列名直接算候选,ENUM 列定义里这次没出现的取值也写进码值(含义待填);VARCHAR2(20 CHAR)
  的长度按 20 算;CHAR 码值去尾随空格;MySQL 上写明按排序规则分组、比较(可能不分大小写)。
- 统计信息估出的行数不超过整表统计上限时再跑一次有上限的计数确认,统计过期的大表不再被当成小表做整表统计。
- 日期列的最小值、最大值只看 0001-01-02 到 9999-12-31 之间的值,零日期、占位日期、PG 的 ±infinity 不计入,
  照实写「另有 N 个无效日期未计入」,日期范围里带 invalid 个数(界面显示)。
- 新增取值观察用的方言方法 date_literal / day_trunc / sample_from,以及按日行数、时间窗口里的取值分布、块采样三类
  查询的拼法(窗口上端截在 9999-12-31 之前,不让 infinity 多出一天)。
- 说明文字不再说「抽样」:大表看的是「按存储顺序取前 N 行」;设置项「抽样键值数」改叫「核对键值数」。

C2 验证:
- tests/test_profile_dialect_golden.py:五种方言 × 全部剖析和观察查询的完整 SQL 钉在 tests/fixtures/dialect/,
  sqlglot 逐条解析、过只读守卫,再用 qualify + annotate_types 核对 IN 两边类型族一致(附旧写法的对照)。
- scripts/dialect-it.sh(不进 CI):本机起四个容器,建虚构夹具(保留字列、Oracle 小写带引号的表、CHAR 码值、
  MySQL ENUM、零日期 / infinity / 占位日期、做旧的统计信息、超上限的大表、带索引的日期列),用 AgentLab 自己的
  探查和剖析代码跑全部查询,断言零报错、结论和手算一致。四种库都通过;MariaDB 插入时自己更新行数估算,统计信息
  做不旧,照实打印。把 IN 列表改回旧写法回跑,PG 上照样报出上面那条错误。跑完删容器、留镜像。
2026-10-16 10:25:28 +08:00

559 lines
28 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 collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.engine.reflection import ObjectKind
from app.core.errors import describe_exception, first_line
from app.data.engine import engines
# 一次探查最多带回多少张表。生产库动辄上千张,全拉回来既慢又没人看得完。
_MAX_TABLES = 200
# summary 里每张表列几个代表字段。够 Copilot 判断"这表是不是我要的"即可。
_PREVIEW_COLUMNS = 6
# 批量反射失败、逐个对象重试时,开头连着这么多个都取不到就不再试了
_GIVE_UP_AFTER = 5
# 各家的系统 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
def _foreign_keys(raw: list[dict[str, Any]], target_schema: str | None,
default_schema: str | None) -> list[dict[str, Any]]:
"""反射出来的外键 → [{columns, to_table, to_columns}]。
to_table 和 schema_cache["tables"] 的键同一个口径:指向同一个 schema 的只写表名;跨 schema 的写
「schema.表名」(那张表不在这次探查的结果里,关系照记,图里是一个悬空的端点)。to_columns 可能为空
(SQLite 的 REFERENCES t 省略了列),由数据目录按被指向表的主键补。
"""
home = {s for s in (target_schema, default_schema) if s}
out: list[dict[str, Any]] = []
for fk in raw:
cols = [c for c in fk.get("constrained_columns") or [] if c]
to_table = fk.get("referred_table")
if not cols or not to_table:
continue
ref_schema = fk.get("referred_schema")
out.append({
"columns": cols,
"to_table": to_table if not ref_schema or ref_schema in home else f"{ref_schema}.{to_table}",
"to_columns": [c for c in fk.get("referred_columns") or [] if c],
})
return out
def _unique_groups(constraints: list[dict[str, Any]], indexes: list[dict[str, Any]],
pk: list[str]) -> list[list[str]]:
"""唯一约束和唯一索引的列组,去重,不含和主键相同的那组。
同一组唯一列在各家可能出现两次:MySQL 的 UNIQUE 约束本身就是唯一索引,PostgreSQL 的唯一约束背后也有
一个索引,Oracle 的主键背后还有一个唯一索引。表达式索引(列名里有 None)不算。
"""
seen = {tuple(c.lower() for c in pk)} if pk else set()
groups: list[list[str]] = []
candidates = [u.get("column_names") for u in constraints]
candidates += [ix.get("column_names") for ix in indexes if ix.get("unique")]
for cols in candidates:
if not cols or any(c is None for c in cols):
continue
key = tuple(str(c).lower() for c in cols)
if key in seen:
continue
seen.add(key)
groups.append([str(c) for c in cols])
return groups
def type_text(col_type: Any, dialect: Any) -> str:
"""反射到的列类型 → 存进表结构的写法:按这个数据源的方言编译的完整写法(VARCHAR2(20 CHAR)、NUMBER(10, 0)、
ENUM('paid','refund')、TIMESTAMP WITH TIME ZONE),编译不了的退回 str()。
以前存的是 str(类型),即 SQLAlchemy 的通用写法:Oracle 的 VARCHAR2、NUMBER 丢了长度和精度,MySQL 的 ENUM 丢了
取值列表,PostgreSQL 的 TIMESTAMP 丢了时区。类型写不全,表结构前后比对就看不出类型变了,ENUM 状态列也进不了
码值候选。这一改会让新探查的表结构快照哈希变化(提示词里的类型写法变长),旧运行封存的快照不受影响。"""
if col_type is None:
return ""
try:
compiled = str(col_type.compile(dialect=dialect))
except Exception: # noqa: BLE001 - 编译不了(NullType 等)就用通用写法,不让一列拖垮整次探查
return str(col_type)
if getattr(dialect, "name", "") == "oracle" and compiled.startswith("VARCHAR("):
# SQLAlchemy 把 Oracle 的 VARCHAR2 反射成通用的 VARCHAR,按 Oracle 方言编译出来是 VARCHAR(10 CHAR)(容器集成
# 测试实测)。Oracle 的数据字典里只有 VARCHAR2(建表写 VARCHAR 也存成 VARCHAR2),照数据字典写
compiled = "VARCHAR2" + compiled[len("VARCHAR"):]
return compiled
def _enum_values(col_type: Any) -> list[str] | None:
"""ENUM(MySQL、PostgreSQL 的枚举,都是 sqltypes.Enum 的子类)的取值列表,MySQL SET 的成员列表;别的类型 None。"""
from sqlalchemy.dialects.mysql import SET
from sqlalchemy.types import Enum
if isinstance(col_type, Enum):
values = list(col_type.enums or [])
elif isinstance(col_type, SET):
values = list(col_type.values or [])
else:
return None
return [str(v) for v in values]
def _forced_quote(name: Any) -> bool:
"""名字必须原样加引号:Oracle 反射回来的全小写对象(建表时写了 "visits")是 quoted_name(quote=True)。
它存进 JSON 就成了普通字符串,剖析拼 SQL 时不加引号,Oracle 把它转成大写 VISITS,查的是另一个不存在的对象。"""
return getattr(name, "quote", None) is True
def column_entry(col: Mapping[str, Any], dialect: Any) -> dict[str, Any]:
"""反射到的一列 → 表结构里的一列:名字、按方言编译的类型、可空、注释;ENUM / SET 另记取值列表(enum_values),
要强制加引号的名字另记 quote_name。只在有的时候写这两个键:其余列和以前一字不差。"""
entry: dict[str, Any] = {
"name": str(col["name"]),
"type": type_text(col.get("type"), dialect),
"nullable": bool(col.get("nullable", True)),
"comment": col.get("comment") or None,
}
values = _enum_values(col.get("type"))
if values:
entry["enum_values"] = values
if _forced_quote(col.get("name")):
entry["quote_name"] = True
return entry
def index_first_columns(indexes: list[dict[str, Any]], pk: list[str]) -> list[str]:
"""哪些列打头有索引:各索引(含主键)的第一列,去重、按名字排序。取值观察看大表的最新业务日期、按日行数时,
只在业务日期列打头有索引时才发窗口查询(按它走索引,不扫全表)。表达式索引(列名是 None)不算。"""
first = {str(pk[0])} if pk else set()
for ix in indexes:
cols = ix.get("column_names") or []
if cols and cols[0] is not None:
first.add(str(cols[0]))
return sorted(first)
async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any]:
"""探查整个库的结构,结果直接存进 DataSource.schema_cache。
每张表:列(名字、类型、可空、注释)、主键、表注释、是不是视图;读得到的话还有外键约束
(foreign_keys)和唯一约束 / 唯一索引(unique)——读不到的表不写这两个键,探查照常完成。
"""
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]
# 批量反射接口(SQLAlchemy 2.0)。
#
# **别指望它能治超时。** 实测 60 张表:逐表调用 121 次往返、批量 122 次
# ——SQLAlchemy 的反射缓存本来就把 get_columns / get_pk_constraint 合并
# 成了同一次底层查询,而 MySQL 方言压根没实现 get_multi_*,落到基类
# 还是每表一次 SHOW CREATE TABLE。真正能少往返的是 PostgreSQL 这类
# 原生实现了批量的方言。
#
# 换成它是因为这是 2.0 的正经接口、少三层 try/except,不是因为它快。
# shop 那次 "Lost connection to MySQL server during query"
# (run 554a0f92)多半是偶发的服务端超时——所以真正的对策不在这里,
# 在下面:失败要如实记成失败,让工具和界面都说真话。
#
# 批量是一锤子买卖:其中一个对象反射出错,整批都拿不到。SQLite 连接关了双引号
# 字符串兼容以后,老库里一个写着 `"yes" AS flag` 的视图就让 PRAGMA 报错;以前这里
# 吞掉异常返回空,好好的表跟着一起消失,探查却显示「成功、0 张表」。所以批量失败
# 时逐个对象再取一遍,只跳过真正取不到的那个,名字和原因记进 skipped。
failures: dict[str, str] = {}
def _multi(fn: Any, single: Any, names: list[str], *,
errors: dict[str, str] | None = None, ok: set[str] | None = None) -> dict[Any, Any]:
"""批量反射,失败时逐个对象重试。ok 给了的话,记下真正读到了的对象名:批量成功时是全部
(没有约束的表批量接口也会给空列表,或者干脆不给),逐个重试时只有没报错的那些。"""
try:
got = dict(fn(schema=target_schema, filter_names=names, kind=ObjectKind.ANY))
if ok is not None:
ok.update(names)
return got
except NotImplementedError:
return {} # 表注释这类不是所有方言都支持
except Exception: # noqa: BLE001 - 批量取不到,逐个对象再试
pass
out: dict[Any, Any] = {}
for name in names:
try:
out[(target_schema, name)] = single(name, schema=target_schema)
if ok is not None:
ok.add(name)
except NotImplementedError:
break
except Exception as e: # noqa: BLE001 - 只跳过这一个对象
if errors is not None:
errors[name] = first_line(e) or describe_exception(e)
if not out and len(errors or ()) >= _GIVE_UP_AFTER:
break # 开头几个全取不到,多半是连接本身出了问题,不再挨个耗时间
return out
columns_by = _multi(inspector.get_multi_columns, inspector.get_columns, picked,
errors=failures)
readable = [n for n in picked if columns_by.get((target_schema, n))]
pk_by = _multi(inspector.get_multi_pk_constraint, inspector.get_pk_constraint, readable)
comment_by = _multi(inspector.get_multi_table_comment, inspector.get_table_comment, readable)
# 外键和唯一约束给数据目录起草关系用(data/catalog.py)。放在列、主键、注释之后读:PostgreSQL 上一条
# 语句报错会让整个事务作废,先读的那几样不能被它连累。读不到的表不写这两个键(ok 集合里没有它),
# 下游据此分得清「读过、没有」和「不知道」——升级前探查的缓存里也没有这两个键。
fk_ok: set[str] = set()
uq_ok: set[str] = set()
ix_ok: set[str] = set()
fk_by = _multi(inspector.get_multi_foreign_keys, inspector.get_foreign_keys, readable, errors={}, ok=fk_ok)
uq_by = _multi(inspector.get_multi_unique_constraints, inspector.get_unique_constraints, readable,
errors={}, ok=uq_ok)
ix_by = _multi(inspector.get_multi_indexes, inspector.get_indexes, readable, errors={}, ok=ix_ok)
try:
default_schema = inspector.default_schema_name
except Exception: # noqa: BLE001 - 只用来判断外键是不是指向别的 schema
default_schema = None
tables: dict[str, Any] = {}
skipped: dict[str, str] = {}
for name in picked:
key = (target_schema, name)
columns = columns_by.get(key)
if not columns:
# 这个对象反射不出来,跳过,别毁掉整次探查;但要留下名字和原因
skipped[name] = failures.get(name) or "读不到列"
continue
pk = (pk_by.get(key) or {}).get("constrained_columns") or []
comment = (comment_by.get(key) or {}).get("text")
tables[name] = {
# 写 SQL 要用的全名。没有 schema 前缀时 Oracle 直接 ORA-00942——
# 只读账号名下没有这些对象,全靠跨 schema 授权
"qualified": f"{target_schema}.{name}" if target_schema else name,
"schema": target_schema,
"columns": [column_entry(c, sync_conn.dialect) for c in columns],
"primary_key": pk,
"comment": comment,
"is_view": name in views,
}
if _forced_quote(name):
tables[name]["quote_name"] = True
if name in fk_ok:
tables[name]["foreign_keys"] = _foreign_keys(fk_by.get(key) or [], target_schema, default_schema)
if name in uq_ok:
tables[name]["unique"] = _unique_groups(uq_by.get(key) or [],
(ix_by.get(key) or []) if name in ix_ok else [], pk)
if name in ix_ok:
tables[name]["index_first_columns"] = index_first_columns(ix_by.get(key) or [], pk)
payload: dict[str, Any] = {"tables": tables, "truncated": truncated,
"total": len(names) + len(views)}
if failures and not tables:
# 一个对象也取不到、而且确实出了错:如实记成失败,不能显示成「已同步、0 张表」
payload.update(failed=True, error=next(iter(failures.values())))
elif skipped:
payload["skipped"] = skipped
return payload
try:
async with engine.connect() as conn:
payload = await conn.run_sync(_collect)
except Exception as exc: # noqa: BLE001
# schema 名填错是高频事故(大小写、拼写、或者根本不知道该填什么),
# 直接把驱动异常抛出去等于让人自己去猜。先看看连接本身好不好:
# 能列出候选说明只是 schema 不对,这种情况给出可选项比报错有用得多。
#
# 但**这里回来的是一次失败,不是"还没探查"**。以前两者存成了一模一样
# 的形状(tables 空 + 一个没人读的 error 字段),于是工具对 agent 说
# "还没有探查过结构,请在设置页里执行一次"——而用户明明执行过,是超时了。
# run 554a0f92 里 agent 因此花了 6 次调用、29 秒自己重建 schema。
# failed 这个标记就是为了让下游分得清。
candidates = await list_schemas(source)
if not candidates:
raise
# error 原样画在数据源卡片上、也交给 agent 看:驱动的异常类名和文档链接去掉,只留它自己的说明
payload = {"tables": {}, "truncated": False, "total": 0,
"failed": True, "error": first_line(exc) or describe_exception(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 why_empty(cache: dict[str, Any]) -> str:
"""结构为什么是空的。一句话,给 agent 和用户看的是同一句。
分清"还没探查"和"探查失败了":前者该去点一下按钮,后者点了也没用,
要先解决超时或换 schema。以前两者说的是同一句话。
"""
if not cache.get("failed"):
return "尚未探查结构"
err = str(cache.get("error") or "").strip()
return f"结构探查失败:{err[:200]}" if err else "结构探查失败"
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"|{why_empty(cache)}{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 find_table(source: Any, table: str) -> dict[str, Any] | None:
"""在缓存里找一张表。全名、只有表名、大小写不一致都认——模型和人都可能这么写。"""
tables = (source.schema_cache or {}).get("tables") or {}
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)
return meta or None
def table_columns(meta: dict[str, Any]) -> list[dict[str, Any]]:
"""结构化的列:{name, type, pk, not_null, comment}。
界面要画表格,以前只能去解析 describe_table 那段给模型看的文本,文本格式
一改就全乱。两份出自同一份缓存,说法一致。
"""
pk = set(meta.get("primary_key") or [])
return [
{
"name": col["name"],
"type": col.get("type") or "",
"pk": col["name"] in pk,
"not_null": not col.get("nullable", True),
"comment": col.get("comment") or None,
}
for col in meta.get("columns") or []
]
def describe_table(source: Any, table: str, *, catalog: Mapping[str, Mapping[str, Any]] | None = None) -> str:
"""单表的完整字段说明。agent 调 db_schema 工具时返回这个。
catalog 是这个源的数据目录 {表名: notes}(data/catalog.py),给了且这张表有可给模型看的内容时,在字段
清单后面附上目录:中文名、粒度、业务主键、业务日期、有效记录条件、每列的含义 / 单位 / 度量类型 / 码值、
关联关系,推断的项标出来。导入表格的源,表和列的说明是系统按核对结果生成的:说明照旧拼在表名和列后面,
目录只追加说明没有覆盖的、或人工确认过的项(catalog.render_table_notes)。不给 catalog 时输出和以前一样。
"""
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
# 模型可能传全名(ANALYTICS.V_TRIP_FACT),也可能只传表名,两种都认
meta = find_table(source, table)
if not meta:
if not tables:
# 缓存是空的,说"里面没有这张表"就是在误导——真相是我们什么都不知道。
# run 554a0f92 里 agent 一次问了 7 个表名,收到 7 条一模一样的
# "没有 X。现有的对象:(还没探查过结构)",白花一整步
return (
f"数据源「{source.name}」的结构信息不可用({why_empty(cache)}),"
"因此无法判断是否存在这张表。请直接查询数据字典"
"(information_schema.tables / columns,SQLite 用 sqlite_master)。"
)
available = "、".join(
m.get("qualified", n) for n, m in list(tables.items())[:30]
)
if cache.get("truncated"):
# 只探查了一部分表:没探查到不等于没有。以前照样回「中没有 X」,模型据此换了别的表或者编答案
total = cache.get("total")
of = f"共 {total} 个对象," if isinstance(total, int) else ""
return (
f"数据源「{source.name}」{of}只探查了其中 {len(tables)} 个,{table} 不在已探查的范围内,"
"无法判断是否存在。请直接查询数据字典(information_schema.tables / columns,SQLite 用 "
f"sqlite_master)确认它的结构。已探查的对象包括:{available}"
)
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"])
values = col.get("enum_values")
if values and "(" not in str(col.get("type") or ""):
# PostgreSQL 的枚举类型写法只有类型名(order_status):取值列表另外写出来,模型才知道能写哪些值
marks.append("取值:" + "、".join(str(v) for v in values[:30]) + ("…" if len(values) > 30 else ""))
suffix = f" {'、'.join(marks)}" if marks else ""
lines.append(f" {col['name']} {col['type']}{suffix}")
if catalog:
from app.data import catalog as catalog_mod
key = catalog_mod.resolve_table_name(cache, table)
block = catalog_mod.render_table_notes(catalog.get(key) if key else None, meta=meta,
system_notes=catalog_mod.system_notes_source(source))
if block:
lines.append(block)
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()]