Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
基于 smolagents 框架的 Hugging Face 数据集自然语言查询智能代理
|
| 3 |
+
|
| 4 |
+
功能:
|
| 5 |
+
1. 启动时自动获取数据集文件列表,作为系统提示词告知用户可用数据
|
| 6 |
+
2. 接收用户自然语言输入,从中提取数据集查询所需的参数信息
|
| 7 |
+
3. 使用提取的参数从 Hugging Face 数据集获取数据(支持 ZIP/CSV/Excel 等格式)
|
| 8 |
+
4. 返回结构化的查询结果
|
| 9 |
+
|
| 10 |
+
依赖安装:
|
| 11 |
+
pip install 'smolagents[gradio]' requests pandas huggingface_hub openpyxl
|
| 12 |
+
|
| 13 |
+
使用前:
|
| 14 |
+
1. 将文件顶部 HF_TOKEN 常量的值替换为个人 Hugging Face Settings 下申请的 Read Token(用于数据集访问)
|
| 15 |
+
2. 将文件顶部 OPENAI_API_KEY 常量的值替换为你的 OpenAI API Key(用于 LLM 接入)
|
| 16 |
+
3. 如使用第三方兼容服务,修改 OPENAI_API_BASE 常量的地址
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import io
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import re
|
| 23 |
+
import zipfile
|
| 24 |
+
from typing import Optional, List
|
| 25 |
+
|
| 26 |
+
import requests
|
| 27 |
+
import pandas as pd
|
| 28 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 29 |
+
from smolagents import Tool, ToolCallingAgent, OpenAIServerModel, GradioUI
|
| 30 |
+
|
| 31 |
+
# ===========================================================================
|
| 32 |
+
# Hugging Face 数据集访问 Token(用于从 HF 数据集获取数据)
|
| 33 |
+
# ===========================================================================
|
| 34 |
+
HF_TOKEN =
|
| 35 |
+
|
| 36 |
+
# ===========================================================================
|
| 37 |
+
# 数据集仓库 ID
|
| 38 |
+
# ===========================================================================
|
| 39 |
+
HF_DATASET_REPO = "squid-lab/squid_dataset" # Hugging Face 数据集仓库 ID
|
| 40 |
+
|
| 41 |
+
# ===========================================================================
|
| 42 |
+
# OpenAI 模型配置常量
|
| 43 |
+
# ===========================================================================
|
| 44 |
+
OPENAI_API_KEY = # 请替换为你的 OpenAI API Key
|
| 45 |
+
OPENAI_API_BASE = "https://api.deepseek.com/v1" # OpenAI API 地址(注意需要 /v1 后缀)
|
| 46 |
+
MODEL_ID = "deepseek-chat" # DeepSeek 模型:deepseek-chat 支持工具调用,deepseek-reasoner 不支持
|
| 47 |
+
|
| 48 |
+
# ===========================================================================
|
| 49 |
+
# DeepSeek 思考模式配置
|
| 50 |
+
# ===========================================================================
|
| 51 |
+
THINKING_MODE_ENABLED = True # 是否启用思考模式(True 启用,False 禁用)
|
| 52 |
+
THINKING_EFFORT = "high" # 思考强度:low/medium/high/max(实际 low/medium 会映射为 high)
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# 日志配置
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
logging.basicConfig(
|
| 58 |
+
level=logging.INFO,
|
| 59 |
+
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
| 60 |
+
)
|
| 61 |
+
logger = logging.getLogger("hf_data_agent")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ===========================================================================
|
| 65 |
+
# 数据集文件列表获取函数
|
| 66 |
+
# ===========================================================================
|
| 67 |
+
def get_dataset_file_list() -> List[str]:
|
| 68 |
+
"""
|
| 69 |
+
从 Hugging Face 数据集仓库获取一级文件和文件夹名称列表。
|
| 70 |
+
|
| 71 |
+
使用 HfApi.dataset_info() 获取数据集元信息,提取 siblings 中的文件路径,
|
| 72 |
+
只保留一级目录/文件(不包含深层嵌套的文件)。
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
一级文件和文件夹名称列表,如 ['中西太平洋WCPFC/', 'README.md', 'data.csv']
|
| 76 |
+
"""
|
| 77 |
+
logger.info("正在获取数据集文件列表: %s", HF_DATASET_REPO)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
api = HfApi(token=HF_TOKEN)
|
| 81 |
+
dataset_info = api.dataset_info(repo_id=HF_DATASET_REPO)
|
| 82 |
+
|
| 83 |
+
# 提取所有文件路径
|
| 84 |
+
all_files = [sibling.rfilename for sibling in dataset_info.siblings]
|
| 85 |
+
|
| 86 |
+
# 只保留一级目录/文件(路径中不包含 '/' 的项,或者一级文件夹)
|
| 87 |
+
first_level_items = set()
|
| 88 |
+
for file_path in all_files:
|
| 89 |
+
parts = file_path.split("/")
|
| 90 |
+
# 一级文件(无子目录)
|
| 91 |
+
if len(parts) == 1:
|
| 92 |
+
first_level_items.add(parts[0])
|
| 93 |
+
# 一级文件夹(取第一部分,添加 '/' 后缀标识为文件夹)
|
| 94 |
+
else:
|
| 95 |
+
first_level_items.add(parts[0] + "/")
|
| 96 |
+
|
| 97 |
+
file_list = sorted(list(first_level_items))
|
| 98 |
+
logger.info("获取到 %d 个一级文件/文件夹: %s", len(file_list), file_list)
|
| 99 |
+
return file_list
|
| 100 |
+
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.error("获取数据集文件列表失败: %s", e, exc_info=True)
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def build_system_prompt_with_file_list() -> str:
|
| 107 |
+
"""
|
| 108 |
+
构建包含数据集文件列表的系统提示词。
|
| 109 |
+
|
| 110 |
+
在对话启动时告知用户当前可用的数据集文件,帮助用户了解可查询的内容。
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
格式化的系统提示词字符串
|
| 114 |
+
"""
|
| 115 |
+
file_list = get_dataset_file_list()
|
| 116 |
+
|
| 117 |
+
if not file_list:
|
| 118 |
+
return (
|
| 119 |
+
"你是一个 Hugging Face 数据集查询助手。"
|
| 120 |
+
"用户可以询问数据集内容,你将帮助用户查询和获取数据。"
|
| 121 |
+
"当前无法获取数据集文件列表,请检查 HF_TOKEN 配置是否正确。"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# 格式化文件列表为提示词
|
| 125 |
+
files_str = "\n".join([f" - {item}" for item in file_list])
|
| 126 |
+
system_prompt = (
|
| 127 |
+
"你是一个 Hugging Face 数据集查询助手。\n\n"
|
| 128 |
+
"当前可查询的数据集仓库: " + HF_DATASET_REPO + "\n\n"
|
| 129 |
+
"数据集一级文件和文件夹列表:\n" + files_str + "\n\n"
|
| 130 |
+
"用户可以询问数据集中的具体内容,例如:\n"
|
| 131 |
+
" - '查看中西太平洋的渔获数据'\n"
|
| 132 |
+
" - '查询1967-2024年的月尺度数据'\n"
|
| 133 |
+
" - '获取某个特定文件的内容'\n\n"
|
| 134 |
+
"请根据用户的需求,使用工具帮助用户查询和获取数据。"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
logger.info("系统提示词已构建,包含 %d 个文件/文件夹", len(file_list))
|
| 138 |
+
return system_prompt
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ===========================================================================
|
| 142 |
+
# DeepSeek 思考模式自定义模型类
|
| 143 |
+
# ===========================================================================
|
| 144 |
+
class DeepSeekThinkingModel(OpenAIServerModel):
|
| 145 |
+
"""
|
| 146 |
+
继承 OpenAIServerModel,为 DeepSeek 思考模式添加特殊处理。
|
| 147 |
+
|
| 148 |
+
DeepSeek 思考模式特殊要求:
|
| 149 |
+
1. 使用 extra_body={"thinking": {"type": "enabled"}} 开启思考模式
|
| 150 |
+
2. 设置 reasoning_effort 参数控制思考强度
|
| 151 |
+
3. 思考模式不支持 tool_choice 参数(会导致 400 错误)
|
| 152 |
+
4. 工具调用后必须回传 reasoning_content
|
| 153 |
+
"""
|
| 154 |
+
|
| 155 |
+
def __init__(
|
| 156 |
+
self,
|
| 157 |
+
model_id: str = MODEL_ID,
|
| 158 |
+
api_key: str = OPENAI_API_KEY,
|
| 159 |
+
api_base: str = OPENAI_API_BASE,
|
| 160 |
+
thinking_enabled: bool = THINKING_MODE_ENABLED,
|
| 161 |
+
reasoning_effort: str = THINKING_EFFORT,
|
| 162 |
+
**kwargs,
|
| 163 |
+
):
|
| 164 |
+
"""
|
| 165 |
+
初始化 DeepSeek 思考模式模型。
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
model_id: 模型 ID
|
| 169 |
+
api_key: API Key
|
| 170 |
+
api_base: API 地址
|
| 171 |
+
thinking_enabled: 是否启用思考模式
|
| 172 |
+
reasoning_effort: 思考强度(low/medium/high/max)
|
| 173 |
+
"""
|
| 174 |
+
# 调用父类初始化(移除不支持的工具调用参数)
|
| 175 |
+
super().__init__(
|
| 176 |
+
model_id=model_id,
|
| 177 |
+
api_key=api_key,
|
| 178 |
+
api_base=api_base,
|
| 179 |
+
**kwargs,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
self.thinking_enabled = thinking_enabled
|
| 183 |
+
self.reasoning_effort = reasoning_effort
|
| 184 |
+
logger.info(
|
| 185 |
+
"DeepSeek 思考模式模型初始化: model_id=%s, thinking=%s, effort=%s",
|
| 186 |
+
model_id,
|
| 187 |
+
thinking_enabled,
|
| 188 |
+
reasoning_effort,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def generate(self, messages, tools=None, tool_choice=None, **kwargs):
|
| 192 |
+
"""
|
| 193 |
+
重写 generate 方法,添加思考模式特殊处理。
|
| 194 |
+
|
| 195 |
+
关键处理:
|
| 196 |
+
1. 移除 tool_choice 参数(思考模式不支持)
|
| 197 |
+
2. 添加 extra_body 参数开启思考模式
|
| 198 |
+
3. 设置 reasoning_effort 参数
|
| 199 |
+
"""
|
| 200 |
+
# --- 处理 tool_choice 参数 ---
|
| 201 |
+
# DeepSeek 思考模式不支持 tool_choice,必须移除或设置为 None
|
| 202 |
+
if self.thinking_enabled and tool_choice is not None:
|
| 203 |
+
logger.warning(
|
| 204 |
+
"DeepSeek 思考模式不支持 tool_choice 参数,已自动移除。"
|
| 205 |
+
"如果需要强制工具调用,请设置 THINKING_MODE_ENABLED = False"
|
| 206 |
+
)
|
| 207 |
+
tool_choice = None # 强制设为 None,避免 400 错误
|
| 208 |
+
|
| 209 |
+
# --- 构建思考模式参数 ---
|
| 210 |
+
if self.thinking_enabled:
|
| 211 |
+
# 使用 extra_body 传递思考模式参数(OpenAI SDK 不直接支持)
|
| 212 |
+
extra_body = {
|
| 213 |
+
"thinking": {"type": "enabled"},
|
| 214 |
+
}
|
| 215 |
+
# 设置 reasoning_effort(可以通过 kwargs 或直接传入)
|
| 216 |
+
if "reasoning_effort" not in kwargs:
|
| 217 |
+
kwargs["reasoning_effort"] = self.reasoning_effort
|
| 218 |
+
|
| 219 |
+
# 将 extra_body 传入 kwargs(OpenAI SDK 会将其传递给 API)
|
| 220 |
+
if "extra_body" not in kwargs:
|
| 221 |
+
kwargs["extra_body"] = extra_body
|
| 222 |
+
else:
|
| 223 |
+
# 合并已有的 extra_body
|
| 224 |
+
kwargs["extra_body"].update(extra_body)
|
| 225 |
+
|
| 226 |
+
# --- 调用父类 generate ---
|
| 227 |
+
# 注意:移除不支持的温度等参数
|
| 228 |
+
unsupported_params = ["temperature", "top_p", "presence_penalty", "frequency_penalty"]
|
| 229 |
+
for param in unsupported_params:
|
| 230 |
+
if param in kwargs:
|
| 231 |
+
logger.warning("DeepSeek 思考模式不支持 %s 参数,已自动移除", param)
|
| 232 |
+
kwargs.pop(param)
|
| 233 |
+
|
| 234 |
+
return super().generate(
|
| 235 |
+
messages=messages,
|
| 236 |
+
tools=tools,
|
| 237 |
+
tool_choice=tool_choice,
|
| 238 |
+
**kwargs,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ===========================================================================
|
| 243 |
+
# Tool 1: 参数提取 —— 从自然语言中解析出数据集查询参数
|
| 244 |
+
# ===========================================================================
|
| 245 |
+
class ParameterExtractionTool(Tool):
|
| 246 |
+
"""从用户的自然语言描述中提取 Hugging Face 数据集查询所需的结构化参数。"""
|
| 247 |
+
|
| 248 |
+
name = "parameter_extractor"
|
| 249 |
+
description = (
|
| 250 |
+
"从用户的自然语言查询请求中提取数据集查询参数,包括:"
|
| 251 |
+
"region(区域,如中西太平洋)、time_range(时间范围,如1967-2024)、"
|
| 252 |
+
"time_scale(时间尺度,如月尺度)、spatial_resolution(空间分辨率,如1x1)、"
|
| 253 |
+
"file_name(文件名)。返回 JSON 格式的参数字典。"
|
| 254 |
+
)
|
| 255 |
+
inputs = {
|
| 256 |
+
"user_query": {
|
| 257 |
+
"type": "string",
|
| 258 |
+
"description": (
|
| 259 |
+
"用户的自然语言查询请求,例如'查询中西太平洋1967-2024年月尺度1x1分辨率的渔获数据'"
|
| 260 |
+
),
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
output_type = "string"
|
| 264 |
+
|
| 265 |
+
# --- 区域关键字映射表 ---
|
| 266 |
+
REGION_KEYWORDS = {
|
| 267 |
+
"中西太平洋": "中西太平洋WCPFC",
|
| 268 |
+
"wcpfc": "中西太平洋WCPFC",
|
| 269 |
+
"东太平洋": "东太平洋",
|
| 270 |
+
"太平洋": "中西太平洋WCPFC",
|
| 271 |
+
"印度洋": "印度洋",
|
| 272 |
+
"大西洋": "大西洋",
|
| 273 |
+
"南海": "南海",
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
# --- 时间尺度映射表 ---
|
| 277 |
+
TIME_SCALE_KEYWORDS = {
|
| 278 |
+
"月尺度": "月尺度",
|
| 279 |
+
"月度": "月尺度",
|
| 280 |
+
"月": "月尺度",
|
| 281 |
+
"年尺度": "年尺度",
|
| 282 |
+
"年度": "年尺度",
|
| 283 |
+
"年": "年尺度",
|
| 284 |
+
"日尺度": "日尺度",
|
| 285 |
+
"日度": "日尺度",
|
| 286 |
+
"日": "日尺度",
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
# --- 空间分辨率映射表 ---
|
| 290 |
+
SPATIAL_RESOLUTION_KEYWORDS = {
|
| 291 |
+
"1x1": "1x1",
|
| 292 |
+
"1度": "1x1",
|
| 293 |
+
"0.5x0.5": "0.5x0.5",
|
| 294 |
+
"0.5度": "0.5x0.5",
|
| 295 |
+
"0.1x0.1": "0.1x0.1",
|
| 296 |
+
"0.1度": "0.1x0.1",
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
def forward(self, user_query: str) -> str:
|
| 300 |
+
"""
|
| 301 |
+
解析自然语言,提取数据集查询参数。
|
| 302 |
+
|
| 303 |
+
参数提取流程:
|
| 304 |
+
1. 识别目标区域(优先匹配关键字映射表)
|
| 305 |
+
2. 识别时间范围(起始年份-结束年份)
|
| 306 |
+
3. 识别时间尺度(月尺度/年尺度/日尺度)
|
| 307 |
+
4. 识别空间分辨率(1x1/0.5x0.5等)
|
| 308 |
+
5. 识别文件名(用户指定的具体文件名)
|
| 309 |
+
"""
|
| 310 |
+
logger.info("开始提取参数,输入: %s", user_query)
|
| 311 |
+
|
| 312 |
+
try:
|
| 313 |
+
params = {
|
| 314 |
+
"region": self._extract_region(user_query),
|
| 315 |
+
"time_range": self._extract_time_range(user_query),
|
| 316 |
+
"time_scale": self._extract_time_scale(user_query),
|
| 317 |
+
"spatial_resolution": self._extract_spatial_resolution(user_query),
|
| 318 |
+
"file_name": self._extract_file_name(user_query),
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
# 校验必要参数:区域不能为空
|
| 322 |
+
if not params["region"]:
|
| 323 |
+
error_msg = (
|
| 324 |
+
"无法从输入中识别目标区域,请在查询中明确指定区域。"
|
| 325 |
+
f"支持的区域关键字: {list(self.REGION_KEYWORDS.keys())}"
|
| 326 |
+
)
|
| 327 |
+
logger.warning(error_msg)
|
| 328 |
+
return json.dumps({"error": error_msg}, ensure_ascii=False)
|
| 329 |
+
|
| 330 |
+
logger.info("参数提取成功: %s", params)
|
| 331 |
+
return json.dumps(params, ensure_ascii=False)
|
| 332 |
+
|
| 333 |
+
except Exception as e:
|
| 334 |
+
logger.error("参数提取失败: %s", e, exc_info=True)
|
| 335 |
+
return json.dumps(
|
| 336 |
+
{"error": f"参数提取过程发生错误: {str(e)}"},
|
| 337 |
+
ensure_ascii=False,
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# ----- 以下为私有辅助方法,按提取维度拆分 -----
|
| 341 |
+
|
| 342 |
+
def _extract_region(self, query: str) -> Optional[str]:
|
| 343 |
+
"""
|
| 344 |
+
从自然语言中识别目标区域。
|
| 345 |
+
优先级:关键字映射 → 直接匹配区域名称
|
| 346 |
+
"""
|
| 347 |
+
# 优先通过关键字映射识别
|
| 348 |
+
for cn_keyword, region_name in self.REGION_KEYWORDS.items():
|
| 349 |
+
if cn_keyword.lower() in query.lower():
|
| 350 |
+
return region_name
|
| 351 |
+
|
| 352 |
+
return None
|
| 353 |
+
|
| 354 |
+
def _extract_time_range(self, query: str) -> Optional[str]:
|
| 355 |
+
"""
|
| 356 |
+
从自然语言中识别时间范围。
|
| 357 |
+
匹配"XXXX-XXXX"格式或"XXXX年到XXXX年"格式。
|
| 358 |
+
"""
|
| 359 |
+
# 匹配"1967-2024"格式
|
| 360 |
+
match = re.search(r"(\d{4})-(\d{4})", query)
|
| 361 |
+
if match:
|
| 362 |
+
return f"{match.group(1)}-{match.group(2)}"
|
| 363 |
+
|
| 364 |
+
# 匹配"1967年到2024年"或"1967至2024"格式
|
| 365 |
+
match = re.search(r"(\d{4})\s*(?:年到|至|-)\s*(\d{4})", query)
|
| 366 |
+
if match:
|
| 367 |
+
return f"{match.group(1)}-{match.group(2)}"
|
| 368 |
+
|
| 369 |
+
# 匹配单一年份"2024年"
|
| 370 |
+
match = re.search(r"(\d{4})\s*年", query)
|
| 371 |
+
if match:
|
| 372 |
+
year = match.group(1)
|
| 373 |
+
return f"{year}-{year}"
|
| 374 |
+
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
def _extract_time_scale(self, query: str) -> Optional[str]:
|
| 378 |
+
"""
|
| 379 |
+
从自然语言中识别时间尺度。
|
| 380 |
+
优先级:关键字映射 → 默认月尺度
|
| 381 |
+
"""
|
| 382 |
+
for cn_keyword, scale_name in self.TIME_SCALE_KEYWORDS.items():
|
| 383 |
+
if cn_keyword in query:
|
| 384 |
+
return scale_name
|
| 385 |
+
|
| 386 |
+
return "月尺度" # 默认月尺度
|
| 387 |
+
|
| 388 |
+
def _extract_spatial_resolution(self, query: str) -> Optional[str]:
|
| 389 |
+
"""
|
| 390 |
+
从自然语言中识别空间分辨率。
|
| 391 |
+
匹配"XxX"格式或"X度"格式。
|
| 392 |
+
"""
|
| 393 |
+
# 优先通过关键字映射识别
|
| 394 |
+
for cn_keyword, resolution in self.SPATIAL_RESOLUTION_KEYWORDS.items():
|
| 395 |
+
if cn_keyword in query:
|
| 396 |
+
return resolution
|
| 397 |
+
|
| 398 |
+
# 匹配"1x1"格式
|
| 399 |
+
match = re.search(r"(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)", query)
|
| 400 |
+
if match:
|
| 401 |
+
return f"{match.group(1)}x{match.group(2)}"
|
| 402 |
+
|
| 403 |
+
return "1x1" # 默认1x1分辨率
|
| 404 |
+
|
| 405 |
+
def _extract_file_name(self, query: str) -> Optional[str]:
|
| 406 |
+
"""
|
| 407 |
+
从自然语言中识别具体文件名。
|
| 408 |
+
匹配"xxx.zip"或"xxx数据"模式。
|
| 409 |
+
"""
|
| 410 |
+
# 匹配".zip"结尾的文件名
|
| 411 |
+
match = re.search(r"(\S+)\.zip", query)
|
| 412 |
+
if match:
|
| 413 |
+
return f"{match.group(1)}.zip"
|
| 414 |
+
|
| 415 |
+
# 匹配"xxx文件"或"xxx数据"模式
|
| 416 |
+
match = re.search(r"(\w+)\s*(?:文件|数据)", query)
|
| 417 |
+
if match:
|
| 418 |
+
return f"{match.group(1)}.zip"
|
| 419 |
+
|
| 420 |
+
return None
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ===========================================================================
|
| 424 |
+
# Tool 2: Hugging Face 数据集获取 —— 从云端获取并解析数据
|
| 425 |
+
# ===========================================================================
|
| 426 |
+
class HFDataQueryTool(Tool):
|
| 427 |
+
"""从 Hugging Face 数据集安全地获取数据并返回结构化结果。"""
|
| 428 |
+
|
| 429 |
+
name = "hf_data_query"
|
| 430 |
+
description = (
|
| 431 |
+
"根据提取的结构化参数从 Hugging Face 数据集获取数据。"
|
| 432 |
+
"参数需包含 file_path(文件路径,如 '文件夹名/文件名.csv')。"
|
| 433 |
+
"支持多种格式:ZIP(自动解压)、CSV、Excel、Parquet、JSON。"
|
| 434 |
+
"使用 hf_hub_download 下载文件,自动缓存到本地。"
|
| 435 |
+
)
|
| 436 |
+
inputs = {
|
| 437 |
+
"query_params_json": {
|
| 438 |
+
"type": "string",
|
| 439 |
+
"description": (
|
| 440 |
+
"JSON 格式的查询参数,由 parameter_extractor 工具生成或用户直接提供文件路径。"
|
| 441 |
+
"格式示例: {\"file_path\": \"中西太平洋WCPFC 1967-2024 月尺度 1x1/渔获数据.csv\"}"
|
| 442 |
+
),
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
output_type = "string"
|
| 446 |
+
|
| 447 |
+
def __init__(self, *args, **kwargs):
|
| 448 |
+
super().__init__(*args, **kwargs)
|
| 449 |
+
self._hf_token = HF_TOKEN
|
| 450 |
+
self._repo_id = HF_DATASET_REPO
|
| 451 |
+
|
| 452 |
+
def forward(self, query_params_json: str) -> str:
|
| 453 |
+
"""
|
| 454 |
+
解析参数 JSON,使用 hf_hub_download 获取文件并解析数据。
|
| 455 |
+
|
| 456 |
+
处理流程:
|
| 457 |
+
1. 解析参数 JSON,获取文件路径
|
| 458 |
+
2. 使用 hf_hub_download 下载文件(支持任意格式)
|
| 459 |
+
3. 根据文件格式解析数据(ZIP/CSV/Excel/Parquet/JSON)
|
| 460 |
+
4. 返回结构化的查询结果
|
| 461 |
+
"""
|
| 462 |
+
logger.info("收到数据获取请求,参数: %s", query_params_json)
|
| 463 |
+
|
| 464 |
+
# ----- 步骤1: 解析参数 JSON -----
|
| 465 |
+
try:
|
| 466 |
+
params = json.loads(query_params_json)
|
| 467 |
+
except json.JSONDecodeError as e:
|
| 468 |
+
logger.error("参数 JSON 解析失败: %s", e)
|
| 469 |
+
return json.dumps(
|
| 470 |
+
{"error": f"参数格式错误,无法解析为 JSON: {str(e)}"},
|
| 471 |
+
ensure_ascii=False,
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
# 检查参数提取阶段是否已产生错误
|
| 475 |
+
if "error" in params:
|
| 476 |
+
return query_params_json # 直接返回上游错误信息
|
| 477 |
+
|
| 478 |
+
# ----- 步骤2: 获取文件路径 -----
|
| 479 |
+
# 优先使用用户直接指定的 file_path,否则尝试从其他参数构建
|
| 480 |
+
file_path = params.get("file_path")
|
| 481 |
+
if not file_path:
|
| 482 |
+
# 尝试从旧格式参数构建路径
|
| 483 |
+
file_path = self._build_file_path(params)
|
| 484 |
+
|
| 485 |
+
if not file_path:
|
| 486 |
+
return json.dumps(
|
| 487 |
+
{"error": "缺少必填参数: file_path(文件路径),请指定要获取的文件"},
|
| 488 |
+
ensure_ascii=False,
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
logger.info("准备获取文件: %s", file_path)
|
| 492 |
+
|
| 493 |
+
# ----- 步骤3: 使用 hf_hub_download 下载文件 -----
|
| 494 |
+
try:
|
| 495 |
+
local_file_path = hf_hub_download(
|
| 496 |
+
repo_id=self._repo_id,
|
| 497 |
+
filename=file_path,
|
| 498 |
+
repo_type="dataset",
|
| 499 |
+
token=self._hf_token,
|
| 500 |
+
)
|
| 501 |
+
logger.info("文件下载成功,本地路径: %s", local_file_path)
|
| 502 |
+
except Exception as e:
|
| 503 |
+
logger.error("文件下载失败: %s", e, exc_info=True)
|
| 504 |
+
return json.dumps(
|
| 505 |
+
{"error": f"文件下载失败: {str(e)}"},
|
| 506 |
+
ensure_ascii=False,
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
# ----- 步骤4: 根据文件格式解析数据 -----
|
| 510 |
+
try:
|
| 511 |
+
data_content = self._parse_file_by_format(local_file_path)
|
| 512 |
+
if not data_content:
|
| 513 |
+
return json.dumps(
|
| 514 |
+
{"error": f"文件解析失败,未找到有效的数据内容"},
|
| 515 |
+
ensure_ascii=False,
|
| 516 |
+
)
|
| 517 |
+
logger.info("数据解析成功,返回 %d 条记录", len(data_content))
|
| 518 |
+
except Exception as e:
|
| 519 |
+
logger.error("数据解析失败: %s", e, exc_info=True)
|
| 520 |
+
return json.dumps(
|
| 521 |
+
{"error": f"数据解析过程发生错误: {str(e)}"},
|
| 522 |
+
ensure_ascii=False,
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# ----- 步骤5: 返回结构化结果 -----
|
| 526 |
+
display_limit = 100
|
| 527 |
+
result = {
|
| 528 |
+
"success": True,
|
| 529 |
+
"source": f"{self._repo_id}/{file_path}",
|
| 530 |
+
"local_path": local_file_path,
|
| 531 |
+
"total_records": len(data_content),
|
| 532 |
+
"display_records": min(len(data_content), display_limit),
|
| 533 |
+
"data_preview": data_content[:display_limit],
|
| 534 |
+
"columns": list(data_content[0].keys()) if data_content else [],
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
return json.dumps(result, ensure_ascii=False, default=str)
|
| 538 |
+
|
| 539 |
+
def _build_file_path(self, params: dict) -> Optional[str]:
|
| 540 |
+
"""
|
| 541 |
+
从旧格式参数构建文件路径(兼容之前的参数提取方式)。
|
| 542 |
+
|
| 543 |
+
Args:
|
| 544 |
+
params: 包含 region、time_range、time_scale、spatial_resolution、file_name 的字典
|
| 545 |
+
|
| 546 |
+
Returns:
|
| 547 |
+
构建的文件路径字符串,如 '中西太平洋WCPFC 1967-2024 月尺度 1x1/渔获数据.zip'
|
| 548 |
+
"""
|
| 549 |
+
region = params.get("region", "")
|
| 550 |
+
time_range = params.get("time_range", "")
|
| 551 |
+
time_scale = params.get("time_scale", "月尺度")
|
| 552 |
+
spatial_resolution = params.get("spatial_resolution", "1x1")
|
| 553 |
+
file_name = params.get("file_name")
|
| 554 |
+
|
| 555 |
+
if not region or not file_name:
|
| 556 |
+
return None
|
| 557 |
+
|
| 558 |
+
folder_path = f"{region} {time_range} {time_scale} {spatial_resolution}"
|
| 559 |
+
return f"{folder_path}/{file_name}"
|
| 560 |
+
|
| 561 |
+
def _parse_file_by_format(self, local_file_path: str) -> List[dict]:
|
| 562 |
+
"""
|
| 563 |
+
根据文件扩展名选择解析方式。
|
| 564 |
+
|
| 565 |
+
支持的格式:
|
| 566 |
+
- ZIP:解压后解析内部数据文件
|
| 567 |
+
- CSV:直接读取
|
| 568 |
+
- Excel (.xlsx/.xls):使用 pandas 读取
|
| 569 |
+
- Parquet:使用 pandas 读取
|
| 570 |
+
- JSON:直接解析
|
| 571 |
+
|
| 572 |
+
Args:
|
| 573 |
+
local_file_path: 本地文件路径
|
| 574 |
+
|
| 575 |
+
Returns:
|
| 576 |
+
解析后的数据列表,每个元素为字典
|
| 577 |
+
"""
|
| 578 |
+
file_lower = local_file_path.lower()
|
| 579 |
+
|
| 580 |
+
# ZIP 文件:解压并解析
|
| 581 |
+
if file_lower.endswith(".zip"):
|
| 582 |
+
logger.info("检测到 ZIP 文件,开始解压并解析")
|
| 583 |
+
with open(local_file_path, "rb") as f:
|
| 584 |
+
zip_content = f.read()
|
| 585 |
+
return self._extract_and_parse_zip(zip_content)
|
| 586 |
+
|
| 587 |
+
# CSV 文件:直接读取
|
| 588 |
+
if file_lower.endswith(".csv"):
|
| 589 |
+
logger.info("检测到 CSV 文件,开始解析")
|
| 590 |
+
return self._parse_csv_file(local_file_path)
|
| 591 |
+
|
| 592 |
+
# Excel 文件:使用 pandas 读取
|
| 593 |
+
if file_lower.endswith(".xlsx") or file_lower.endswith(".xls"):
|
| 594 |
+
logger.info("检测到 Excel 文件,开始解析")
|
| 595 |
+
return self._parse_excel_file(local_file_path)
|
| 596 |
+
|
| 597 |
+
# Parquet 文件:使用 pandas 读取
|
| 598 |
+
if file_lower.endswith(".parquet"):
|
| 599 |
+
logger.info("检测到 Parquet 文件,开始解析")
|
| 600 |
+
return self._parse_parquet_file(local_file_path)
|
| 601 |
+
|
| 602 |
+
# JSON 文件:直接解析
|
| 603 |
+
if file_lower.endswith(".json"):
|
| 604 |
+
logger.info("检测到 JSON 文件,开始解析")
|
| 605 |
+
return self._parse_json_file(local_file_path)
|
| 606 |
+
|
| 607 |
+
logger.warning("未知文件格式: %s,尝试作为 CSV 解析", local_file_path)
|
| 608 |
+
# 尝试作为 CSV 解析
|
| 609 |
+
return self._parse_csv_file(local_file_path)
|
| 610 |
+
|
| 611 |
+
def _parse_csv_file(self, file_path: str) -> List[dict]:
|
| 612 |
+
"""解析 CSV 文件(支持多种编码)。"""
|
| 613 |
+
encodings = ["utf-8", "gbk", "gb2312", "latin1"]
|
| 614 |
+
for encoding in encodings:
|
| 615 |
+
try:
|
| 616 |
+
df = pd.read_csv(file_path, encoding=encoding)
|
| 617 |
+
records = df.to_dict(orient="records")
|
| 618 |
+
logger.info("CSV 解析成功,编码: %s,记录数: %d", encoding, len(records))
|
| 619 |
+
return records
|
| 620 |
+
except UnicodeDecodeError:
|
| 621 |
+
continue
|
| 622 |
+
except Exception as e:
|
| 623 |
+
logger.error("CSV 解析失败(编码 %s): %s", encoding, e)
|
| 624 |
+
continue
|
| 625 |
+
|
| 626 |
+
logger.error("CSV 文件编码无法识别")
|
| 627 |
+
return []
|
| 628 |
+
|
| 629 |
+
def _parse_excel_file(self, file_path: str) -> List[dict]:
|
| 630 |
+
"""解析 Excel 文件(.xlsx/.xls)。"""
|
| 631 |
+
try:
|
| 632 |
+
df = pd.read_excel(file_path)
|
| 633 |
+
records = df.to_dict(orient="records")
|
| 634 |
+
logger.info("Excel 解析成功,记��数: %d", len(records))
|
| 635 |
+
return records
|
| 636 |
+
except Exception as e:
|
| 637 |
+
logger.error("Excel 解析失败: %s", e)
|
| 638 |
+
return []
|
| 639 |
+
|
| 640 |
+
def _parse_parquet_file(self, file_path: str) -> List[dict]:
|
| 641 |
+
"""解析 Parquet 文件。"""
|
| 642 |
+
try:
|
| 643 |
+
df = pd.read_parquet(file_path)
|
| 644 |
+
records = df.to_dict(orient="records")
|
| 645 |
+
logger.info("Parquet 解析成功,记录数: %d", len(records))
|
| 646 |
+
return records
|
| 647 |
+
except Exception as e:
|
| 648 |
+
logger.error("Parquet 解析失败: %s", e)
|
| 649 |
+
return []
|
| 650 |
+
|
| 651 |
+
def _parse_json_file(self, file_path: str) -> List[dict]:
|
| 652 |
+
"""解析 JSON 文件。"""
|
| 653 |
+
try:
|
| 654 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 655 |
+
data = json.load(f)
|
| 656 |
+
|
| 657 |
+
if isinstance(data, list):
|
| 658 |
+
logger.info("JSON 解析成功,记录数: %d", len(data))
|
| 659 |
+
return data
|
| 660 |
+
|
| 661 |
+
if isinstance(data, dict):
|
| 662 |
+
for key in ["data", "records", "items", "results"]:
|
| 663 |
+
if key in data and isinstance(data[key], list):
|
| 664 |
+
logger.info("JSON 解析成功,从 '%s' 字段提取 %d 条记录", key, len(data[key]))
|
| 665 |
+
return data[key]
|
| 666 |
+
|
| 667 |
+
logger.warning("JSON 格式无法识别,尝试包装为单条记录")
|
| 668 |
+
return [data]
|
| 669 |
+
except Exception as e:
|
| 670 |
+
logger.error("JSON 解析失败: %s", e)
|
| 671 |
+
return []
|
| 672 |
+
|
| 673 |
+
def _extract_and_parse_zip(self, zip_content: bytes) -> list:
|
| 674 |
+
"""
|
| 675 |
+
解压 ZIP 文件并解析其中的数据文件。
|
| 676 |
+
|
| 677 |
+
支持的数据格式:
|
| 678 |
+
- CSV (.csv)
|
| 679 |
+
- Parquet (.parquet)
|
| 680 |
+
- JSON (.json)
|
| 681 |
+
- Excel (.xlsx/.xls)
|
| 682 |
+
|
| 683 |
+
返回: 包含字典元素的列表,每个字典代表一条记录
|
| 684 |
+
"""
|
| 685 |
+
data_records = []
|
| 686 |
+
|
| 687 |
+
# 使用 io.BytesIO 将 bytes 转为文件对象
|
| 688 |
+
with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_file:
|
| 689 |
+
# 获取 ZIP 内所有文件名
|
| 690 |
+
file_names = zip_file.namelist()
|
| 691 |
+
logger.info("ZIP 文件包含: %s", file_names)
|
| 692 |
+
|
| 693 |
+
# 遍历 ZIP 内的文件,寻找可解析的数据文件
|
| 694 |
+
for file_name in file_names:
|
| 695 |
+
# 跳过目录和非数据文件
|
| 696 |
+
if file_name.endswith("/") or not self._is_data_file(file_name):
|
| 697 |
+
continue
|
| 698 |
+
|
| 699 |
+
logger.info("正在解析数据文件: %s", file_name)
|
| 700 |
+
file_content = zip_file.read(file_name)
|
| 701 |
+
|
| 702 |
+
# 根据文件扩展名选择解析方式(使用内存中的 bytes 解析)
|
| 703 |
+
if file_name.lower().endswith(".csv"):
|
| 704 |
+
data_records = self._parse_csv_bytes(file_content)
|
| 705 |
+
elif file_name.lower().endswith(".parquet"):
|
| 706 |
+
data_records = self._parse_parquet_bytes(file_content)
|
| 707 |
+
elif file_name.lower().endswith(".json"):
|
| 708 |
+
data_records = self._parse_json_bytes(file_content)
|
| 709 |
+
elif file_name.lower().endswith((".xlsx", ".xls")):
|
| 710 |
+
data_records = self._parse_excel_bytes(file_content)
|
| 711 |
+
|
| 712 |
+
# 找到第一个有效的数据文件就返回
|
| 713 |
+
if data_records:
|
| 714 |
+
break
|
| 715 |
+
|
| 716 |
+
if not data_records:
|
| 717 |
+
logger.warning("ZIP 文件中未找到可解析的数据文件")
|
| 718 |
+
|
| 719 |
+
return data_records
|
| 720 |
+
|
| 721 |
+
def _is_data_file(self, file_name: str) -> bool:
|
| 722 |
+
"""判断文件是否为可解析的数据文件。"""
|
| 723 |
+
data_extensions = {".csv", ".parquet", ".json", ".xlsx", ".xls"}
|
| 724 |
+
return any(file_name.lower().endswith(ext) for ext in data_extensions)
|
| 725 |
+
|
| 726 |
+
def _parse_csv_bytes(self, file_content: bytes) -> list:
|
| 727 |
+
"""解析内存中的 CSV 文件内容(从 ZIP 解压)。"""
|
| 728 |
+
encodings = ["utf-8", "gbk", "gb2312", "latin1"]
|
| 729 |
+
for encoding in encodings:
|
| 730 |
+
try:
|
| 731 |
+
df = pd.read_csv(io.BytesIO(file_content), encoding=encoding)
|
| 732 |
+
records = df.to_dict(orient="records")
|
| 733 |
+
logger.info("CSV 解析成功(内存),编码: %s,记录数: %d", encoding, len(records))
|
| 734 |
+
return records
|
| 735 |
+
except UnicodeDecodeError:
|
| 736 |
+
continue
|
| 737 |
+
except Exception as e:
|
| 738 |
+
logger.error("CSV 解析失败(编码 %s): %s", encoding, e)
|
| 739 |
+
continue
|
| 740 |
+
return []
|
| 741 |
+
|
| 742 |
+
def _parse_parquet_bytes(self, file_content: bytes) -> list:
|
| 743 |
+
"""解析内存中的 Parquet 文件内容(从 ZIP 解压)。"""
|
| 744 |
+
try:
|
| 745 |
+
df = pd.read_parquet(io.BytesIO(file_content))
|
| 746 |
+
records = df.to_dict(orient="records")
|
| 747 |
+
logger.info("Parquet 解析成功(内存),记录数: %d", len(records))
|
| 748 |
+
return records
|
| 749 |
+
except Exception as e:
|
| 750 |
+
logger.error("Parquet 解析失败: %s", e)
|
| 751 |
+
return []
|
| 752 |
+
|
| 753 |
+
def _parse_json_bytes(self, file_content: bytes) -> list:
|
| 754 |
+
"""解析内存中的 JSON 文件内容(从 ZIP 解压)。"""
|
| 755 |
+
try:
|
| 756 |
+
data = json.loads(file_content.decode("utf-8"))
|
| 757 |
+
if isinstance(data, list):
|
| 758 |
+
logger.info("JSON 解析成功(内存),记录数: %d", len(data))
|
| 759 |
+
return data
|
| 760 |
+
if isinstance(data, dict):
|
| 761 |
+
for key in ["data", "records", "items", "results"]:
|
| 762 |
+
if key in data and isinstance(data[key], list):
|
| 763 |
+
logger.info("JSON 解析成功(内存),从 '%s' 字段提取 %d 条记录", key, len(data[key]))
|
| 764 |
+
return data[key]
|
| 765 |
+
return [data]
|
| 766 |
+
except Exception as e:
|
| 767 |
+
logger.error("JSON 解析失败: %s", e)
|
| 768 |
+
return []
|
| 769 |
+
|
| 770 |
+
def _parse_excel_bytes(self, file_content: bytes) -> list:
|
| 771 |
+
"""解析内存中的 Excel 文件内容(从 ZIP 解压)。"""
|
| 772 |
+
try:
|
| 773 |
+
df = pd.read_excel(io.BytesIO(file_content))
|
| 774 |
+
records = df.to_dict(orient="records")
|
| 775 |
+
logger.info("Excel 解析成功(内存),记录数: %d", len(records))
|
| 776 |
+
return records
|
| 777 |
+
except Exception as e:
|
| 778 |
+
logger.error("Excel 解析失败: %s", e)
|
| 779 |
+
return []
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
# ===========================================================================
|
| 783 |
+
# Agent 工厂函数:创建配置好的 ToolCallingAgent
|
| 784 |
+
# ===========================================================================
|
| 785 |
+
def create_hf_data_agent(
|
| 786 |
+
model_id: str = MODEL_ID,
|
| 787 |
+
api_key: Optional[str] = None,
|
| 788 |
+
api_base: Optional[str] = None,
|
| 789 |
+
thinking_enabled: bool = THINKING_MODE_ENABLED,
|
| 790 |
+
reasoning_effort: str = THINKING_EFFORT,
|
| 791 |
+
max_steps: int = 5,
|
| 792 |
+
) -> ToolCallingAgent:
|
| 793 |
+
"""
|
| 794 |
+
创建并返回一个配置好的 Hugging Face 数据集查询智能代理。
|
| 795 |
+
|
| 796 |
+
在 Agent 启动时会自动获取数据集文件列表并作为系统提示词告知用户。
|
| 797 |
+
|
| 798 |
+
Args:
|
| 799 |
+
model_id: 模型 ID,默认使用文件顶部 MODEL_ID 常量
|
| 800 |
+
api_key: API Key,默认使用文件顶部 OPENAI_API_KEY 常量
|
| 801 |
+
api_base: API 地址,默认使用文件顶部 OPENAI_API_BASE 常量
|
| 802 |
+
thinking_enabled: 是否启用 DeepSeek 思考模式
|
| 803 |
+
reasoning_effort: 思考强度(low/medium/high/max)
|
| 804 |
+
max_steps: Agent 最大推理步骤数
|
| 805 |
+
|
| 806 |
+
Returns:
|
| 807 |
+
配置好的 ToolCallingAgent 实例
|
| 808 |
+
"""
|
| 809 |
+
key = api_key or OPENAI_API_KEY
|
| 810 |
+
base = api_base or OPENAI_API_BASE
|
| 811 |
+
|
| 812 |
+
# 初始化 LLM 模型(使用 DeepSeekThinkingModel 处理思考模式)
|
| 813 |
+
model = DeepSeekThinkingModel(
|
| 814 |
+
model_id=model_id,
|
| 815 |
+
api_key=key,
|
| 816 |
+
api_base=base,
|
| 817 |
+
thinking_enabled=thinking_enabled,
|
| 818 |
+
reasoning_effort=reasoning_effort,
|
| 819 |
+
)
|
| 820 |
+
|
| 821 |
+
# 实例化工具
|
| 822 |
+
param_tool = ParameterExtractionTool()
|
| 823 |
+
hf_data_tool = HFDataQueryTool()
|
| 824 |
+
|
| 825 |
+
# 构建包含数据集文件列表的指令(会被插入到系统提示词中)
|
| 826 |
+
instructions = build_system_prompt_with_file_list()
|
| 827 |
+
logger.info("Agent 自定义指令: %s", instructions[:200] + "...") # 只打印前200字符
|
| 828 |
+
|
| 829 |
+
# 创建 ToolCallingAgent,使用 instructions 参数设置自定义指令
|
| 830 |
+
agent = ToolCallingAgent(
|
| 831 |
+
tools=[param_tool, hf_data_tool],
|
| 832 |
+
model=model,
|
| 833 |
+
max_steps=max_steps,
|
| 834 |
+
instructions=instructions,
|
| 835 |
+
)
|
| 836 |
+
|
| 837 |
+
logger.info("Hugging Face 数据集查询代理创建成功,模型: %s", model_id)
|
| 838 |
+
return agent
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
# ===========================================================================
|
| 842 |
+
# 主入口:GradioUI 交互式 Web 界面
|
| 843 |
+
# ===========================================================================
|
| 844 |
+
def main():
|
| 845 |
+
"""
|
| 846 |
+
使用 GradioUI 启动交互式 Web 界面。
|
| 847 |
+
用户在浏览器中输入自然语言查询,代理实时展示思考过程和数据获取结果。
|
| 848 |
+
界面基于 gr.ChatInterface,支持流式输出和步骤可视化。
|
| 849 |
+
"""
|
| 850 |
+
agent = create_hf_data_agent()
|
| 851 |
+
|
| 852 |
+
# GradioUI 将 agent 包装为 gr.ChatInterface Web 应用
|
| 853 |
+
# - share=True: 生成公网可访问的临时链接(72小时有效)
|
| 854 |
+
# - reset_agent_memory=False: 保留对话上下文,允许多轮交互
|
| 855 |
+
demo = GradioUI(
|
| 856 |
+
agent,
|
| 857 |
+
reset_agent_memory=False,
|
| 858 |
+
)
|
| 859 |
+
|
| 860 |
+
logger.info("正在启动 GradioUI Web 界面...")
|
| 861 |
+
demo.launch(share=True)
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
if __name__ == "__main__":
|
| 865 |
+
main()
|