research-agent / agent.py
saim1309's picture
Upload 3 files
5948219 verified
Raw
History Blame Contribute Delete
12.3 kB
# ── CELL 1: Imports & API Keys ──────────────────────────────────────────────
# Uses a local Ollama model β€” no API key needed.
# Make sure Ollama is installed and running, and the model is pulled:
# ollama pull qwen2.5:7b-instruct
import os
import time
import uuid
from typing import TypedDict
from dotenv import load_dotenv
from ddgs import DDGS # DuckDuckGo search
from langchain_huggingface import HuggingFaceEndpoint # Local Ollama LLM (replaces Gemini)
from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
load_dotenv()
# ── Logging helper: BACKEND ONLY β€” this prints to the terminal/server console.
# It is intentionally NOT surfaced in the Streamlit UI; the UI only shows a
# generic "running" status. Watch your terminal to see step-by-step progress.
def log(msg: str):
print(msg)
# ── CELL 2: State ────────────────────────────────────────────────────────────
class ResearchState(TypedDict):
query: str # Original question from the user
web_results: str # Formatted text of DuckDuckGo search results
search_query: str # Rewritten / improved search query
final_answer: str # LLM-synthesized answer
needs_retry: bool # True if critic says answer is poor
retry_count: int # Number of retry attempts so far
human_approved: bool # Whether the human approved the answer
human_feedback: str # Feedback from the human if they rejected
# ── CELL 3: LLM (Ollama, local) ──────────────────────────────────────────────
# qwen2.5:7b-instruct is a strong general-purpose pick for this pipeline:
# it follows formatting instructions well (Markdown, ## headings, [1]/[2]
# citations), is a reliable yes/no judge for the critic step, and is small
# enough (7B, ~4-5GB) to run comfortably on a single consumer GPU or even CPU.
# Override via env vars if you want a different model/host.
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b-instruct")
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
llm = HuggingFaceEndpoint(
repo_id="Qwen/Qwen2.5-7B-Instruct",
temperature=0.3,
huggingfacehub_api_token=os.environ["HF_TOKEN"]
)
# ── Retry helper: handles transient local errors (Ollama not up yet, model ──
# still loading into memory, brief timeout) with a short exponential backoff.
# Unlike a hosted API, Ollama has no request quota β€” retries here are just
# for "server not ready yet", not rate limiting.
def llm_invoke_with_retry(messages, max_retries: int = 3, base_wait: float = 3.0):
"""
Calls llm.invoke(messages). If a connection/timeout error occurs (e.g.
`ollama serve` isn't running yet, or the model is still loading),
waits and retries up to max_retries times with exponential backoff.
Raises the last exception if all retries are exhausted.
"""
for attempt in range(max_retries + 1):
try:
if attempt == 0:
log(f" πŸ“‘ Calling Ollama model '{OLLAMA_MODEL}'...")
return llm.invoke(messages)
except Exception as e:
err_str = str(e)
is_transient = any(s in err_str.lower() for s in
["connection", "timeout", "timed out", "refused"])
if is_transient and attempt < max_retries:
wait_time = base_wait * (2 ** attempt) # 3s, 6s, 12s
log(f" ⚠️ Ollama not responding yet. Waiting {wait_time:.0f}s "
f"before retry {attempt + 1}/{max_retries}... "
f"(is `ollama serve` running and is '{OLLAMA_MODEL}' pulled?)")
time.sleep(wait_time)
log(f" πŸ“‘ Retrying Ollama call ({attempt + 1}/{max_retries})...")
else:
log(f" ❌ Ollama call failed permanently: {err_str[:200]}")
raise
# ── CELL 4: Node 1 β€” Query Rewrite ───────────────────────────────────────────
REWRITE_PROMPT = """\
You rewrite user questions into a short, high-signal web search query.
Return ONLY the search query text. No explanation, no quotes.
"""
def query_rewrite_node(state: ResearchState) -> dict:
log("πŸ”„ STEP: Query Rewrite β€” turning your question into a search query")
feedback = state.get("human_feedback", "").strip()
if feedback:
user_content = (
f"Original question: {state['query']}\n"
f"Previous answer was rejected. Human feedback: {feedback}\n"
f"Rewrite the search query to address this feedback."
)
log(f" Using human feedback to improve query: '{feedback}'")
else:
user_content = state["query"]
messages = [
SystemMessage(content=REWRITE_PROMPT),
HumanMessage(content=user_content)
]
response = llm_invoke_with_retry(messages)
rewritten = response.content.strip()
log(f" βœ… Search query: {rewritten}")
return {"search_query": rewritten}
# ── CELL 5: Node 2 β€” Web Search (DuckDuckGo) ─────────────────────────────────
def web_search_node(state: ResearchState) -> dict:
log(f"🌐 STEP: Web Search β€” querying DuckDuckGo for '{state['search_query']}'")
formatted_results = []
try:
with DDGS() as ddgs:
raw = list(ddgs.text(state["search_query"], max_results=5))
log(f" βœ… Got {len(raw)} results")
except Exception as e:
log(f" ❌ DuckDuckGo search error: {e}")
raw = []
for i, result in enumerate(raw, start=1):
title = result.get("title", "No title")
url = result.get("href", "") # DuckDuckGo uses 'href' not 'url'
content = result.get("body", "").strip() # DuckDuckGo uses 'body' not 'content'
formatted_results.append(
f"[{i}] {title}\n"
f" URL: {url}\n"
f" {content}"
)
web_results_text = "\n\n".join(formatted_results)
return {"web_results": web_results_text}
# ── CELL 6: Node 3 β€” Synthesizer (Ollama) ────────────────────────────────────
SYSTEM_PROMPT = """\
You are an expert research assistant.
You will be given:
1. A user research query.
2. Five web search results (title, URL, snippet).
Your job:
- Synthesize the web results with your own knowledge.
- Write a clear, well-structured, comprehensive answer using Markdown.
- Use ## headings to organize sections (e.g. ## Overview, ## Key Findings).
- Cite sources inline using [1], [2], etc. where relevant.
- Highlight any conflicting information across sources.
- End with a "## References" section listing all URLs as:
[1] https://...
[2] https://...
- Be factual. Do not hallucinate beyond the provided data.
"""
def synthesizer_node(state: ResearchState) -> dict:
log("🧠 STEP: Synthesizer β€” asking the local Ollama model to write the sourced answer")
user_prompt = (
f"Research Query: {state['query']}\n\n"
f"Web Search Results:\n\n{state['web_results']}"
)
messages = [
SystemMessage(content=SYSTEM_PROMPT),
HumanMessage(content=user_prompt)
]
response = llm_invoke_with_retry(messages)
log(" βœ… Answer drafted")
return {"final_answer": response.content}
# ── CELL 7: Node 4 β€” Critic ──────────────────────────────────────────────────
CRITIC_PROMPT = """\
You judge whether a research answer is well supported by the given sources.
Reply with exactly one word: "yes" if well supported,
"no" if it is thin, vague, or barely uses the sources.
"""
def critic_node(state: ResearchState) -> dict:
log("πŸ” STEP: Critic β€” checking whether the answer is well supported")
user_prompt = (
f"Sources:\n{state['web_results']}\n\n"
f"Answer:\n{state['final_answer']}"
)
messages = [
SystemMessage(content=CRITIC_PROMPT),
HumanMessage(content=user_prompt)
]
verdict = llm_invoke_with_retry(messages).content.strip().lower()
if verdict.startswith("no") and state["retry_count"] < 3:
needs_retry = True
log(f" πŸ” Verdict: '{verdict}' β€” retrying search (attempt {state['retry_count'] + 1}/3)")
else:
needs_retry = False
log(f" βœ… Verdict: '{verdict}' β€” moving to human review")
return {
"needs_retry": needs_retry,
"retry_count": state["retry_count"] + 1
}
# ── CELL 8: Node 5 β€” Human Approval ──────────────────────────────────────────
def human_approval_node(state: ResearchState) -> dict:
log("βœ… STEP: Human Approval β€” pausing for your review")
# Pause graph β€” resumes when user submits feedback via Streamlit
user_input = interrupt("Type 'ok' to approve, or give feedback to improve the answer: ")
approved = user_input.strip().lower() == "ok"
if approved:
log("βœ… Approved!")
return {
"human_approved": True,
"human_feedback": ""
}
else:
feedback = user_input.strip()
log(f"πŸ”„ Rejected. Feedback: '{feedback}'. Retrying with improved query...")
return {
"human_approved": False,
"human_feedback": feedback,
"retry_count": 0
}
# ── CELL 9: Build Graph ───────────────────────────────────────────────────────
#
# START β†’ query_rewrite β†’ web_search β†’ synthesizer β†’ critic
# ↑ |
# └──── needs_retry=True β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
# (else)
# ↓
# human_approval ← graph PAUSES here
# / \
# approved rejected (with feedback)
# ↓ ↓
# END query_rewrite (smarter retry)
def route_after_critic(state: ResearchState):
return "web_search" if state["needs_retry"] else "human_approval"
def route_after_human(state: ResearchState):
return "end" if state["human_approved"] else "query_rewrite"
workflow = StateGraph(ResearchState)
workflow.add_node("query_rewrite", query_rewrite_node)
workflow.add_node("web_search", web_search_node)
workflow.add_node("synthesizer", synthesizer_node)
workflow.add_node("critic", critic_node)
workflow.add_node("human_approval", human_approval_node)
workflow.add_edge(START, "query_rewrite")
workflow.add_edge("query_rewrite", "web_search")
workflow.add_edge("web_search", "synthesizer")
workflow.add_edge("synthesizer", "critic")
workflow.add_conditional_edges(
"critic",
route_after_critic,
{"web_search": "web_search", "human_approval": "human_approval"}
)
workflow.add_conditional_edges(
"human_approval",
route_after_human,
{"end": END, "query_rewrite": "query_rewrite"}
)
memory = MemorySaver()
agent = workflow.compile(checkpointer=memory)
# ── Helper: create a fresh thread config ─────────────────────────────────────
def new_thread() -> dict:
return {"configurable": {"thread_id": str(uuid.uuid4())}}