mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
3e311ec551
整合(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(两遍),检查脚本全绿。
358 lines
16 KiB
Python
358 lines
16 KiB
Python
"""探查数据库结构。
|
||
|
||
产出两种粒度,对应两种消费者:
|
||
|
||
- `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()]
|