Files
AgentLab/backend/app/data/introspect.py
T
ThearchyHelios 3e311ec551 第三波收尾:跨模块整合、全站扫尾、检查脚本整合;网页工具认得本机代理的 fake-ip
整合(3c-integrate)
- 公共层:跨天时长(formatSpan / formatOffset / formatLapse)、本机署名(lib/actor)、检索分类色令牌、
  数据源目录(useDatasources)收进公共层;失败说明能一次列出多个写坏的自定义工具,查询超时指到数据源的查询时限
- 管理页收尾:设置页接入统一的离开前确认;检查脚本不再把沙箱里的真实值打进输出
- 画布 ↔ 右栏:点出具印章、点节点能跳到右栏对应位置;「定位」直接聚焦到出问题的字段
- 回答流 / 问数据 / 记录页:失败信息带「去哪修」且不再被翻译两遍;协作团队的结局按执行截取;
  等审批时断流重连、而运行已在空档里跑完的,补全事件

扫尾(3c-sweep)
- 术语统一:开始运行 / 继续运行、工作流最大步数;未保存的运行统一叫「未保存的工作流」(后端新运行同步)
- 沙箱报错不再带 Python 异常类名;新增 check-tokens,未定义的 CSS 变量为 0,两个旧别名迁移后删除

检查脚本(3c-checks)
- check-ui 跟上新界面(运行列表按 data 属性找行),加上令牌与选中态的回归断言,输出里的名字打码
- 新增 check-all:按顺序跑全部检查、汇总退出码,--repeat 列出偶发项
- 单节出错只记这一节失败、不再带崩整个脚本;外壳遥测浮层「重新检测」时焦点不再掉到 body

网页工具与 fake-ip
- Clash / mihomo 一类代理开 TUN + fake-ip 时,查什么都先拿到 198.18 段的假地址,被当成内网拦截。
  新增 AGENTLAB_HTTP_TOOL_FAKE_IP_RANGES(默认空):落在段里的解析结果改向公共 DNS(DoH)核实真实地址,
  公网放行、内网照拦、核实不了按拦截处理。DoH 默认先问境外的,境内公共 DNS 会对被封锁的域名返回污染结果
- 列表型环境变量接受逗号分隔(原来注释说逗号分隔、实际只认 JSON 数组);域名解析改为异步,不再阻塞事件循环

验证(沙箱):tsc 干净,pytest 866 passed(两遍),检查脚本全绿。
2026-09-27 23:37:32 +08:00

358 lines
16 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
# 各家的系统 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)多半是偶发的服务端超时——所以真正的对策不在这里,
# 在下面:失败要如实记成失败,让工具和界面都说真话。
def _multi(fn: Any, **kw: Any) -> dict[Any, Any]:
try:
return dict(fn(schema=target_schema, filter_names=picked,
kind=ObjectKind.ANY, **kw))
except NotImplementedError:
return {} # 表注释这类不是所有方言都支持
except Exception: # noqa: BLE001 - 取不到不该让整次探查失败
return {}
columns_by = _multi(inspector.get_multi_columns)
pk_by = _multi(inspector.get_multi_pk_constraint)
comment_by = _multi(inspector.get_multi_table_comment)
tables: dict[str, Any] = {}
for name in picked:
key = (target_schema, name)
columns = columns_by.get(key)
if not columns:
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,
}
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 不对,这种情况给出可选项比报错有用得多。
#
# 但**这里回来的是一次失败,不是"还没探查"**。以前两者存成了一模一样
# 的形状(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()]