rqueraud commited on
Commit
4d5f444
·
1 Parent(s): 81917a3

Before refactoring tools

Browse files
Files changed (13) hide show
  1. .gitignore +10 -0
  2. AGENTS.md +6 -0
  3. README.md +13 -1
  4. app.py +7 -17
  5. app_raw.py +196 -0
  6. poetry.lock +0 -0
  7. pyproject.toml +39 -0
  8. requirements.txt +0 -2
  9. src/__init__.py +1 -0
  10. src/flexible_agent.py +586 -0
  11. src/gaio.py +85 -0
  12. src/gaio_chat_model.py +319 -0
  13. 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 = BasicAgent()
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("# 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
 
 
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)}"