Spaces:
Sleeping
Sleeping
no_json
Browse files
app.py
CHANGED
@@ -16,10 +16,21 @@ from langchain.schema import HumanMessage, AIMessage, SystemMessage
|
|
16 |
# Create a ToolNode that knows about your web_search function
|
17 |
import json
|
18 |
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
# --- Constants ---
|
24 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
25 |
|
@@ -29,108 +40,143 @@ tool_node = ToolNode([ocr_image, parse_excel, web_search])
|
|
29 |
agent = create_react_agent(model=llm, tools=tool_node)
|
30 |
|
31 |
# 2) Build a two‐edge graph:
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
compiled_graph = graph.compile()
|
37 |
|
|
|
38 |
def respond_to_input(user_input: str) -> str:
|
39 |
-
#
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
" 2) parse_excel(path:str,sheet_name:str)\n"
|
45 |
-
" 3) ocr_image(path:str)\n\n"
|
46 |
-
"⚠️ **MANDATORY** ⚠️: If (and only if) you need to call a tool, your entire response MUST be exactly ONE JSON OBJECT and NOTHING ELSE. \n"
|
47 |
-
"For example, if you want to call web_search, you must respond with exactly:\n"
|
48 |
-
"```json\n"
|
49 |
-
'{"tool":"web_search","query":"Mercedes Sosa studio albums 2000-2009"}\n'
|
50 |
-
"```\n"
|
51 |
-
"That JSON string must start at the very first character of your response and end at the very last character—"
|
52 |
-
"no surrounding quotes, no markdown fences, no explanatory text. \n\n"
|
53 |
-
"If you do NOT need to call any tool, then you must respond with your final answer as plain text (no JSON)."
|
54 |
-
)
|
55 |
-
)
|
56 |
|
57 |
-
# 2) Initialize state with just that SystemMessage
|
58 |
-
initial_state = {
|
59 |
-
"messages": [
|
60 |
-
system_msg,
|
61 |
-
HumanMessage(content=user_input)
|
62 |
-
]
|
63 |
-
}
|
64 |
|
65 |
-
# C) FIRST PASS: invoke with only initial_state (no second argument!)
|
66 |
-
try:
|
67 |
-
first_pass = compiled_graph.invoke(initial_state)
|
68 |
-
except Exception as e:
|
69 |
-
print("‼️ ERROR during first invoke:", repr(e))
|
70 |
-
return "" # return fallback
|
71 |
-
|
72 |
-
# D) Log the AIMessage(s) from first_pass
|
73 |
-
print("===== AGENT MESSAGES (First Pass) =====")
|
74 |
-
for idx, msg in enumerate(first_pass["messages"]):
|
75 |
-
if isinstance(msg, AIMessage):
|
76 |
-
print(f"[AIMessage #{idx}]: {repr(msg.content)}")
|
77 |
-
print("=========================================")
|
78 |
-
|
79 |
-
# E) Find the very last AIMessage content
|
80 |
-
last_msg = None
|
81 |
-
for msg in reversed(first_pass["messages"]):
|
82 |
-
if isinstance(msg, AIMessage):
|
83 |
-
last_msg = msg.content
|
84 |
-
break
|
85 |
-
|
86 |
-
# F) Attempt to parse last_msg as JSON for a tool call (inline, no parse_tool_json)
|
87 |
-
tool_dict = None
|
88 |
-
t = (last_msg or "").strip()
|
89 |
-
if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
|
90 |
-
t = t[1:-1]
|
91 |
-
try:
|
92 |
-
obj = json.loads(t)
|
93 |
-
if isinstance(obj, dict) and "tool" in obj:
|
94 |
-
tool_dict = obj
|
95 |
-
except Exception:
|
96 |
-
tool_dict = None
|
97 |
-
|
98 |
-
if tool_dict:
|
99 |
-
# G) If valid JSON, run the tool
|
100 |
-
print(">> Parsed tool call:", tool_dict)
|
101 |
-
tool_result = tool_node.run(tool_dict)
|
102 |
-
print(f">> Tool '{tool_dict['tool']}' returned: {repr(tool_result)}")
|
103 |
-
|
104 |
-
# H) SECOND PASS: feed the tool's output back in as an AIMessage,
|
105 |
-
# with no new human input
|
106 |
-
continuation_state = {
|
107 |
-
"messages": [
|
108 |
-
*first_pass["messages"],
|
109 |
-
AIMessage(content=tool_result)
|
110 |
-
]
|
111 |
-
}
|
112 |
-
try:
|
113 |
-
second_pass = compiled_graph.invoke(continuation_state)
|
114 |
-
except Exception as e2:
|
115 |
-
print("‼️ ERROR during second invoke:", repr(e2))
|
116 |
-
return ""
|
117 |
-
|
118 |
-
# I) Log second_pass AIMessage(s)
|
119 |
-
print("===== AGENT MESSAGES (Second Pass) =====")
|
120 |
-
for idx, msg in enumerate(second_pass["messages"]):
|
121 |
-
if isinstance(msg, AIMessage):
|
122 |
-
print(f"[AIMessage2 #{idx}]: {repr(msg.content)}")
|
123 |
-
print("=========================================")
|
124 |
-
|
125 |
-
# J) Return the final AIMessage from second_pass
|
126 |
-
for msg in reversed(second_pass["messages"]):
|
127 |
-
if isinstance(msg, AIMessage):
|
128 |
-
return msg.content or ""
|
129 |
-
return ""
|
130 |
-
|
131 |
-
else:
|
132 |
-
# K) If not JSON → treat last_msg as plain text final answer
|
133 |
-
return last_msg or ""
|
134 |
|
135 |
class BasicAgent:
|
136 |
def __init__(self):
|
|
|
16 |
# Create a ToolNode that knows about your web_search function
|
17 |
import json
|
18 |
|
19 |
+
from typing import TypedDict, Annotated
|
20 |
+
|
21 |
+
class AgentState(TypedDict, total=False):
|
22 |
+
messages: Annotated[list, add_messages]
|
23 |
+
# Fields that the agent node can set to request a tool
|
24 |
+
web_search_query: str
|
25 |
+
ocr_path: str
|
26 |
+
excel_path: str
|
27 |
+
excel_sheet_name: str
|
28 |
+
# Fields to hold the tool outputs
|
29 |
+
web_search_result: str
|
30 |
+
ocr_result: str
|
31 |
+
excel_result: str
|
32 |
+
# A “final_answer” field that the last agent node will fill
|
33 |
+
final_answer: str# (Keep Constants as is)
|
34 |
# --- Constants ---
|
35 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
36 |
|
|
|
40 |
agent = create_react_agent(model=llm, tools=tool_node)
|
41 |
|
42 |
# 2) Build a two‐edge graph:
|
43 |
+
def plan_node(state: AgentState, user_input: str) -> AgentState:
|
44 |
+
"""
|
45 |
+
Reads state['messages'] + user_input and decides:
|
46 |
+
• If it needs to call web_search, set state['web_search_query'] to a query.
|
47 |
+
• Else if it needs to call ocr, set state['ocr_path'] to the image path.
|
48 |
+
• Else if it needs Excel, set state['excel_path'] and 'excel_sheet_name'.
|
49 |
+
• Otherwise, set state['final_answer'] to a plain text answer.
|
50 |
+
We also append user_input to state['messages'] so the LLM sees the full history.
|
51 |
+
"""
|
52 |
+
# 4.a) Grab prior chat history, append user_input:
|
53 |
+
prior = state.get("messages", [])
|
54 |
+
chat_history = prior + [f"USER: {user_input}"]
|
55 |
+
|
56 |
+
# 4.b) Send that to the LLM with a prompt explaining the new schema:
|
57 |
+
prompt = chat_history + [
|
58 |
+
"ASSISTANT: You can set one of the following keys:\n"
|
59 |
+
" • web_search_query: <string> \n"
|
60 |
+
" • ocr_path: <path> \n"
|
61 |
+
" • excel_path: <path> \n"
|
62 |
+
" • excel_sheet_name: <sheet> \n"
|
63 |
+
"Or, if no tool is needed, set final_answer: <your answer>.\n"
|
64 |
+
"Respond with a Python‐dict literal that contains exactly one of those keys.\n"
|
65 |
+
"Example: {'web_search_query':'Mercedes Sosa discography'}\n"
|
66 |
+
"No additional text!"
|
67 |
+
]
|
68 |
+
llm_out = llm(prompt).content.strip()
|
69 |
+
|
70 |
+
# 4.c) Try to eval as a Python dict:
|
71 |
+
try:
|
72 |
+
parsed = eval(llm_out, {}, {}) # trust that user obeyed instructions
|
73 |
+
if isinstance(parsed, dict):
|
74 |
+
# Only keep recognized keys, ignore anything else
|
75 |
+
new_state: AgentState = {"messages": chat_history}
|
76 |
+
allowed = {
|
77 |
+
"web_search_query",
|
78 |
+
"ocr_path",
|
79 |
+
"excel_path",
|
80 |
+
"excel_sheet_name",
|
81 |
+
"final_answer"
|
82 |
+
}
|
83 |
+
for k, v in parsed.items():
|
84 |
+
if k in allowed:
|
85 |
+
new_state[k] = v
|
86 |
+
return new_state
|
87 |
+
except Exception:
|
88 |
+
pass
|
89 |
+
|
90 |
+
# 4.d) If parsing failed, or they returned something else, set a fallback
|
91 |
+
return {
|
92 |
+
"messages": chat_history,
|
93 |
+
"final_answer": "Sorry, I could not parse your intent."
|
94 |
+
}
|
95 |
+
|
96 |
+
# ─── 5) Define “finalize” node: compose the final answer using any tool results ───
|
97 |
+
def finalize_node(state: AgentState) -> AgentState:
|
98 |
+
"""
|
99 |
+
By this point:
|
100 |
+
- state['messages'] contains the chat history (ending with how we requested a tool).
|
101 |
+
- One or more of web_search_result, ocr_result, excel_result might be filled.
|
102 |
+
- Or, state['final_answer'] is already set, meaning no tool was needed.
|
103 |
+
We ask the LLM to produce a final text answer.
|
104 |
+
"""
|
105 |
+
# 5.a) Build a prompt listing any tool results:
|
106 |
+
parts = state.get("messages", [])
|
107 |
+
if "web_search_result" in state and state["web_search_result"] is not None:
|
108 |
+
parts.append(f"WEB_SEARCH_RESULT: {state['web_search_result']}")
|
109 |
+
if "ocr_result" in state and state["ocr_result"] is not None:
|
110 |
+
parts.append(f"OCR_RESULT: {state['ocr_result']}")
|
111 |
+
if "excel_result" in state and state["excel_result"] is not None:
|
112 |
+
parts.append(f"EXCEL_RESULT: {state['excel_result']}")
|
113 |
+
|
114 |
+
parts.append("ASSISTANT: Please provide the final answer now.")
|
115 |
+
llm_out = llm(parts).content.strip()
|
116 |
+
|
117 |
+
return {"final_answer": llm_out}
|
118 |
+
|
119 |
+
|
120 |
+
|
121 |
+
|
122 |
+
|
123 |
+
|
124 |
+
|
125 |
+
|
126 |
+
|
127 |
+
|
128 |
+
|
129 |
+
graph = StateGraph(AgentState)
|
130 |
+
|
131 |
+
# 6.a) Register nodes in order:
|
132 |
+
graph.add_node("plan", plan_node)
|
133 |
+
graph.add_node("tools", tool_node)
|
134 |
+
graph.add_node("finalize", finalize_node)
|
135 |
+
|
136 |
+
# 6.b) START → "plan"
|
137 |
+
graph.add_edge(START, "plan")
|
138 |
+
|
139 |
+
# 6.c) If plan_node sets a tool‐query key, go to "tools"; otherwise go to "finalize".
|
140 |
+
def route_plan(state: AgentState, plan_out: AgentState) -> str:
|
141 |
+
# If plan_node placed a "web_search_query", "ocr_path", or "excel_path", go to tools.
|
142 |
+
# (Note: plan_out already replaced state["messages"])
|
143 |
+
if plan_out.get("web_search_query") or plan_out.get("ocr_path") or plan_out.get("excel_path"):
|
144 |
+
return "tools"
|
145 |
+
return "finalize"
|
146 |
+
|
147 |
+
graph.add_conditional_edges(
|
148 |
+
"plan",
|
149 |
+
route_plan,
|
150 |
+
{"tools": "tools", "finalize": "finalize"}
|
151 |
+
)
|
152 |
+
|
153 |
+
def run_tools(state: AgentState, tool_out: AgentState) -> AgentState:
|
154 |
+
"""
|
155 |
+
When a tool‐wrapper returns, it has already consumed the relevant key
|
156 |
+
(e.g. set web_search_query back to None) and added tool_result.
|
157 |
+
We just merge that into state.
|
158 |
+
"""
|
159 |
+
new_state = {**state, **tool_out}
|
160 |
+
return new_state
|
161 |
+
|
162 |
+
|
163 |
+
|
164 |
+
graph.add_edge("tools", "finalize", run_tools)
|
165 |
+
|
166 |
+
# 6.e) "finalize" → END
|
167 |
+
graph.add_edge("finalize", END)
|
168 |
+
|
169 |
compiled_graph = graph.compile()
|
170 |
|
171 |
+
# ─── 7) Define respond_to_input that drives the graph ───
|
172 |
def respond_to_input(user_input: str) -> str:
|
173 |
+
# On first turn, messages=[], no query keys set.
|
174 |
+
initial_state: AgentState = {"messages": []}
|
175 |
+
final_state = compiled_graph.invoke(initial_state, user_input)
|
176 |
+
# final_state should have 'final_answer'
|
177 |
+
return final_state.get("final_answer", "Error: No final answer generated.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
180 |
|
181 |
class BasicAgent:
|
182 |
def __init__(self):
|
tools.py
CHANGED
@@ -1,78 +1,71 @@
|
|
1 |
-
from langchain_core.tools import tool
|
2 |
-
from langchain_community.tools import DuckDuckGoSearchRun
|
3 |
-
import pandas as pd
|
4 |
-
@tool
|
5 |
-
def web_search(query: str) -> str:
|
6 |
-
"""
|
7 |
-
Search the web for information.
|
8 |
-
Args:
|
9 |
-
query: The query to search the web for.
|
10 |
-
Returns:
|
11 |
-
The search results.
|
12 |
-
"""
|
13 |
-
print(f"Reached: web_search: {query}")
|
14 |
-
ddg = DuckDuckGoSearchRun()
|
15 |
-
return ddg.run(query)
|
16 |
-
|
17 |
-
|
18 |
-
@tool
|
19 |
-
def parse_excel(path: str, sheet_name: str = None) -> str:
|
20 |
-
|
21 |
-
"""
|
22 |
-
Read in an Excel file at `path`, optionally select a sheet by name (or default to the first sheet),
|
23 |
-
then convert the DataFrame to a JSON-like string. Return that text so the LLM can reason over it.
|
24 |
-
|
25 |
-
Example return value (collapsed):
|
26 |
-
"[{'Name': 'Alice', 'Score': 95}, {'Name': 'Bob', 'Score': 88}, ...]"
|
27 |
-
"""
|
28 |
-
# 1. Load the Excel workbook
|
29 |
-
print(f"Reached: parse_excel: {path} {sheet_name}")
|
30 |
-
try:
|
31 |
-
xls = pd.ExcelFile(path)
|
32 |
-
except FileNotFoundError:
|
33 |
-
return f"Error: could not find file at {path}."
|
34 |
-
|
35 |
-
# 2. Choose the sheet
|
36 |
-
if sheet_name and sheet_name in xls.sheet_names:
|
37 |
-
df = pd.read_excel(xls, sheet_name=sheet_name)
|
38 |
-
else:
|
39 |
-
# default to first sheet
|
40 |
-
df = pd.read_excel(xls, sheet_name=xls.sheet_names[0])
|
41 |
-
|
42 |
-
# 3. Option A: convert to JSON
|
43 |
-
records = df.to_dict(orient="records")
|
44 |
-
return str(records)
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
# tools.py
|
49 |
|
|
|
|
|
50 |
from pathlib import Path
|
51 |
from PIL import Image
|
52 |
import pytesseract
|
53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
|
55 |
-
|
56 |
-
def ocr_image(path: str) -> str:
|
57 |
"""
|
58 |
-
|
59 |
-
|
60 |
-
- If the file is missing or unreadable, returns an error string.
|
61 |
"""
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
return f"Error: could not find image at {path}"
|
66 |
try:
|
67 |
-
|
68 |
-
|
|
|
69 |
except Exception as e:
|
70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
|
72 |
try:
|
73 |
-
|
74 |
-
|
|
|
|
|
|
|
|
|
|
|
75 |
except Exception as e:
|
76 |
-
|
77 |
-
|
78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# tools.py
|
2 |
|
3 |
+
import pandas as pd
|
4 |
+
from langchain_community.tools import DuckDuckGoSearchRun
|
5 |
from pathlib import Path
|
6 |
from PIL import Image
|
7 |
import pytesseract
|
8 |
|
9 |
+
def web_search_tool(state: AgentState) -> AgentState:
|
10 |
+
"""
|
11 |
+
Expects: state["web_search_query"] is a non‐empty string.
|
12 |
+
Returns: {"web_search_query": None, "web_search_result": <string>}
|
13 |
+
We also clear web_search_query so we don’t loop forever.
|
14 |
+
"""
|
15 |
+
query = state.get("web_search_query", "")
|
16 |
+
if not query:
|
17 |
+
return {} # nothing to do
|
18 |
+
|
19 |
+
# Run DuckDuckGo
|
20 |
+
ddg = DuckDuckGoSearchRun()
|
21 |
+
result_text = ddg.run(query)
|
22 |
+
return {
|
23 |
+
"web_search_query": None,
|
24 |
+
"web_search_result": result_text
|
25 |
+
}
|
26 |
|
27 |
+
def ocr_image_tool(state: AgentState) -> AgentState:
|
|
|
28 |
"""
|
29 |
+
Expects: state["ocr_path"] is a path to an image file.
|
30 |
+
Returns: {"ocr_path": None, "ocr_result": <string>}.
|
|
|
31 |
"""
|
32 |
+
path = state.get("ocr_path", "")
|
33 |
+
if not path:
|
34 |
+
return {}
|
|
|
35 |
try:
|
36 |
+
img = Image.open(path)
|
37 |
+
text = pytesseract.image_to_string(img)
|
38 |
+
text = text.strip() or "(no visible text)"
|
39 |
except Exception as e:
|
40 |
+
text = f"Error during OCR: {e}"
|
41 |
+
return {
|
42 |
+
"ocr_path": None,
|
43 |
+
"ocr_result": text
|
44 |
+
}
|
45 |
+
|
46 |
+
def parse_excel_tool(state: AgentState) -> AgentState:
|
47 |
+
"""
|
48 |
+
Expects: state["excel_path"] is a path to an .xlsx file,
|
49 |
+
and state["excel_sheet_name"] optionally names a sheet.
|
50 |
+
Returns: {"excel_path": None, "excel_sheet_name": None, "excel_result": <string>}.
|
51 |
+
"""
|
52 |
+
path = state.get("excel_path", "")
|
53 |
+
sheet = state.get("excel_sheet_name", "")
|
54 |
+
if not path:
|
55 |
+
return {}
|
56 |
|
57 |
try:
|
58 |
+
xls = pd.ExcelFile(path)
|
59 |
+
if sheet and sheet in xls.sheet_names:
|
60 |
+
df = pd.read_excel(xls, sheet_name=sheet)
|
61 |
+
else:
|
62 |
+
df = pd.read_excel(xls, sheet_name=xls.sheet_names[0])
|
63 |
+
records = df.to_dict(orient="records")
|
64 |
+
text = str(records)
|
65 |
except Exception as e:
|
66 |
+
text = f"Error reading Excel: {e}"
|
67 |
+
return {
|
68 |
+
"excel_path": None,
|
69 |
+
"excel_sheet_name": None,
|
70 |
+
"excel_result": text
|
71 |
+
}
|