StarHidden commited on
Commit
be2339f
·
verified ·
1 Parent(s): 74a036a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +865 -0
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()