Files
my-v0-server/app/agent/data_tools.py
T

139 lines
5.6 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.
"""数据文件的本地工具:describe_data 查看列概况,query_data 执行只读 SQL;问答和 screen agent 共用
apply_actions 里的 $query 也通过 DataContext.query 执行,见 tools.py
工具只能看到 DataContext 里的表,也就是当前用户在本会话里未过期的文件,查询引擎也只为这些表建视图
工具通过 runtime.context.data 读取 DataContext,用到它们的 agent 上下文都要有 data 字段;
上下文类型因 agent 而异,所以写 ToolRuntime[Any](不写类型参数时按 None 处理,序列化会告警)
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import ToolMessage
from pydantic import Field
from app.config import settings
from app.datafiles.query import MAX_ROWS, QueryError, QueryResult, run_query
from app.schemas.data_file import RequestDataFile, RequestDataTable
# 列超过这么多时,不指定 columns 只返回列名和类型,避免一次塞进太多概况
BRIEF_COLUMNS = 50
@dataclass(frozen=True)
class DataContext:
files: tuple[RequestDataFile, ...] = ()
def find(self, name: str) -> tuple[RequestDataFile, RequestDataTable] | None:
for file in self.files:
for table in file.tables:
if table.table == name:
return file, table
return None
def paths(self) -> dict[str, Path]:
return {
table.table: Path(table.path)
for file in self.files
for table in file.tables
}
def query(self, sql: str, *, max_rows: int = MAX_ROWS) -> QueryResult:
"""执行一条只读 SQL;失败时抛 QueryError,原因可以直接返回给模型修正"""
paths = self.paths()
# 任务执行期间被删除或清理的文件跳过,不影响查询其他表
existing = {name: path for name, path in paths.items() if path.exists()}
try:
return run_query(
sql, existing, timeout=settings.data_query_timeout, max_rows=max_rows
)
except QueryError as error:
if gone := [name for name in paths if name not in existing]:
raise QueryError(
f"{error};{'、'.join(gone)} 的文件已删除或过期,不能再查询"
) from None
raise
def _error(runtime: ToolRuntime, message: str) -> ToolMessage:
# 返回给模型修正,不中断 agent
return ToolMessage(message, tool_call_id=runtime.tool_call_id, status="error")
def _table_list(data: DataContext) -> str:
return "、".join(data.paths()) or "无"
@tool
def describe_data(
table: str,
runtime: ToolRuntime[Any],
columns: Annotated[
list[str] | None,
Field(description="只看这些列;不传时看全部列"),
] = None,
) -> dict[str, Any] | ToolMessage:
"""查看一张数据表的列概况(类型、空值率、去重值个数、数值范围和四分位、常见值、样例)、随机样例行,以及解析时做的调整
分析或取数前先看要用的表;表名见请求 data_files 里的 table。统计值来自全表,样例只是其中几行
列很多时只返回列名和类型,再用 columns 指定要看的列
"""
data: DataContext = runtime.context.data
found = data.find(table)
if found is None:
return _error(runtime, f"没有表 {table},可用的表:{_table_list(data)}")
file, item = found
profiled: list[dict[str, Any]] = item.profile["columns"]
result: dict[str, Any] = {
"table": item.table,
"label": item.label,
"rows": item.rows,
}
if columns is None and len(profiled) > BRIEF_COLUMNS:
result["columns"] = [
{key: column[key] for key in ("name", "type", "format") if key in column}
for column in profiled
]
result["note"] = (
f"共 {len(profiled)} 列,只列出列名和类型;用 columns 指定列查看详细概况和样例"
)
else:
index = {column["name"]: position for position, column in enumerate(profiled)}
names = list(index) if columns is None else list(dict.fromkeys(columns))
positions = [index[name] for name in names if name in index]
result["columns"] = [profiled[position] for position in positions]
# 样例行的值和 columns 一一对应
result["sample_rows"] = [
[row[position] for position in positions] for row in item.profile["sample"]
]
if missing := [name for name in names if name not in index]:
result["missing_columns"] = missing
# 针对这张表的警告,以及针对整个文件的警告(例如跳过的坏行)
result["warnings"] = [
warning.message
for warning in file.warnings
if warning.table in (item.table, None)
]
return result
@tool
def query_data(sql: str, runtime: ToolRuntime[Any]) -> dict[str, Any] | ToolMessage:
"""对上传的数据表执行一条只读 SQL(DuckDB 语法),表名是请求 data_files 里的 table(t1、t2……)
中文或带符号的列名用双引号括起来;宽表转长表用 UNPIVOT,PIVOT 要用 IN 写明取值
最多返回 200 行,truncated 为 true 时结果不完整,改用聚合或 LIMIT
回答和图表里的数字以查询结果为准,不要自己心算
"""
data: DataContext = runtime.context.data
try:
return data.query(sql).to_dict()
except QueryError as error:
return _error(runtime, str(error))
DATA_TOOLS = [describe_data, query_data]