Ollama Deep Research
Ollama Deep Research
- Generated on: May 27, 2025 at 19:17:57 PST
- Source Directory:
https://github.com/langchain-ai/local-deep-researcher/tree/main/src/ollama_deep_researcher
- Total Files: 6 Python files
File Index
__init__.py
- Package initialization (0 bytes, 0 lines)configuration.py
- LangGraph runtime configuration with dataclass schema and local Ollama model settings (1,419 bytes, 37 lines)graph.py
- LangGraph StateGraph with conditional routing, iterative loops, and state persistence (7,196 bytes, 160 lines)prompts.py
- JSON-mode structured output prompts for query generation and reflection workflows (3,413 bytes, 77 lines)state.py
- Dataclass state schema with operator.add reducers for LangGraph workflow state management (811 bytes, 20 lines)utils.py
- Async search API implementations with content deduplication and source formatting (8,821 bytes, 228 lines)
init.py
File Metadata:
- Path:
__init__.py
- Size: 0 bytes
- Lines: 0
- Purpose: Package initialization file (empty)
# Empty file - package initialization
configuration.py
File Metadata:
- Path:
configuration.py
- Size: 1,419 bytes
- Lines: 37
- Purpose: LangGraph runtime configuration with dataclass schema and local Ollama model settings
import os
from dataclasses import dataclass, fields
from typing import Any, Optional
from langchain_core.runnables import RunnableConfig
from dataclasses import dataclass
from enum import Enum
class SearchAPI(Enum):
PERPLEXITY = "perplexity"
TAVILY = "tavily"
DUCKDUCKGO = "duckduckgo"
@dataclass(kw_only=True)
class Configuration:
"""The configurable fields for the research assistant."""
max_web_research_loops: int = int(os.environ.get("MAX_WEB_RESEARCH_LOOPS", "3"))
local_llm: str = os.environ.get("OLLAMA_MODEL", "llama3.2")
search_api: SearchAPI = SearchAPI(os.environ.get("SEARCH_API", SearchAPI.DUCKDUCKGO.value)) # Default to DUCKDUCKGO
fetch_full_page: bool = os.environ.get("FETCH_FULL_PAGE", "False").lower() in ("true", "1", "t")
ollama_base_url: str = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/")
@classmethod
def from_runnable_config(
cls, config: Optional[RunnableConfig] = None
) -> "Configuration":
"""Create a Configuration instance from a RunnableConfig."""
configurable = (
config["configurable"] if config and "configurable" in config else {}
)
values: dict[str, Any] = {
f.name: os.environ.get(f.name.upper(), configurable.get(f.name))
for f in fields(cls)
if f.init
}
return cls(**{k: v for k, v in values.items() if v})
graph.py
File Metadata:
- Path:
graph.py
- Size: 7,196 bytes
- Lines: 160
- Purpose: LangGraph StateGraph with conditional routing, iterative loops, and state persistence
import json
from typing_extensions import Literal
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from langchain_ollama import ChatOllama
from langgraph.graph import START, END, StateGraph
from assistant.configuration import Configuration, SearchAPI
from assistant.utils import deduplicate_and_format_sources, tavily_search, format_sources, perplexity_search, duckduckgo_search
from assistant.state import SummaryState, SummaryStateInput, SummaryStateOutput
from assistant.prompts import query_writer_instructions, summarizer_instructions, reflection_instructions
# Nodes
def generate_query(state: SummaryState, config: RunnableConfig):
""" Generate a query for web search """
# Format the prompt
query_writer_instructions_formatted = query_writer_instructions.format(research_topic=state.research_topic)
# Generate a query
configurable = Configuration.from_runnable_config(config)
llm_json_mode = ChatOllama(base_url=configurable.ollama_base_url, model=configurable.local_llm, temperature=0, format="json")
result = llm_json_mode.invoke(
[SystemMessage(content=query_writer_instructions_formatted),
HumanMessage(content=f"Generate a query for web search:")]
)
query = json.loads(result.content)
return {"search_query": query['query']}
def web_research(state: SummaryState, config: RunnableConfig):
""" Gather information from the web """
# Configure
configurable = Configuration.from_runnable_config(config)
# Handle both cases for search_api:
# 1. When selected in Studio UI -> returns a string (e.g. "tavily")
# 2. When using default -> returns an Enum (e.g. SearchAPI.TAVILY)
if isinstance(configurable.search_api, str):
search_api = configurable.search_api
else:
search_api = configurable.search_api.value
# Search the web
if search_api == "tavily":
search_results = tavily_search(state.search_query, include_raw_content=True, max_results=1)
search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, include_raw_content=True)
elif search_api == "perplexity":
search_results = perplexity_search(state.search_query, state.research_loop_count)
search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, include_raw_content=False)
elif search_api == "duckduckgo":
search_results = duckduckgo_search(state.search_query, max_results=3, fetch_full_page=configurable.fetch_full_page)
search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, include_raw_content=True)
else:
raise ValueError(f"Unsupported search API: {configurable.search_api}")
return {"sources_gathered": [format_sources(search_results)], "research_loop_count": state.research_loop_count + 1, "web_research_results": [search_str]}
def summarize_sources(state: SummaryState, config: RunnableConfig):
""" Summarize the gathered sources """
# Existing summary
existing_summary = state.running_summary
# Most recent web research
most_recent_web_research = state.web_research_results[-1]
# Build the human message
if existing_summary:
human_message_content = (
f"<User Input> \n {state.research_topic} \n <User Input>\n\n"
f"<Existing Summary> \n {existing_summary} \n <Existing Summary>\n\n"
f"<New Search Results> \n {most_recent_web_research} \n <New Search Results>"
)
else:
human_message_content = (
f"<User Input> \n {state.research_topic} \n <User Input>\n\n"
f"<Search Results> \n {most_recent_web_research} \n <Search Results>"
)
# Run the LLM
configurable = Configuration.from_runnable_config(config)
llm = ChatOllama(base_url=configurable.ollama_base_url, model=configurable.local_llm, temperature=0)
result = llm.invoke(
[SystemMessage(content=summarizer_instructions),
HumanMessage(content=human_message_content)]
)
running_summary = result.content
# TODO: This is a hack to remove the <think> tags w/ Deepseek models
# It appears very challenging to prompt them out of the responses
while "<think>" in running_summary and "</think>" in running_summary:
start = running_summary.find("<think>")
end = running_summary.find("</think>") + len("</think>")
running_summary = running_summary[:start] + running_summary[end:]
return {"running_summary": running_summary}
def reflect_on_summary(state: SummaryState, config: RunnableConfig):
""" Reflect on the summary and generate a follow-up query """
# Generate a query
configurable = Configuration.from_runnable_config(config)
llm_json_mode = ChatOllama(base_url=configurable.ollama_base_url, model=configurable.local_llm, temperature=0, format="json")
result = llm_json_mode.invoke(
[SystemMessage(content=reflection_instructions.format(research_topic=state.research_topic)),
HumanMessage(content=f"Identify a knowledge gap and generate a follow-up web search query based on our existing knowledge: {state.running_summary}")]
)
follow_up_query = json.loads(result.content)
# Get the follow-up query
query = follow_up_query.get('follow_up_query')
# JSON mode can fail in some cases
if not query:
# Fallback to a placeholder query
return {"search_query": f"Tell me more about {state.research_topic}"}
# Update search query with follow-up query
return {"search_query": follow_up_query['follow_up_query']}
def finalize_summary(state: SummaryState):
""" Finalize the summary """
# Format all accumulated sources into a single bulleted list
all_sources = "\n".join(source for source in state.sources_gathered)
state.running_summary = f"## Summary\n\n{state.running_summary}\n\n ### Sources:\n{all_sources}"
return {"running_summary": state.running_summary}
def route_research(state: SummaryState, config: RunnableConfig) -> Literal["finalize_summary", "web_research"]:
""" Route the research based on the follow-up query """
configurable = Configuration.from_runnable_config(config)
if state.research_loop_count <= int(configurable.max_web_research_loops):
return "web_research"
else:
return "finalize_summary"
# Add nodes and edges
builder = StateGraph(SummaryState, input=SummaryStateInput, output=SummaryStateOutput, config_schema=Configuration)
builder.add_node("generate_query", generate_query)
builder.add_node("web_research", web_research)
builder.add_node("summarize_sources", summarize_sources)
builder.add_node("reflect_on_summary", reflect_on_summary)
builder.add_node("finalize_summary", finalize_summary)
# Add edges
builder.add_edge(START, "generate_query")
builder.add_edge("generate_query", "web_research")
builder.add_edge("web_research", "summarize_sources")
builder.add_edge("summarize_sources", "reflect_on_summary")
builder.add_conditional_edges("reflect_on_summary", route_research)
builder.add_edge("finalize_summary", END)
graph = builder.compile()
prompts.py
File Metadata:
- Path:
prompts.py
- Size: 3,413 bytes
- Lines: 77
- Purpose: JSON-mode structured output prompts for query generation and reflection workflows
from datetime import datetime
# Get current date in a readable format
def get_current_date():
return datetime.now().strftime("%B %d, %Y")
query_writer_instructions="""Your goal is to generate a targeted web search query.
<CONTEXT>
Current date: {current_date}
Please ensure your queries account for the most current information available as of this date.
</CONTEXT>
<TOPIC>
{research_topic}
</TOPIC>
<FORMAT>
Format your response as a JSON object with ALL three of these exact keys:
- "query": The actual search query string
- "rationale": Brief explanation of why this query is relevant
</FORMAT>
<EXAMPLE>
Example output:
{{
"query": "machine learning transformer architecture explained",
"rationale": "Understanding the fundamental structure of transformer models"
}}
</EXAMPLE>
Provide your response in JSON format:"""
summarizer_instructions="""
<GOAL>
Generate a high-quality summary of the provided context.
</GOAL>
<REQUIREMENTS>
When creating a NEW summary:
1. Highlight the most relevant information related to the user topic from the search results
2. Ensure a coherent flow of information
When EXTENDING an existing summary:
1. Read the existing summary and new search results carefully.
2. Compare the new information with the existing summary.
3. For each piece of new information:
a. If it's related to existing points, integrate it into the relevant paragraph.
b. If it's entirely new but relevant, add a new paragraph with a smooth transition.
c. If it's not relevant to the user topic, skip it.
4. Ensure all additions are relevant to the user's topic.
5. Verify that your final output differs from the input summary.
< /REQUIREMENTS >
< FORMATTING >
- Start directly with the updated summary, without preamble or titles. Do not use XML tags in the output.
< /FORMATTING >
<Task>
Think carefully about the provided Context first. Then generate a summary of the context to address the User Input.
</Task>
"""
reflection_instructions = """You are an expert research assistant analyzing a summary about {research_topic}.
<GOAL>
1. Identify knowledge gaps or areas that need deeper exploration
2. Generate a follow-up question that would help expand your understanding
3. Focus on technical details, implementation specifics, or emerging trends that weren't fully covered
</GOAL>
<REQUIREMENTS>
Ensure the follow-up question is self-contained and includes necessary context for web search.
</REQUIREMENTS>
<FORMAT>
Format your response as a JSON object with these exact keys:
- knowledge_gap: Describe what information is missing or needs clarification
- follow_up_query: Write a specific question to address this gap
</FORMAT>
<Task>
Reflect carefully on the Summary to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format:
{{
"knowledge_gap": "The summary lacks information about performance metrics and benchmarks",
"follow_up_query": "What are typical performance benchmarks and metrics used to evaluate [specific technology]?"
}}
</Task>
Provide your analysis in JSON format:"""
state.py
File Metadata:
- Path:
state.py
- Size: 811 bytes
- Lines: 20
- Purpose: Dataclass state schema with operator.add reducers for LangGraph workflow state management
import operator
from dataclasses import dataclass, field
from typing_extensions import TypedDict, Annotated
@dataclass(kw_only=True)
class SummaryState:
research_topic: str = field(default=None) # Report topic
search_query: str = field(default=None) # Search query
web_research_results: Annotated[list, operator.add] = field(default_factory=list)
sources_gathered: Annotated[list, operator.add] = field(default_factory=list)
research_loop_count: int = field(default=0) # Research loop count
running_summary: str = field(default=None) # Final report
@dataclass(kw_only=True)
class SummaryStateInput:
research_topic: str = field(default=None) # Report topic
@dataclass(kw_only=True)
class SummaryStateOutput:
running_summary: str = field(default=None) # Final report
utils.py
File Metadata:
- Path:
utils.py
- Size: 8,821 bytes
- Lines: 228
- Purpose: Async search API implementations with content deduplication and source formatting
import os
import requests
from typing import Dict, Any, List, Optional
from langsmith import traceable
from tavily import TavilyClient
from duckduckgo_search import DDGS
def deduplicate_and_format_sources(search_response, max_tokens_per_source, include_raw_content=False):
"""
Takes either a single search response or list of responses from search APIs and formats them.
Limits the raw_content to approximately max_tokens_per_source.
include_raw_content specifies whether to include the raw_content from Tavily in the formatted string.
Args:
search_response: Either:
- A dict with a 'results' key containing a list of search results
- A list of dicts, each containing search results
Returns:
str: Formatted string with deduplicated sources
"""
# Convert input to list of results
if isinstance(search_response, dict):
sources_list = search_response['results']
elif isinstance(search_response, list):
sources_list = []
for response in search_response:
if isinstance(response, dict) and 'results' in response:
sources_list.extend(response['results'])
else:
sources_list.extend(response)
else:
raise ValueError("Input must be either a dict with 'results' or a list of search results")
# Deduplicate by URL
unique_sources = {}
for source in sources_list:
if source['url'] not in unique_sources:
unique_sources[source['url']] = source
# Format output
formatted_text = "Sources:\n\n"
for i, source in enumerate(unique_sources.values(), 1):
formatted_text += f"Source {source['title']}:\n===\n"
formatted_text += f"URL: {source['url']}\n===\n"
formatted_text += f"Most relevant content from source: {source['content']}\n===\n"
if include_raw_content:
# Using rough estimate of 4 characters per token
char_limit = max_tokens_per_source * 4
# Handle None raw_content
raw_content = source.get('raw_content', '')
if raw_content is None:
raw_content = ''
print(f"Warning: No raw_content found for source {source['url']}")
if len(raw_content) > char_limit:
raw_content = raw_content[:char_limit] + "... [truncated]"
formatted_text += f"Full source content limited to {max_tokens_per_source} tokens: {raw_content}\n\n"
return formatted_text.strip()
def format_sources(search_results):
"""Format search results into a bullet-point list of sources.
Args:
search_results (dict): Tavily search response containing results
Returns:
str: Formatted string with sources and their URLs
"""
return '\n'.join(
f"* {source['title']} : {source['url']}"
for source in search_results['results']
)
@traceable
def duckduckgo_search(query: str, max_results: int = 3, fetch_full_page: bool = False) -> Dict[str, List[Dict[str, str]]]:
"""Search the web using DuckDuckGo.
Args:
query (str): The search query to execute
max_results (int): Maximum number of results to return
Returns:
dict: Search response containing:
- results (list): List of search result dictionaries, each containing:
- title (str): Title of the search result
- url (str): URL of the search result
- content (str): Snippet/summary of the content
- raw_content (str): Same as content since DDG doesn't provide full page content
"""
try:
with DDGS() as ddgs:
results = []
search_results = list(ddgs.text(query, max_results=max_results))
for r in search_results:
url = r.get('href')
title = r.get('title')
content = r.get('body')
if not all([url, title, content]):
print(f"Warning: Incomplete result from DuckDuckGo: {r}")
continue
raw_content = content
if fetch_full_page:
try:
# Try to fetch the full page content using curl
import urllib.request
from bs4 import BeautifulSoup
response = urllib.request.urlopen(url)
html = response.read()
soup = BeautifulSoup(html, 'html.parser')
raw_content = soup.get_text()
except Exception as e:
print(f"Warning: Failed to fetch full page content for {url}: {str(e)}")
# Add result to list
result = {
"title": title,
"url": url,
"content": content,
"raw_content": raw_content
}
results.append(result)
return {"results": results}
except Exception as e:
print(f"Error in DuckDuckGo search: {str(e)}")
print(f"Full error details: {type(e).__name__}")
return {"results": []}
@traceable
def tavily_search(query, include_raw_content=True, max_results=3):
""" Search the web using the Tavily API.
Args:
query (str): The search query to execute
include_raw_content (bool): Whether to include the raw_content from Tavily in the formatted string
max_results (int): Maximum number of results to return
Returns:
dict: Search response containing:
- results (list): List of search result dictionaries, each containing:
- title (str): Title of the search result
- url (str): URL of the search result
- content (str): Snippet/summary of the content
- raw_content (str): Full content of the page if available"""
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
raise ValueError("TAVILY_API_KEY environment variable is not set")
tavily_client = TavilyClient(api_key=api_key)
return tavily_client.search(query,
max_results=max_results,
include_raw_content=include_raw_content)
@traceable
def perplexity_search(query: str, perplexity_search_loop_count: int) -> Dict[str, Any]:
"""Search the web using the Perplexity API.
Args:
query (str): The search query to execute
perplexity_search_loop_count (int): The loop step for perplexity search (starts at 0)
Returns:
dict: Search response containing:
- results (list): List of search result dictionaries, each containing:
- title (str): Title of the search result
- url (str): URL of the search result
- content (str): Snippet/summary of the content
- raw_content (str): Full content of the page if available
"""
headers = {
"accept": "application/json",
"content-type": "application/json",
"Authorization": f"Bearer {os.getenv('PERPLEXITY_API_KEY')}"
}
payload = {
"model": "sonar-pro",
"messages": [
{
"role": "system",
"content": "Search the web and provide factual information with sources."
},
{
"role": "user",
"content": query
}
]
}
response = requests.post(
"https://api.perplexity.ai/chat/completions",
headers=headers,
json=payload
)
response.raise_for_status() # Raise exception for bad status codes
# Parse the response
data = response.json()
content = data["choices"][0]["message"]["content"]
# Perplexity returns a list of citations for a single search result
citations = data.get("citations", ["https://perplexity.ai"])
# Return first citation with full content, others just as references
results = [{
"title": f"Perplexity Search {perplexity_search_loop_count + 1}, Source 1",
"url": citations[0],
"content": content,
"raw_content": content
}]
# Add additional citations without duplicating content
for i, citation in enumerate(citations[1:], start=2):
results.append({
"title": f"Perplexity Search {perplexity_search_loop_count + 1}, Source {i}",
"url": citation,
"content": "See above for full content",
"raw_content": None
})
return {"results": results}
Summary
This compilation contains all 6 Python files from the src/assistant
directory of the Ollama Deep Research Assistant project. The files collectively implement a research assistant that:
- Configuration Management - Handles environment variables and API settings
- Workflow Orchestration - Uses LangGraph to manage the research process
- Prompt Engineering - Provides structured prompts for different stages
- State Management - Tracks research progress and accumulated data
- Web Search Integration - Supports multiple search APIs (Tavily, Perplexity, DuckDuckGo)
Total Code Size: 21,660 bytes across 522 lines of Python code.