Files
AgentLab/backend/app/data/introspect.py
T
ThearchyHelios 8059cd5147 Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本
连接层
- 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检
- 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询
- 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因

解析(新 xlsx_scan + tabular)
- 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个
- 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收
- 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖
- 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执
- 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完

版本底座(新 table_versions、raw_store)
- 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查
- 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断)
- 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥
- 上传接口的解析移到线程池

运行固定数据版本
- 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询
- 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version

证据层
- 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变
- 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出

前端
- 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存
- 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2026-10-01 13:42:04 +08:00

391 lines
18 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 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
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]
# 批量反射接口(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) -> dict[Any, Any]:
try:
return dict(fn(schema=target_schema, filter_names=names, kind=ObjectKind.ANY))
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)
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)
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": [
{
"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,
}
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) -> str:
"""单表的完整字段说明。agent 调 db_schema 工具时返回这个。"""
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]
)
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()]