Files
AgentLab/backend/app/data/schema_view.py
T

259 lines
13 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.
"""数据源的只读表结构视图:库概览、表目录、表详情(字段、数据目录、连接条件、两跳的连接路径)。
助手查看(app/look/tables.py)、运行时 agent 的 db_schema__(tools/datasource.py)、/api/datasources/{id}/schema?table=
三处共用这一份(look 文档 D6:同一张表只有一种说法,同一套遮罩规矩)。以前三处各拼各的:助手的小库摘要每张表只列
前 6 列,db_schema 和助手又都把遮罩列的目录原样交给模型。
**遮罩规矩。** 遮罩列(data/engine.py 的 masked_columns,按列名设在整个数据源上)只给列名和类型:它的注释、目录里
这一列的各项(含码值、带具体取值的说明)都不给。和表助手(data/catalog_assistant.py)是同一条规矩。代价是运行时
agent 看到的目录少了遮罩列那部分(README 写明)。
只读:只看表结构缓存(schema_cache)和调用方读好的数据目录,不碰业务库。这里的文字都是写给模型看的,不上界面。
"""
from __future__ import annotations
import copy
from collections.abc import Iterable, Mapping
from typing import Any
from app.data import catalog, introspect
from app.data.catalog import CARDINALITY_LABEL, INFERRED_MARK, JoinEdge
from app.data.engine import masked_columns
#: 一个源最多列多少条连接条件、多少条连接路径
JOIN_LINES_LIMIT = 40
PATH_LINES_LIMIT = 12
# ==========================================================================
# 写给模型的文字
# ==========================================================================
OVERVIEW_LINE_PROMPT = "- {name}({kind},{access}):{description}|{state}"
OVERVIEW_TABLES_PROMPT = "共 {total} 张表"
OVERVIEW_PARTIAL_PROMPT = "共 {total} 张表,只探查了 {explored} 张"
OVERVIEW_CATALOG_ALL_PROMPT = "都有数据目录"
OVERVIEW_CATALOG_SOME_PROMPT = "{n} 张有数据目录"
OVERVIEW_CATALOG_NONE_PROMPT = "还没有数据目录"
OVERVIEW_MASKED_PROMPT = "设了遮罩"
OVERVIEW_SCHEMAS_PROMPT = "该账号名下没有对象,数据可能在这些 schema:{names}"
MASKED_COLUMN_PROMPT = "已遮罩,只给列名和类型"
BRIEF_PROMPT = "{name}[{kind}]:{columns}(全部 {n} 列,字数超了只给字段名,要看字段说明和目录时点名再 look)"
JOIN_HEAD_PROMPT = "表之间的连接条件(写 JOIN … ON … 时照抄):"
JOIN_MORE_PROMPT = "…另有 {n} 条,点名查看相关的表看各表的关联关系"
PATH_HEAD_PROMPT = "连接路径:"
ROUTE_PROMPT = "{a} 与 {b} 没有直接关联,经 {mid} 连接:FROM {a} JOIN {mid} ON {first} JOIN {b} ON {second}"
# ==========================================================================
# 遮罩
# ==========================================================================
def masked_set(source: Any) -> set[str]:
"""这个源的遮罩列(小写)。"""
return {c.lower() for c in masked_columns(getattr(source, "options", None))}
def _masked_meta(meta: Mapping[str, Any], masked: set[str]) -> dict[str, Any]:
"""表结构里遮罩列的注释抹掉:注释里常有取值说明(「1=男 2=女」),遮罩列只给列名和类型。"""
out = dict(meta)
out["columns"] = [{**col, "comment": None} if str(col.get("name") or "").lower() in masked else col
for col in meta.get("columns") or [] if isinstance(col, Mapping)]
return out
def masked_notes(notes: Mapping[str, Any] | None, masked: set[str]) -> dict[str, Any]:
"""目录里遮罩列的各项整列去掉(含码值和说明)。表级的项、关联关系只是列名,照给。"""
out = copy.deepcopy(dict(notes or {}))
columns = out.get("columns")
if masked and isinstance(columns, dict):
out["columns"] = {name: items for name, items in columns.items() if str(name).lower() not in masked}
return out
# ==========================================================================
# 库概览、表目录
# ==========================================================================
def overview_line(source: Any, *, catalog_tables: int) -> str:
"""system 里一个库一行:名称、类型、是否只读、说明、表数(没探查全时写明)、有数据目录的表数、有没有设遮罩;
没探查或探查失败的写原因(introspect.why_empty)。"""
cache = getattr(source, "schema_cache", None) or {}
tables = cache.get("tables") or {}
if not tables:
state = introspect.why_empty(cache)
if candidates := cache.get("available_schemas"):
state += "," + OVERVIEW_SCHEMAS_PROMPT.format(names="、".join(candidates[:8]))
else:
total = cache.get("total")
total = max(total if isinstance(total, int) and not isinstance(total, bool) else 0, len(tables))
parts = [OVERVIEW_PARTIAL_PROMPT.format(total=total, explored=len(tables)) if cache.get("truncated")
and total > len(tables) else OVERVIEW_TABLES_PROMPT.format(total=total)]
if catalog_tables >= len(tables):
parts.append(OVERVIEW_CATALOG_ALL_PROMPT)
elif catalog_tables:
parts.append(OVERVIEW_CATALOG_SOME_PROMPT.format(n=catalog_tables))
else:
parts.append(OVERVIEW_CATALOG_NONE_PROMPT)
if masked_set(source):
parts.append(OVERVIEW_MASKED_PROMPT)
state = ",".join(parts)
return OVERVIEW_LINE_PROMPT.format(
name=source.name, kind=source.kind, access="只读" if source.readonly else "可写",
description=source.description or "未填说明", state=state)
def table_index(source: Any, notes_by_table: Mapping[str, Mapping[str, Any]], *,
tables: Iterable[str] | None = None) -> str:
"""紧凑的表目录,每张表一行:表名|中文名|粒度(catalog.render_table_index)。挑选失败时交回给模型点名。"""
return catalog.render_table_index(getattr(source, "schema_cache", None), notes_by_table, tables=tables)
# ==========================================================================
# 表详情
# ==========================================================================
def table_detail(source: Any, table: str, *, catalog_notes: Mapping[str, Mapping[str, Any]] | None = None) -> str:
"""单表的完整字段说明,有可给模型看的目录时附在后面;遮罩列只给列名和类型。
catalog_notes 是这个源的数据目录 {表名: notes}(原样,含驳回项:render_table_notes 自己去掉)。找不到这张表时
交回的几种说法(结构没探查、只探查了一部分、确实没有)和 introspect.describe_table 一样。
"""
meta = introspect.find_table(source, table)
if not meta:
return introspect.describe_table(source, table)
masked = masked_set(source)
shown = _masked_meta(meta, masked)
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 shown["columns"]:
marks = []
if str(col.get("name") or "").lower() in masked:
marks.append(MASKED_COLUMN_PROMPT)
else:
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.get('type') or ''}{suffix}")
if catalog_notes:
key = catalog.resolve_table_name(getattr(source, "schema_cache", None), table)
block = catalog.render_table_notes(masked_notes(catalog_notes.get(key) if key else None, masked),
meta=shown, system_notes=catalog.system_notes_source(source))
if block:
lines.append(block)
return "\n".join(lines)
def table_brief(source: Any, table: str) -> str:
"""字数超了时的一行:表名和全部字段名(遮罩列的名字照给,名字本身不是值)。"""
meta = introspect.find_table(source, table) or {}
columns = [str(c.get("name")) for c in meta.get("columns") or [] if isinstance(c, Mapping) and c.get("name")]
return BRIEF_PROMPT.format(name=meta.get("qualified", table), kind="视图" if meta.get("is_view") else "表",
columns="、".join(columns), n=len(columns))
# ==========================================================================
# 连接条件与连接路径
# ==========================================================================
def relation_graph(source: Any, raw_notes: Mapping[str, Mapping[str, Any]]) -> dict[str, list[JoinEdge]]:
"""这个源的关系图:目录里的关系(驳回的不进,驳回项还要压住同一条关系的现推结果,所以给原样的目录)加上按表结构
现推的(外键约束、命名推断)。"""
return catalog.relation_graph(raw_notes, schema_cache=getattr(source, "schema_cache", None))
def complete(picked: list[str], graph: Mapping[str, list[JoinEdge]],
room: int) -> tuple[list[str], list[tuple[str, str, tuple[JoinEdge, ...]]]]:
"""补连接用的中间表:(给详情的表, [(a, b, 经中间表的两跳路径)])。room 是还能补几张。
挑中的表两两之间已经直接相连的不补;有一条路径的中间表已经在里面的也不补。
"""
tables = list(picked)
routes: list[tuple[str, str, tuple[JoinEdge, ...]]] = []
for i, a in enumerate(picked):
for b in picked[i + 1:]:
if any(e.to_table == b for e in graph.get(a, ())):
continue
paths = [p for p in catalog.join_paths(graph, a, b, max_hops=2) if len(p) == 2]
via = next((p for p in paths if p[0].to_table in tables), None)
if via is None and paths and len(tables) - len(picked) < room:
via = paths[0]
tables.append(via[0].to_table)
if via is not None:
routes.append((a, b, via))
return tables, routes
def qualifier(source: Any):
"""表名(schema_cache 的键)→ 全名:模型照着全名写 SQL,漏了 schema 前缀在 Oracle 上直接报错。"""
tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
return lambda key: str((tables.get(key) or {}).get("qualified") or key)
def _condition(edge: JoinEdge, qualified) -> str:
"""连接条件,表名用全名:channel_visits.visit_id = visits.id(多列用 AND 连接)。"""
left, right = qualified(edge.from_table), qualified(edge.to_table)
return " AND ".join(f"{left}.{a} = {right}.{b}" for a, b in zip(edge.from_columns, edge.to_columns))
def _edge_line(edge: JoinEdge, qualified) -> str:
card = f"({CARDINALITY_LABEL[edge.cardinality]})" if edge.cardinality in CARDINALITY_LABEL else ""
return _condition(edge, qualified) + card + (INFERRED_MARK if edge.status == "proposed" else "")
def _edges_within(graph: Mapping[str, list[JoinEdge]], tables: list[str]) -> list[JoinEdge]:
"""两端都在 tables 里的关系,每条一次(按 relation_id);一对多的翻过来,从「多」的一端写起(外键所在的表在左)。"""
wanted = set(tables)
seen: set[str] = set()
out = []
for table in tables:
for edge in graph.get(table, ()):
if edge.to_table not in wanted or edge.relation_id in seen:
continue
seen.add(edge.relation_id)
out.append(edge.reversed() if edge.cardinality == "one_to_many" else edge)
return out
def join_lines(source: Any, graph: Mapping[str, list[JoinEdge]], tables: list[str], *, indent: str = " ") -> list[str]:
"""给出的几张表之间的连接条件,可以照抄进 SQL。没有就是空列表。"""
edges = _edges_within(graph, tables)
if not edges:
return []
qualified = qualifier(source)
lines = [indent + JOIN_HEAD_PROMPT]
lines += [f"{indent} {_edge_line(e, qualified)}" for e in edges[:JOIN_LINES_LIMIT]]
if len(edges) > JOIN_LINES_LIMIT:
lines.append(indent + " " + JOIN_MORE_PROMPT.format(n=len(edges) - JOIN_LINES_LIMIT))
return lines
def route_lines(source: Any, routes: list[tuple[str, str, tuple[JoinEdge, ...]]], *, indent: str = " ") -> list[str]:
"""两张表没有直接关联、要经一张中间表连接的写法。"""
if not routes:
return []
qualified = qualifier(source)
lines = [indent + PATH_HEAD_PROMPT]
for a, b, (first, second) in routes[:PATH_LINES_LIMIT]:
line = ROUTE_PROMPT.format(a=qualified(a), b=qualified(b), mid=qualified(first.to_table),
first=_condition(first, qualified), second=_condition(second, qualified))
inferred = first.status == "proposed" or second.status == "proposed"
lines.append(f"{indent} {line}" + (INFERRED_MARK if inferred else ""))
return lines
__all__ = [
"JOIN_LINES_LIMIT", "PATH_LINES_LIMIT", "complete", "join_lines", "masked_notes", "masked_set", "overview_line",
"qualifier", "relation_graph", "route_lines", "table_brief", "table_detail", "table_index",
]