├── .env.example ├── .github └── workflows │ └── docker-image.yml ├── Dockerfile ├── LICENSE ├── README.md ├── langgraph.json ├── pyproject.toml └── src └── ollama_deep_researcher ├── __init__.py ├── configuration.py ├── graph.py ├── lmstudio.py ├── prompts.py ├── state.py └── utils.py /.env.example: -------------------------------------------------------------------------------- 1 | # Which search service to use, either 'duckduckgo', 'tavily', 'perplexity', Searxng 2 | SEARCH_API='duckduckgo' 3 | # For Searxng search, defaults to http://localhost:8888 4 | SEARXNG_URL= 5 | 6 | # Web Search API Keys (choose one or both) 7 | TAVILY_API_KEY=tvly-xxxxx # Get your key at https://tavily.com 8 | PERPLEXITY_API_KEY=pplx-xxxxx # Get your key at https://www.perplexity.ai 9 | 10 | # LLM Configuration 11 | LLM_PROVIDER=lmstudio # Options: ollama, lmstudio 12 | LOCAL_LLM=qwen_qwq-32b # Model name in LMStudio/Ollama 13 | LMSTUDIO_BASE_URL=http://localhost:1234/v1 # LMStudio OpenAI-compatible API URL 14 | OLLAMA_BASE_URL=http://localhost:11434 # the endpoint of the Ollama service, defaults to http://localhost:11434 if not set 15 | 16 | MAX_WEB_RESEARCH_LOOPS=3 17 | FETCH_FULL_PAGE=True -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | # Use docker.io for Docker Hub if empty 11 | REGISTRY: ghcr.io 12 | # github.repository as / 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | # Setup QEMU for multi-platform build support 24 | # https://docs.docker.com/build/ci/github-actions/multi-platform/ 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | 28 | # Set up BuildKit Docker container builder to be able to build 29 | # multi-platform images and export cache 30 | # https://github.com/docker/setup-buildx-action 31 | - name: Setup Docker buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | # Login against a Docker registry except on PR 35 | # https://github.com/docker/login-action 36 | - name: Log into registry ${{ env.REGISTRY }} 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ${{ env.REGISTRY }} 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GH_TOKEN }} 42 | 43 | # Extract metadata (tags, labels) for Docker 44 | # https://github.com/docker/metadata-action 45 | - name: Extract Docker metadata 46 | id: meta 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 50 | 51 | # Build and push Docker image with Buildx (don't push on PR) 52 | # https://github.com/docker/build-push-action 53 | - name: Build and push Docker image 54 | id: build-and-push 55 | uses: docker/build-push-action@v5 56 | with: 57 | context: . 58 | platforms: linux/amd64,linux/arm64 59 | push: true 60 | tags: | 61 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 62 | labels: ${{ steps.meta.outputs.labels }} 63 | 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update && apt-get install -y --no-install-recommends \ 6 | curl \ 7 | ca-certificates \ 8 | build-essential \ 9 | python3-dev \ 10 | libssl-dev \ 11 | libffi-dev \ 12 | rustc \ 13 | cargo \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # Install uv package manager (use pip for safer cross-arch install) 17 | RUN pip install uv 18 | ENV PATH="/root/.local/bin:${PATH}" 19 | 20 | # 2) Copy the repository content 21 | COPY . /app 22 | 23 | # 3) Provide default environment variables to point to Ollama (running elsewhere) 24 | # Adjust the OLLAMA_URL to match your actual Ollama container or service. 25 | ENV OLLAMA_BASE_URL="http://localhost:11434/" 26 | 27 | # 4) Expose the port that LangGraph dev server uses (default: 2024) 28 | EXPOSE 2024 29 | 30 | # 5) Launch the assistant with the LangGraph dev server: 31 | # Equivalent to the quickstart: uvx --refresh --from "langgraph-cli[inmem]" --with-editable . --python 3.11 langgraph dev 32 | CMD ["uvx", \ 33 | "--refresh", \ 34 | "--from", "langgraph-cli[inmem]", \ 35 | "--with-editable", ".", \ 36 | "--python", "3.11", \ 37 | "langgraph", \ 38 | "dev", \ 39 | "--host", "0.0.0.0"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lance Martin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE.MIT License 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local Deep Researcher 2 | 3 | Local Deep Researcher is a fully local web research assistant that uses any LLM hosted by [Ollama](https://ollama.com/search) or [LMStudio](https://lmstudio.ai/). Give it a topic and it will generate a web search query, gather web search results, summarize the results of web search, reflect on the summary to examine knowledge gaps, generate a new search query to address the gaps, and repeat for a user-defined number of cycles. It will provide the user a final markdown summary with all sources used to generate the summary. 4 | 5 | ![ollama-deep-research](https://github.com/user-attachments/assets/1c6b28f8-6b64-42ba-a491-1ab2875d50ea) 6 | 7 | Short summary video: 8 | 9 | 10 | ## 📺 Video Tutorials 11 | 12 | See it in action or build it yourself? Check out these helpful video tutorials: 13 | - [Overview of Local Deep Researcher with R1](https://www.youtube.com/watch?v=sGUjmyfof4Q) - Load and test [DeepSeek R1](https://api-docs.deepseek.com/news/news250120) [distilled models](https://ollama.com/library/deepseek-r1). 14 | - [Building Local Deep Researcher from Scratch](https://www.youtube.com/watch?v=XGuTzHoqlj8) - Overview of how this is built. 15 | 16 | ## 🚀 Quickstart 17 | 18 | Clone the repository: 19 | ```shell 20 | git clone https://github.com/langchain-ai/local-deep-researcher.git 21 | cd local-deep-researcher 22 | ``` 23 | 24 | Then edit the `.env` file to customize the environment variables according to your needs. These environment variables control the model selection, search tools, and other configuration settings. When you run the application, these values will be automatically loaded via `python-dotenv` (because `langgraph.json` point to the "env" file). 25 | ```shell 26 | cp .env.example .env 27 | ``` 28 | 29 | ### Selecting local model with Ollama 30 | 31 | 1. Download the Ollama app for Mac [here](https://ollama.com/download). 32 | 33 | 2. Pull a local LLM from [Ollama](https://ollama.com/search). As an [example](https://ollama.com/library/deepseek-r1:8b): 34 | ```shell 35 | ollama pull deepseek-r1:8b 36 | ``` 37 | 38 | 3. Optionally, update the `.env` file with the following Ollama configuration settings. 39 | 40 | * If set, these values will take precedence over the defaults set in the `Configuration` class in `configuration.py`. 41 | ```shell 42 | LLM_PROVIDER=ollama 43 | OLLAMA_BASE_URL="http://localhost:11434" # Ollama service endpoint, defaults to `http://localhost:11434` 44 | LOCAL_LLM=model # the model to use, defaults to `llama3.2` if not set 45 | ``` 46 | 47 | ### Selecting local model with LMStudio 48 | 49 | 1. Download and install LMStudio from [here](https://lmstudio.ai/). 50 | 51 | 2. In LMStudio: 52 | - Download and load your preferred model (e.g., qwen_qwq-32b) 53 | - Go to the "Local Server" tab 54 | - Start the server with the OpenAI-compatible API 55 | - Note the server URL (default: http://localhost:1234/v1) 56 | 57 | 3. Optionally, update the `.env` file with the following LMStudio configuration settings. 58 | 59 | * If set, these values will take precedence over the defaults set in the `Configuration` class in `configuration.py`. 60 | ```shell 61 | LLM_PROVIDER=lmstudio 62 | LOCAL_LLM=qwen_qwq-32b # Use the exact model name as shown in LMStudio 63 | LMSTUDIO_BASE_URL=http://localhost:1234/v1 64 | ``` 65 | 66 | ### Selecting search tool 67 | 68 | By default, it will use [DuckDuckGo](https://duckduckgo.com/) for web search, which does not require an API key. But you can also use [SearXNG](https://docs.searxng.org/), [Tavily](https://tavily.com/) or [Perplexity](https://www.perplexity.ai/hub/blog/introducing-the-sonar-pro-api) by adding their API keys to the environment file. Optionally, update the `.env` file with the following search tool configuration and API keys. If set, these values will take precedence over the defaults set in the `Configuration` class in `configuration.py`. 69 | ```shell 70 | SEARCH_API=xxx # the search API to use, such as `duckduckgo` (default) 71 | TAVILY_API_KEY=xxx # the tavily API key to use 72 | PERPLEXITY_API_KEY=xxx # the perplexity API key to use 73 | MAX_WEB_RESEARCH_LOOPS=xxx # the maximum number of research loop steps, defaults to `3` 74 | FETCH_FULL_PAGE=xxx # fetch the full page content (with `duckduckgo`), defaults to `false` 75 | ``` 76 | 77 | ### Running with LangGraph Studio 78 | 79 | #### Mac 80 | 81 | 1. (Recommended) Create a virtual environment: 82 | ```bash 83 | python -m venv .venv 84 | source .venv/bin/activate 85 | ``` 86 | 87 | 2. Launch LangGraph server: 88 | 89 | ```bash 90 | # Install uv package manager 91 | curl -LsSf https://astral.sh/uv/install.sh | sh 92 | uvx --refresh --from "langgraph-cli[inmem]" --with-editable . --python 3.11 langgraph dev 93 | ``` 94 | 95 | #### Windows 96 | 97 | 1. (Recommended) Create a virtual environment: 98 | 99 | * Install `Python 3.11` (and add to PATH during installation). 100 | * Restart your terminal to ensure Python is available, then create and activate a virtual environment: 101 | 102 | ```powershell 103 | python -m venv .venv 104 | .venv\Scripts\Activate.ps1 105 | ``` 106 | 107 | 2. Launch LangGraph server: 108 | 109 | ```powershell 110 | # Install dependencies 111 | pip install -e . 112 | pip install -U "langgraph-cli[inmem]" 113 | 114 | # Start the LangGraph server 115 | langgraph dev 116 | ``` 117 | 118 | ### Using the LangGraph Studio UI 119 | 120 | When you launch LangGraph server, you should see the following output and Studio will open in your browser: 121 | > Ready! 122 | 123 | > API: http://127.0.0.1:2024 124 | 125 | > Docs: http://127.0.0.1:2024/docs 126 | 127 | > LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 128 | 129 | Open `LangGraph Studio Web UI` via the URL above. In the `configuration` tab, you can directly set various assistant configurations. Keep in mind that the priority order for configuration values is: 130 | 131 | ``` 132 | 1. Environment variables (highest priority) 133 | 2. LangGraph UI configuration 134 | 3. Default values in the Configuration class (lowest priority) 135 | ``` 136 | 137 | Screenshot 2025-01-24 at 10 08 31 PM 138 | 139 | Give the assistant a topic for research, and you can visualize its process! 140 | 141 | Screenshot 2025-01-24 at 10 08 22 PM 142 | 143 | ### Model Compatibility Note 144 | 145 | When selecting a local LLM, set steps use structured JSON output. Some models may have difficulty with this requirement, and the assistant has fallback mechanisms to handle this. As an example, the [DeepSeek R1 (7B)](https://ollama.com/library/deepseek-llm:7b) and [DeepSeek R1 (1.5B)](https://ollama.com/library/deepseek-r1:1.5b) models have difficulty producing required JSON output, and the assistant will use a fallback mechanism to handle this. 146 | 147 | ### Browser Compatibility Note 148 | 149 | When accessing the LangGraph Studio UI: 150 | - Firefox is recommended for the best experience 151 | - Safari users may encounter security warnings due to mixed content (HTTPS/HTTP) 152 | - If you encounter issues, try: 153 | 1. Using Firefox or another browser 154 | 2. Disabling ad-blocking extensions 155 | 3. Checking browser console for specific error messages 156 | 157 | ## How it works 158 | 159 | Local Deep Researcher is inspired by [IterDRAG](https://arxiv.org/html/2410.04343v1#:~:text=To%20tackle%20this%20issue%2C%20we,used%20to%20generate%20intermediate%20answers.). This approach will decompose a query into sub-queries, retrieve documents for each one, answer the sub-query, and then build on the answer by retrieving docs for the second sub-query. Here, we do similar: 160 | - Given a user-provided topic, use a local LLM (via [Ollama](https://ollama.com/search) or [LMStudio](https://lmstudio.ai/)) to generate a web search query 161 | - Uses a search engine / tool to find relevant sources 162 | - Uses LLM to summarize the findings from web search related to the user-provided research topic 163 | - Then, it uses the LLM to reflect on the summary, identifying knowledge gaps 164 | - It generates a new search query to address the knowledge gaps 165 | - The process repeats, with the summary being iteratively updated with new information from web search 166 | - Runs for a configurable number of iterations (see `configuration` tab) 167 | 168 | ## Outputs 169 | 170 | The output of the graph is a markdown file containing the research summary, with citations to the sources used. All sources gathered during research are saved to the graph state. You can visualize them in the graph state, which is visible in LangGraph Studio: 171 | 172 | ![Screenshot 2024-12-05 at 4 08 59 PM](https://github.com/user-attachments/assets/e8ac1c0b-9acb-4a75-8c15-4e677e92f6cb) 173 | 174 | The final summary is saved to the graph state as well: 175 | 176 | ![Screenshot 2024-12-05 at 4 10 11 PM](https://github.com/user-attachments/assets/f6d997d5-9de5-495f-8556-7d3891f6bc96) 177 | 178 | ## Deployment Options 179 | 180 | There are [various ways](https://langchain-ai.github.io/langgraph/concepts/#deployment-options) to deploy this graph. See [Module 6](https://github.com/langchain-ai/langchain-academy/tree/main/module-6) of LangChain Academy for a detailed walkthrough of deployment options with LangGraph. 181 | 182 | ## TypeScript Implementation 183 | 184 | A TypeScript port of this project (without Perplexity search) is available at: 185 | https://github.com/PacoVK/ollama-deep-researcher-ts 186 | 187 | ## Running as a Docker container 188 | 189 | The included `Dockerfile` only runs LangChain Studio with local-deep-researcher as a service, but does not include Ollama as a dependant service. You must run Ollama separately and configure the `OLLAMA_BASE_URL` environment variable. Optionally you can also specify the Ollama model to use by providing the `LOCAL_LLM` environment variable. 190 | 191 | Clone the repo and build an image: 192 | ``` 193 | $ docker build -t local-deep-researcher . 194 | ``` 195 | 196 | Run the container: 197 | ``` 198 | $ docker run --rm -it -p 2024:2024 \ 199 | -e SEARCH_API="tavily" \ 200 | -e TAVILY_API_KEY="tvly-***YOUR_KEY_HERE***" \ 201 | -e LLM_PROVIDER=ollama 202 | -e OLLAMA_BASE_URL="http://host.docker.internal:11434/" \ 203 | -e LOCAL_LLM="llama3.2" \ 204 | local-deep-researcher 205 | ``` 206 | 207 | NOTE: You will see log message: 208 | ``` 209 | 2025-02-10T13:45:04.784915Z [info ] 🎨 Opening Studio in your browser... [browser_opener] api_variant=local_dev message=🎨 Opening Studio in your browser... 210 | URL: https://smith.langchain.com/studio/?baseUrl=http://0.0.0.0:2024 211 | ``` 212 | ...but the browser will not launch from the container. 213 | 214 | Instead, visit this link with the correct baseUrl IP address: [`https://smith.langchain.com/studio/thread?baseUrl=http://127.0.0.1:2024`](https://smith.langchain.com/studio/thread?baseUrl=http://127.0.0.1:2024) 215 | -------------------------------------------------------------------------------- /langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerfile_lines": [], 3 | "graphs": { 4 | "ollama_deep_researcher": "./src/ollama_deep_researcher/graph.py:graph" 5 | }, 6 | "python_version": "3.11", 7 | "env": "./.env", 8 | "dependencies": [ 9 | "." 10 | ] 11 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ollama-deep-researcher" 3 | version = "0.0.1" 4 | description = "Fully local web research and summarization assistant with Ollama and LangGraph." 5 | authors = [ 6 | { name = "Lance Martin" } 7 | ] 8 | readme = "README.md" 9 | license = { text = "MIT" } 10 | requires-python = ">=3.9" 11 | dependencies = [ 12 | "langgraph>=0.2.55", 13 | "langchain-community>=0.3.9", 14 | "tavily-python>=0.5.0", 15 | "langchain-ollama>=0.2.1", 16 | "duckduckgo-search>=7.3.0", 17 | "langchain-openai>=0.1.1", 18 | "openai>=1.12.0", 19 | "langchain_openai>=0.3.9", 20 | "httpx>=0.28.1", 21 | "markdownify>=0.11.0", 22 | "python-dotenv==1.0.1", 23 | ] 24 | 25 | [project.optional-dependencies] 26 | dev = ["mypy>=1.11.1", "ruff>=0.6.1"] 27 | 28 | [build-system] 29 | requires = ["setuptools>=73.0.0", "wheel"] 30 | build-backend = "setuptools.build_meta" 31 | 32 | [tool.setuptools] 33 | packages = ["ollama_deep_researcher"] 34 | 35 | [tool.setuptools.package-dir] 36 | "ollama_deep_researcher" = "src/ollama_deep_researcher" 37 | 38 | [tool.setuptools.package-data] 39 | "*" = ["py.typed"] 40 | 41 | [tool.ruff] 42 | lint.select = [ 43 | "E", # pycodestyle 44 | "F", # pyflakes 45 | "I", # isort 46 | "D", # pydocstyle 47 | "D401", # First line should be in imperative mood 48 | "T201", 49 | "UP", 50 | ] 51 | lint.ignore = [ 52 | "UP006", 53 | "UP007", 54 | "UP035", 55 | "D417", 56 | "E501", 57 | ] 58 | 59 | [tool.ruff.lint.per-file-ignores] 60 | "tests/*" = ["D", "UP"] 61 | 62 | [tool.ruff.lint.pydocstyle] 63 | convention = "google" -------------------------------------------------------------------------------- /src/ollama_deep_researcher/__init__.py: -------------------------------------------------------------------------------- 1 | version = "0.0.1" -------------------------------------------------------------------------------- /src/ollama_deep_researcher/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from enum import Enum 3 | from pydantic import BaseModel, Field 4 | from typing import Any, Optional, Literal 5 | 6 | from langchain_core.runnables import RunnableConfig 7 | 8 | class SearchAPI(Enum): 9 | PERPLEXITY = "perplexity" 10 | TAVILY = "tavily" 11 | DUCKDUCKGO = "duckduckgo" 12 | SEARXNG = "searxng" 13 | 14 | class Configuration(BaseModel): 15 | """The configurable fields for the research assistant.""" 16 | 17 | max_web_research_loops: int = Field( 18 | default=3, 19 | title="Research Depth", 20 | description="Number of research iterations to perform" 21 | ) 22 | local_llm: str = Field( 23 | default="llama3.2", 24 | title="LLM Model Name", 25 | description="Name of the LLM model to use" 26 | ) 27 | llm_provider: Literal["ollama", "lmstudio"] = Field( 28 | default="ollama", 29 | title="LLM Provider", 30 | description="Provider for the LLM (Ollama or LMStudio)" 31 | ) 32 | search_api: Literal["perplexity", "tavily", "duckduckgo", "searxng"] = Field( 33 | default="duckduckgo", 34 | title="Search API", 35 | description="Web search API to use" 36 | ) 37 | fetch_full_page: bool = Field( 38 | default=True, 39 | title="Fetch Full Page", 40 | description="Include the full page content in the search results" 41 | ) 42 | ollama_base_url: str = Field( 43 | default="http://localhost:11434/", 44 | title="Ollama Base URL", 45 | description="Base URL for Ollama API" 46 | ) 47 | lmstudio_base_url: str = Field( 48 | default="http://localhost:1234/v1", 49 | title="LMStudio Base URL", 50 | description="Base URL for LMStudio OpenAI-compatible API" 51 | ) 52 | strip_thinking_tokens: bool = Field( 53 | default=True, 54 | title="Strip Thinking Tokens", 55 | description="Whether to strip tokens from model responses" 56 | ) 57 | 58 | @classmethod 59 | def from_runnable_config( 60 | cls, config: Optional[RunnableConfig] = None 61 | ) -> "Configuration": 62 | """Create a Configuration instance from a RunnableConfig.""" 63 | configurable = ( 64 | config["configurable"] if config and "configurable" in config else {} 65 | ) 66 | 67 | # Get raw values from environment or config 68 | raw_values: dict[str, Any] = { 69 | name: os.environ.get(name.upper(), configurable.get(name)) 70 | for name in cls.model_fields.keys() 71 | } 72 | 73 | # Filter out None values 74 | values = {k: v for k, v in raw_values.items() if v is not None} 75 | 76 | return cls(**values) -------------------------------------------------------------------------------- /src/ollama_deep_researcher/graph.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from typing_extensions import Literal 4 | 5 | from langchain_core.messages import HumanMessage, SystemMessage 6 | from langchain_core.runnables import RunnableConfig 7 | from langchain_ollama import ChatOllama 8 | from langgraph.graph import START, END, StateGraph 9 | 10 | from ollama_deep_researcher.configuration import Configuration, SearchAPI 11 | from ollama_deep_researcher.utils import deduplicate_and_format_sources, tavily_search, format_sources, perplexity_search, duckduckgo_search, searxng_search, strip_thinking_tokens, get_config_value 12 | from ollama_deep_researcher.state import SummaryState, SummaryStateInput, SummaryStateOutput 13 | from ollama_deep_researcher.prompts import query_writer_instructions, summarizer_instructions, reflection_instructions, get_current_date 14 | from ollama_deep_researcher.lmstudio import ChatLMStudio 15 | 16 | # Nodes 17 | def generate_query(state: SummaryState, config: RunnableConfig): 18 | """LangGraph node that generates a search query based on the research topic. 19 | 20 | Uses an LLM to create an optimized search query for web research based on 21 | the user's research topic. Supports both LMStudio and Ollama as LLM providers. 22 | 23 | Args: 24 | state: Current graph state containing the research topic 25 | config: Configuration for the runnable, including LLM provider settings 26 | 27 | Returns: 28 | Dictionary with state update, including search_query key containing the generated query 29 | """ 30 | 31 | # Format the prompt 32 | current_date = get_current_date() 33 | formatted_prompt = query_writer_instructions.format( 34 | current_date=current_date, 35 | research_topic=state.research_topic 36 | ) 37 | 38 | # Generate a query 39 | configurable = Configuration.from_runnable_config(config) 40 | 41 | # Choose the appropriate LLM based on the provider 42 | if configurable.llm_provider == "lmstudio": 43 | llm_json_mode = ChatLMStudio( 44 | base_url=configurable.lmstudio_base_url, 45 | model=configurable.local_llm, 46 | temperature=0, 47 | format="json" 48 | ) 49 | else: # Default to Ollama 50 | llm_json_mode = ChatOllama( 51 | base_url=configurable.ollama_base_url, 52 | model=configurable.local_llm, 53 | temperature=0, 54 | format="json" 55 | ) 56 | 57 | result = llm_json_mode.invoke( 58 | [SystemMessage(content=formatted_prompt), 59 | HumanMessage(content=f"Generate a query for web search:")] 60 | ) 61 | 62 | # Get the content 63 | content = result.content 64 | 65 | # Parse the JSON response and get the query 66 | try: 67 | query = json.loads(content) 68 | search_query = query['query'] 69 | except (json.JSONDecodeError, KeyError): 70 | # If parsing fails or the key is not found, use a fallback query 71 | if configurable.strip_thinking_tokens: 72 | content = strip_thinking_tokens(content) 73 | search_query = content 74 | return {"search_query": search_query} 75 | 76 | def web_research(state: SummaryState, config: RunnableConfig): 77 | """LangGraph node that performs web research using the generated search query. 78 | 79 | Executes a web search using the configured search API (tavily, perplexity, 80 | duckduckgo, or searxng) and formats the results for further processing. 81 | 82 | Args: 83 | state: Current graph state containing the search query and research loop count 84 | config: Configuration for the runnable, including search API settings 85 | 86 | Returns: 87 | Dictionary with state update, including sources_gathered, research_loop_count, and web_research_results 88 | """ 89 | 90 | # Configure 91 | configurable = Configuration.from_runnable_config(config) 92 | 93 | # Get the search API 94 | search_api = get_config_value(configurable.search_api) 95 | 96 | # Search the web 97 | if search_api == "tavily": 98 | search_results = tavily_search(state.search_query, fetch_full_page=configurable.fetch_full_page, max_results=1) 99 | search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, fetch_full_page=configurable.fetch_full_page) 100 | elif search_api == "perplexity": 101 | search_results = perplexity_search(state.search_query, state.research_loop_count) 102 | search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, fetch_full_page=configurable.fetch_full_page) 103 | elif search_api == "duckduckgo": 104 | search_results = duckduckgo_search(state.search_query, max_results=3, fetch_full_page=configurable.fetch_full_page) 105 | search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, fetch_full_page=configurable.fetch_full_page) 106 | elif search_api == "searxng": 107 | search_results = searxng_search(state.search_query, max_results=3, fetch_full_page=configurable.fetch_full_page) 108 | search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000, fetch_full_page=configurable.fetch_full_page) 109 | else: 110 | raise ValueError(f"Unsupported search API: {configurable.search_api}") 111 | 112 | return {"sources_gathered": [format_sources(search_results)], "research_loop_count": state.research_loop_count + 1, "web_research_results": [search_str]} 113 | 114 | def summarize_sources(state: SummaryState, config: RunnableConfig): 115 | """LangGraph node that summarizes web research results. 116 | 117 | Uses an LLM to create or update a running summary based on the newest web research 118 | results, integrating them with any existing summary. 119 | 120 | Args: 121 | state: Current graph state containing research topic, running summary, 122 | and web research results 123 | config: Configuration for the runnable, including LLM provider settings 124 | 125 | Returns: 126 | Dictionary with state update, including running_summary key containing the updated summary 127 | """ 128 | 129 | # Existing summary 130 | existing_summary = state.running_summary 131 | 132 | # Most recent web research 133 | most_recent_web_research = state.web_research_results[-1] 134 | 135 | # Build the human message 136 | if existing_summary: 137 | human_message_content = ( 138 | f" \n {existing_summary} \n \n\n" 139 | f" \n {most_recent_web_research} \n " 140 | f"Update the Existing Summary with the New Context on this topic: \n \n {state.research_topic} \n \n\n" 141 | ) 142 | else: 143 | human_message_content = ( 144 | f" \n {most_recent_web_research} \n " 145 | f"Create a Summary using the Context on this topic: \n \n {state.research_topic} \n \n\n" 146 | ) 147 | 148 | # Run the LLM 149 | configurable = Configuration.from_runnable_config(config) 150 | 151 | # Choose the appropriate LLM based on the provider 152 | if configurable.llm_provider == "lmstudio": 153 | llm = ChatLMStudio( 154 | base_url=configurable.lmstudio_base_url, 155 | model=configurable.local_llm, 156 | temperature=0 157 | ) 158 | else: # Default to Ollama 159 | llm = ChatOllama( 160 | base_url=configurable.ollama_base_url, 161 | model=configurable.local_llm, 162 | temperature=0 163 | ) 164 | 165 | result = llm.invoke( 166 | [SystemMessage(content=summarizer_instructions), 167 | HumanMessage(content=human_message_content)] 168 | ) 169 | 170 | # Strip thinking tokens if configured 171 | running_summary = result.content 172 | if configurable.strip_thinking_tokens: 173 | running_summary = strip_thinking_tokens(running_summary) 174 | 175 | return {"running_summary": running_summary} 176 | 177 | def reflect_on_summary(state: SummaryState, config: RunnableConfig): 178 | """LangGraph node that identifies knowledge gaps and generates follow-up queries. 179 | 180 | Analyzes the current summary to identify areas for further research and generates 181 | a new search query to address those gaps. Uses structured output to extract 182 | the follow-up query in JSON format. 183 | 184 | Args: 185 | state: Current graph state containing the running summary and research topic 186 | config: Configuration for the runnable, including LLM provider settings 187 | 188 | Returns: 189 | Dictionary with state update, including search_query key containing the generated follow-up query 190 | """ 191 | 192 | # Generate a query 193 | configurable = Configuration.from_runnable_config(config) 194 | 195 | # Choose the appropriate LLM based on the provider 196 | if configurable.llm_provider == "lmstudio": 197 | llm_json_mode = ChatLMStudio( 198 | base_url=configurable.lmstudio_base_url, 199 | model=configurable.local_llm, 200 | temperature=0, 201 | format="json" 202 | ) 203 | else: # Default to Ollama 204 | llm_json_mode = ChatOllama( 205 | base_url=configurable.ollama_base_url, 206 | model=configurable.local_llm, 207 | temperature=0, 208 | format="json" 209 | ) 210 | 211 | result = llm_json_mode.invoke( 212 | [SystemMessage(content=reflection_instructions.format(research_topic=state.research_topic)), 213 | HumanMessage(content=f"Reflect on our existing knowledge: \n === \n {state.running_summary}, \n === \n And now identify a knowledge gap and generate a follow-up web search query:")] 214 | ) 215 | 216 | # Strip thinking tokens if configured 217 | try: 218 | # Try to parse as JSON first 219 | reflection_content = json.loads(result.content) 220 | # Get the follow-up query 221 | query = reflection_content.get('follow_up_query') 222 | # Check if query is None or empty 223 | if not query: 224 | # Use a fallback query 225 | return {"search_query": f"Tell me more about {state.research_topic}"} 226 | return {"search_query": query} 227 | except (json.JSONDecodeError, KeyError, AttributeError): 228 | # If parsing fails or the key is not found, use a fallback query 229 | return {"search_query": f"Tell me more about {state.research_topic}"} 230 | 231 | def finalize_summary(state: SummaryState): 232 | """LangGraph node that finalizes the research summary. 233 | 234 | Prepares the final output by deduplicating and formatting sources, then 235 | combining them with the running summary to create a well-structured 236 | research report with proper citations. 237 | 238 | Args: 239 | state: Current graph state containing the running summary and sources gathered 240 | 241 | Returns: 242 | Dictionary with state update, including running_summary key containing the formatted final summary with sources 243 | """ 244 | 245 | # Deduplicate sources before joining 246 | seen_sources = set() 247 | unique_sources = [] 248 | 249 | for source in state.sources_gathered: 250 | # Split the source into lines and process each individually 251 | for line in source.split('\n'): 252 | # Only process non-empty lines 253 | if line.strip() and line not in seen_sources: 254 | seen_sources.add(line) 255 | unique_sources.append(line) 256 | 257 | # Join the deduplicated sources 258 | all_sources = "\n".join(unique_sources) 259 | state.running_summary = f"## Summary\n{state.running_summary}\n\n ### Sources:\n{all_sources}" 260 | return {"running_summary": state.running_summary} 261 | 262 | def route_research(state: SummaryState, config: RunnableConfig) -> Literal["finalize_summary", "web_research"]: 263 | """LangGraph routing function that determines the next step in the research flow. 264 | 265 | Controls the research loop by deciding whether to continue gathering information 266 | or to finalize the summary based on the configured maximum number of research loops. 267 | 268 | Args: 269 | state: Current graph state containing the research loop count 270 | config: Configuration for the runnable, including max_web_research_loops setting 271 | 272 | Returns: 273 | String literal indicating the next node to visit ("web_research" or "finalize_summary") 274 | """ 275 | 276 | configurable = Configuration.from_runnable_config(config) 277 | if state.research_loop_count <= configurable.max_web_research_loops: 278 | return "web_research" 279 | else: 280 | return "finalize_summary" 281 | 282 | # Add nodes and edges 283 | builder = StateGraph(SummaryState, input=SummaryStateInput, output=SummaryStateOutput, config_schema=Configuration) 284 | builder.add_node("generate_query", generate_query) 285 | builder.add_node("web_research", web_research) 286 | builder.add_node("summarize_sources", summarize_sources) 287 | builder.add_node("reflect_on_summary", reflect_on_summary) 288 | builder.add_node("finalize_summary", finalize_summary) 289 | 290 | # Add edges 291 | builder.add_edge(START, "generate_query") 292 | builder.add_edge("generate_query", "web_research") 293 | builder.add_edge("web_research", "summarize_sources") 294 | builder.add_edge("summarize_sources", "reflect_on_summary") 295 | builder.add_conditional_edges("reflect_on_summary", route_research) 296 | builder.add_edge("finalize_summary", END) 297 | 298 | graph = builder.compile() -------------------------------------------------------------------------------- /src/ollama_deep_researcher/lmstudio.py: -------------------------------------------------------------------------------- 1 | """LMStudio integration for the research assistant.""" 2 | 3 | import json 4 | import logging 5 | from typing import Any, List, Optional 6 | 7 | from langchain_core.callbacks.manager import CallbackManagerForLLMRun 8 | from langchain_core.messages import ( 9 | BaseMessage, 10 | ) 11 | from langchain_core.outputs import ChatResult 12 | from langchain_openai import ChatOpenAI 13 | from pydantic import Field 14 | 15 | # Set up logging 16 | logger = logging.getLogger(__name__) 17 | 18 | class ChatLMStudio(ChatOpenAI): 19 | """Chat model that uses LMStudio's OpenAI-compatible API.""" 20 | 21 | format: Optional[str] = Field(default=None, description="Format for the response (e.g., 'json')") 22 | 23 | def __init__( 24 | self, 25 | base_url: str = "http://localhost:1234/v1", 26 | model: str = "qwen_qwq-32b", 27 | temperature: float = 0.7, 28 | format: Optional[str] = None, 29 | api_key: str = "not-needed-for-local-models", 30 | **kwargs: Any, 31 | ): 32 | """Initialize the ChatLMStudio. 33 | 34 | Args: 35 | base_url: Base URL for LMStudio's OpenAI-compatible API 36 | model: Model name to use 37 | temperature: Temperature for sampling 38 | format: Format for the response (e.g., "json") 39 | api_key: API key (not actually used, but required by OpenAI client) 40 | **kwargs: Additional arguments to pass to the OpenAI client 41 | """ 42 | # Initialize the base class 43 | super().__init__( 44 | base_url=base_url, 45 | model=model, 46 | temperature=temperature, 47 | api_key=api_key, 48 | **kwargs, 49 | ) 50 | self.format = format 51 | 52 | def _generate( 53 | self, 54 | messages: List[BaseMessage], 55 | stop: Optional[List[str]] = None, 56 | run_manager: Optional[CallbackManagerForLLMRun] = None, 57 | **kwargs: Any, 58 | ) -> ChatResult: 59 | 60 | """Generate a chat response using LMStudio's OpenAI-compatible API.""" 61 | 62 | if self.format == "json": 63 | # Set response_format for JSON mode 64 | kwargs["response_format"] = {"type": "json_object"} 65 | logger.info(f"Using response_format={kwargs['response_format']}") 66 | 67 | # Call the parent class's _generate method 68 | result = super()._generate(messages, stop, run_manager, **kwargs) 69 | 70 | # If JSON format is requested, try to clean up the response 71 | if self.format == "json" and result.generations: 72 | try: 73 | # Get the raw text 74 | raw_text = result.generations[0][0].text 75 | logger.info(f"Raw model response: {raw_text}") 76 | 77 | # Try to find JSON in the response 78 | json_start = raw_text.find('{') 79 | json_end = raw_text.rfind('}') + 1 80 | 81 | if json_start >= 0 and json_end > json_start: 82 | # Extract just the JSON part 83 | json_text = raw_text[json_start:json_end] 84 | # Validate it's proper JSON 85 | json.loads(json_text) 86 | logger.info(f"Cleaned JSON: {json_text}") 87 | # Update the generation with the cleaned JSON 88 | result.generations[0][0].text = json_text 89 | else: 90 | logger.warning("Could not find JSON in response") 91 | except Exception as e: 92 | logger.error(f"Error processing JSON response: {str(e)}") 93 | # If any error occurs during cleanup, just use the original response 94 | pass 95 | 96 | return result -------------------------------------------------------------------------------- /src/ollama_deep_researcher/prompts.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | # Get current date in a readable format 4 | def get_current_date(): 5 | return datetime.now().strftime("%B %d, %Y") 6 | 7 | query_writer_instructions="""Your goal is to generate a targeted web search query. 8 | 9 | 10 | Current date: {current_date} 11 | Please ensure your queries account for the most current information available as of this date. 12 | 13 | 14 | 15 | {research_topic} 16 | 17 | 18 | 19 | Format your response as a JSON object with ALL three of these exact keys: 20 | - "query": The actual search query string 21 | - "rationale": Brief explanation of why this query is relevant 22 | 23 | 24 | 25 | Example output: 26 | {{ 27 | "query": "machine learning transformer architecture explained", 28 | "rationale": "Understanding the fundamental structure of transformer models" 29 | }} 30 | 31 | 32 | Provide your response in JSON format:""" 33 | 34 | summarizer_instructions=""" 35 | 36 | Generate a high-quality summary of the provided context. 37 | 38 | 39 | 40 | When creating a NEW summary: 41 | 1. Highlight the most relevant information related to the user topic from the search results 42 | 2. Ensure a coherent flow of information 43 | 44 | When EXTENDING an existing summary: 45 | 1. Read the existing summary and new search results carefully. 46 | 2. Compare the new information with the existing summary. 47 | 3. For each piece of new information: 48 | a. If it's related to existing points, integrate it into the relevant paragraph. 49 | b. If it's entirely new but relevant, add a new paragraph with a smooth transition. 50 | c. If it's not relevant to the user topic, skip it. 51 | 4. Ensure all additions are relevant to the user's topic. 52 | 5. Verify that your final output differs from the input summary. 53 | < /REQUIREMENTS > 54 | 55 | < FORMATTING > 56 | - Start directly with the updated summary, without preamble or titles. Do not use XML tags in the output. 57 | < /FORMATTING > 58 | 59 | 60 | Think carefully about the provided Context first. Then generate a summary of the context to address the User Input. 61 | 62 | """ 63 | 64 | reflection_instructions = """You are an expert research assistant analyzing a summary about {research_topic}. 65 | 66 | 67 | 1. Identify knowledge gaps or areas that need deeper exploration 68 | 2. Generate a follow-up question that would help expand your understanding 69 | 3. Focus on technical details, implementation specifics, or emerging trends that weren't fully covered 70 | 71 | 72 | 73 | Ensure the follow-up question is self-contained and includes necessary context for web search. 74 | 75 | 76 | 77 | Format your response as a JSON object with these exact keys: 78 | - knowledge_gap: Describe what information is missing or needs clarification 79 | - follow_up_query: Write a specific question to address this gap 80 | 81 | 82 | 83 | Reflect carefully on the Summary to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format: 84 | {{ 85 | "knowledge_gap": "The summary lacks information about performance metrics and benchmarks", 86 | "follow_up_query": "What are typical performance benchmarks and metrics used to evaluate [specific technology]?" 87 | }} 88 | 89 | 90 | Provide your analysis in JSON format:""" -------------------------------------------------------------------------------- /src/ollama_deep_researcher/state.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from dataclasses import dataclass, field 3 | from typing_extensions import Annotated 4 | 5 | @dataclass(kw_only=True) 6 | class SummaryState: 7 | research_topic: str = field(default=None) # Report topic 8 | search_query: str = field(default=None) # Search query 9 | web_research_results: Annotated[list, operator.add] = field(default_factory=list) 10 | sources_gathered: Annotated[list, operator.add] = field(default_factory=list) 11 | research_loop_count: int = field(default=0) # Research loop count 12 | running_summary: str = field(default=None) # Final report 13 | 14 | @dataclass(kw_only=True) 15 | class SummaryStateInput: 16 | research_topic: str = field(default=None) # Report topic 17 | 18 | @dataclass(kw_only=True) 19 | class SummaryStateOutput: 20 | running_summary: str = field(default=None) # Final report -------------------------------------------------------------------------------- /src/ollama_deep_researcher/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import httpx 3 | import requests 4 | from typing import Dict, Any, List, Union, Optional 5 | 6 | from markdownify import markdownify 7 | from langsmith import traceable 8 | from tavily import TavilyClient 9 | from duckduckgo_search import DDGS 10 | 11 | from langchain_community.utilities import SearxSearchWrapper 12 | 13 | def get_config_value(value: Any) -> str: 14 | """ 15 | Convert configuration values to string format, handling both string and enum types. 16 | 17 | Args: 18 | value (Any): The configuration value to process. Can be a string or an Enum. 19 | 20 | Returns: 21 | str: The string representation of the value. 22 | 23 | Examples: 24 | >>> get_config_value("tavily") 25 | 'tavily' 26 | >>> get_config_value(SearchAPI.TAVILY) 27 | 'tavily' 28 | """ 29 | return value if isinstance(value, str) else value.value 30 | 31 | def strip_thinking_tokens(text: str) -> str: 32 | """ 33 | Remove and tags and their content from the text. 34 | 35 | Iteratively removes all occurrences of content enclosed in thinking tokens. 36 | 37 | Args: 38 | text (str): The text to process 39 | 40 | Returns: 41 | str: The text with thinking tokens and their content removed 42 | """ 43 | while "" in text and "" in text: 44 | start = text.find("") 45 | end = text.find("") + len("") 46 | text = text[:start] + text[end:] 47 | return text 48 | 49 | def deduplicate_and_format_sources( 50 | search_response: Union[Dict[str, Any], List[Dict[str, Any]]], 51 | max_tokens_per_source: int, 52 | fetch_full_page: bool = False 53 | ) -> str: 54 | """ 55 | Format and deduplicate search responses from various search APIs. 56 | 57 | Takes either a single search response or list of responses from search APIs, 58 | deduplicates them by URL, and formats them into a structured string. 59 | 60 | Args: 61 | search_response (Union[Dict[str, Any], List[Dict[str, Any]]]): Either: 62 | - A dict with a 'results' key containing a list of search results 63 | - A list of dicts, each containing search results 64 | max_tokens_per_source (int): Maximum number of tokens to include for each source's content 65 | fetch_full_page (bool, optional): Whether to include the full page content. Defaults to False. 66 | 67 | Returns: 68 | str: Formatted string with deduplicated sources 69 | 70 | Raises: 71 | ValueError: If input is neither a dict with 'results' key nor a list of search results 72 | """ 73 | # Convert input to list of results 74 | if isinstance(search_response, dict): 75 | sources_list = search_response['results'] 76 | elif isinstance(search_response, list): 77 | sources_list = [] 78 | for response in search_response: 79 | if isinstance(response, dict) and 'results' in response: 80 | sources_list.extend(response['results']) 81 | else: 82 | sources_list.extend(response) 83 | else: 84 | raise ValueError("Input must be either a dict with 'results' or a list of search results") 85 | 86 | # Deduplicate by URL 87 | unique_sources = {} 88 | for source in sources_list: 89 | if source['url'] not in unique_sources: 90 | unique_sources[source['url']] = source 91 | 92 | # Format output 93 | formatted_text = "Sources:\n\n" 94 | for i, source in enumerate(unique_sources.values(), 1): 95 | formatted_text += f"Source: {source['title']}\n===\n" 96 | formatted_text += f"URL: {source['url']}\n===\n" 97 | formatted_text += f"Most relevant content from source: {source['content']}\n===\n" 98 | if fetch_full_page: 99 | # Using rough estimate of 4 characters per token 100 | char_limit = max_tokens_per_source * 4 101 | # Handle None raw_content 102 | raw_content = source.get('raw_content', '') 103 | if raw_content is None: 104 | raw_content = '' 105 | print(f"Warning: No raw_content found for source {source['url']}") 106 | if len(raw_content) > char_limit: 107 | raw_content = raw_content[:char_limit] + "... [truncated]" 108 | formatted_text += f"Full source content limited to {max_tokens_per_source} tokens: {raw_content}\n\n" 109 | 110 | return formatted_text.strip() 111 | 112 | def format_sources(search_results: Dict[str, Any]) -> str: 113 | """ 114 | Format search results into a bullet-point list of sources with URLs. 115 | 116 | Creates a simple bulleted list of search results with title and URL for each source. 117 | 118 | Args: 119 | search_results (Dict[str, Any]): Search response containing a 'results' key with 120 | a list of search result objects 121 | 122 | Returns: 123 | str: Formatted string with sources as bullet points in the format "* title : url" 124 | """ 125 | return '\n'.join( 126 | f"* {source['title']} : {source['url']}" 127 | for source in search_results['results'] 128 | ) 129 | 130 | def fetch_raw_content(url: str) -> Optional[str]: 131 | """ 132 | Fetch HTML content from a URL and convert it to markdown format. 133 | 134 | Uses a 10-second timeout to avoid hanging on slow sites or large pages. 135 | 136 | Args: 137 | url (str): The URL to fetch content from 138 | 139 | Returns: 140 | Optional[str]: The fetched content converted to markdown if successful, 141 | None if any error occurs during fetching or conversion 142 | """ 143 | try: 144 | # Create a client with reasonable timeout 145 | with httpx.Client(timeout=10.0) as client: 146 | response = client.get(url) 147 | response.raise_for_status() 148 | return markdownify(response.text) 149 | except Exception as e: 150 | print(f"Warning: Failed to fetch full page content for {url}: {str(e)}") 151 | return None 152 | 153 | @traceable 154 | def duckduckgo_search(query: str, max_results: int = 3, fetch_full_page: bool = False) -> Dict[str, List[Dict[str, Any]]]: 155 | """ 156 | Search the web using DuckDuckGo and return formatted results. 157 | 158 | Uses the DDGS library to perform web searches through DuckDuckGo. 159 | 160 | Args: 161 | query (str): The search query to execute 162 | max_results (int, optional): Maximum number of results to return. Defaults to 3. 163 | fetch_full_page (bool, optional): Whether to fetch full page content from result URLs. 164 | Defaults to False. 165 | Returns: 166 | Dict[str, List[Dict[str, Any]]]: Search response containing: 167 | - results (list): List of search result dictionaries, each containing: 168 | - title (str): Title of the search result 169 | - url (str): URL of the search result 170 | - content (str): Snippet/summary of the content 171 | - raw_content (str or None): Full page content if fetch_full_page is True, 172 | otherwise same as content 173 | """ 174 | try: 175 | with DDGS() as ddgs: 176 | results = [] 177 | search_results = list(ddgs.text(query, max_results=max_results)) 178 | 179 | for r in search_results: 180 | url = r.get('href') 181 | title = r.get('title') 182 | content = r.get('body') 183 | 184 | if not all([url, title, content]): 185 | print(f"Warning: Incomplete result from DuckDuckGo: {r}") 186 | continue 187 | 188 | raw_content = content 189 | if fetch_full_page: 190 | raw_content = fetch_raw_content(url) 191 | 192 | # Add result to list 193 | result = { 194 | "title": title, 195 | "url": url, 196 | "content": content, 197 | "raw_content": raw_content 198 | } 199 | results.append(result) 200 | 201 | return {"results": results} 202 | except Exception as e: 203 | print(f"Error in DuckDuckGo search: {str(e)}") 204 | print(f"Full error details: {type(e).__name__}") 205 | return {"results": []} 206 | 207 | @traceable 208 | def searxng_search(query: str, max_results: int = 3, fetch_full_page: bool = False) -> Dict[str, List[Dict[str, Any]]]: 209 | """ 210 | Search the web using SearXNG and return formatted results. 211 | 212 | Uses the SearxSearchWrapper to perform searches through a SearXNG instance. 213 | The SearXNG host URL is read from the SEARXNG_URL environment variable 214 | or defaults to http://localhost:8888. 215 | 216 | Args: 217 | query (str): The search query to execute 218 | max_results (int, optional): Maximum number of results to return. Defaults to 3. 219 | fetch_full_page (bool, optional): Whether to fetch full page content from result URLs. 220 | Defaults to False. 221 | 222 | Returns: 223 | Dict[str, List[Dict[str, Any]]]: Search response containing: 224 | - results (list): List of search result dictionaries, each containing: 225 | - title (str): Title of the search result 226 | - url (str): URL of the search result 227 | - content (str): Snippet/summary of the content 228 | - raw_content (str or None): Full page content if fetch_full_page is True, 229 | otherwise same as content 230 | """ 231 | host=os.environ.get("SEARXNG_URL", "http://localhost:8888") 232 | s = SearxSearchWrapper(searx_host=host) 233 | 234 | results = [] 235 | search_results = s.results(query, num_results=max_results) 236 | for r in search_results: 237 | url = r.get('link') 238 | title = r.get('title') 239 | content = r.get('snippet') 240 | 241 | if not all([url, title, content]): 242 | print(f"Warning: Incomplete result from SearXNG: {r}") 243 | continue 244 | 245 | raw_content = content 246 | if fetch_full_page: 247 | raw_content = fetch_raw_content(url) 248 | 249 | # Add result to list 250 | result = { 251 | "title": title, 252 | "url": url, 253 | "content": content, 254 | "raw_content": raw_content 255 | } 256 | results.append(result) 257 | return {"results": results} 258 | 259 | @traceable 260 | def tavily_search(query: str, fetch_full_page: bool = True, max_results: int = 3) -> Dict[str, List[Dict[str, Any]]]: 261 | """ 262 | Search the web using the Tavily API and return formatted results. 263 | 264 | Uses the TavilyClient to perform searches. Tavily API key must be configured 265 | in the environment. 266 | 267 | Args: 268 | query (str): The search query to execute 269 | fetch_full_page (bool, optional): Whether to include raw content from sources. 270 | Defaults to True. 271 | max_results (int, optional): Maximum number of results to return. Defaults to 3. 272 | 273 | Returns: 274 | Dict[str, List[Dict[str, Any]]]: Search response containing: 275 | - results (list): List of search result dictionaries, each containing: 276 | - title (str): Title of the search result 277 | - url (str): URL of the search result 278 | - content (str): Snippet/summary of the content 279 | - raw_content (str or None): Full content of the page if available and 280 | fetch_full_page is True 281 | """ 282 | 283 | tavily_client = TavilyClient() 284 | return tavily_client.search(query, 285 | max_results=max_results, 286 | include_raw_content=fetch_full_page) 287 | 288 | @traceable 289 | def perplexity_search(query: str, perplexity_search_loop_count: int = 0) -> Dict[str, Any]: 290 | """ 291 | Search the web using the Perplexity API and return formatted results. 292 | 293 | Uses the Perplexity API to perform searches with the 'sonar-pro' model. 294 | Requires a PERPLEXITY_API_KEY environment variable to be set. 295 | 296 | Args: 297 | query (str): The search query to execute 298 | perplexity_search_loop_count (int, optional): The loop step for perplexity search 299 | (used for source labeling). Defaults to 0. 300 | 301 | Returns: 302 | Dict[str, Any]: Search response containing: 303 | - results (list): List of search result dictionaries, each containing: 304 | - title (str): Title of the search result (includes search counter) 305 | - url (str): URL of the citation source 306 | - content (str): Content of the response or reference to main content 307 | - raw_content (str or None): Full content for the first source, None for additional 308 | citation sources 309 | 310 | Raises: 311 | requests.exceptions.HTTPError: If the API request fails 312 | """ 313 | 314 | headers = { 315 | "accept": "application/json", 316 | "content-type": "application/json", 317 | "Authorization": f"Bearer {os.getenv('PERPLEXITY_API_KEY')}" 318 | } 319 | 320 | payload = { 321 | "model": "sonar-pro", 322 | "messages": [ 323 | { 324 | "role": "system", 325 | "content": "Search the web and provide factual information with sources." 326 | }, 327 | { 328 | "role": "user", 329 | "content": query 330 | } 331 | ] 332 | } 333 | 334 | response = requests.post( 335 | "https://api.perplexity.ai/chat/completions", 336 | headers=headers, 337 | json=payload 338 | ) 339 | response.raise_for_status() # Raise exception for bad status codes 340 | 341 | # Parse the response 342 | data = response.json() 343 | content = data["choices"][0]["message"]["content"] 344 | 345 | # Perplexity returns a list of citations for a single search result 346 | citations = data.get("citations", ["https://perplexity.ai"]) 347 | 348 | # Return first citation with full content, others just as references 349 | results = [{ 350 | "title": f"Perplexity Search {perplexity_search_loop_count + 1}, Source 1", 351 | "url": citations[0], 352 | "content": content, 353 | "raw_content": content 354 | }] 355 | 356 | # Add additional citations without duplicating content 357 | for i, citation in enumerate(citations[1:], start=2): 358 | results.append({ 359 | "title": f"Perplexity Search {perplexity_search_loop_count + 1}, Source {i}", 360 | "url": citation, 361 | "content": "See above for full content", 362 | "raw_content": None 363 | }) 364 | 365 | return {"results": results} --------------------------------------------------------------------------------