Spaces:
Runtime error
Runtime error
| # ββ 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())}} |