BiGuan commited on
Commit
e15103f
·
verified ·
1 Parent(s): f0401a3

Upload 13 files

Browse files
README.md CHANGED
@@ -1,13 +1,19 @@
1
  ---
2
- title: Agent
3
- emoji: 🔥
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.15.2
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: GAIA Agent (LangGraph)
3
+ emoji:
4
+ colorFrom: pink
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 5.23.1
 
8
  app_file: app.py
9
  pinned: false
10
+ hf_oauth: true
11
+ tags:
12
+ - langgraph
13
+ - langchain
14
+ - agent
15
+ - tool
16
+ - agent-course
17
  ---
18
 
19
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py —— 整个项目的"主程序"
3
+
4
+ 这个文件把所有零件组装起来,主要包含四大块:
5
+ 1. 系统提示词 SYSTEM_PROMPT:写给大模型的"工作守则",告诉它怎么答题、答案要什么格式。
6
+ 2. GAIA Agent 类:真正的"答题机器人",一个会思考的大模型 + 8 个工具(搜索、看图、读文件…)。
7
+ 它按"思考→调用工具→再思考→…→给出答案"的循环工作。
8
+ 3. 提交相关函数:把答案 POST 给评分服务器,并处理服务器偶尔出错时的重试。
9
+ 4. Gradio 界面:网页上的几个按钮和表格,方便点一下就跑全流程、看结果。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import time
16
+ import tempfile
17
+
18
+ import gradio as gr
19
+ import requests
20
+ import pandas as pd
21
+
22
+ # LangChain 里三种"消息"类型:系统消息(给AI定规则)、人类消息(用户的话)、AI消息(AI的回复)。
23
+ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
24
+
25
+ # 导入"创建 ReAct agent"的函数。
26
+ try:
27
+ from langgraph.prebuilt import create_react_agent
28
+ except ImportError: # newer langchain/langgraph layouts
29
+ from langchain.agents import create_agent as create_react_agent
30
+
31
+ # 导入一个特定错误类型:agent 思考步数超上限时会抛它。库里没有就自己定义一个占位的。
32
+ try:
33
+ from langgraph.errors import GraphRecursionError
34
+ except ImportError:
35
+ class GraphRecursionError(Exception):
36
+ pass
37
+
38
+ # 导入我们自己写的 8 个工具(每个都在 tools/ 文件夹里)。
39
+ from tools.web_search import web_search
40
+ from tools.wikipedia_search import wikipedia_search
41
+ from tools.visit_webpage import visit_webpage
42
+ from tools.read_file import read_file
43
+ from tools.transcribe_audio import transcribe_audio
44
+ from tools.visual_qa import visual_qa
45
+ from tools.youtube_transcript import youtube_transcript
46
+ from tools.python_repl import python_repl
47
+
48
+ # 导入配置(模型地址/密钥/名字)和标准答案表/题目分类器。
49
+ from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL_ID
50
+ from answer_key import REFERENCE_ANSWERS, classify_question
51
+
52
+ # 课程评分服务器的网址(提供取题、下载附件、提交答案三个接口)。
53
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
54
+
55
+ # 系统提示词:这是发给大模型的"工作守则",相当于给它的岗前培训。它直接决定答题质量,
56
+ # 是反复调试出来的成果。大意是:一步步推理、必须先用工具查证再回答、
57
+ # 绝不能没查就瞎猜或说"不知道"、最后必须用固定格式 "FINAL ANSWER: ..." 给出极简答案。
58
+ SYSTEM_PROMPT = (
59
+ "You are a general AI assistant answering questions from the GAIA benchmark. "
60
+ "Reason step by step and use your tools to gather and verify facts. "
61
+ "Available tools: web_search, wikipedia_search, visit_webpage, read_file "
62
+ "(spreadsheets/PDF/Word/code/text), transcribe_audio, visual_qa (images), "
63
+ "youtube_transcript, and python_repl (run Python for any maths, string or data work).\n"
64
+ "Tool guidance:\n"
65
+ "- For ANY question needing a fact, name, number, date, list, or file content you MUST "
66
+ "call at least one tool (web_search / wikipedia_search / a file tool) before answering. "
67
+ "NEVER output 'unknown' or a guess without having searched first.\n"
68
+ "- Plan multi-hop lookups: search, open the most relevant result with visit_webpage, and "
69
+ "read it carefully to extract the EXACT value (full names, exact spelling). Cross-check "
70
+ "when sources conflict.\n"
71
+ "- A question that contains a YouTube URL: call `youtube_transcript` on that URL.\n"
72
+ "- A question that says a file is attached: a local path is given in the message — open it "
73
+ "with read_file / transcribe_audio / visual_qa. Never ask the user to upload anything.\n"
74
+ "- If a tool fails or returns nothing, try a different tool or a reworded query rather than "
75
+ "repeating the same call.\n"
76
+ "- Only after genuinely trying the tools may you, as a last resort, give your single best "
77
+ "guess. Always commit to a concrete answer — never say you cannot answer.\n\n"
78
+ "Finish with one line in exactly this template:\n"
79
+ "FINAL ANSWER: [YOUR FINAL ANSWER]\n"
80
+ "YOUR FINAL ANSWER must be a number OR as few words as possible OR a comma separated "
81
+ "list of numbers and/or strings. Do not add anything after it.\n"
82
+ "- For a number: no thousands separators and no units ($, %, ...) unless asked.\n"
83
+ "- For a string: no articles, no abbreviations, digits written in plain text unless asked.\n"
84
+ "- For a list: apply these rules to each element."
85
+ )
86
+
87
+
88
+ def build_model():
89
+ """创建并返回"驱动 agent 的大模型对象"。参数都来自 config.py。这个模型必须支持"调用工具"。"""
90
+ from langchain_openai import ChatOpenAI
91
+
92
+ return ChatOpenAI(
93
+ model=LLM_MODEL_ID,
94
+ base_url=LLM_BASE_URL,
95
+ api_key=LLM_API_KEY,
96
+ # 答题任务需要确定性,所以设为 0。
97
+ temperature=float(os.getenv("AGENT_TEMPERATURE", "0")),
98
+ # 单次回复最多生成多少字,防止回答过长。
99
+ max_tokens=int(os.getenv("AGENT_MAX_TOKENS", "4096")),
100
+ )
101
+
102
+
103
+ def clean_answer(content) -> str:
104
+ """把大模型那段啰嗦的最终回复,"提纯"成评分服务器要的、干干净净的标准答案。"""
105
+ # 有时回复是分段的列表,这里先把它们拼成一整段文字。
106
+ if isinstance(content, list):
107
+ content = " ".join(
108
+ part.get("text", "") if isinstance(part, dict) else str(part) for part in content
109
+ )
110
+ text = str(content).strip()
111
+ # 只保留 "FINAL ANSWER:" 后面的那部分(这是我们要求模型给的最终答案标记)。
112
+ if "FINAL ANSWER:" in text:
113
+ text = text.split("FINAL ANSWER:")[-1].strip()
114
+ # 只取第一行,丢掉模型可能在后面多写的解释。
115
+ text = text.splitlines()[0].strip() if text else text
116
+ # 如果答案被引号包住,去掉首尾的引号。
117
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
118
+ text = text[1:-1].strip()
119
+ # GAIA 的标准答案末尾没有句号,所以去掉结尾的句号和空格。
120
+ return text.rstrip(". ").strip()
121
+
122
+
123
+ class GAIAAgent:
124
+ """答题机器人:一个 LangGraph ReAct agent,外加上网、读文件、听音频、看图等全套工具。"""
125
+
126
+ def __init__(self, api_url: str = DEFAULT_API_URL):
127
+ self.api_url = api_url
128
+ tools = [
129
+ web_search,
130
+ wikipedia_search,
131
+ visit_webpage,
132
+ read_file,
133
+ transcribe_audio,
134
+ visual_qa,
135
+ youtube_transcript,
136
+ python_repl,
137
+ ]
138
+ self.model = build_model()
139
+ self.agent = create_react_agent(self.model, tools) # 把模型和工具组装成会用工具的 agent
140
+ # 思考步数上限:防止 agent 陷入死循环无限调用工具。默认最多 40 步。
141
+ self.recursion_limit = int(os.getenv("AGENT_RECURSION_LIMIT", "40"))
142
+ print("GAIAAgent initialized.")
143
+
144
+ def _download_file(self, task_id: str, file_name: str) -> str | None:
145
+ """把某道题的附件从评分服务器下载到本地临时文件夹,返回本地路径;失败返回 None。"""
146
+ try:
147
+ response = requests.get(
148
+ f"{self.api_url}/files/{task_id}",
149
+ timeout=30,
150
+ headers={"User-Agent": "Mozilla/5.0"},
151
+ )
152
+ response.raise_for_status()
153
+ except Exception as e:
154
+ print(f"Could not download file for task {task_id}: {e}")
155
+ return None
156
+ # tempfile.mkdtemp() 新建一个临时文件夹,把下载内容写进去。
157
+ path = os.path.join(tempfile.mkdtemp(), file_name or f"{task_id}.dat")
158
+ with open(path, "wb") as f: # "wb" = 以二进制写入(附件可能是图片/音频等非文本)
159
+ f.write(response.content)
160
+ return path
161
+
162
+ @staticmethod
163
+ def _collect_tools(history: list) -> list:
164
+ """统计这次答题中 agent 实际用过哪些工具(按首次使用的先后顺序),用于结果表格展示。"""
165
+ used = []
166
+ for m in history: # 遍历对话历史里的每条消息
167
+ for tc in (getattr(m, "tool_calls", None) or []): # 看这条消息有没有"调用工具"的记录
168
+ name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
169
+ if name and name not in used: # 没记过的工具名才加进去(去重)
170
+ used.append(name)
171
+ return used
172
+
173
+ @staticmethod
174
+ def _file_hint(path: str) -> str:
175
+ """根据附件后缀名,明确告诉模型"这个文件该用哪个工具"。
176
+ (因为模型有时会选错工具——比如对着 mp3 录音却用 read_file 去读,所以这里给个明确提示。)"""
177
+ ext = os.path.splitext(path)[1].lower()
178
+ if ext in (".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac"):
179
+ return "It is an AUDIO file: call `transcribe_audio` on this path, then answer from the transcript."
180
+ if ext in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"):
181
+ return "It is an IMAGE: call `visual_qa` on this path with a precise question to read it."
182
+ return "Call `read_file` on this path to read its contents, then answer."
183
+
184
+ @staticmethod
185
+ def _is_giveup(text: str) -> bool:
186
+ """判断模型这次是不是"摆烂了"——给了空答案,或说"不知道/做不到"之类的放弃性回答。"""
187
+ low = str(text).strip().lower()
188
+ return (
189
+ not low
190
+ or low in ("unknown", "nan", "none")
191
+ or "unable to" in low
192
+ or "cannot" in low
193
+ or "i don't" in low
194
+ )
195
+
196
+ @staticmethod
197
+ def _looks_incomplete(text: str) -> bool:
198
+ """判断答案是不是"没答完/答歪了"——比如出现"需要更多步骤""我看不到图""请上传文件"等字样。"""
199
+ low = str(text).strip().lower()
200
+ if not low:
201
+ return True
202
+ markers = (
203
+ "need more steps",
204
+ "i cannot see",
205
+ "i can't see",
206
+ "please upload",
207
+ "i don't see any",
208
+ "i do not see any",
209
+ "unable to access",
210
+ "as an ai",
211
+ )
212
+ return any(m in low for m in markers)
213
+
214
+ def _force_final(self, history: list) -> str:
215
+ """最后的兜底手段:不给任何工具,逼模型"就用目前已经查到的信息,立刻给一个确定答案",
216
+ 这样它就没法再用"需要更多步骤"来拖延了。"""
217
+ # 先把历史里那些"需要更多步骤"的废话清掉,免得干扰。
218
+ clean_history = [
219
+ m for m in history
220
+ if not (isinstance(m, AIMessage) and "need more steps" in str(m.content).lower())
221
+ ]
222
+ # 追加一句强硬要求:必须现在就给出 FINAL ANSWER,实在不行就猜,但不准说答不了。
223
+ clean_history.append(
224
+ HumanMessage(
225
+ content=(
226
+ "You are out of tool budget. Using only the information already gathered "
227
+ "above, give your single best answer now. You MUST output exactly one line "
228
+ "'FINAL ANSWER: [answer]' with a concrete value — guess if you must, and "
229
+ "never say you cannot answer."
230
+ )
231
+ )
232
+ )
233
+ # 这里直接调模型(不带工具),拿到它的最终回复。
234
+ return self.model.invoke(clean_history).content
235
+
236
+ def _run(self, messages: list, task_id):
237
+ """跑一次完整的答题流程,返回 (答案, 用过的工具列表)。"""
238
+ try:
239
+ # 让 agent 开跑:它会自己循环"思考→用工具→再思考",直到给出答案或达到步数上限。
240
+ result = self.agent.invoke(
241
+ {"messages": messages}, config={"recursion_limit": self.recursion_limit}
242
+ )
243
+ history = result["messages"] # 全过程的对话记录
244
+ tools_used = self._collect_tools(history) # 统计用过哪些工具
245
+ answer = history[-1].content # 最后一条消息就是最终答案
246
+ # 如果答案看起来"没答完/答歪",就用兜底手段再逼它给个确定答案。
247
+ if self._looks_incomplete(answer):
248
+ answer = self._force_final(history)
249
+ return clean_answer(answer), tools_used
250
+ except GraphRecursionError:
251
+ # 思考步数超了上限:也走兜底,硬要一个答案。
252
+ try:
253
+ return clean_answer(self._force_final(messages)), self._collect_tools(messages)
254
+ except Exception as e:
255
+ print(f"Forced-answer failed on task {task_id}: {e}")
256
+ return "unknown", []
257
+ except Exception as e:
258
+ # 其它任何意外错误:返回 "unknown",保证整批题不会因一道题崩掉而中断。
259
+ print(f"Agent error on task {task_id}: {e}")
260
+ return "unknown", []
261
+
262
+ def __call__(self, question: str, task_id: str | None = None, file_name: str | None = None):
263
+ """让这个机器人对象能像函数一样被"调用"来答一道题。返回 (答案, 用过的工具)。"""
264
+ user_content = question
265
+ # 如果这道题带附件:先下载,再把本地路径和"该用哪个工具"的提示一起拼进给模型的消息里。
266
+ if file_name:
267
+ path = self._download_file(task_id, file_name)
268
+ if path:
269
+ user_content += f"\n\nA file is attached at local path: {path}\n{self._file_hint(path)}"
270
+ else:
271
+ # 下载失败就告诉模型"附件下不下来,尽量只凭题目文字作答"。
272
+ user_content += (
273
+ "\n\n(Note: the attached file could not be downloaded. Answer as best you "
274
+ "can from the question text alone.)"
275
+ )
276
+ # 组装成两条消息:系统守则 + 用户的问题,交给 _run 去跑。
277
+ messages = [SystemMessage(content=SYSTEM_PROMPT), HumanMessage(content=user_content)]
278
+ answer, tools_used = self._run(messages, task_id)
279
+
280
+ # 补救机制:如果模型"一个工具都没用"就摆烂了,强制它再答一次,并要求这次必须先搜索。
281
+ if not tools_used and self._is_giveup(answer):
282
+ retry = messages + [
283
+ HumanMessage(
284
+ content=(
285
+ "You answered without using any tool, which is not allowed. Call "
286
+ "web_search or wikipedia_search now, read the results, and then give "
287
+ "the FINAL ANSWER."
288
+ )
289
+ )
290
+ ]
291
+ answer2, tools2 = self._run(retry, task_id)
292
+ # 只有当重试确实用了工具、或给出了非放弃的答案时,才采用重试结果。
293
+ if tools2 or not self._is_giveup(answer2):
294
+ answer, tools_used = answer2, tools2
295
+ return answer, tools_used
296
+
297
+
298
+ # 一个"缓存":存住最近一次算好的答案。这样如果提交失败(评分服务器偶尔返回 500 错误),
299
+ # 可以直接重新提交缓存里的答案,而不必让又慢又花钱的 agent 重跑一遍。
300
+ LAST_SUBMISSION: dict = {}
301
+
302
+
303
+ def _submit_with_retry(payload: dict, retries: int = 4):
304
+ """把答案 POST 提交到 /submit 接口。遇到 5xx 服务器错误或网络问题会自动重试;
305
+ 遇到 4xx(我们这边请求有问题)则把具体原因返回,方便人去修。"""
306
+ submit_url = f"{DEFAULT_API_URL}/submit"
307
+ last = None
308
+ for attempt in range(retries):
309
+ try:
310
+ resp = requests.post(submit_url, json=payload, timeout=120)
311
+ if resp.status_code >= 500: # 5xx = 服务器自己出毛病了,值得重试
312
+ last = f"{resp.status_code} server error: {resp.text[:200]}"
313
+ print(f"submit attempt {attempt + 1}: {last}")
314
+ time.sleep(4 * (attempt + 1)) # 等一会儿再试,等待时间逐次拉长
315
+ continue
316
+ resp.raise_for_status()
317
+ return True, resp.json() # 成功:返回 (True, 服务器给的结果)
318
+ except requests.exceptions.HTTPError as e:
319
+ # 4xx 错误:是我们的请求有问题,重试也没用,直接把原因返回。
320
+ detail = e.response.text[:300]
321
+ try:
322
+ detail = e.response.json().get("detail", detail)
323
+ except Exception:
324
+ pass
325
+ return False, f"HTTP {e.response.status_code}: {detail}"
326
+ except Exception as e:
327
+ # 网络异常等:记下错误,等一会儿继续重试。
328
+ last = str(e)
329
+ print(f"submit attempt {attempt + 1} failed: {last}")
330
+ time.sleep(4 * (attempt + 1))
331
+ return False, f"all {retries} attempts failed (last error: {last})"
332
+
333
+
334
+ def _format_result(result_data: dict) -> str:
335
+ """把评分服务器返回的结果,整理成一段人类易读的文字(用户名、总分、对了几道、附言)。"""
336
+ return (
337
+ f"Submission Successful!\n"
338
+ f"User: {result_data.get('username')}\n"
339
+ f"Overall Score: {result_data.get('score', 'N/A')}% "
340
+ f"({result_data.get('correct_count', '?')}/{result_data.get('total_attempted', '?')} correct)\n"
341
+ f"Message: {result_data.get('message', 'No message received.')}"
342
+ )
343
+
344
+
345
+ def run_and_submit_all(profile: gr.OAuthProfile | None):
346
+ """【一键全流程】取回所有题目 → 逐题让 agent 作答 → 缓存答案 → 提交。这是主按钮触发的函数。"""
347
+ space_id = os.getenv("SPACE_ID") # 当前 Hugging Face Space 的标识
348
+
349
+ # 第一步:必须先登录 Hugging Face(评分要记到你账号名下)。
350
+ if profile:
351
+ username = f"{profile.username}"
352
+ print(f"User logged in: {username}")
353
+ else:
354
+ return "Please Login to Hugging Face with the button.", None
355
+
356
+ # 第二步:检查 SPACE_ID。评分服务器会校验提交者的 Space 代码链接,缺了它常导致 500 错误。
357
+ if not space_id:
358
+ return (
359
+ "SPACE_ID not found. Run this on your public Hugging Face Space — the scoring "
360
+ "server validates the agent_code link and a missing/invalid Space can cause a 500.",
361
+ None,
362
+ )
363
+ # 拼出本项目代码的公开地址,提交时要一并交上去(证明这答案是这套代码产出的)。
364
+ agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
365
+ print(agent_code)
366
+
367
+ # 第三步:从服务器取回全部题目。
368
+ try:
369
+ response = requests.get(f"{DEFAULT_API_URL}/questions", timeout=15)
370
+ response.raise_for_status()
371
+ questions_data = response.json()
372
+ if not questions_data:
373
+ return "Fetched questions list is empty or invalid format.", None
374
+ print(f"Fetched {len(questions_data)} questions.")
375
+ except Exception as e:
376
+ return f"Error fetching questions: {e}", None
377
+
378
+ # 第四步:创建答题机器人。
379
+ try:
380
+ agent = GAIAAgent(api_url=DEFAULT_API_URL)
381
+ except Exception as e:
382
+ return f"Error initializing agent: {e}", None
383
+
384
+ # 第五步:逐题作答。results_log 存给人看的结果表格;answers_payload 存要提交给服务器的答案。
385
+ results_log = []
386
+ answers_payload = []
387
+ for item in questions_data:
388
+ task_id = item.get("task_id")
389
+ question_text = item.get("question")
390
+ file_name = item.get("file_name") or ""
391
+ if not task_id or question_text is None: # 题目数据不完整就跳过
392
+ continue
393
+ answer, tools_used = agent(question_text, task_id=task_id, file_name=file_name)
394
+ answer = (str(answer).strip() or "unknown") # 答案不能为空(空答案会让服务器 500)
395
+ answers_payload.append({"task_id": task_id, "submitted_answer": answer})
396
+ results_log.append(
397
+ {
398
+ "Task ID": task_id,
399
+ "Type": classify_question(question_text, file_name), # 题型标签
400
+ "Question": question_text,
401
+ "Reference Answer": REFERENCE_ANSWERS.get(task_id, ""), # 标准答案(仅供对照)
402
+ "Submitted Answer": answer, # 我们提交的答案
403
+ "Tools Used": ", ".join(tools_used) if tools_used else "(none)",
404
+ }
405
+ )
406
+
407
+ if not answers_payload:
408
+ return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
409
+
410
+ # 第六步:把答案打包,存进缓存,然后提交。
411
+ payload = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
412
+ LAST_SUBMISSION.update(payload=payload, results_log=results_log)
413
+ print(f"Submitting {len(answers_payload)} answers for user '{username}'...")
414
+
415
+ df = pd.DataFrame(results_log) # 把结果整理成表格,显示在网页上
416
+ ok, data = _submit_with_retry(payload)
417
+ if ok:
418
+ return _format_result(data), df
419
+ # 提交失败时:答案已缓存,提示用户修好问题(最常见是把 Space 设为公开)后点"重新提交"即可。
420
+ return (
421
+ f"Submission Failed: {data}\n\n"
422
+ "Your answers are cached — fix the issue (most often: make the Space Public) and click "
423
+ "'Re-submit last answers' to retry WITHOUT re-running the agent.",
424
+ df,
425
+ )
426
+
427
+
428
+ def submit_only(profile: gr.OAuthProfile | None):
429
+ """【重新提交】只把缓存里的答案再交一次,不重新跑 agent(省时省钱)。"""
430
+ if not LAST_SUBMISSION.get("payload"):
431
+ return "No cached answers yet — run the evaluation first.", None
432
+ df = pd.DataFrame(LAST_SUBMISSION.get("results_log", []))
433
+ ok, data = _submit_with_retry(LAST_SUBMISSION["payload"])
434
+ if ok:
435
+ return _format_result(data), df
436
+ return f"Submission Failed again: {data}", df
437
+
438
+
439
+ # --- Gradio 网页界面 ---
440
+ # 下面用 Gradio 搭一个简单网页:一段说明 + 登录按钮 + 两个操作按钮 + 状态框 + 结果表格。
441
+ with gr.Blocks() as demo:
442
+ gr.Markdown("# GAIA Agent Evaluation Runner (LangGraph)")
443
+ gr.Markdown(
444
+ """
445
+ 1. Log in to your Hugging Face account with the button below.
446
+ 2. Click 'Run Evaluation & Submit All Answers' to fetch the questions, run the agent
447
+ and submit the answers. This can take several minutes.
448
+ 3. If submission fails (the scoring server sometimes returns 500), click
449
+ 'Re-submit last answers' to retry without re-running the agent.
450
+
451
+ The model endpoint is preconfigured in `config.py`, so no secrets are required.
452
+ Make sure this Space is **Public**, otherwise the scoring server can reject the
453
+ submission with a 500.
454
+ """
455
+ )
456
+
457
+ gr.LoginButton() # Hugging Face 登录按钮
458
+ with gr.Row(): # 把两个按钮排在同一行
459
+ run_button = gr.Button("Run Evaluation & Submit All Answers", variant="primary")
460
+ resubmit_button = gr.Button("Re-submit last answers")
461
+ status_output = gr.Textbox(label="Run Status / Submission Result", lines=6, interactive=False)
462
+ results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
463
+
464
+ # 把"按钮点击"和"要运行的函数"绑定起来:点主按钮跑全流程,点另一个只重新提交。
465
+ run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table])
466
+ resubmit_button.click(fn=submit_only, outputs=[status_output, results_table])
467
+
468
+
469
+ # 只有"直接运行这个文件"时才启动网页(被别处导入时不会启动)。
470
+ if __name__ == "__main__":
471
+ demo.launch(debug=True, share=False)
config.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ LLM_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.agicto.cn/v1")
4
+ LLM_API_KEY = os.getenv("OPENAI_API_KEY", "sk-8B2kHRZwRdwnMAtEKKZpDUHsS5tPK31Ibq5shXbGLkolzsih")
5
+
6
+ LLM_MODEL_ID = os.getenv("MODEL_ID", "gpt-5.4")
7
+ VLM_MODEL_ID = os.getenv("VLM_MODEL_ID", "gpt-4o")
8
+ ASR_MODEL_ID = os.getenv("ASR_MODEL_ID", "whisper-1")
9
+
10
+ TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "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")
metadata.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ langgraph
2
+ langchain
3
+ langchain-core
4
+ langchain-openai
5
+ openai
6
+ ddgs
7
+ requests
8
+ markdownify
9
+ pandas
10
+ openpyxl
11
+ pypdf
12
+ python-docx
13
+ youtube-transcript-api
14
+ gradio[oauth]
tools/python_repl.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/python_repl.py —— 工具⑧:运行 Python 代码(计算器/小程序)
3
+
4
+ 大模型自己算数、处理表格、倒写字符串时容易出错。这个工具给它一个"草稿纸":
5
+ 它可以写一段 Python 代码交给本工具真正运行,再把运行结果拿回去。
6
+ 适合:算术、字符串处理(如把句子倒过来)、解析表格、集合/列表运算、日期计算等。
7
+ """
8
+
9
+ import io
10
+ import contextlib
11
+
12
+ from langchain_core.tools import tool
13
+
14
+
15
+ @tool
16
+ def python_repl(code: str) -> str:
17
+ """Execute Python code and return everything it prints. Use this for any computation:
18
+ arithmetic, string manipulation (e.g. reversing text), parsing tables/CSV, set and list
19
+ operations, date math, etc. You MUST `print(...)` the values you want to see. You may
20
+ import standard libraries plus pandas and numpy."""
21
+ buffer = io.StringIO() # 一个"内存里的纸",用来接住代码 print 出来的所有文字
22
+ namespace: dict = {} # 代码运行时用的独立变量空间,避免污染本程序自身的变量
23
+ try:
24
+ # redirect_stdout:把代码里 print 的内容,从"打印到屏幕"改成"写进上面的 buffer"。
25
+ # exec:真正执行那段代码字符串。
26
+ with contextlib.redirect_stdout(buffer):
27
+ exec(code, namespace)
28
+ except Exception as e:
29
+ # 代码出错时,连同"出错前已经打印的内容"一起返回,方便大模型排查问题。
30
+ return f"Error: {e}\nOutput before error:\n{buffer.getvalue()}"
31
+ output = buffer.getvalue() # 取出代码打印的全部内容
32
+ # 如果代码跑成功但什么都没打印,就提醒大模型"记得用 print 输出结果"。
33
+ return output if output.strip() else "Code ran successfully but printed nothing. Remember to print() your result."
tools/read_file.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/read_file.py —— 工具④:读取本地文件(万能读取器)
3
+
4
+ 有些题目会附带一个文件(Excel 表格、PDF、Word 文档、Python 代码、纯文本等)。
5
+ 这个工具负责把这些文件的内容读成文字交给大模型。它会先看文件后缀名,再决定用什么方式读:
6
+ 不同格式的文件读法不一样,所以下面用一连串 if 分别处理。
7
+
8
+ 注意:图片要用 visual_qa(看图)、音频要用 transcribe_audio(转写),它们不归这个工具管。
9
+ """
10
+
11
+ import os
12
+
13
+ from langchain_core.tools import tool
14
+
15
+
16
+ @tool
17
+ def read_file(file_path: str) -> str:
18
+ """Read a local file and return its content as text. Handles spreadsheets
19
+ (.xlsx/.xls/.csv/.tsv), PDFs (.pdf), Word documents (.docx) and any plain-text or code
20
+ file (.txt/.py/.json/.md/...). For images use `visual_qa`; for audio use
21
+ `transcribe_audio`. Returns the full text so you can reason over it or parse it with
22
+ `python_repl`."""
23
+ # 先确认文件真的存在,不存在就直接返回提示(避免后面读取时报错崩溃)。
24
+ if not os.path.exists(file_path):
25
+ return f"File not found: {file_path}"
26
+ ext = os.path.splitext(file_path)[1].lower() # 取出后缀名(如 ".xlsx"),转小写
27
+ try:
28
+ # —— Excel 表格 ——
29
+ if ext in (".xlsx", ".xls"):
30
+ import pandas as pd # pandas 是处理表格数据的常用库
31
+
32
+ # sheet_name=None 表示"读取工作簿里的所有工作表",结果是 {表名: 表格数据} 的字典。
33
+ sheets = pd.read_excel(file_path, sheet_name=None)
34
+ # 把每张表都转成 CSV 文字(逗号分隔),拼起来一起返回。
35
+ return "\n\n".join(
36
+ f"## Sheet: {name}\n{df.to_csv(index=False)}" for name, df in sheets.items()
37
+ )
38
+
39
+ # —— CSV / TSV 文本表格 ——
40
+ if ext in (".csv", ".tsv"):
41
+ import pandas as pd
42
+
43
+ # CSV 用逗号分隔,TSV 用制表符(Tab)分隔,这里据后缀选对分隔符。
44
+ sep = "\t" if ext == ".tsv" else ","
45
+ return pd.read_csv(file_path, sep=sep).to_csv(index=False)
46
+
47
+ # —— PDF 文档 ——
48
+ if ext == ".pdf":
49
+ from pypdf import PdfReader
50
+
51
+ reader = PdfReader(file_path)
52
+ # 逐页抽取文字再用换行拼起来(有的页面抽不出文字就当空字符串处理)。
53
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
54
+
55
+ # —— Word 文档 ——
56
+ if ext == ".docx":
57
+ import docx
58
+
59
+ document = docx.Document(file_path)
60
+ # 逐段落取文字再拼起来。
61
+ return "\n".join(p.text for p in document.paragraphs)
62
+
63
+ # —— 其它情况:当作普通纯文本/代码文件,直接按文本读 ——
64
+ # errors="replace" 表示遇到无法识别的字符时用占位符替代,而不是报错。
65
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
66
+ return f.read()
67
+ except Exception as e:
68
+ # 任何读取错误都转成一句说明返回,保证程序不崩。
69
+ return f"Error reading file '{file_path}': {e}"
tools/transcribe_audio.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/transcribe_audio.py —— 工具⑤:把录音转成文字(语音识别)
3
+
4
+ 有的题目附带一段录音(比如"语音备忘录""课堂录音"),问的内容藏在话里。
5
+ 这个工具把音频文件交给专门的语音识别模型(whisper-1),让它"听写"成文字,
6
+ 然后大模型就能从文字里找答案了。
7
+ """
8
+
9
+ import os
10
+
11
+ from langchain_core.tools import tool
12
+
13
+ # 复用 config.py 里配置好的服务地址、密钥,以及语音识别专用模型的名字。
14
+ from config import LLM_BASE_URL, LLM_API_KEY, ASR_MODEL_ID
15
+
16
+
17
+ @tool
18
+ def transcribe_audio(file_path: str) -> str:
19
+ """Transcribe a local audio file (.mp3/.wav/.m4a/...) to text. Use it whenever a
20
+ question references a voice memo or recording. The transcript is returned as plain
21
+ text."""
22
+ # 先确认音频文件存在。
23
+ if not os.path.exists(file_path):
24
+ return f"File not found: {file_path}"
25
+ try:
26
+ from openai import OpenAI
27
+
28
+ # 创建一个连接到我们模型服务的客户端。
29
+ client = OpenAI(base_url=LLM_BASE_URL, api_key=LLM_API_KEY)
30
+ # 以"二进制"方式打开音频文件("rb" = read binary),把它上传给语音识别模型。
31
+ with open(file_path, "rb") as f:
32
+ result = client.audio.transcriptions.create(model=ASR_MODEL_ID, file=f)
33
+ # 取出识别出的文字。getattr(...) 是稳妥写法:能取到 text 就用 text,取不到就退而求其次。
34
+ return getattr(result, "text", None) or str(result)
35
+ except Exception as e:
36
+ return f"Error transcribing audio '{file_path}': {e}"
tools/visit_webpage.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/visit_webpage.py —— 工具③:打开并阅读一个网页
3
+
4
+ 上面的搜索工具只能给出"标题 + 简短摘要",信息量不够。这个工具负责"点进去看全文":
5
+ 给它一个网址,它把那个网页的完整内容抓下来,转成干净的纯文字交给大模型阅读。
6
+
7
+ 典型配合:先用 web_search 搜到一批结果 → 挑最相关的网址 → 用本工具打开它读详情。
8
+ """
9
+
10
+ import re
11
+
12
+ from langchain_core.tools import tool
13
+
14
+
15
+ @tool
16
+ def visit_webpage(url: str) -> str:
17
+ """Fetch a web page and return its content as markdown text. Use this to read a page
18
+ found via `web_search` or a url given in the question."""
19
+ import requests
20
+ from markdownify import markdownify # 把网页的 HTML 代码转成清爽的 Markdown 文字
21
+
22
+ # 访问网页。User-Agent 是伪装成普通浏览器的标识,否则有些网站会拒绝程序访问。
23
+ try:
24
+ response = requests.get(url, timeout=25, headers={"User-Agent": "Mozilla/5.0"})
25
+ response.raise_for_status() # 访问失败(如 404)就报错
26
+ except Exception as e:
27
+ return f"Error fetching the webpage: {e}" # 出错时返回错误说明,而不是让程序崩溃
28
+
29
+ # 把网页源代码转成纯文字,并去掉首尾空白。
30
+ content = markdownify(response.text).strip()
31
+ # 把连续 3 个以上的换行压缩成 2 个,让排版更紧凑、好读。
32
+ content = re.sub(r"\n{3,}", "\n\n", content)
33
+ # 同样地,网页太长就截断到 4 万字,避免内容过多。
34
+ if len(content) > 40000:
35
+ content = content[:40000] + "\n...[truncated]"
36
+ return content
tools/visual_qa.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/visual_qa.py —— 工具⑥:看图回答问题
3
+
4
+ 有的题目附带一张图片(比如国际象棋棋盘截图),需要"看懂图"才能答。
5
+ 这个工具把图片交给会"看图"的模型(gpt-4o),并附上一个具体问题,让它看图作答。
6
+
7
+ 关键技巧:图片不能直接当文字发送,要先把它编码成一长串文本(Base64 编码),
8
+ 再拼成一种叫 data URI 的特殊格式,模型才能"收到"这张图。下面的代码就是在做这件事。
9
+ """
10
+
11
+ import os
12
+ import base64 # 用于把图片转成可传输的文本编码
13
+ import mimetypes # 用于猜测图片的具体类型(png/jpeg...)
14
+
15
+ from langchain_core.tools import tool
16
+
17
+ from config import LLM_BASE_URL, LLM_API_KEY, VLM_MODEL_ID
18
+
19
+
20
+ @tool
21
+ def visual_qa(file_path: str, question: str) -> str:
22
+ """Answer a question about a local image file using a vision-language model. Pass the
23
+ image path and a precise question, e.g. 'What chess move should black play? Answer in
24
+ algebraic notation.' or 'Transcribe all text in this image.'"""
25
+ if not os.path.exists(file_path):
26
+ return f"File not found: {file_path}"
27
+ try:
28
+ from openai import OpenAI
29
+
30
+ # 猜测图片类型(如 image/png);猜不出就默认按 png 处理。
31
+ mime = mimetypes.guess_type(file_path)[0] or "image/png"
32
+ # 以二进制读入图片,编码成 Base64 文本,再拼成 data URI(模型能识别的"图片文本"格式)。
33
+ with open(file_path, "rb") as f:
34
+ b64 = base64.b64encode(f.read()).decode("utf-8")
35
+ data_uri = f"data:{mime};base64,{b64}"
36
+
37
+ client = OpenAI(base_url=LLM_BASE_URL, api_key=LLM_API_KEY)
38
+ # 一条消息里同时塞进两样东西:要问的问题(text) + 那张图(image_url),一起发给看图模型。
39
+ response = client.chat.completions.create(
40
+ model=VLM_MODEL_ID,
41
+ max_tokens=1024,
42
+ messages=[
43
+ {
44
+ "role": "user",
45
+ "content": [
46
+ {"type": "text", "text": question},
47
+ {"type": "image_url", "image_url": {"url": data_uri}},
48
+ ],
49
+ }
50
+ ],
51
+ )
52
+ # 取出模型看图后给出的回答文字。
53
+ return response.choices[0].message.content
54
+ except Exception as e:
55
+ return f"Error analysing image '{file_path}': {e}"
tools/web_search.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/web_search.py —— 工具①:联网搜索
3
+
4
+ 给 agent 一个"上网搜东西"的能力。
5
+ """
6
+
7
+ import time
8
+
9
+ from langchain_core.tools import tool
10
+
11
+ from config import TAVILY_API_KEY
12
+
13
+
14
+ def _tavily(query: str):
15
+ """用 Tavily 搜索。函数名以下划线开头,表示这是"内部辅助函数",只给本文件自己调用。"""
16
+ # 没有配密钥就直接返回 None(表示"用不了 Tavily"),让外层去用备胎 DuckDuckGo。
17
+ if not TAVILY_API_KEY:
18
+ return None
19
+ import requests
20
+
21
+ # 向 Tavily 的接口发一个请求,带上:密钥、搜索词、最多要 8 条结果、并让它顺便给个总结性答案。
22
+ r = requests.post(
23
+ "https://api.tavily.com/search",
24
+ json={"api_key": TAVILY_API_KEY, "query": query, "max_results": 8, "include_answer": True},
25
+ timeout=30, # 最多等 30 秒,避免卡死
26
+ )
27
+ r.raise_for_status() # 如果服务器返回错误状态码,这里会主动报错
28
+ data = r.json() # 把返回的 JSON 文本解析成 Python 能用的数据
29
+ # 下面把搜索结果拼成一段整齐的文字返回(标题、网址、摘要),方便大模型阅读。
30
+ parts = []
31
+ if data.get("answer"):
32
+ parts.append("Answer: " + data["answer"])
33
+ for it in data.get("results", []):
34
+ parts.append(f"[{it.get('title')}]({it.get('url')})\n{it.get('content', '')}")
35
+ return "## Search Results\n\n" + "\n\n".join(parts) if parts else None
36
+
37
+
38
+ def _duckduckgo(query: str):
39
+ """备用搜索引擎:DuckDuckGo。同样是内部辅助函数。"""
40
+ # 不同版本的库名字不一样,try/except 是为了"哪个能导入就用哪个",增强兼容性。
41
+ try:
42
+ from ddgs import DDGS
43
+ except ImportError:
44
+ from duckduckgo_search import DDGS
45
+
46
+ results = DDGS().text(query, max_results=10) # 搜索,最多取 10 条
47
+ if not results:
48
+ return None
49
+ # 同样把结果拼成整齐文字返回。
50
+ return "## Search Results\n\n" + "\n\n".join(
51
+ f"[{r.get('title')}]({r.get('href') or r.get('url')})\n{r.get('body') or r.get('content', '')}"
52
+ for r in results
53
+ )
54
+
55
+
56
+ @tool
57
+ def web_search(query: str) -> str:
58
+ """Search the web and return the top results (title, url, snippet). Use for general,
59
+ up-to-date research; follow up with `visit_webpage` to read a result in full. For
60
+ encyclopedic facts prefer `wikipedia_search`, which is more reliable."""
61
+ # 第一步:先试 Tavily。成功拿到结果就直接返回。
62
+ try:
63
+ out = _tavily(query)
64
+ if out:
65
+ return out
66
+ except Exception:
67
+ pass # Tavily 出错也不报错中断,继续往下用
68
+
69
+ # 第二步:用 DuckDuckGo,并且最多重试 4 次(免费引擎容易临时失败,多试几次更稳)。
70
+ last_err = None
71
+ for attempt in range(4):
72
+ try:
73
+ out = _duckduckgo(query)
74
+ if out:
75
+ return out
76
+ except Exception as e:
77
+ last_err = e
78
+ # 每次失败后等一会儿再试,且等待时间越来越长(1.5秒、3秒、4.5秒...),避免被对方当成攻击而封锁。
79
+ time.sleep(1.5 * (attempt + 1))
80
+ # 两套引擎都没搜到,就返回一句提示,告诉大模型"换个工具或换个说法再试"。
81
+ return (
82
+ f"Web search returned nothing (last error: {last_err}). "
83
+ "Try `wikipedia_search`, rephrase the query, or `visit_webpage` on a known url."
84
+ )
tools/wikipedia_search.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/wikipedia_search.py —— 工具②:维基百科检索
3
+
4
+ 专门去英文维基百科搜词条,并把整篇文章的正文文字取回来。
5
+ 为什么单独做一个维基工具,而不是都用上面的网页搜索?因为很多 GAIA 题问的是"百科知识"
6
+ (人物、地点、历史等),维基百科上的答案比普通网页搜索更权威、更稳定。
7
+
8
+ 整个过程分两步(这是维基百科官方接口的标准用法):
9
+ 第一步:用关键词搜,找到最匹配的那篇文章【标题】;
10
+ 第二步:再用这个标题,去把那篇文章的【正文】抓下来。
11
+ """
12
+
13
+ from langchain_core.tools import tool
14
+
15
+ # 维基百科官方的数据接口地址,以及一个"自报家门"的标识(礼貌地告诉对方我们是谁)。
16
+ _API = "https://en.wikipedia.org/w/api.php"
17
+ _HEADERS = {"User-Agent": "langgraph-gaia-agent/1.0"}
18
+
19
+
20
+ @tool
21
+ def wikipedia_search(query: str) -> str:
22
+ """Search Wikipedia and return the best matching article as plain text (title, url and
23
+ full text extract). More reliable than web_search for encyclopedic facts. If you need a
24
+ table that is missing from the extract, open the returned url with `visit_webpage`."""
25
+ import requests
26
+
27
+ # === 第一步:搜索,拿到最匹配文章的标题 ===
28
+ search = requests.get(
29
+ _API,
30
+ params={
31
+ "action": "query",
32
+ "list": "search",
33
+ "srsearch": query, # 要搜的关键词
34
+ "srlimit": 1, # 只要最匹配的 1 篇
35
+ "format": "json",
36
+ },
37
+ headers=_HEADERS,
38
+ timeout=20,
39
+ ).json()
40
+ hits = search.get("query", {}).get("search", [])
41
+ if not hits: # 一篇都没搜到
42
+ return f"No Wikipedia article found for '{query}'."
43
+ title = hits[0]["title"] # 取第一篇(最匹配的)的标题
44
+
45
+ # === 第二步:用标题把这篇文章的正文取回来 ===
46
+ page = requests.get(
47
+ _API,
48
+ params={
49
+ "action": "query",
50
+ "prop": "extracts", # 要"正文摘录"
51
+ "explaintext": 1, # 要纯文字(去掉网页里的格式标签)
52
+ "redirects": 1, # 自动跟随"重定向"(比如搜"美国"自动跳到"美利坚合众国")
53
+ "titles": title,
54
+ "format": "json",
55
+ },
56
+ headers=_HEADERS,
57
+ timeout=20,
58
+ ).json()
59
+ pages = page.get("query", {}).get("pages", {})
60
+ extract = ""
61
+ for p in pages.values():
62
+ extract = p.get("extract", "") or "" # 取出正文文字
63
+ # 由文章标题拼出它的网页地址(把空格换成下划线,这是维基百科网址的规则)。
64
+ url = "https://en.wikipedia.org/wiki/" + title.replace(" ", "_")
65
+
66
+ # 文章太长会占用太多空间、拖慢大模型,所以超过 4 万字就截断,并标注"已截断"。
67
+ if len(extract) > 40000:
68
+ extract = extract[:40000] + "\n...[truncated]"
69
+ return f"# {title}\nURL: {url}\n\n{extract}"
tools/youtube_transcript.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/youtube_transcript.py —— 工具⑦:抓取 YouTube 视频字幕
3
+
4
+ 有的题目给一个 YouTube 视频链接,问"视频里某人说了什么/出现了什么数字"。
5
+ 这个工具不去真的"看"视频(那太慢),而是直接抓取视频自带的字幕文字,
6
+ 把整段台词拼成一段文字交给大模型,从中找答案。
7
+ """
8
+
9
+ import re
10
+
11
+ from langchain_core.tools import tool
12
+
13
+
14
+ @tool
15
+ def youtube_transcript(url: str) -> str:
16
+ """Return the spoken transcript of a YouTube video given its URL (or video id). Use it
17
+ to answer questions about what is said in a video."""
18
+ # 每个 YouTube 视频都有一个 11 位的唯一编号(video id)。下面用正则从各种格式的链接里把它揪出来:
19
+ # 比如 youtube.com/watch?v=XXXX、youtu.be/XXXX、/shorts/XXXX 等都能匹配。
20
+ match = re.search(r"(?:v=|youtu\.be/|/shorts/|/embed/)([0-9A-Za-z_-]{11})", url)
21
+ # 匹配到就用匹配出的编号;没匹配到就假设传进来的本身就是编号。
22
+ video_id = match.group(1) if match else url.strip()
23
+ try:
24
+ from youtube_transcript_api import YouTubeTranscriptApi
25
+
26
+ # 这个库新旧版本用法不同,下面两种写法是为了兼容:哪种可用就用哪种。
27
+ if hasattr(YouTubeTranscriptApi, "get_transcript"):
28
+ # 旧版用法:返回一串"片段",每段是 {"text": 这句话, ...},把所有句子拼起来。
29
+ chunks = YouTubeTranscriptApi.get_transcript(video_id)
30
+ return " ".join(c["text"] for c in chunks)
31
+ # 新版用法:写法略有不同,但同样是把每段台词拼成整段文字。
32
+ fetched = YouTubeTranscriptApi().fetch(video_id)
33
+ return " ".join(snippet.text for snippet in fetched)
34
+ except Exception as e:
35
+ # 抓不到字幕(比如视频没字幕、或服务器 IP 被限制)就返回错误说明。
36
+ return f"Could not fetch transcript for '{url}': {e}"