Upload 13 files
Browse files- README.md +12 -6
- app.py +471 -0
- config.py +10 -0
- metadata.jsonl +0 -0
- requirements.txt +14 -0
- tools/python_repl.py +33 -0
- tools/read_file.py +69 -0
- tools/transcribe_audio.py +36 -0
- tools/visit_webpage.py +36 -0
- tools/visual_qa.py +55 -0
- tools/web_search.py +84 -0
- tools/wikipedia_search.py +69 -0
- tools/youtube_transcript.py +36 -0
README.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title: Agent
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 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}"
|