Repository: langchain-ai/local-deep-researcher Branch: main Commit: 36ac93b9f4c1 Files: 16 Total size: 62.0 KB Directory structure: gitextract_9htj25wl/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── claude-code-review.yml │ ├── claude.yml │ └── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" ================================================ FILE: .github/workflows/claude-code-review.yml ================================================ name: Claude Code Review on: pull_request: types: [opened, synchronize] # Optional: Only run on specific file changes # paths: # - "src/**/*.ts" # - "src/**/*.tsx" # - "src/**/*.js" # - "src/**/*.jsx" jobs: claude-review: if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) # model: "claude-opus-4-20250514" # Direct prompt for automated review (no @claude mention needed) direct_prompt: | Please review this pull request and provide feedback on: - Code quality and best practices - Potential bugs or issues - Performance considerations - Security concerns - Test coverage Be constructive and helpful in your feedback. # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR # use_sticky_comment: true # Optional: Customize review based on file types # direct_prompt: | # Review this PR focusing on: # - For TypeScript files: Type safety and proper interface usage # - For API endpoints: Security, input validation, and error handling # - For React components: Performance, accessibility, and best practices # - For tests: Coverage, edge cases, and test quality # Optional: Different prompts for different authors # direct_prompt: | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} # Optional: Add specific tools for running tests or linting # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" # Optional: Skip review for certain conditions # if: | # !contains(github.event.pull_request.title, '[skip-review]') && # !contains(github.event.pull_request.title, '[WIP]') ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) # model: "claude-opus-4-20250514" # Optional: Customize the trigger phrase (default: @claude) # trigger_phrase: "/claude" # Optional: Trigger when specific user is assigned to an issue # assignee_trigger: "claude-bot" # Optional: Allow Claude to run specific commands # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" # Optional: Add custom instructions for Claude to customize its behavior for your project # custom_instructions: | # Follow our coding standards # Ensure all new code has tests # Use TypeScript for new files # Optional: Custom environment variables for Claude # claude_env: | # NODE_ENV: test ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: Docker Image CI on: push: branches: [ "main" ] pull_request: branches: [ "main" ] permissions: contents: read env: # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io # github.repository as / IMAGE_NAME: ${{ github.repository }} jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 # Setup QEMU for multi-platform build support # https://docs.docker.com/build/ci/github-actions/multi-platform/ - name: Set up QEMU uses: docker/setup-qemu-action@v3 # Set up BuildKit Docker container builder to be able to build # multi-platform images and export cache # https://github.com/docker/setup-buildx-action - name: Setup Docker buildx uses: docker/setup-buildx-action@v3 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GH_TOKEN }} # Extract metadata (tags, labels) for Docker # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: Dockerfile ================================================ FROM --platform=$BUILDPLATFORM python:3.11-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ build-essential \ python3-dev \ libssl-dev \ libffi-dev \ rustc \ cargo \ && rm -rf /var/lib/apt/lists/* # Install uv package manager (use pip for safer cross-arch install) RUN pip install uv ENV PATH="/root/.local/bin:${PATH}" # 2) Copy the repository content COPY . /app # 3) Provide default environment variables to point to Ollama (running elsewhere) # Adjust the OLLAMA_URL to match your actual Ollama container or service. ENV OLLAMA_BASE_URL="http://localhost:11434/" # 4) Expose the port that LangGraph dev server uses (default: 2024) EXPOSE 2024 # 5) Launch the assistant with the LangGraph dev server: # Equivalent to the quickstart: uvx --refresh --from "langgraph-cli[inmem]" --with-editable . --python 3.11 langgraph dev CMD ["uvx", \ "--refresh", \ "--from", "langgraph-cli[inmem]", \ "--with-editable", ".", \ "--python", "3.11", \ "langgraph", \ "dev", \ "--host", "0.0.0.0"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Lance Martin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.MIT License ================================================ FILE: README.md ================================================ # Local Deep Researcher 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. ![ollama-deep-research](https://github.com/user-attachments/assets/1c6b28f8-6b64-42ba-a491-1ab2875d50ea) Short summary video: ## 🔥 Updates * 8/6/25: Added support for tool calling and [gpt-oss](https://openai.com/index/introducing-gpt-oss/). > ⚠️ **WARNING (8/6/25)**: The `gpt-oss` models do not support JSON mode in Ollama. Select `use_tool_calling` in the configuration to use tool calling instead of JSON mode. ## 📺 Video Tutorials See it in action or build it yourself? Check out these helpful video tutorials: - [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). - [Building Local Deep Researcher from Scratch](https://www.youtube.com/watch?v=XGuTzHoqlj8) - Overview of how this is built. ## 🚀 Quickstart Clone the repository: ```shell git clone https://github.com/langchain-ai/local-deep-researcher.git cd local-deep-researcher ``` 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). ```shell cp .env.example .env ``` ### Selecting local model with Ollama 1. Download the Ollama app for Mac [here](https://ollama.com/download). 2. Pull a local LLM from [Ollama](https://ollama.com/search). As an [example](https://ollama.com/library/deepseek-r1:8b): ```shell ollama pull deepseek-r1:8b ``` 3. Optionally, update the `.env` file with the following Ollama configuration settings. * If set, these values will take precedence over the defaults set in the `Configuration` class in `configuration.py`. ```shell LLM_PROVIDER=ollama OLLAMA_BASE_URL="http://localhost:11434" # Ollama service endpoint, defaults to `http://localhost:11434` LOCAL_LLM=model # the model to use, defaults to `llama3.2` if not set ``` ### Selecting local model with LMStudio 1. Download and install LMStudio from [here](https://lmstudio.ai/). 2. In LMStudio: - Download and load your preferred model (e.g., qwen_qwq-32b) - Go to the "Local Server" tab - Start the server with the OpenAI-compatible API - Note the server URL (default: http://localhost:1234/v1) 3. Optionally, update the `.env` file with the following LMStudio configuration settings. * If set, these values will take precedence over the defaults set in the `Configuration` class in `configuration.py`. ```shell LLM_PROVIDER=lmstudio LOCAL_LLM=qwen_qwq-32b # Use the exact model name as shown in LMStudio LMSTUDIO_BASE_URL=http://localhost:1234/v1 ``` ### Selecting search tool 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`. ```shell SEARCH_API=xxx # the search API to use, such as `duckduckgo` (default) TAVILY_API_KEY=xxx # the tavily API key to use PERPLEXITY_API_KEY=xxx # the perplexity API key to use MAX_WEB_RESEARCH_LOOPS=xxx # the maximum number of research loop steps, defaults to `3` FETCH_FULL_PAGE=xxx # fetch the full page content (with `duckduckgo`), defaults to `false` ``` ### Running with LangGraph Studio #### Mac 1. (Recommended) Create a virtual environment: ```bash python -m venv .venv source .venv/bin/activate ``` 2. Launch LangGraph server: ```bash # Install uv package manager curl -LsSf https://astral.sh/uv/install.sh | sh uvx --refresh --from "langgraph-cli[inmem]" --with-editable . --python 3.11 langgraph dev ``` #### Windows 1. (Recommended) Create a virtual environment: * Install `Python 3.11` (and add to PATH during installation). * Restart your terminal to ensure Python is available, then create and activate a virtual environment: ```powershell python -m venv .venv .venv\Scripts\Activate.ps1 ``` 2. Launch LangGraph server: ```powershell # Install dependencies pip install -e . pip install -U "langgraph-cli[inmem]" # Start the LangGraph server langgraph dev ``` ### Using the LangGraph Studio UI When you launch LangGraph server, you should see the following output and Studio will open in your browser: > Ready! > API: http://127.0.0.1:2024 > Docs: http://127.0.0.1:2024/docs > LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 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: ``` 1. Environment variables (highest priority) 2. LangGraph UI configuration 3. Default values in the Configuration class (lowest priority) ``` Screenshot 2025-01-24 at 10 08 31 PM Give the assistant a topic for research, and you can visualize its process! Screenshot 2025-01-24 at 10 08 22 PM ### Model Compatibility Note 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. ### Browser Compatibility Note When accessing the LangGraph Studio UI: - Firefox is recommended for the best experience - Safari users may encounter security warnings due to mixed content (HTTPS/HTTP) - If you encounter issues, try: 1. Using Firefox or another browser 2. Disabling ad-blocking extensions 3. Checking browser console for specific error messages ## How it works 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: - 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 - Uses a search engine / tool to find relevant sources - Uses LLM to summarize the findings from web search related to the user-provided research topic - Then, it uses the LLM to reflect on the summary, identifying knowledge gaps - It generates a new search query to address the knowledge gaps - The process repeats, with the summary being iteratively updated with new information from web search - Runs for a configurable number of iterations (see `configuration` tab) ## Outputs 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: ![Screenshot 2024-12-05 at 4 08 59 PM](https://github.com/user-attachments/assets/e8ac1c0b-9acb-4a75-8c15-4e677e92f6cb) The final summary is saved to the graph state as well: ![Screenshot 2024-12-05 at 4 10 11 PM](https://github.com/user-attachments/assets/f6d997d5-9de5-495f-8556-7d3891f6bc96) ## Deployment Options 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. ## TypeScript Implementation A TypeScript port of this project (without Perplexity search) is available at: https://github.com/PacoVK/ollama-deep-researcher-ts ## Running as a Docker container 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. Clone the repo and build an image: ``` $ docker build -t local-deep-researcher . ``` Run the container: ``` $ docker run --rm -it -p 2024:2024 \ -e SEARCH_API="tavily" \ -e TAVILY_API_KEY="tvly-***YOUR_KEY_HERE***" \ -e LLM_PROVIDER=ollama \ -e OLLAMA_BASE_URL="http://host.docker.internal:11434/" \ -e LOCAL_LLM="llama3.2" \ local-deep-researcher ``` NOTE: You will see log message: ``` 2025-02-10T13:45:04.784915Z [info ] 🎨 Opening Studio in your browser... [browser_opener] api_variant=local_dev message=🎨 Opening Studio in your browser... URL: https://smith.langchain.com/studio/?baseUrl=http://0.0.0.0:2024 ``` ...but the browser will not launch from the container. 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) ================================================ FILE: langgraph.json ================================================ { "dockerfile_lines": [], "graphs": { "ollama_deep_researcher": "./src/ollama_deep_researcher/graph.py:graph" }, "python_version": "3.11", "env": "./.env", "dependencies": [ "." ] } ================================================ FILE: pyproject.toml ================================================ [project] name = "ollama-deep-researcher" version = "0.0.1" description = "Fully local web research and summarization assistant with Ollama and LangGraph." authors = [ { name = "Lance Martin" } ] readme = "README.md" license = { text = "MIT" } requires-python = ">=3.9" dependencies = [ "langgraph>=0.2.55", "langchain-community>=0.3.9", "tavily-python>=0.5.0", "langchain-ollama>=0.3.6", "duckduckgo-search>=7.3.0", "langchain-openai>=0.1.1", "openai>=1.12.0", "langchain_openai>=0.3.9", "httpx>=0.28.1", "markdownify>=0.11.0", "python-dotenv==1.2.1", ] [project.optional-dependencies] dev = ["mypy>=1.11.1", "ruff>=0.6.1"] [build-system] requires = ["setuptools>=73.0.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["ollama_deep_researcher"] [tool.setuptools.package-dir] "ollama_deep_researcher" = "src/ollama_deep_researcher" [tool.setuptools.package-data] "*" = ["py.typed"] [tool.ruff] lint.select = [ "E", # pycodestyle "F", # pyflakes "I", # isort "D", # pydocstyle "D401", # First line should be in imperative mood "T201", "UP", ] lint.ignore = [ "UP006", "UP007", "UP035", "D417", "E501", ] [tool.ruff.lint.per-file-ignores] "tests/*" = ["D", "UP"] [tool.ruff.lint.pydocstyle] convention = "google" [dependency-groups] dev = [ "ruff>=0.12.7", ] ================================================ FILE: src/ollama_deep_researcher/__init__.py ================================================ version = "0.0.1" ================================================ FILE: src/ollama_deep_researcher/configuration.py ================================================ import os from enum import Enum from pydantic import BaseModel, Field from typing import Any, Optional, Literal from langchain_core.runnables import RunnableConfig class SearchAPI(Enum): PERPLEXITY = "perplexity" TAVILY = "tavily" DUCKDUCKGO = "duckduckgo" SEARXNG = "searxng" class Configuration(BaseModel): """The configurable fields for the research assistant.""" max_web_research_loops: int = Field( default=3, title="Research Depth", description="Number of research iterations to perform", ) local_llm: str = Field( default="llama3.2", title="LLM Model Name", description="Name of the LLM model to use", ) llm_provider: Literal["ollama", "lmstudio"] = Field( default="ollama", title="LLM Provider", description="Provider for the LLM (Ollama or LMStudio)", ) search_api: Literal["perplexity", "tavily", "duckduckgo", "searxng"] = Field( default="duckduckgo", title="Search API", description="Web search API to use" ) fetch_full_page: bool = Field( default=True, title="Fetch Full Page", description="Include the full page content in the search results", ) ollama_base_url: str = Field( default="http://localhost:11434/", title="Ollama Base URL", description="Base URL for Ollama API", ) lmstudio_base_url: str = Field( default="http://localhost:1234/v1", title="LMStudio Base URL", description="Base URL for LMStudio OpenAI-compatible API", ) strip_thinking_tokens: bool = Field( default=True, title="Strip Thinking Tokens", description="Whether to strip tokens from model responses", ) use_tool_calling: bool = Field( default=False, title="Use Tool Calling", description="Use tool calling instead of JSON mode for structured output", ) @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 {} ) # Get raw values from environment or config raw_values: dict[str, Any] = { name: os.environ.get(name.upper(), configurable.get(name)) for name in cls.model_fields.keys() } # Filter out None values values = {k: v for k, v in raw_values.items() if v is not None} return cls(**values) ================================================ FILE: src/ollama_deep_researcher/graph.py ================================================ import json from pydantic import BaseModel, Field from typing_extensions import Literal from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from langchain_core.tools import tool from langchain_ollama import ChatOllama from langgraph.graph import START, END, StateGraph from ollama_deep_researcher.configuration import Configuration, SearchAPI 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, ) from ollama_deep_researcher.state import ( SummaryState, SummaryStateInput, SummaryStateOutput, ) from ollama_deep_researcher.prompts import ( query_writer_instructions, summarizer_instructions, reflection_instructions, get_current_date, json_mode_query_instructions, tool_calling_query_instructions, json_mode_reflection_instructions, tool_calling_reflection_instructions, ) from ollama_deep_researcher.lmstudio import ChatLMStudio # Constants MAX_TOKENS_PER_SOURCE = 1000 CHARS_PER_TOKEN = 4 def generate_search_query_with_structured_output( configurable: Configuration, messages: list, tool_class, fallback_query: str, tool_query_field: str, json_query_field: str, ): """Helper function to generate search queries using either tool calling or JSON mode. Args: configurable: Configuration object messages: List of messages to send to LLM tool_class: Tool class for tool calling mode fallback_query: Fallback search query if extraction fails tool_query_field: Field name in tool args containing the query json_query_field: Field name in JSON response containing the query Returns: Dictionary with "search_query" key """ if configurable.use_tool_calling: llm = get_llm(configurable).bind_tools([tool_class]) result = llm.invoke(messages) if not result.tool_calls: return {"search_query": fallback_query} try: tool_data = result.tool_calls[0]["args"] search_query = tool_data.get(tool_query_field) return {"search_query": search_query} except (IndexError, KeyError): return {"search_query": fallback_query} else: # Use JSON mode llm = get_llm(configurable) result = llm.invoke(messages) print(f"result: {result}") content = result.content try: parsed_json = json.loads(content) search_query = parsed_json.get(json_query_field) if not search_query: return {"search_query": fallback_query} return {"search_query": search_query} except (json.JSONDecodeError, KeyError): if configurable.strip_thinking_tokens: content = strip_thinking_tokens(content) return {"search_query": fallback_query} def get_llm(configurable: Configuration): """Helper function to initialize LLM based on configuration. Uses JSON mode if use_tool_calling is False, otherwise regular mode for tool calling. Args: configurable: Configuration object containing LLM settings Returns: Configured LLM instance """ if configurable.llm_provider == "lmstudio": if configurable.use_tool_calling: return ChatLMStudio( base_url=configurable.lmstudio_base_url, model=configurable.local_llm, temperature=0, ) else: return ChatLMStudio( base_url=configurable.lmstudio_base_url, model=configurable.local_llm, temperature=0, format="json", ) else: # Default to Ollama if configurable.use_tool_calling: return ChatOllama( base_url=configurable.ollama_base_url, model=configurable.local_llm, temperature=0, ) else: return ChatOllama( base_url=configurable.ollama_base_url, model=configurable.local_llm, temperature=0, format="json", ) # Nodes def generate_query(state: SummaryState, config: RunnableConfig): """LangGraph node that generates a search query based on the research topic. Uses an LLM to create an optimized search query for web research based on the user's research topic. Supports both LMStudio and Ollama as LLM providers. Args: state: Current graph state containing the research topic config: Configuration for the runnable, including LLM provider settings Returns: Dictionary with state update, including search_query key containing the generated query """ # Format the prompt current_date = get_current_date() formatted_prompt = query_writer_instructions.format( current_date=current_date, research_topic=state.research_topic ) # Generate a query configurable = Configuration.from_runnable_config(config) @tool class Query(BaseModel): """ This tool is used to generate a query for web search. """ query: str = Field(description="The actual search query string") rationale: str = Field( description="Brief explanation of why this query is relevant" ) messages = [ SystemMessage( content=formatted_prompt + ( tool_calling_query_instructions if configurable.use_tool_calling else json_mode_query_instructions ) ), HumanMessage(content="Generate a query for web search:"), ] return generate_search_query_with_structured_output( configurable=configurable, messages=messages, tool_class=Query, fallback_query=f"Tell me more about {state.research_topic}", tool_query_field="query", json_query_field="query", ) def web_research(state: SummaryState, config: RunnableConfig): """LangGraph node that performs web research using the generated search query. Executes a web search using the configured search API (tavily, perplexity, duckduckgo, or searxng) and formats the results for further processing. Args: state: Current graph state containing the search query and research loop count config: Configuration for the runnable, including search API settings Returns: Dictionary with state update, including sources_gathered, research_loop_count, and web_research_results """ # Configure configurable = Configuration.from_runnable_config(config) # Get the search API search_api = get_config_value(configurable.search_api) # Search the web if search_api == "tavily": search_results = tavily_search( state.search_query, fetch_full_page=configurable.fetch_full_page, max_results=1, ) search_str = deduplicate_and_format_sources( search_results, max_tokens_per_source=MAX_TOKENS_PER_SOURCE, fetch_full_page=configurable.fetch_full_page, ) 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=MAX_TOKENS_PER_SOURCE, fetch_full_page=configurable.fetch_full_page, ) 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=MAX_TOKENS_PER_SOURCE, fetch_full_page=configurable.fetch_full_page, ) elif search_api == "searxng": search_results = searxng_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=MAX_TOKENS_PER_SOURCE, fetch_full_page=configurable.fetch_full_page, ) 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): """LangGraph node that summarizes web research results. Uses an LLM to create or update a running summary based on the newest web research results, integrating them with any existing summary. Args: state: Current graph state containing research topic, running summary, and web research results config: Configuration for the runnable, including LLM provider settings Returns: Dictionary with state update, including running_summary key containing the updated summary """ # 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" \n {existing_summary} \n \n\n" f" \n {most_recent_web_research} \n " f"Update the Existing Summary with the New Context on this topic: \n \n {state.research_topic} \n \n\n" ) else: human_message_content = ( f" \n {most_recent_web_research} \n " f"Create a Summary using the Context on this topic: \n \n {state.research_topic} \n \n\n" ) # Run the LLM configurable = Configuration.from_runnable_config(config) # For summarization, we don't need structured output, so always use regular mode if configurable.llm_provider == "lmstudio": llm = ChatLMStudio( base_url=configurable.lmstudio_base_url, model=configurable.local_llm, temperature=0, ) else: # Default to Ollama 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), ] ) # Strip thinking tokens if configured running_summary = result.content if configurable.strip_thinking_tokens: running_summary = strip_thinking_tokens(running_summary) return {"running_summary": running_summary} def reflect_on_summary(state: SummaryState, config: RunnableConfig): """LangGraph node that identifies knowledge gaps and generates follow-up queries. Analyzes the current summary to identify areas for further research and generates a new search query to address those gaps. Uses structured output to extract the follow-up query in JSON format. Args: state: Current graph state containing the running summary and research topic config: Configuration for the runnable, including LLM provider settings Returns: Dictionary with state update, including search_query key containing the generated follow-up query """ # Generate a query configurable = Configuration.from_runnable_config(config) formatted_prompt = reflection_instructions.format( research_topic=state.research_topic ) @tool class FollowUpQuery(BaseModel): """ This tool is used to generate a follow-up query to address a knowledge gap. """ follow_up_query: str = Field( description="Write a specific question to address this gap" ) knowledge_gap: str = Field( description="Describe what information is missing or needs clarification" ) messages = [ SystemMessage( content=formatted_prompt + ( tool_calling_reflection_instructions if configurable.use_tool_calling else json_mode_reflection_instructions ) ), 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:" ), ] return generate_search_query_with_structured_output( configurable=configurable, messages=messages, tool_class=FollowUpQuery, fallback_query=f"Tell me more about {state.research_topic}", tool_query_field="follow_up_query", json_query_field="follow_up_query", ) def finalize_summary(state: SummaryState): """LangGraph node that finalizes the research summary. Prepares the final output by deduplicating and formatting sources, then combining them with the running summary to create a well-structured research report with proper citations. Args: state: Current graph state containing the running summary and sources gathered Returns: Dictionary with state update, including running_summary key containing the formatted final summary with sources """ # Deduplicate sources before joining seen_sources = set() unique_sources = [] for source in state.sources_gathered: # Split the source into lines and process each individually for line in source.split("\n"): # Only process non-empty lines if line.strip() and line not in seen_sources: seen_sources.add(line) unique_sources.append(line) # Join the deduplicated sources all_sources = "\n".join(unique_sources) state.running_summary = ( f"## Summary\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"]: """LangGraph routing function that determines the next step in the research flow. Controls the research loop by deciding whether to continue gathering information or to finalize the summary based on the configured maximum number of research loops. Args: state: Current graph state containing the research loop count config: Configuration for the runnable, including max_web_research_loops setting Returns: String literal indicating the next node to visit ("web_research" or "finalize_summary") """ configurable = Configuration.from_runnable_config(config) if state.research_loop_count <= 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() ================================================ FILE: src/ollama_deep_researcher/lmstudio.py ================================================ """LMStudio integration for the research assistant.""" import json import logging from typing import Any, List, Optional from langchain_core.callbacks.manager import CallbackManagerForLLMRun from langchain_core.messages import ( BaseMessage, ) from langchain_core.outputs import ChatResult from langchain_openai import ChatOpenAI from pydantic import Field # Set up logging logger = logging.getLogger(__name__) class ChatLMStudio(ChatOpenAI): """Chat model that uses LMStudio's OpenAI-compatible API.""" format: Optional[str] = Field( default=None, description="Format for the response (e.g., 'json')" ) def __init__( self, base_url: str = "http://localhost:1234/v1", model: str = "qwen_qwq-32b", temperature: float = 0.7, format: Optional[str] = None, api_key: str = "not-needed-for-local-models", **kwargs: Any, ): """Initialize the ChatLMStudio. Args: base_url: Base URL for LMStudio's OpenAI-compatible API model: Model name to use temperature: Temperature for sampling format: Format for the response (e.g., "json") api_key: API key (not actually used, but required by OpenAI client) **kwargs: Additional arguments to pass to the OpenAI client """ # Initialize the base class super().__init__( base_url=base_url, model=model, temperature=temperature, api_key=api_key, **kwargs, ) self.format = format def _generate( self, messages: List[BaseMessage], stop: Optional[List[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any, ) -> ChatResult: """Generate a chat response using LMStudio's OpenAI-compatible API.""" if self.format == "json": # Set response_format for JSON mode kwargs["response_format"] = {"type": "json_object"} logger.info(f"Using response_format={kwargs['response_format']}") # Call the parent class's _generate method result = super()._generate(messages, stop, run_manager, **kwargs) # If JSON format is requested, try to clean up the response if self.format == "json" and result.generations: try: # Get the raw text raw_text = result.generations[0][0].text logger.info(f"Raw model response: {raw_text}") # Try to find JSON in the response json_start = raw_text.find("{") json_end = raw_text.rfind("}") + 1 if json_start >= 0 and json_end > json_start: # Extract just the JSON part json_text = raw_text[json_start:json_end] # Validate it's proper JSON json.loads(json_text) logger.info(f"Cleaned JSON: {json_text}") # Update the generation with the cleaned JSON result.generations[0][0].text = json_text else: logger.warning("Could not find JSON in response") except Exception as e: logger.error(f"Error processing JSON response: {str(e)}") # If any error occurs during cleanup, just use the original response pass return result ================================================ FILE: src/ollama_deep_researcher/prompts.py ================================================ 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. Current date: {current_date} Please ensure your queries account for the most current information available as of this date. {research_topic} Example output: {{ "query": "machine learning transformer architecture explained", "rationale": "Understanding the fundamental structure of transformer models" }} """ json_mode_query_instructions = """ 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 Provide your response in JSON format:""" tool_calling_query_instructions = """ Call the Query tool to format your response with the following keys: - "query": The actual search query string - "rationale": Brief explanation of why this query is relevant Call the Query Tool to generate a query for this request:""" summarizer_instructions = """ Generate a high-quality summary of the provided context. 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 > Think carefully about the provided Context first. Then generate a summary of the context to address the User Input. """ reflection_instructions = """You are an expert research assistant analyzing a summary about {research_topic}. 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 Ensure the follow-up question is self-contained and includes necessary context for web search. """ json_mode_reflection_instructions = """ 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 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]?" }} Provide your analysis in JSON format:""" tool_calling_reflection_instructions = """ Call the FollowUpQuery tool to format your response with the following keys: - follow_up_query: Write a specific question to address this gap - knowledge_gap: Describe what information is missing or needs clarification Reflect carefully on the Summary to identify knowledge gaps and produce a follow-up query. Call the FollowUpQuery Tool to generate a reflection for this request:""" ================================================ FILE: src/ollama_deep_researcher/state.py ================================================ import operator from dataclasses import dataclass, field from typing_extensions import 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 ================================================ FILE: src/ollama_deep_researcher/utils.py ================================================ import os import httpx import requests from typing import Dict, Any, List, Union, Optional from markdownify import markdownify from langsmith import traceable from tavily import TavilyClient from duckduckgo_search import DDGS from langchain_community.utilities import SearxSearchWrapper # Constants CHARS_PER_TOKEN = 4 def get_config_value(value: Any) -> str: """ Convert configuration values to string format, handling both string and enum types. Args: value (Any): The configuration value to process. Can be a string or an Enum. Returns: str: The string representation of the value. Examples: >>> get_config_value("tavily") 'tavily' >>> get_config_value(SearchAPI.TAVILY) 'tavily' """ return value if isinstance(value, str) else value.value def strip_thinking_tokens(text: str) -> str: """ Remove and tags and their content from the text. Iteratively removes all occurrences of content enclosed in thinking tokens. Args: text (str): The text to process Returns: str: The text with thinking tokens and their content removed """ while "" in text and "" in text: start = text.find("") end = text.find("") + len("") text = text[:start] + text[end:] return text def deduplicate_and_format_sources( search_response: Union[Dict[str, Any], List[Dict[str, Any]]], max_tokens_per_source: int, fetch_full_page: bool = False, ) -> str: """ Format and deduplicate search responses from various search APIs. Takes either a single search response or list of responses from search APIs, deduplicates them by URL, and formats them into a structured string. Args: search_response (Union[Dict[str, Any], List[Dict[str, Any]]]): Either: - A dict with a 'results' key containing a list of search results - A list of dicts, each containing search results max_tokens_per_source (int): Maximum number of tokens to include for each source's content fetch_full_page (bool, optional): Whether to include the full page content. Defaults to False. Returns: str: Formatted string with deduplicated sources Raises: ValueError: If input is neither a dict with 'results' key nor a list of search results """ # 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 fetch_full_page: # Using rough estimate of characters per token char_limit = max_tokens_per_source * CHARS_PER_TOKEN # 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: Dict[str, Any]) -> str: """ Format search results into a bullet-point list of sources with URLs. Creates a simple bulleted list of search results with title and URL for each source. Args: search_results (Dict[str, Any]): Search response containing a 'results' key with a list of search result objects Returns: str: Formatted string with sources as bullet points in the format "* title : url" """ return "\n".join( f"* {source['title']} : {source['url']}" for source in search_results["results"] ) def fetch_raw_content(url: str) -> Optional[str]: """ Fetch HTML content from a URL and convert it to markdown format. Uses a 10-second timeout to avoid hanging on slow sites or large pages. Args: url (str): The URL to fetch content from Returns: Optional[str]: The fetched content converted to markdown if successful, None if any error occurs during fetching or conversion """ try: # Create a client with reasonable timeout with httpx.Client(timeout=10.0) as client: response = client.get(url) response.raise_for_status() return markdownify(response.text) except Exception as e: print(f"Warning: Failed to fetch full page content for {url}: {str(e)}") return None @traceable def duckduckgo_search( query: str, max_results: int = 3, fetch_full_page: bool = False ) -> Dict[str, List[Dict[str, Any]]]: """ Search the web using DuckDuckGo and return formatted results. Uses the DDGS library to perform web searches through DuckDuckGo. Args: query (str): The search query to execute max_results (int, optional): Maximum number of results to return. Defaults to 3. fetch_full_page (bool, optional): Whether to fetch full page content from result URLs. Defaults to False. Returns: Dict[str, List[Dict[str, Any]]]: 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 or None): Full page content if fetch_full_page is True, otherwise same as 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: raw_content = fetch_raw_content(url) # 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 searxng_search( query: str, max_results: int = 3, fetch_full_page: bool = False ) -> Dict[str, List[Dict[str, Any]]]: """ Search the web using SearXNG and return formatted results. Uses the SearxSearchWrapper to perform searches through a SearXNG instance. The SearXNG host URL is read from the SEARXNG_URL environment variable or defaults to http://localhost:8888. Args: query (str): The search query to execute max_results (int, optional): Maximum number of results to return. Defaults to 3. fetch_full_page (bool, optional): Whether to fetch full page content from result URLs. Defaults to False. Returns: Dict[str, List[Dict[str, Any]]]: 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 or None): Full page content if fetch_full_page is True, otherwise same as content """ host = os.environ.get("SEARXNG_URL", "http://localhost:8888") s = SearxSearchWrapper(searx_host=host) results = [] search_results = s.results(query, num_results=max_results) for r in search_results: url = r.get("link") title = r.get("title") content = r.get("snippet") if not all([url, title, content]): print(f"Warning: Incomplete result from SearXNG: {r}") continue raw_content = content if fetch_full_page: raw_content = fetch_raw_content(url) # Add result to list result = { "title": title, "url": url, "content": content, "raw_content": raw_content, } results.append(result) return {"results": results} @traceable def tavily_search( query: str, fetch_full_page: bool = True, max_results: int = 3 ) -> Dict[str, List[Dict[str, Any]]]: """ Search the web using the Tavily API and return formatted results. Uses the TavilyClient to perform searches. Tavily API key must be configured in the environment. Args: query (str): The search query to execute fetch_full_page (bool, optional): Whether to include raw content from sources. Defaults to True. max_results (int, optional): Maximum number of results to return. Defaults to 3. Returns: Dict[str, List[Dict[str, Any]]]: 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 or None): Full content of the page if available and fetch_full_page is True """ tavily_client = TavilyClient() return tavily_client.search( query, max_results=max_results, include_raw_content=fetch_full_page ) @traceable def perplexity_search( query: str, perplexity_search_loop_count: int = 0 ) -> Dict[str, Any]: """ Search the web using the Perplexity API and return formatted results. Uses the Perplexity API to perform searches with the 'sonar-pro' model. Requires a PERPLEXITY_API_KEY environment variable to be set. Args: query (str): The search query to execute perplexity_search_loop_count (int, optional): The loop step for perplexity search (used for source labeling). Defaults to 0. Returns: Dict[str, Any]: Search response containing: - results (list): List of search result dictionaries, each containing: - title (str): Title of the search result (includes search counter) - url (str): URL of the citation source - content (str): Content of the response or reference to main content - raw_content (str or None): Full content for the first source, None for additional citation sources Raises: requests.exceptions.HTTPError: If the API request fails """ 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}