Spaces:
Runtime error
Runtime error
Before refactoring tools
Browse files- .gitignore +10 -0
- AGENTS.md +6 -0
- README.md +13 -1
- app.py +7 -17
- app_raw.py +196 -0
- poetry.lock +0 -0
- pyproject.toml +39 -0
- requirements.txt +0 -2
- src/__init__.py +1 -0
- src/flexible_agent.py +586 -0
- src/gaio.py +85 -0
- src/gaio_chat_model.py +319 -0
- src/tools.py +272 -0
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.cursor/
|
| 2 |
+
env/
|
| 3 |
+
.env
|
| 4 |
+
gemini-sa-key.json
|
| 5 |
+
questions/
|
| 6 |
+
questions_eval/
|
| 7 |
+
.envrc
|
| 8 |
+
.vscode/
|
| 9 |
+
.cursor/
|
| 10 |
+
__pycache__/
|
AGENTS.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Instructions
|
| 2 |
+
|
| 3 |
+
This project aims at implementing a custom agent to answer a list of question.
|
| 4 |
+
A raw file has been given, which is the app_raw.py. You should create a app.py which enrich the app_raw.py by creating the working agent.
|
| 5 |
+
|
| 6 |
+
All python execution you do should be prepended by `direnv exec . poetry run`
|
README.md
CHANGED
|
@@ -12,4 +12,16 @@ hf_oauth: true
|
|
| 12 |
hf_oauth_expiration_minutes: 480
|
| 13 |
---
|
| 14 |
|
| 15 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
hf_oauth_expiration_minutes: 480
|
| 13 |
---
|
| 14 |
|
| 15 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 16 |
+
|
| 17 |
+
# Getting started
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python3 -m venv env
|
| 21 |
+
source env/bin/activate
|
| 22 |
+
pip install -r requirements.txt
|
| 23 |
+
python app.py
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
TODO :
|
| 27 |
+
* If a url is provided, it's not an attachement, in needs to classify wether it's a web search (HTML) or a youtube video for example.
|
app.py
CHANGED
|
@@ -1,24 +1,13 @@
|
|
| 1 |
import os
|
| 2 |
import gradio as gr
|
| 3 |
import requests
|
| 4 |
-
import inspect
|
| 5 |
import pandas as pd
|
|
|
|
| 6 |
|
| 7 |
# (Keep Constants as is)
|
| 8 |
# --- Constants ---
|
| 9 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
| 10 |
|
| 11 |
-
# --- Basic Agent Definition ---
|
| 12 |
-
# ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
|
| 13 |
-
class BasicAgent:
|
| 14 |
-
def __init__(self):
|
| 15 |
-
print("BasicAgent initialized.")
|
| 16 |
-
def __call__(self, question: str) -> str:
|
| 17 |
-
print(f"Agent received question (first 50 chars): {question[:50]}...")
|
| 18 |
-
fixed_answer = "This is a default answer."
|
| 19 |
-
print(f"Agent returning fixed answer: {fixed_answer}")
|
| 20 |
-
return fixed_answer
|
| 21 |
-
|
| 22 |
def run_and_submit_all( profile: gr.OAuthProfile | None):
|
| 23 |
"""
|
| 24 |
Fetches all questions, runs the BasicAgent on them, submits all answers,
|
|
@@ -39,8 +28,9 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
|
|
| 39 |
submit_url = f"{api_url}/submit"
|
| 40 |
|
| 41 |
# 1. Instantiate Agent ( modify this part to create your agent)
|
|
|
|
| 42 |
try:
|
| 43 |
-
agent =
|
| 44 |
except Exception as e:
|
| 45 |
print(f"Error instantiating agent: {e}")
|
| 46 |
return f"Error initializing agent: {e}", None
|
|
@@ -80,7 +70,7 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
|
|
| 80 |
print(f"Skipping item with missing task_id or question: {item}")
|
| 81 |
continue
|
| 82 |
try:
|
| 83 |
-
submitted_answer = agent(question_text)
|
| 84 |
answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
|
| 85 |
results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
|
| 86 |
except Exception as e:
|
|
@@ -142,19 +132,19 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
|
|
| 142 |
|
| 143 |
# --- Build Gradio Interface using Blocks ---
|
| 144 |
with gr.Blocks() as demo:
|
| 145 |
-
gr.Markdown("#
|
| 146 |
gr.Markdown(
|
| 147 |
"""
|
| 148 |
**Instructions:**
|
| 149 |
|
| 150 |
-
1.
|
| 151 |
2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
|
| 152 |
3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
|
| 153 |
|
| 154 |
---
|
| 155 |
**Disclaimers:**
|
| 156 |
Once clicking on the "submit button, it can take quite some time ( this is the time for the agent to go through all the questions).
|
| 157 |
-
This
|
| 158 |
"""
|
| 159 |
)
|
| 160 |
|
|
|
|
| 1 |
import os
|
| 2 |
import gradio as gr
|
| 3 |
import requests
|
|
|
|
| 4 |
import pandas as pd
|
| 5 |
+
from src.flexible_agent import FlexibleAgent
|
| 6 |
|
| 7 |
# (Keep Constants as is)
|
| 8 |
# --- Constants ---
|
| 9 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def run_and_submit_all( profile: gr.OAuthProfile | None):
|
| 12 |
"""
|
| 13 |
Fetches all questions, runs the BasicAgent on them, submits all answers,
|
|
|
|
| 28 |
submit_url = f"{api_url}/submit"
|
| 29 |
|
| 30 |
# 1. Instantiate Agent ( modify this part to create your agent)
|
| 31 |
+
# Note: Langfuse tracing is automatically configured in FlexibleAgent when environment variables are set
|
| 32 |
try:
|
| 33 |
+
agent = FlexibleAgent()
|
| 34 |
except Exception as e:
|
| 35 |
print(f"Error instantiating agent: {e}")
|
| 36 |
return f"Error initializing agent: {e}", None
|
|
|
|
| 70 |
print(f"Skipping item with missing task_id or question: {item}")
|
| 71 |
continue
|
| 72 |
try:
|
| 73 |
+
submitted_answer = agent(question_text, task_id=task_id)
|
| 74 |
answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
|
| 75 |
results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
|
| 76 |
except Exception as e:
|
|
|
|
| 132 |
|
| 133 |
# --- Build Gradio Interface using Blocks ---
|
| 134 |
with gr.Blocks() as demo:
|
| 135 |
+
gr.Markdown("# Flexible Tool-Based Agent")
|
| 136 |
gr.Markdown(
|
| 137 |
"""
|
| 138 |
**Instructions:**
|
| 139 |
|
| 140 |
+
1. This flexible agent intelligently chooses from multiple tools to answer complex questions
|
| 141 |
2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
|
| 142 |
3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
|
| 143 |
|
| 144 |
---
|
| 145 |
**Disclaimers:**
|
| 146 |
Once clicking on the "submit button, it can take quite some time ( this is the time for the agent to go through all the questions).
|
| 147 |
+
This flexible agent uses LLM-powered tool selection for optimal question handling.
|
| 148 |
"""
|
| 149 |
)
|
| 150 |
|
app_raw.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import gradio as gr
|
| 3 |
+
import requests
|
| 4 |
+
import inspect
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
# (Keep Constants as is)
|
| 8 |
+
# --- Constants ---
|
| 9 |
+
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
| 10 |
+
|
| 11 |
+
# --- Basic Agent Definition ---
|
| 12 |
+
# ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
|
| 13 |
+
class BasicAgent:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
print("BasicAgent initialized.")
|
| 16 |
+
def __call__(self, question: str) -> str:
|
| 17 |
+
print(f"Agent received question (first 50 chars): {question[:50]}...")
|
| 18 |
+
fixed_answer = "This is a default answer."
|
| 19 |
+
print(f"Agent returning fixed answer: {fixed_answer}")
|
| 20 |
+
return fixed_answer
|
| 21 |
+
|
| 22 |
+
def run_and_submit_all( profile: gr.OAuthProfile | None):
|
| 23 |
+
"""
|
| 24 |
+
Fetches all questions, runs the BasicAgent on them, submits all answers,
|
| 25 |
+
and displays the results.
|
| 26 |
+
"""
|
| 27 |
+
# --- Determine HF Space Runtime URL and Repo URL ---
|
| 28 |
+
space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
|
| 29 |
+
|
| 30 |
+
if profile:
|
| 31 |
+
username= f"{profile.username}"
|
| 32 |
+
print(f"User logged in: {username}")
|
| 33 |
+
else:
|
| 34 |
+
print("User not logged in.")
|
| 35 |
+
return "Please Login to Hugging Face with the button.", None
|
| 36 |
+
|
| 37 |
+
api_url = DEFAULT_API_URL
|
| 38 |
+
questions_url = f"{api_url}/questions"
|
| 39 |
+
submit_url = f"{api_url}/submit"
|
| 40 |
+
|
| 41 |
+
# 1. Instantiate Agent ( modify this part to create your agent)
|
| 42 |
+
try:
|
| 43 |
+
agent = BasicAgent()
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"Error instantiating agent: {e}")
|
| 46 |
+
return f"Error initializing agent: {e}", None
|
| 47 |
+
# In the case of an app running as a hugging Face space, this link points toward your codebase ( usefull for others so please keep it public)
|
| 48 |
+
agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
|
| 49 |
+
print(agent_code)
|
| 50 |
+
|
| 51 |
+
# 2. Fetch Questions
|
| 52 |
+
print(f"Fetching questions from: {questions_url}")
|
| 53 |
+
try:
|
| 54 |
+
response = requests.get(questions_url, timeout=15)
|
| 55 |
+
response.raise_for_status()
|
| 56 |
+
questions_data = response.json()
|
| 57 |
+
if not questions_data:
|
| 58 |
+
print("Fetched questions list is empty.")
|
| 59 |
+
return "Fetched questions list is empty or invalid format.", None
|
| 60 |
+
print(f"Fetched {len(questions_data)} questions.")
|
| 61 |
+
except requests.exceptions.RequestException as e:
|
| 62 |
+
print(f"Error fetching questions: {e}")
|
| 63 |
+
return f"Error fetching questions: {e}", None
|
| 64 |
+
except requests.exceptions.JSONDecodeError as e:
|
| 65 |
+
print(f"Error decoding JSON response from questions endpoint: {e}")
|
| 66 |
+
print(f"Response text: {response.text[:500]}")
|
| 67 |
+
return f"Error decoding server response for questions: {e}", None
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"An unexpected error occurred fetching questions: {e}")
|
| 70 |
+
return f"An unexpected error occurred fetching questions: {e}", None
|
| 71 |
+
|
| 72 |
+
# 3. Run your Agent
|
| 73 |
+
results_log = []
|
| 74 |
+
answers_payload = []
|
| 75 |
+
print(f"Running agent on {len(questions_data)} questions...")
|
| 76 |
+
for item in questions_data:
|
| 77 |
+
task_id = item.get("task_id")
|
| 78 |
+
question_text = item.get("question")
|
| 79 |
+
if not task_id or question_text is None:
|
| 80 |
+
print(f"Skipping item with missing task_id or question: {item}")
|
| 81 |
+
continue
|
| 82 |
+
try:
|
| 83 |
+
submitted_answer = agent(question_text)
|
| 84 |
+
answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
|
| 85 |
+
results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"Error running agent on task {task_id}: {e}")
|
| 88 |
+
results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
|
| 89 |
+
|
| 90 |
+
if not answers_payload:
|
| 91 |
+
print("Agent did not produce any answers to submit.")
|
| 92 |
+
return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
|
| 93 |
+
|
| 94 |
+
# 4. Prepare Submission
|
| 95 |
+
submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
|
| 96 |
+
status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..."
|
| 97 |
+
print(status_update)
|
| 98 |
+
|
| 99 |
+
# 5. Submit
|
| 100 |
+
print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
|
| 101 |
+
try:
|
| 102 |
+
response = requests.post(submit_url, json=submission_data, timeout=60)
|
| 103 |
+
response.raise_for_status()
|
| 104 |
+
result_data = response.json()
|
| 105 |
+
final_status = (
|
| 106 |
+
f"Submission Successful!\n"
|
| 107 |
+
f"User: {result_data.get('username')}\n"
|
| 108 |
+
f"Overall Score: {result_data.get('score', 'N/A')}% "
|
| 109 |
+
f"({result_data.get('correct_count', '?')}/{result_data.get('total_attempted', '?')} correct)\n"
|
| 110 |
+
f"Message: {result_data.get('message', 'No message received.')}"
|
| 111 |
+
)
|
| 112 |
+
print("Submission successful.")
|
| 113 |
+
results_df = pd.DataFrame(results_log)
|
| 114 |
+
return final_status, results_df
|
| 115 |
+
except requests.exceptions.HTTPError as e:
|
| 116 |
+
error_detail = f"Server responded with status {e.response.status_code}."
|
| 117 |
+
try:
|
| 118 |
+
error_json = e.response.json()
|
| 119 |
+
error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
|
| 120 |
+
except requests.exceptions.JSONDecodeError:
|
| 121 |
+
error_detail += f" Response: {e.response.text[:500]}"
|
| 122 |
+
status_message = f"Submission Failed: {error_detail}"
|
| 123 |
+
print(status_message)
|
| 124 |
+
results_df = pd.DataFrame(results_log)
|
| 125 |
+
return status_message, results_df
|
| 126 |
+
except requests.exceptions.Timeout:
|
| 127 |
+
status_message = "Submission Failed: The request timed out."
|
| 128 |
+
print(status_message)
|
| 129 |
+
results_df = pd.DataFrame(results_log)
|
| 130 |
+
return status_message, results_df
|
| 131 |
+
except requests.exceptions.RequestException as e:
|
| 132 |
+
status_message = f"Submission Failed: Network error - {e}"
|
| 133 |
+
print(status_message)
|
| 134 |
+
results_df = pd.DataFrame(results_log)
|
| 135 |
+
return status_message, results_df
|
| 136 |
+
except Exception as e:
|
| 137 |
+
status_message = f"An unexpected error occurred during submission: {e}"
|
| 138 |
+
print(status_message)
|
| 139 |
+
results_df = pd.DataFrame(results_log)
|
| 140 |
+
return status_message, results_df
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# --- Build Gradio Interface using Blocks ---
|
| 144 |
+
with gr.Blocks() as demo:
|
| 145 |
+
gr.Markdown("# Basic Agent Evaluation Runner")
|
| 146 |
+
gr.Markdown(
|
| 147 |
+
"""
|
| 148 |
+
**Instructions:**
|
| 149 |
+
|
| 150 |
+
1. Please clone this space, then modify the code to define your agent's logic, the tools, the necessary packages, etc ...
|
| 151 |
+
2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
|
| 152 |
+
3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
**Disclaimers:**
|
| 156 |
+
Once clicking on the "submit button, it can take quite some time ( this is the time for the agent to go through all the questions).
|
| 157 |
+
This space provides a basic setup and is intentionally sub-optimal to encourage you to develop your own, more robust solution. For instance for the delay process of the submit button, a solution could be to cache the answers and submit in a seperate action or even to answer the questions in async.
|
| 158 |
+
"""
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
gr.LoginButton()
|
| 162 |
+
|
| 163 |
+
run_button = gr.Button("Run Evaluation & Submit All Answers")
|
| 164 |
+
|
| 165 |
+
status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
|
| 166 |
+
# Removed max_rows=10 from DataFrame constructor
|
| 167 |
+
results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
|
| 168 |
+
|
| 169 |
+
run_button.click(
|
| 170 |
+
fn=run_and_submit_all,
|
| 171 |
+
outputs=[status_output, results_table]
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
print("\n" + "-"*30 + " App Starting " + "-"*30)
|
| 176 |
+
# Check for SPACE_HOST and SPACE_ID at startup for information
|
| 177 |
+
space_host_startup = os.getenv("SPACE_HOST")
|
| 178 |
+
space_id_startup = os.getenv("SPACE_ID") # Get SPACE_ID at startup
|
| 179 |
+
|
| 180 |
+
if space_host_startup:
|
| 181 |
+
print(f"✅ SPACE_HOST found: {space_host_startup}")
|
| 182 |
+
print(f" Runtime URL should be: https://{space_host_startup}.hf.space")
|
| 183 |
+
else:
|
| 184 |
+
print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
|
| 185 |
+
|
| 186 |
+
if space_id_startup: # Print repo URLs if SPACE_ID is found
|
| 187 |
+
print(f"✅ SPACE_ID found: {space_id_startup}")
|
| 188 |
+
print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
|
| 189 |
+
print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
|
| 190 |
+
else:
|
| 191 |
+
print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.")
|
| 192 |
+
|
| 193 |
+
print("-"*(60 + len(" App Starting ")) + "\n")
|
| 194 |
+
|
| 195 |
+
print("Launching Gradio Interface for Basic Agent Evaluation...")
|
| 196 |
+
demo.launch(debug=True, share=False)
|
poetry.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
pyproject.toml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.poetry]
|
| 2 |
+
name = "final-assignment-template"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = ""
|
| 5 |
+
authors = ["Your Name <you@example.com>"]
|
| 6 |
+
readme = "README.md"
|
| 7 |
+
package-mode = false
|
| 8 |
+
|
| 9 |
+
[tool.poetry.dependencies]
|
| 10 |
+
python = ">=3.11,<3.12"
|
| 11 |
+
gradio = "*"
|
| 12 |
+
requests = "*"
|
| 13 |
+
langgraph = "*"
|
| 14 |
+
langchain_openai = "*"
|
| 15 |
+
langchain_huggingface = "*"
|
| 16 |
+
langchain_community = "*"
|
| 17 |
+
langchain_google_genai = "*"
|
| 18 |
+
wikipedia = "*"
|
| 19 |
+
youtube-search-python = "*"
|
| 20 |
+
pillow = "*"
|
| 21 |
+
langchain_experimental = "*"
|
| 22 |
+
langchain-tavily = ">=0.2.11,<0.3.0"
|
| 23 |
+
langchain-anthropic = ">=0.3.20,<0.4.0"
|
| 24 |
+
openai-whisper = "*"
|
| 25 |
+
speechrecognition = "*"
|
| 26 |
+
pydub = "*"
|
| 27 |
+
python-magic = "*"
|
| 28 |
+
librosa = "*"
|
| 29 |
+
rizaio = "*"
|
| 30 |
+
langfuse = "*"
|
| 31 |
+
langchain = "*"
|
| 32 |
+
tesseract = ">=0.1.3,<0.2.0"
|
| 33 |
+
unstructured = {extras = ["all-docs"], version = "*"}
|
| 34 |
+
langchain-google-community = "^2.0.10"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
[build-system]
|
| 38 |
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
| 39 |
+
build-backend = "poetry.core.masonry.api"
|
requirements.txt
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
gradio
|
| 2 |
-
requests
|
|
|
|
|
|
|
|
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Core modules for the Final Assignment Template."""
|
src/flexible_agent.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
from typing import TypedDict, Optional, List, Annotated
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
|
| 7 |
+
from langgraph.graph import START, END, StateGraph
|
| 8 |
+
from langgraph.graph.message import add_messages
|
| 9 |
+
from langgraph.prebuilt import ToolNode, tools_condition
|
| 10 |
+
from langfuse.langchain import CallbackHandler
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
# Try relative imports first (when used as package)
|
| 14 |
+
from .tools import (
|
| 15 |
+
wikipedia_search, youtube_search, decode_text,
|
| 16 |
+
download_and_process_file, web_search
|
| 17 |
+
)
|
| 18 |
+
except ImportError:
|
| 19 |
+
# Fall back to absolute imports (when run directly)
|
| 20 |
+
from tools import (
|
| 21 |
+
wikipedia_search, youtube_search, decode_text,
|
| 22 |
+
download_and_process_file, web_search
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --- Agent State following LangGraph pattern ---
|
| 29 |
+
class AgentState(TypedDict):
|
| 30 |
+
# The original question from the user
|
| 31 |
+
question: str
|
| 32 |
+
|
| 33 |
+
# Task ID for file downloads
|
| 34 |
+
task_id: Optional[str]
|
| 35 |
+
|
| 36 |
+
# File classification results
|
| 37 |
+
requires_file: Optional[bool]
|
| 38 |
+
|
| 39 |
+
# File content if downloaded and processed
|
| 40 |
+
file_content: Optional[str]
|
| 41 |
+
|
| 42 |
+
# Search attempt counter to prevent infinite loops
|
| 43 |
+
search_attempts: int
|
| 44 |
+
|
| 45 |
+
# Final answer
|
| 46 |
+
final_answer: Optional[str]
|
| 47 |
+
|
| 48 |
+
# Messages for LLM interactions (for logging)
|
| 49 |
+
messages: Annotated[List[BaseMessage], add_messages]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# --- Flexible Tool-Based Agent ---
|
| 53 |
+
class FlexibleAgent:
|
| 54 |
+
def __init__(self):
|
| 55 |
+
|
| 56 |
+
# Initialize Gemini chat model for LangChain integration
|
| 57 |
+
self.chat = ChatGoogleGenerativeAI(
|
| 58 |
+
# google_api_key=os.getenv("GEMINI_API_KEY"),
|
| 59 |
+
# model="gemini-2.0-flash-lite",
|
| 60 |
+
model="gemini-2.5-flash-lite",
|
| 61 |
+
temperature=0.0,
|
| 62 |
+
max_tokens=None
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Define available tools (excluding file detection - now handled by graph nodes)
|
| 66 |
+
self.tools = [
|
| 67 |
+
wikipedia_search, youtube_search, decode_text, web_search
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
# Bind tools to the LLM
|
| 71 |
+
self.chat_with_tools = self.chat.bind_tools(self.tools)
|
| 72 |
+
|
| 73 |
+
# Initialize Langfuse CallbackHandler for tracing
|
| 74 |
+
try:
|
| 75 |
+
self.langfuse_handler = CallbackHandler()
|
| 76 |
+
print("✅ Langfuse CallbackHandler initialized successfully")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"⚠️ Warning: Could not initialize Langfuse CallbackHandler: {e}")
|
| 79 |
+
print(" Continuing without Langfuse tracing...")
|
| 80 |
+
self.langfuse_handler = None
|
| 81 |
+
|
| 82 |
+
# Create questions directory for logging
|
| 83 |
+
self.questions_dir = "questions"
|
| 84 |
+
# Clear previous question files
|
| 85 |
+
if os.path.exists(self.questions_dir):
|
| 86 |
+
shutil.rmtree(self.questions_dir)
|
| 87 |
+
os.makedirs(self.questions_dir, exist_ok=True)
|
| 88 |
+
self.question_counter = 0
|
| 89 |
+
|
| 90 |
+
# Build the graph following LangGraph pattern
|
| 91 |
+
self._build_graph()
|
| 92 |
+
print("FlexibleAgent initialized with Gemini LLM and LangGraph workflow.")
|
| 93 |
+
|
| 94 |
+
def log_full_conversation(self, question: str, final_state: dict, answer: str):
|
| 95 |
+
"""Log the complete conversation including all tool calls and LLM interactions"""
|
| 96 |
+
self.question_counter += 1
|
| 97 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 98 |
+
filename = f"question_{self.question_counter:03d}_{timestamp}.txt"
|
| 99 |
+
filepath = os.path.join(self.questions_dir, filename)
|
| 100 |
+
|
| 101 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
| 102 |
+
f.write(f"Question #{self.question_counter}\n")
|
| 103 |
+
f.write(f"Timestamp: {datetime.now().isoformat()}\n")
|
| 104 |
+
f.write(f"Question: {question}\n")
|
| 105 |
+
f.write("="*60 + "\n")
|
| 106 |
+
f.write("FULL CONVERSATION TRACE:\n")
|
| 107 |
+
f.write("="*60 + "\n\n")
|
| 108 |
+
|
| 109 |
+
# Log all messages in the conversation
|
| 110 |
+
messages = final_state.get("messages", [])
|
| 111 |
+
for i, message in enumerate(messages):
|
| 112 |
+
f.write(f"--- Message {i+1}: {type(message).__name__} ---\n")
|
| 113 |
+
f.write(f"Content: {message.content}\n")
|
| 114 |
+
|
| 115 |
+
# If it's an AI message with tool calls, log the tool calls
|
| 116 |
+
if hasattr(message, 'tool_calls') and message.tool_calls:
|
| 117 |
+
f.write(f"Tool Calls: {len(message.tool_calls)}\n")
|
| 118 |
+
for j, tool_call in enumerate(message.tool_calls):
|
| 119 |
+
# Handle both dict and object formats
|
| 120 |
+
if hasattr(tool_call, 'name'):
|
| 121 |
+
f.write(f" Tool {j+1}: {tool_call.name}\n")
|
| 122 |
+
f.write(f" Arguments: {tool_call.args}\n")
|
| 123 |
+
f.write(f" ID: {tool_call.id}\n")
|
| 124 |
+
elif isinstance(tool_call, dict):
|
| 125 |
+
f.write(f" Tool {j+1}: {tool_call.get('name', 'unknown')}\n")
|
| 126 |
+
f.write(f" Arguments: {tool_call.get('args', {})}\n")
|
| 127 |
+
f.write(f" ID: {tool_call.get('id', 'unknown')}\n")
|
| 128 |
+
else:
|
| 129 |
+
f.write(f" Tool {j+1}: {str(tool_call)}\n")
|
| 130 |
+
|
| 131 |
+
# If it's a tool message, show which tool it came from
|
| 132 |
+
if hasattr(message, 'tool_call_id'):
|
| 133 |
+
f.write(f"Tool Call ID: {message.tool_call_id}\n")
|
| 134 |
+
|
| 135 |
+
f.write("\n")
|
| 136 |
+
|
| 137 |
+
f.write("="*60 + "\n")
|
| 138 |
+
f.write(f"FINAL ANSWER: {answer}\n")
|
| 139 |
+
f.write("="*60 + "\n")
|
| 140 |
+
|
| 141 |
+
print(f"Logged full conversation to: {filename}")
|
| 142 |
+
|
| 143 |
+
def classify_file_requirement(self, state: AgentState):
|
| 144 |
+
"""LLM-based classification of whether the question requires a file attachment"""
|
| 145 |
+
question = state["question"]
|
| 146 |
+
|
| 147 |
+
# For the first message, include the question
|
| 148 |
+
if not state.get("messages"):
|
| 149 |
+
# Initial message with question
|
| 150 |
+
first_message = HumanMessage(content=question)
|
| 151 |
+
|
| 152 |
+
# Classification prompt - no need to repeat the question
|
| 153 |
+
classification_prompt = """
|
| 154 |
+
Analyze the question above and determine if it requires accessing an attached file.
|
| 155 |
+
|
| 156 |
+
Determine if the question mentions attached files (like "I've attached", "attached as", "see attached", etc.)
|
| 157 |
+
|
| 158 |
+
If the question requires a file, answer "yes". If not, answer "no".
|
| 159 |
+
If a url is provided, answer "no".
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
# Call the LLM with both messages
|
| 163 |
+
messages = [first_message, HumanMessage(content=classification_prompt)]
|
| 164 |
+
response = self.chat.invoke(messages)
|
| 165 |
+
|
| 166 |
+
# Update messages for tracking
|
| 167 |
+
new_messages = [first_message, HumanMessage(content=classification_prompt), response]
|
| 168 |
+
else:
|
| 169 |
+
# Subsequent call - messages already exist
|
| 170 |
+
classification_prompt = """
|
| 171 |
+
Analyze the question and determine if it requires accessing an attached file.
|
| 172 |
+
|
| 173 |
+
If the question requires a file, answer "yes". If not, answer "no".
|
| 174 |
+
If a url is provided, answer "no".
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
# Call the LLM
|
| 178 |
+
messages = state["messages"] + [HumanMessage(content=classification_prompt)]
|
| 179 |
+
response = self.chat.invoke(messages)
|
| 180 |
+
|
| 181 |
+
# Update messages for tracking
|
| 182 |
+
new_messages = state.get("messages", []) + [
|
| 183 |
+
HumanMessage(content=classification_prompt),
|
| 184 |
+
response
|
| 185 |
+
]
|
| 186 |
+
|
| 187 |
+
# Parse the response to determine if file is required
|
| 188 |
+
response_text = response.content.lower()
|
| 189 |
+
requires_file = response_text == "yes"
|
| 190 |
+
|
| 191 |
+
# Return state updates
|
| 192 |
+
return {
|
| 193 |
+
"requires_file": requires_file,
|
| 194 |
+
"messages": new_messages
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
def download_file_content(self, state: AgentState):
|
| 198 |
+
"""Download and process the file content"""
|
| 199 |
+
task_id = state["task_id"]
|
| 200 |
+
|
| 201 |
+
if not task_id:
|
| 202 |
+
error_msg = "Error: No task_id provided for file download"
|
| 203 |
+
# Add error message to conversation
|
| 204 |
+
new_messages = state.get("messages", []) + [
|
| 205 |
+
HumanMessage(content=error_msg)
|
| 206 |
+
]
|
| 207 |
+
return {
|
| 208 |
+
"file_content": error_msg,
|
| 209 |
+
"messages": new_messages
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
# Use the download tool (but call it directly instead of as a tool)
|
| 214 |
+
file_result = download_and_process_file(task_id)
|
| 215 |
+
|
| 216 |
+
# Add file content to conversation without repeating the question
|
| 217 |
+
file_message = f"File Content:\n{file_result}"
|
| 218 |
+
|
| 219 |
+
new_messages = state.get("messages", []) + [
|
| 220 |
+
HumanMessage(content=file_message)
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
return {
|
| 224 |
+
"file_content": file_result,
|
| 225 |
+
"messages": new_messages
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
error_msg = f"Error downloading file: {str(e)}"
|
| 230 |
+
new_messages = state.get("messages", []) + [
|
| 231 |
+
HumanMessage(content=error_msg)
|
| 232 |
+
]
|
| 233 |
+
return {
|
| 234 |
+
"file_content": error_msg,
|
| 235 |
+
"messages": new_messages
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
def answer_with_tools(self, state: AgentState):
|
| 239 |
+
"""Use tools to answer the question (with or without file content)"""
|
| 240 |
+
# Increment search attempts
|
| 241 |
+
search_attempts = state.get("search_attempts", 0) + 1
|
| 242 |
+
|
| 243 |
+
# Create system prompt for tool usage - question is already in conversation
|
| 244 |
+
system_prompt = f"""
|
| 245 |
+
Use your tools to answer the question above.
|
| 246 |
+
"""
|
| 247 |
+
|
| 248 |
+
# Use existing conversation context
|
| 249 |
+
messages = state.get("messages", []) + [HumanMessage(content=system_prompt)]
|
| 250 |
+
|
| 251 |
+
# Let the LLM decide what tools to use
|
| 252 |
+
response = self.chat_with_tools.invoke(messages)
|
| 253 |
+
|
| 254 |
+
# Update messages for tracking
|
| 255 |
+
new_messages = state.get("messages", []) + [
|
| 256 |
+
HumanMessage(content=system_prompt),
|
| 257 |
+
response
|
| 258 |
+
]
|
| 259 |
+
|
| 260 |
+
return {"messages": new_messages, "search_attempts": search_attempts}
|
| 261 |
+
|
| 262 |
+
def plan_approach(self, state: AgentState):
|
| 263 |
+
"""Decide whether to use tools or answer directly"""
|
| 264 |
+
# Create system prompt for decision making - no need to repeat the question
|
| 265 |
+
planning_prompt = """Now you need to decide how to answer the question above.
|
| 266 |
+
|
| 267 |
+
Should you use tools to answer this question? Respond with ONLY "tools" or "direct":
|
| 268 |
+
|
| 269 |
+
- ALWAYS use "tools" if:
|
| 270 |
+
* The user explicitly mentions "search", "Wikipedia", "YouTube", or any tool name
|
| 271 |
+
* The question asks about factual information that would benefit from Wikipedia search
|
| 272 |
+
* The question mentions YouTube videos or asks about video content
|
| 273 |
+
* The question provides image URLs to analyze
|
| 274 |
+
* The question involves encoded/backwards text
|
| 275 |
+
* The user specifically requests using external sources
|
| 276 |
+
|
| 277 |
+
- Use "direct" if:
|
| 278 |
+
* It's a simple math calculation AND no search is requested
|
| 279 |
+
* It's a general knowledge question you can answer confidently AND no search is requested
|
| 280 |
+
* It's asking for an opinion or creative content
|
| 281 |
+
* No tools would significantly improve the answer AND no search is requested
|
| 282 |
+
"""
|
| 283 |
+
|
| 284 |
+
# Get LLM decision using existing conversation context
|
| 285 |
+
messages = state.get("messages", []) + [HumanMessage(content=planning_prompt)]
|
| 286 |
+
response = self.chat.invoke(messages)
|
| 287 |
+
|
| 288 |
+
# Update messages for tracking
|
| 289 |
+
new_messages = state.get("messages", []) + [
|
| 290 |
+
HumanMessage(content=planning_prompt),
|
| 291 |
+
response
|
| 292 |
+
]
|
| 293 |
+
|
| 294 |
+
return {"messages": new_messages}
|
| 295 |
+
|
| 296 |
+
def answer_directly(self, state: AgentState):
|
| 297 |
+
"""Answer the question directly without tools"""
|
| 298 |
+
# Create system prompt - question is already in conversation
|
| 299 |
+
system_prompt = "You are a helpful assistant. Answer the question above directly and accurately."
|
| 300 |
+
|
| 301 |
+
# Use existing conversation context
|
| 302 |
+
messages = state.get("messages", []) + [AIMessage(content=system_prompt)]
|
| 303 |
+
|
| 304 |
+
# Get response
|
| 305 |
+
response = self.chat.invoke(messages)
|
| 306 |
+
|
| 307 |
+
# Update messages for tracking
|
| 308 |
+
new_messages = state.get("messages", []) + [
|
| 309 |
+
AIMessage(content=system_prompt),
|
| 310 |
+
response
|
| 311 |
+
]
|
| 312 |
+
|
| 313 |
+
return {"messages": new_messages}
|
| 314 |
+
|
| 315 |
+
def provide_final_answer(self, state: AgentState):
|
| 316 |
+
"""Provide a final answer based on tool results, or request more searches if needed"""
|
| 317 |
+
search_attempts = state.get("search_attempts", 0)
|
| 318 |
+
|
| 319 |
+
# If we've reached the search limit, force a final answer
|
| 320 |
+
if search_attempts >= 5:
|
| 321 |
+
final_prompt = """You have reached the maximum number of search attempts (5).
|
| 322 |
+
|
| 323 |
+
Based on all the information gathered in this conversation, provide the best possible answer to the original question.
|
| 324 |
+
If you could not find the specific information requested, clearly state that the information could not be found."""
|
| 325 |
+
|
| 326 |
+
# Use regular chat (without tools) to force a final answer
|
| 327 |
+
messages = state.get("messages", []) + [HumanMessage(content=final_prompt)]
|
| 328 |
+
response = self.chat.invoke(messages)
|
| 329 |
+
|
| 330 |
+
new_messages = state.get("messages", []) + [
|
| 331 |
+
HumanMessage(content=final_prompt),
|
| 332 |
+
response
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
return {"messages": new_messages}
|
| 336 |
+
else:
|
| 337 |
+
# Allow more searches if under the limit
|
| 338 |
+
final_prompt = f"""Based on the conversation above and any tool results, either:
|
| 339 |
+
|
| 340 |
+
1. Provide a clear and direct answer to the original question if you have enough information, OR
|
| 341 |
+
2. Use additional tools to search for missing information
|
| 342 |
+
|
| 343 |
+
SEARCH ATTEMPTS: {search_attempts}/5 (Maximum 5 attempts)
|
| 344 |
+
|
| 345 |
+
SEARCH STRATEGY FOR COMPLEX QUESTIONS:
|
| 346 |
+
- If you couldn't find information with one search, try breaking it down:
|
| 347 |
+
* For questions about actors in different shows, search each show/movie separately
|
| 348 |
+
* For questions about adaptations, search for the original work first, then the adaptation
|
| 349 |
+
* Use simpler, more specific search terms
|
| 350 |
+
* Try different keyword combinations if first search fails
|
| 351 |
+
|
| 352 |
+
CURRENT SITUATION:
|
| 353 |
+
- Review what searches you've already tried
|
| 354 |
+
- If previous searches failed, try different, simpler search terms
|
| 355 |
+
- Break complex questions into their component parts and search each separately
|
| 356 |
+
|
| 357 |
+
If you need more information, use the tools. If you have enough information, provide the final answer."""
|
| 358 |
+
|
| 359 |
+
# Use the chat with tools so it can decide to search more
|
| 360 |
+
messages = state.get("messages", []) + [HumanMessage(content=final_prompt)]
|
| 361 |
+
response = self.chat_with_tools.invoke(messages)
|
| 362 |
+
|
| 363 |
+
# Update messages for tracking
|
| 364 |
+
new_messages = state.get("messages", []) + [
|
| 365 |
+
HumanMessage(content=final_prompt),
|
| 366 |
+
response
|
| 367 |
+
]
|
| 368 |
+
|
| 369 |
+
return {"messages": new_messages}
|
| 370 |
+
|
| 371 |
+
def route_after_classification(self, state: AgentState) -> str:
|
| 372 |
+
"""Determine the next step based on file requirement classification"""
|
| 373 |
+
if state["requires_file"]:
|
| 374 |
+
return "file_required"
|
| 375 |
+
else:
|
| 376 |
+
return "no_file_required"
|
| 377 |
+
|
| 378 |
+
def route_after_planning(self, state: AgentState) -> str:
|
| 379 |
+
"""Determine whether to use tools or answer directly based on LLM decision"""
|
| 380 |
+
messages = state.get("messages", [])
|
| 381 |
+
|
| 382 |
+
# Get the last AI message (the planning decision)
|
| 383 |
+
for msg in reversed(messages):
|
| 384 |
+
if isinstance(msg, AIMessage):
|
| 385 |
+
decision = msg.content.lower().strip()
|
| 386 |
+
if "tools" in decision:
|
| 387 |
+
return "use_tools"
|
| 388 |
+
elif "direct" in decision:
|
| 389 |
+
return "answer_direct"
|
| 390 |
+
break
|
| 391 |
+
|
| 392 |
+
# Default to direct if unclear
|
| 393 |
+
return "answer_direct"
|
| 394 |
+
|
| 395 |
+
def extract_final_answer(self, state: AgentState):
|
| 396 |
+
"""Extract ONLY the final answer from the conversation"""
|
| 397 |
+
# Create a dedicated extraction prompt that looks at the entire conversation
|
| 398 |
+
extraction_prompt = """Look at the entire conversation above and extract ONLY the final answer to the original question.
|
| 399 |
+
Return just the answer with no extra words, explanations, or formatting.
|
| 400 |
+
|
| 401 |
+
If the answer is a number, write it in digits.
|
| 402 |
+
|
| 403 |
+
Examples:
|
| 404 |
+
- If the conversation concludes "The capital is Paris", return: Paris
|
| 405 |
+
- If the conversation concludes "2 + 2 equals 4", return: 4
|
| 406 |
+
- If the conversation concludes "The opposite of left is right", return: right
|
| 407 |
+
- If the conversation concludes "Based on search results, the answer is 42", return: 42
|
| 408 |
+
|
| 409 |
+
Final answer only:"""
|
| 410 |
+
|
| 411 |
+
try:
|
| 412 |
+
# Use the full conversation context for extraction
|
| 413 |
+
messages = state["messages"] + [HumanMessage(content=extraction_prompt)]
|
| 414 |
+
response = self.chat.invoke(messages)
|
| 415 |
+
answer = response.content.strip()
|
| 416 |
+
return answer
|
| 417 |
+
except Exception as e:
|
| 418 |
+
print(f"Answer extraction error: {e}")
|
| 419 |
+
# Fallback: get the last AI message content
|
| 420 |
+
messages = state["messages"]
|
| 421 |
+
for msg in reversed(messages):
|
| 422 |
+
if isinstance(msg, AIMessage) and not getattr(msg, 'tool_calls', None):
|
| 423 |
+
return msg.content.strip()
|
| 424 |
+
return "No answer found"
|
| 425 |
+
|
| 426 |
+
def _build_graph(self):
|
| 427 |
+
"""Build the LangGraph workflow with proper planning approach"""
|
| 428 |
+
graph = StateGraph(AgentState)
|
| 429 |
+
|
| 430 |
+
# Add nodes
|
| 431 |
+
graph.add_node("classify_file_requirement", self.classify_file_requirement)
|
| 432 |
+
graph.add_node("download_file_content", self.download_file_content)
|
| 433 |
+
graph.add_node("plan_approach", self.plan_approach)
|
| 434 |
+
graph.add_node("answer_with_tools", self.answer_with_tools)
|
| 435 |
+
graph.add_node("answer_directly", self.answer_directly)
|
| 436 |
+
graph.add_node("tools", ToolNode(self.tools))
|
| 437 |
+
|
| 438 |
+
# Define the flow - Start with file classification
|
| 439 |
+
graph.add_edge(START, "classify_file_requirement")
|
| 440 |
+
|
| 441 |
+
# Add conditional branching after classification
|
| 442 |
+
graph.add_conditional_edges(
|
| 443 |
+
"classify_file_requirement",
|
| 444 |
+
self.route_after_classification,
|
| 445 |
+
{
|
| 446 |
+
"file_required": "download_file_content",
|
| 447 |
+
"no_file_required": "plan_approach"
|
| 448 |
+
}
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# After downloading file, plan the approach
|
| 452 |
+
graph.add_edge("download_file_content", "plan_approach")
|
| 453 |
+
|
| 454 |
+
# After planning, decide whether to use tools or answer directly
|
| 455 |
+
graph.add_conditional_edges(
|
| 456 |
+
"plan_approach",
|
| 457 |
+
self.route_after_planning,
|
| 458 |
+
{
|
| 459 |
+
"use_tools": "answer_with_tools",
|
| 460 |
+
"answer_direct": "answer_directly"
|
| 461 |
+
}
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
# From answer_with_tools, either use tools or end
|
| 465 |
+
graph.add_conditional_edges(
|
| 466 |
+
"answer_with_tools",
|
| 467 |
+
tools_condition,
|
| 468 |
+
{
|
| 469 |
+
"tools": "tools",
|
| 470 |
+
END: END,
|
| 471 |
+
}
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
# From answer_directly, just end (no tool checking after direct answer)
|
| 475 |
+
graph.add_edge("answer_directly", END)
|
| 476 |
+
|
| 477 |
+
# After tools, check if more tools are needed or provide final answer
|
| 478 |
+
graph.add_node("provide_final_answer", self.provide_final_answer)
|
| 479 |
+
graph.add_conditional_edges(
|
| 480 |
+
"tools",
|
| 481 |
+
tools_condition,
|
| 482 |
+
{
|
| 483 |
+
"tools": "tools", # Allow multiple tool cycles
|
| 484 |
+
END: "provide_final_answer",
|
| 485 |
+
}
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
# Allow provide_final_answer to also use more tools if needed
|
| 489 |
+
graph.add_conditional_edges(
|
| 490 |
+
"provide_final_answer",
|
| 491 |
+
tools_condition,
|
| 492 |
+
{
|
| 493 |
+
"tools": "tools", # Can go back to tools for more searches
|
| 494 |
+
END: END,
|
| 495 |
+
}
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
# Compile the graph
|
| 499 |
+
self.compiled_graph = graph.compile()
|
| 500 |
+
# self.compiled_graph.get_graph().draw_mermaid_png()
|
| 501 |
+
|
| 502 |
+
def __call__(self, question: str, task_id: Optional[str] = None) -> str:
|
| 503 |
+
"""Process question using LangGraph workflow"""
|
| 504 |
+
print(f"Processing: {question[:50]}...")
|
| 505 |
+
|
| 506 |
+
# Create initial state following the new structure
|
| 507 |
+
initial_state = {
|
| 508 |
+
"question": question,
|
| 509 |
+
"task_id": task_id,
|
| 510 |
+
"requires_file": None,
|
| 511 |
+
"file_content": None,
|
| 512 |
+
"search_attempts": 0,
|
| 513 |
+
"final_answer": None,
|
| 514 |
+
"messages": []
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
try:
|
| 518 |
+
# Run the graph with recursion limit configuration and Langfuse tracing
|
| 519 |
+
config = {"recursion_limit": 25} # Higher limit for multiple tool usage
|
| 520 |
+
|
| 521 |
+
# Add Langfuse callback handler if available
|
| 522 |
+
if self.langfuse_handler:
|
| 523 |
+
config["callbacks"] = [self.langfuse_handler]
|
| 524 |
+
print("🔌 Running with Langfuse tracing enabled")
|
| 525 |
+
|
| 526 |
+
result = self.compiled_graph.invoke(initial_state, config=config)
|
| 527 |
+
|
| 528 |
+
# Extract the final answer
|
| 529 |
+
answer = self.extract_final_answer(result)
|
| 530 |
+
print(f"Answer: {answer[:50]}...")
|
| 531 |
+
|
| 532 |
+
# Log the complete conversation for review
|
| 533 |
+
self.log_full_conversation(question, result, answer)
|
| 534 |
+
|
| 535 |
+
return answer
|
| 536 |
+
|
| 537 |
+
except Exception as e:
|
| 538 |
+
print(f"Error: {e}")
|
| 539 |
+
error_answer = "Error occurred."
|
| 540 |
+
# Create a minimal state for error logging
|
| 541 |
+
error_state = {
|
| 542 |
+
"question": question,
|
| 543 |
+
"messages": [
|
| 544 |
+
HumanMessage(content=question),
|
| 545 |
+
AIMessage(content=f"Error: {str(e)}")
|
| 546 |
+
]
|
| 547 |
+
}
|
| 548 |
+
self.log_full_conversation(question, error_state, error_answer)
|
| 549 |
+
return error_answer
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
if __name__ == "__main__":
|
| 553 |
+
print("Testing FlexibleAgent with a simple question...")
|
| 554 |
+
|
| 555 |
+
try:
|
| 556 |
+
# Create an instance of the agent
|
| 557 |
+
agent = FlexibleAgent()
|
| 558 |
+
|
| 559 |
+
# Test with a simple math question
|
| 560 |
+
test_question = "How much is 2+2?"
|
| 561 |
+
print(f"\nQuestion: {test_question}")
|
| 562 |
+
|
| 563 |
+
# Get the answer
|
| 564 |
+
answer = agent(test_question)
|
| 565 |
+
print(f"Answer: {answer}")
|
| 566 |
+
|
| 567 |
+
# Check if the answer is correct
|
| 568 |
+
if answer == "4":
|
| 569 |
+
print("✅ Test passed! The agent correctly answered the math question.")
|
| 570 |
+
else:
|
| 571 |
+
print("❌ Test failed. Expected the answer to be '4'.")
|
| 572 |
+
|
| 573 |
+
answer = agent("What is the surname of the equine veterinarian mentioned in 1.E Exercises from the chemistry materials licensed by Marisa Alviar-Agnew & Henry Agnew under the CK-12 license in LibreText's Introductory Chemistry materials as compiled 08/21/2023?")
|
| 574 |
+
print(f"Answer: {answer}")
|
| 575 |
+
|
| 576 |
+
if answer == "Louvrier":
|
| 577 |
+
print("✅ Test passed! The agent correctly answered the question.")
|
| 578 |
+
else:
|
| 579 |
+
print("❌ Test failed. Expected the answer to contain 'Louvrier'.")
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
except Exception as e:
|
| 583 |
+
import traceback
|
| 584 |
+
print(f"❌ Test failed with error: {e}")
|
| 585 |
+
print("Full traceback:")
|
| 586 |
+
traceback.print_exc()
|
src/gaio.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DEPRECATED: This file has been replaced by gemini_chat_model.py
|
| 2 |
+
# Please use GeminiChatModel instead of Gaio for LLM integration
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Gaio:
|
| 9 |
+
def __init__(self, api_key, api_url):
|
| 10 |
+
self.api_key = api_key
|
| 11 |
+
self.api_url = api_url
|
| 12 |
+
|
| 13 |
+
def InvokeGaio(self, userPrompt):
|
| 14 |
+
payload = {
|
| 15 |
+
"model": "azure/gemini-2.5-pro",
|
| 16 |
+
"messages": [
|
| 17 |
+
{
|
| 18 |
+
"role": "user",
|
| 19 |
+
"content": userPrompt
|
| 20 |
+
}
|
| 21 |
+
],
|
| 22 |
+
"temperature": 0.00,
|
| 23 |
+
"max_tokens": 100000,
|
| 24 |
+
"stream": False
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
headers = {
|
| 28 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 29 |
+
"Content-Type": "application/json"
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# Make the POST request
|
| 33 |
+
response = requests.post(
|
| 34 |
+
self.api_url,
|
| 35 |
+
headers=headers,
|
| 36 |
+
json=payload,
|
| 37 |
+
timeout=30
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Parse the JSON response
|
| 41 |
+
result = response.json()
|
| 42 |
+
message = result["choices"][0]["message"]["content"]
|
| 43 |
+
return message
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main():
|
| 47 |
+
"""Test Gaio with a simple question and verify the answer."""
|
| 48 |
+
print("Testing Gaio with a simple math question...")
|
| 49 |
+
|
| 50 |
+
# Get API credentials from environment variables
|
| 51 |
+
api_key = os.getenv("GAIO_API_TOKEN")
|
| 52 |
+
api_url = os.getenv("GAIO_URL")
|
| 53 |
+
|
| 54 |
+
if not api_key or not api_url:
|
| 55 |
+
print("❌ Test failed: Missing environment variables.")
|
| 56 |
+
print("Please set the following environment variables:")
|
| 57 |
+
print("- GAIO_API_TOKEN: Your API token")
|
| 58 |
+
print("- GAIO_URL: The API URL")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
# Create Gaio instance
|
| 63 |
+
gaio = Gaio(api_key, api_url)
|
| 64 |
+
|
| 65 |
+
# Test with the specific question
|
| 66 |
+
test_question = "How much is 2 + 2 ? Only answer with the response number and nothing else."
|
| 67 |
+
print(f"\nQuestion: {test_question}")
|
| 68 |
+
|
| 69 |
+
# Get the answer
|
| 70 |
+
answer = gaio.InvokeGaio(test_question)
|
| 71 |
+
print(f"Answer: '{answer}'")
|
| 72 |
+
|
| 73 |
+
# Check if the answer is exactly "4"
|
| 74 |
+
answer_stripped = answer.strip()
|
| 75 |
+
if answer_stripped == "4":
|
| 76 |
+
print("✅ Test passed! The answer is exactly '4'.")
|
| 77 |
+
else:
|
| 78 |
+
print(f"❌ Test failed. Expected '4', but got '{answer_stripped}'.")
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"❌ Test failed with error: {e}")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
src/gaio_chat_model.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DEPRECATED: This file has been replaced by gemini_chat_model.py
|
| 2 |
+
# Please use GeminiChatModel instead of GaioChatModel for LLM integration
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any, Dict, Iterator, List, Optional
|
| 8 |
+
from pydantic import Field, SecretStr
|
| 9 |
+
from langchain_core.callbacks.manager import CallbackManagerForLLMRun
|
| 10 |
+
from langchain_core.language_models.chat_models import BaseChatModel
|
| 11 |
+
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
| 12 |
+
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
| 13 |
+
from langchain_core.messages.tool import ToolCall
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
# Try relative import first (when used as package)
|
| 17 |
+
from .gaio import Gaio
|
| 18 |
+
except ImportError:
|
| 19 |
+
# Fall back to absolute import (when run directly)
|
| 20 |
+
from gaio import Gaio
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class GaioChatModel(BaseChatModel):
|
| 24 |
+
"""Custom LangChain chat model wrapper for Gaio API.
|
| 25 |
+
|
| 26 |
+
This model integrates with the Gaio API service to provide chat completion
|
| 27 |
+
capabilities within the LangChain framework.
|
| 28 |
+
|
| 29 |
+
Example:
|
| 30 |
+
```python
|
| 31 |
+
model = GaioChatModel(
|
| 32 |
+
api_key="your-api-key",
|
| 33 |
+
api_url="https://your-gaio-endpoint.com/chat/completions"
|
| 34 |
+
)
|
| 35 |
+
response = model.invoke([HumanMessage(content="Hello!")])
|
| 36 |
+
```
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
api_key: SecretStr = Field(description="API key for Gaio service")
|
| 40 |
+
api_url: str = Field(description="API endpoint URL for Gaio service")
|
| 41 |
+
model_name: str = Field(default="azure/gpt-4o", description="Name of the model to use")
|
| 42 |
+
temperature: float = Field(default=0.05, ge=0.0, le=2.0, description="Sampling temperature")
|
| 43 |
+
max_tokens: int = Field(default=1000, gt=0, description="Maximum number of tokens to generate")
|
| 44 |
+
gaio_client: Optional[Gaio] = Field(default=None, exclude=True)
|
| 45 |
+
|
| 46 |
+
class Config:
|
| 47 |
+
"""Pydantic model configuration."""
|
| 48 |
+
arbitrary_types_allowed = True
|
| 49 |
+
|
| 50 |
+
def __init__(self, api_key: str, api_url: str, **kwargs):
|
| 51 |
+
# Set the fields before calling super().__init__
|
| 52 |
+
kwargs['api_key'] = SecretStr(api_key)
|
| 53 |
+
kwargs['api_url'] = api_url
|
| 54 |
+
super().__init__(**kwargs)
|
| 55 |
+
# Initialize the Gaio client after parent initialization
|
| 56 |
+
self.gaio_client = Gaio(api_key, api_url)
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def _llm_type(self) -> str:
|
| 60 |
+
"""Return identifier of the LLM."""
|
| 61 |
+
return "gaio"
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def _identifying_params(self) -> Dict[str, Any]:
|
| 65 |
+
"""Return a dictionary of identifying parameters.
|
| 66 |
+
|
| 67 |
+
This information is used by the LangChain callback system for tracing.
|
| 68 |
+
Note: API key is excluded for security reasons.
|
| 69 |
+
"""
|
| 70 |
+
return {
|
| 71 |
+
"model_name": self.model_name,
|
| 72 |
+
"api_url": self.api_url,
|
| 73 |
+
"temperature": self.temperature,
|
| 74 |
+
"max_tokens": self.max_tokens,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
def _format_messages_for_gaio(self, messages: List[BaseMessage]) -> str:
|
| 78 |
+
"""Convert LangChain messages to a single prompt string for gaio."""
|
| 79 |
+
formatted_parts = []
|
| 80 |
+
|
| 81 |
+
for message in messages:
|
| 82 |
+
if isinstance(message, HumanMessage):
|
| 83 |
+
formatted_parts.append(f"user: {message.content}")
|
| 84 |
+
elif isinstance(message, AIMessage):
|
| 85 |
+
formatted_parts.append(f"assistant: {message.content}")
|
| 86 |
+
elif isinstance(message, SystemMessage):
|
| 87 |
+
formatted_parts.append(f"system: {message.content}")
|
| 88 |
+
elif isinstance(message, ToolMessage):
|
| 89 |
+
formatted_parts.append(f"tool_result: {message.content}")
|
| 90 |
+
# Add instruction after tool result
|
| 91 |
+
formatted_parts.append("Now provide your final answer based on the tool result above. Do NOT make another tool call.")
|
| 92 |
+
else:
|
| 93 |
+
raise RuntimeError(f"Unknown message type: {type(message)}")
|
| 94 |
+
|
| 95 |
+
# If tools are bound, add tool information to the prompt
|
| 96 |
+
if hasattr(self, '_bound_tools') and self._bound_tools:
|
| 97 |
+
tool_descriptions = []
|
| 98 |
+
for tool in self._bound_tools:
|
| 99 |
+
tool_name = tool.name
|
| 100 |
+
tool_desc = tool.description
|
| 101 |
+
tool_descriptions.append(f"- {tool_name}: {tool_desc}")
|
| 102 |
+
|
| 103 |
+
tool_format = '{"tool_call": {"name": "tool_name", "arguments": {"parameter_name": "value"}}}'
|
| 104 |
+
wikipedia_example = '{"tool_call": {"name": "wikipedia_search", "arguments": {"query": "capital of France"}}}'
|
| 105 |
+
youtube_example = '{"tool_call": {"name": "youtube_search", "arguments": {"query": "python tutorial"}}}'
|
| 106 |
+
decode_example = '{"tool_call": {"name": "decode_text", "arguments": {"text": "backwards text here"}}}'
|
| 107 |
+
|
| 108 |
+
tools_prompt = f"""
|
| 109 |
+
|
| 110 |
+
You have access to the following tools:
|
| 111 |
+
{chr(10).join(tool_descriptions)}
|
| 112 |
+
|
| 113 |
+
When you need to use a tool, you MUST respond with exactly this format:
|
| 114 |
+
{tool_format}
|
| 115 |
+
|
| 116 |
+
Examples:
|
| 117 |
+
- To search Wikipedia: {wikipedia_example}
|
| 118 |
+
- To search YouTube: {youtube_example}
|
| 119 |
+
- To decode text: {decode_example}
|
| 120 |
+
|
| 121 |
+
CRITICAL: Use the correct parameter names:
|
| 122 |
+
- wikipedia_search and youtube_search use "query"
|
| 123 |
+
- decode_text uses "text"
|
| 124 |
+
|
| 125 |
+
Always try tools first for factual information before saying you cannot help."""
|
| 126 |
+
|
| 127 |
+
formatted_parts.append(tools_prompt)
|
| 128 |
+
|
| 129 |
+
return "\n\n".join(formatted_parts)
|
| 130 |
+
|
| 131 |
+
def _parse_tool_calls(self, response_content: str) -> tuple[str, List[ToolCall]]:
|
| 132 |
+
"""Parse tool calls from the response content."""
|
| 133 |
+
tool_calls = []
|
| 134 |
+
remaining_content = response_content
|
| 135 |
+
|
| 136 |
+
# Look for JSON tool call pattern - more flexible regex
|
| 137 |
+
tool_call_pattern = r'\{"tool_call":\s*\{"name":\s*"([^"]+)",\s*"arguments":\s*(\{[^}]*\})\}\}'
|
| 138 |
+
matches = list(re.finditer(tool_call_pattern, response_content))
|
| 139 |
+
|
| 140 |
+
for i, match in enumerate(matches):
|
| 141 |
+
tool_name = match.group(1)
|
| 142 |
+
try:
|
| 143 |
+
arguments_str = match.group(2)
|
| 144 |
+
arguments = json.loads(arguments_str)
|
| 145 |
+
tool_call = ToolCall(
|
| 146 |
+
name=tool_name,
|
| 147 |
+
args=arguments,
|
| 148 |
+
id=f"call_{len(tool_calls)}"
|
| 149 |
+
)
|
| 150 |
+
tool_calls.append(tool_call)
|
| 151 |
+
# Remove the tool call from the content
|
| 152 |
+
remaining_content = remaining_content.replace(match.group(0), "").strip()
|
| 153 |
+
except json.JSONDecodeError:
|
| 154 |
+
continue
|
| 155 |
+
return remaining_content, tool_calls
|
| 156 |
+
|
| 157 |
+
def _generate(
|
| 158 |
+
self,
|
| 159 |
+
messages: List[BaseMessage],
|
| 160 |
+
stop: Optional[List[str]] = None,
|
| 161 |
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
| 162 |
+
**kwargs: Any,
|
| 163 |
+
) -> ChatResult:
|
| 164 |
+
"""Generate a response from the model."""
|
| 165 |
+
# Convert messages to prompt format
|
| 166 |
+
prompt = self._format_messages_for_gaio(messages)
|
| 167 |
+
|
| 168 |
+
# Call gaio API
|
| 169 |
+
try:
|
| 170 |
+
response_content = self.gaio_client.InvokeGaio(prompt)
|
| 171 |
+
|
| 172 |
+
# Parse any tool calls from the response
|
| 173 |
+
content, tool_calls = self._parse_tool_calls(response_content)
|
| 174 |
+
|
| 175 |
+
# Estimate token usage (simple approximation)
|
| 176 |
+
input_tokens = self._estimate_tokens(prompt)
|
| 177 |
+
output_tokens = self._estimate_tokens(content)
|
| 178 |
+
usage_metadata = {
|
| 179 |
+
"input_tokens": input_tokens,
|
| 180 |
+
"output_tokens": output_tokens,
|
| 181 |
+
"total_tokens": input_tokens + output_tokens
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
# Create AI message with tool calls if any
|
| 185 |
+
if tool_calls:
|
| 186 |
+
ai_message = AIMessage(
|
| 187 |
+
content=content,
|
| 188 |
+
tool_calls=tool_calls,
|
| 189 |
+
usage_metadata=usage_metadata,
|
| 190 |
+
response_metadata={"model": self.model_name}
|
| 191 |
+
)
|
| 192 |
+
else:
|
| 193 |
+
ai_message = AIMessage(
|
| 194 |
+
content=content,
|
| 195 |
+
usage_metadata=usage_metadata,
|
| 196 |
+
response_metadata={"model": self.model_name}
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Create chat generation
|
| 200 |
+
generation = ChatGeneration(
|
| 201 |
+
message=ai_message,
|
| 202 |
+
generation_info={"model": self.model_name}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
return ChatResult(generations=[generation])
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
raise RuntimeError(f"Error calling Gaio API: {e}")
|
| 209 |
+
|
| 210 |
+
def _estimate_tokens(self, text: str) -> int:
|
| 211 |
+
"""Simple token estimation (roughly 4 characters per token for English)."""
|
| 212 |
+
return max(1, len(text) // 4)
|
| 213 |
+
|
| 214 |
+
async def _agenerate(
|
| 215 |
+
self,
|
| 216 |
+
messages: List[BaseMessage],
|
| 217 |
+
stop: Optional[List[str]] = None,
|
| 218 |
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
| 219 |
+
**kwargs: Any,
|
| 220 |
+
) -> ChatResult:
|
| 221 |
+
"""Async generate - for now, just call the sync version."""
|
| 222 |
+
# For simplicity, we'll use the sync version
|
| 223 |
+
# In production, you might want to implement true async using aiohttp
|
| 224 |
+
return self._generate(messages, stop, run_manager, **kwargs)
|
| 225 |
+
|
| 226 |
+
def _stream(
|
| 227 |
+
self,
|
| 228 |
+
messages: List[BaseMessage],
|
| 229 |
+
stop: Optional[List[str]] = None,
|
| 230 |
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
| 231 |
+
**kwargs: Any,
|
| 232 |
+
) -> Iterator[ChatGenerationChunk]:
|
| 233 |
+
"""Stream the response. Since Gaio doesn't support streaming, simulate it."""
|
| 234 |
+
# Get the full response first
|
| 235 |
+
result = self._generate(messages, stop, run_manager, **kwargs)
|
| 236 |
+
message = result.generations[0].message
|
| 237 |
+
|
| 238 |
+
# Stream character by character to simulate streaming
|
| 239 |
+
content = message.content
|
| 240 |
+
for i, char in enumerate(content):
|
| 241 |
+
chunk_content = char
|
| 242 |
+
if i == len(content) - 1: # Last chunk gets full metadata
|
| 243 |
+
chunk = ChatGenerationChunk(
|
| 244 |
+
message=AIMessageChunk(
|
| 245 |
+
content=chunk_content,
|
| 246 |
+
usage_metadata=message.usage_metadata,
|
| 247 |
+
response_metadata=message.response_metadata,
|
| 248 |
+
tool_calls=getattr(message, 'tool_calls', None) if i == len(content) - 1 else None
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
else:
|
| 252 |
+
chunk = ChatGenerationChunk(
|
| 253 |
+
message=AIMessageChunk(content=chunk_content)
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
if run_manager:
|
| 257 |
+
run_manager.on_llm_new_token(char, chunk=chunk)
|
| 258 |
+
yield chunk
|
| 259 |
+
|
| 260 |
+
def bind_tools(self, tools: List[Any], **kwargs: Any) -> "GaioChatModel":
|
| 261 |
+
"""Bind tools to the model."""
|
| 262 |
+
# Create a copy of the current model with tools bound
|
| 263 |
+
bound_model = GaioChatModel(
|
| 264 |
+
api_key=self.api_key.get_secret_value(),
|
| 265 |
+
api_url=self.api_url,
|
| 266 |
+
model_name=self.model_name,
|
| 267 |
+
temperature=self.temperature,
|
| 268 |
+
max_tokens=self.max_tokens
|
| 269 |
+
)
|
| 270 |
+
# Store the tools for potential use in generation
|
| 271 |
+
bound_model._bound_tools = tools
|
| 272 |
+
return bound_model
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def main():
|
| 276 |
+
"""Test GaioChatModel with a simple question and verify the answer."""
|
| 277 |
+
print("Testing GaioChatModel with a simple math question...")
|
| 278 |
+
|
| 279 |
+
# Get API credentials from environment variables
|
| 280 |
+
api_key = os.getenv("GAIO_API_TOKEN")
|
| 281 |
+
api_url = os.getenv("GAIO_URL")
|
| 282 |
+
|
| 283 |
+
if not api_key or not api_url:
|
| 284 |
+
print("❌ Test failed: Missing environment variables.")
|
| 285 |
+
print("Please set the following environment variables:")
|
| 286 |
+
print("- GAIO_API_TOKEN: Your API token")
|
| 287 |
+
print("- GAIO_URL: The API URL")
|
| 288 |
+
return
|
| 289 |
+
|
| 290 |
+
try:
|
| 291 |
+
# Create GaioChatModel instance
|
| 292 |
+
chat_model = GaioChatModel(api_key=api_key, api_url=api_url)
|
| 293 |
+
|
| 294 |
+
# Test with the specific question using LangChain message format
|
| 295 |
+
test_question = "How much is 2 + 2 ? Only answer with the response number and nothing else."
|
| 296 |
+
messages = [HumanMessage(content=test_question)]
|
| 297 |
+
|
| 298 |
+
print(f"\nQuestion: {test_question}")
|
| 299 |
+
print("Using LangChain message format...")
|
| 300 |
+
|
| 301 |
+
# Get the answer using LangChain's invoke method
|
| 302 |
+
result = chat_model.invoke(messages)
|
| 303 |
+
answer = result.content
|
| 304 |
+
print(f"Answer: '{answer}'")
|
| 305 |
+
|
| 306 |
+
# Check if the answer is exactly "4"
|
| 307 |
+
answer_stripped = answer.strip()
|
| 308 |
+
if answer_stripped == "4":
|
| 309 |
+
print("✅ Test passed! GaioChatModel correctly answered '4'.")
|
| 310 |
+
else:
|
| 311 |
+
print(f"❌ Test failed. Expected '4', but got '{answer_stripped}'.")
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
print(f"❌ Test failed with error: {e}")
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
if __name__ == "__main__":
|
| 318 |
+
main()
|
| 319 |
+
|
src/tools.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tools for the FlexibleAgent
|
| 3 |
+
All tool functions that the agent can use
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import requests
|
| 9 |
+
import tempfile
|
| 10 |
+
import mimetypes
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from langchain_core.tools import tool
|
| 14 |
+
from langchain_community.retrievers import WikipediaRetriever
|
| 15 |
+
from langchain_community.document_loaders import (
|
| 16 |
+
UnstructuredFileLoader,
|
| 17 |
+
TextLoader,
|
| 18 |
+
CSVLoader,
|
| 19 |
+
PDFPlumberLoader,
|
| 20 |
+
UnstructuredImageLoader,
|
| 21 |
+
UnstructuredMarkdownLoader,
|
| 22 |
+
UnstructuredWordDocumentLoader,
|
| 23 |
+
UnstructuredPowerPointLoader,
|
| 24 |
+
UnstructuredExcelLoader
|
| 25 |
+
)
|
| 26 |
+
from langchain_community.tools.tavily_search import TavilySearchResults
|
| 27 |
+
from langchain_core.tools import Tool
|
| 28 |
+
from langchain_google_community import GoogleSearchAPIWrapper
|
| 29 |
+
from langchain_community.tools import DuckDuckGoSearchResults
|
| 30 |
+
from langchain_community.document_loaders import WebBaseLoader
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@tool
|
| 34 |
+
def wikipedia_search(query: str) -> str:
|
| 35 |
+
"""Search Wikipedia for information. Use this for factual information and encyclopedic content.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
query: The search query."""
|
| 39 |
+
try:
|
| 40 |
+
retriever = WikipediaRetriever(load_max_docs=10)
|
| 41 |
+
docs = retriever.invoke(query)
|
| 42 |
+
|
| 43 |
+
if not docs:
|
| 44 |
+
return f"No Wikipedia articles found for '{query}'"
|
| 45 |
+
|
| 46 |
+
output = f"Wikipedia search results for '{query}':\n\n"
|
| 47 |
+
|
| 48 |
+
# Format the search results as HTML
|
| 49 |
+
formatted_search_docs = "\n\n---\n\n".join(
|
| 50 |
+
[
|
| 51 |
+
f'<Document source="{doc.metadata["source"]}" page="{doc.metadata.get("page", "")}"/>\n{doc.page_content}\n</Document>'
|
| 52 |
+
for doc in docs
|
| 53 |
+
]
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return output + formatted_search_docs
|
| 57 |
+
except Exception as e:
|
| 58 |
+
return f"Wikipedia search failed: {str(e)}"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@tool
|
| 62 |
+
def youtube_search(query: str) -> str:
|
| 63 |
+
"""Search YouTube for videos and get video information. Use this when you need YouTube-specific content."""
|
| 64 |
+
try:
|
| 65 |
+
from youtubesearchpython import VideosSearch
|
| 66 |
+
search = VideosSearch(query, limit=3)
|
| 67 |
+
results = search.result()
|
| 68 |
+
|
| 69 |
+
output = f"YouTube search results for '{query}':\n"
|
| 70 |
+
for video in results['result']:
|
| 71 |
+
output += f"- {video['title']} by {video['channel']['name']}\n"
|
| 72 |
+
output += f" Duration: {video['duration']}, Views: {video['viewCount']['text']}\n"
|
| 73 |
+
output += f" URL: {video['link']}\n\n"
|
| 74 |
+
|
| 75 |
+
return output
|
| 76 |
+
except Exception as e:
|
| 77 |
+
return f"YouTube search failed: {str(e)}"
|
| 78 |
+
|
| 79 |
+
@tool
|
| 80 |
+
def web_search(query: str) -> str:
|
| 81 |
+
"""Search the web for a query and return the first results.
|
| 82 |
+
Args:
|
| 83 |
+
query: The search query."""
|
| 84 |
+
|
| 85 |
+
result = "Results from web search:\n\n"
|
| 86 |
+
|
| 87 |
+
search = DuckDuckGoSearchResults(output_format="list")
|
| 88 |
+
|
| 89 |
+
search_results = search.invoke(query)
|
| 90 |
+
urls = [search_result['link'] for search_result in search_results[:3]]
|
| 91 |
+
|
| 92 |
+
loader = WebBaseLoader(web_paths=urls)
|
| 93 |
+
|
| 94 |
+
for doc in loader.lazy_load():
|
| 95 |
+
result += f"{doc.metadata}\n\n"
|
| 96 |
+
result += f"{doc.page_content}\n\n"
|
| 97 |
+
result += f"--------------------------------\n\n"
|
| 98 |
+
|
| 99 |
+
return result
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@tool
|
| 103 |
+
def decode_text(text: str) -> str:
|
| 104 |
+
"""Decode or reverse text that might be encoded backwards or in other ways."""
|
| 105 |
+
try:
|
| 106 |
+
# Try reversing words
|
| 107 |
+
words = text.split()
|
| 108 |
+
reversed_words = [word[::-1] for word in words]
|
| 109 |
+
reversed_text = " ".join(reversed_words)
|
| 110 |
+
|
| 111 |
+
# Try reversing the entire string
|
| 112 |
+
fully_reversed = text[::-1]
|
| 113 |
+
|
| 114 |
+
return f"Original: {text}\nWord-by-word reversed: {reversed_text}\nFully reversed: {fully_reversed}"
|
| 115 |
+
except Exception as e:
|
| 116 |
+
return f"Text decoding failed: {str(e)}"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@tool
|
| 120 |
+
def download_and_process_file(task_id: str) -> str:
|
| 121 |
+
"""Download and process a file from the GAIA API using the task_id.
|
| 122 |
+
Use this tool when detect_file_requirement indicates a file is needed."""
|
| 123 |
+
api_url = "https://agents-course-unit4-scoring.hf.space"
|
| 124 |
+
try:
|
| 125 |
+
# Download file from API
|
| 126 |
+
file_url = f"{api_url}/files/{task_id}"
|
| 127 |
+
print(f"Downloading file from: {file_url}")
|
| 128 |
+
|
| 129 |
+
response = requests.get(file_url, timeout=30)
|
| 130 |
+
response.raise_for_status()
|
| 131 |
+
|
| 132 |
+
# Get filename from Content-Disposition header or use task_id
|
| 133 |
+
filename = task_id
|
| 134 |
+
if 'Content-Disposition' in response.headers:
|
| 135 |
+
cd = response.headers['Content-Disposition']
|
| 136 |
+
filename_match = re.search(r'filename="?([^"]+)"?', cd)
|
| 137 |
+
if filename_match:
|
| 138 |
+
filename = filename_match.group(1)
|
| 139 |
+
|
| 140 |
+
# Create temporary file
|
| 141 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{filename}") as tmp_file:
|
| 142 |
+
tmp_file.write(response.content)
|
| 143 |
+
temp_path = tmp_file.name
|
| 144 |
+
|
| 145 |
+
# Process the file based on type
|
| 146 |
+
file_content = _process_downloaded_file(temp_path, filename)
|
| 147 |
+
|
| 148 |
+
# Clean up
|
| 149 |
+
os.unlink(temp_path)
|
| 150 |
+
|
| 151 |
+
return f"FILE PROCESSED: {filename}\n\nContent:\n{file_content}"
|
| 152 |
+
|
| 153 |
+
except requests.exceptions.RequestException as e:
|
| 154 |
+
return f"File download failed: {str(e)}"
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return f"File processing failed: {str(e)}"
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _process_downloaded_file(file_path: str, filename: str) -> str:
|
| 160 |
+
"""Process a downloaded file based on its type and return content."""
|
| 161 |
+
try:
|
| 162 |
+
# Determine file type
|
| 163 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 164 |
+
file_extension = Path(filename).suffix.lower()
|
| 165 |
+
|
| 166 |
+
# Handle audio files
|
| 167 |
+
if mime_type and mime_type.startswith('audio') or file_extension in ['.mp3', '.wav', '.m4a', '.ogg']:
|
| 168 |
+
return _process_audio_file(file_path)
|
| 169 |
+
|
| 170 |
+
# Handle image files
|
| 171 |
+
elif mime_type and mime_type.startswith('image') or file_extension in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']:
|
| 172 |
+
return _process_image_file(file_path)
|
| 173 |
+
|
| 174 |
+
# Handle documents
|
| 175 |
+
elif file_extension in ['.pdf']:
|
| 176 |
+
loader = PDFPlumberLoader(file_path)
|
| 177 |
+
docs = loader.load()
|
| 178 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 179 |
+
|
| 180 |
+
elif file_extension in ['.docx', '.doc']:
|
| 181 |
+
loader = UnstructuredWordDocumentLoader(file_path)
|
| 182 |
+
docs = loader.load()
|
| 183 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 184 |
+
|
| 185 |
+
elif file_extension in ['.pptx', '.ppt']:
|
| 186 |
+
loader = UnstructuredPowerPointLoader(file_path)
|
| 187 |
+
docs = loader.load()
|
| 188 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 189 |
+
|
| 190 |
+
elif file_extension in ['.xlsx', '.xls']:
|
| 191 |
+
loader = UnstructuredExcelLoader(file_path)
|
| 192 |
+
docs = loader.load()
|
| 193 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 194 |
+
|
| 195 |
+
elif file_extension in ['.csv']:
|
| 196 |
+
loader = CSVLoader(file_path)
|
| 197 |
+
docs = loader.load()
|
| 198 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 199 |
+
|
| 200 |
+
elif file_extension in ['.md', '.markdown']:
|
| 201 |
+
loader = UnstructuredMarkdownLoader(file_path)
|
| 202 |
+
docs = loader.load()
|
| 203 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 204 |
+
|
| 205 |
+
elif file_extension in ['.txt'] or mime_type and mime_type.startswith('text'):
|
| 206 |
+
loader = TextLoader(file_path)
|
| 207 |
+
docs = loader.load()
|
| 208 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 209 |
+
|
| 210 |
+
# Fallback: try unstructured loader
|
| 211 |
+
else:
|
| 212 |
+
loader = UnstructuredFileLoader(file_path)
|
| 213 |
+
docs = loader.load()
|
| 214 |
+
return "\n".join([doc.page_content for doc in docs])
|
| 215 |
+
|
| 216 |
+
except Exception as e:
|
| 217 |
+
return f"Error processing file {filename}: {str(e)}"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _process_audio_file(file_path: str) -> str:
|
| 221 |
+
"""Process audio files using speech recognition."""
|
| 222 |
+
try:
|
| 223 |
+
import speech_recognition as sr
|
| 224 |
+
from pydub import AudioSegment
|
| 225 |
+
|
| 226 |
+
# Convert to WAV if needed
|
| 227 |
+
audio = AudioSegment.from_file(file_path)
|
| 228 |
+
wav_path = file_path + ".wav"
|
| 229 |
+
audio.export(wav_path, format="wav")
|
| 230 |
+
|
| 231 |
+
# Use speech recognition
|
| 232 |
+
recognizer = sr.Recognizer()
|
| 233 |
+
with sr.AudioFile(wav_path) as source:
|
| 234 |
+
audio_data = recognizer.record(source)
|
| 235 |
+
text = recognizer.recognize_google(audio_data)
|
| 236 |
+
|
| 237 |
+
# Clean up temporary WAV file
|
| 238 |
+
if os.path.exists(wav_path):
|
| 239 |
+
os.unlink(wav_path)
|
| 240 |
+
|
| 241 |
+
return f"Audio transcription:\n{text}"
|
| 242 |
+
|
| 243 |
+
except ImportError:
|
| 244 |
+
return "Audio processing requires additional dependencies (speech_recognition, pydub)"
|
| 245 |
+
except Exception as e:
|
| 246 |
+
# Fallback: try with whisper if available
|
| 247 |
+
try:
|
| 248 |
+
import whisper
|
| 249 |
+
model = whisper.load_model("base")
|
| 250 |
+
result = model.transcribe(file_path)
|
| 251 |
+
return f"Audio transcription (Whisper):\n{result['text']}"
|
| 252 |
+
except ImportError:
|
| 253 |
+
return f"Audio processing failed: {str(e)}. Consider installing speech_recognition, pydub, or openai-whisper."
|
| 254 |
+
except Exception as e2:
|
| 255 |
+
return f"Audio processing failed: {str(e2)}"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _process_image_file(file_path: str) -> str:
|
| 259 |
+
"""Process image files."""
|
| 260 |
+
try:
|
| 261 |
+
# Use unstructured image loader
|
| 262 |
+
loader = UnstructuredImageLoader(file_path)
|
| 263 |
+
docs = loader.load()
|
| 264 |
+
content = "\n".join([doc.page_content for doc in docs])
|
| 265 |
+
|
| 266 |
+
if content.strip():
|
| 267 |
+
return f"Image content extracted:\n{content}"
|
| 268 |
+
else:
|
| 269 |
+
return f"Image file detected but no text content could be extracted. Consider using OCR or image analysis tools."
|
| 270 |
+
|
| 271 |
+
except Exception as e:
|
| 272 |
+
return f"Image processing failed: {str(e)}"
|