Building a Research Assistant with Web Search

Ma Mahalakshmi V Updated 16 Sep 2026
8 min read ·Lesson 70 of 224

What Distinguishes a Research Assistant from a Simple Q&A Call

Everything so far in this unit has worked with single-turn requests: one question in, one grounded answer out. A research assistant is a different shape of application. It needs to handle a research question — something broader than a single fact lookup — by potentially issuing multiple searches, keeping track of what it has found, and producing a structured summary with attribution, rather than a single paragraph.

This lesson builds a small but complete research assistant that wraps the patterns from Lessons 2 through 4 — search configuration, response inspection, and citation extraction — into a reusable class. The goal is not to build a massive framework, but to show how the pieces you already have combine into something a real application could be built around.

Designing the Assistant's Responsibilities

Before writing code, it helps to be explicit about what this assistant needs to do, because vague scope is how small utilities become unmaintainable:

  1. Accept a research question from the caller.
  2. Issue a request to the Responses API with web search enabled, using a prompt that encourages thorough, multi-source research rather than a single quick answer.
  3. Extract both the synthesized answer and the list of sources used.
  4. Return both together as a single structured result, so the caller never has to choose between "the answer" and "the sources" — they get both, always paired.

Deliberately excluded from this first version: automatic follow-up questions, conversation memory across multiple research sessions, and result caching. Those are reasonable extensions, but adding them now would obscure the core pattern this lesson is teaching. Start narrow, and expand once the basic shape is solid — a principle that applies to almost any piece of application code, not just this one.

Building the Assistant

from dataclasses import dataclass, field
from openai import OpenAI


@dataclass
class ResearchResult:
    question: str
    answer: str
    sources: list[dict] = field(default_factory=list)


class ResearchAssistant:
    def __init__(self, client: OpenAI, model: str = "gpt-5.6-terra"):
        self.client = client
        self.model = model

    def research(self, question: str) -> ResearchResult:
        prompt = (
            "You are conducting careful research to answer the following question. "
            "Search the web as needed, consult more than one source when the topic "
            "is not settled, and clearly state when sources disagree. "
            f"Question: {question}"
        )

        response = self.client.responses.create(
            model=self.model,
            tools=[{"type": "web_search"}],
            input=prompt,
        )

        return ResearchResult(
            question=question,
            answer=response.output_text,
            sources=self._extract_sources(response),
        )

    @staticmethod
    def _extract_sources(response) -> list[dict]:
        seen_urls = set()
        sources = []
        for item in response.output:
            if item.type != "message":
                continue
            for content_block in item.content:
                for ann in getattr(content_block, "annotations", []) or []:
                    url = getattr(ann, "url", None)
                    if not url or url in seen_urls:
                        continue
                    seen_urls.add(url)
                    sources.append({
                        "url": url,
                        "title": getattr(ann, "title", url),
                    })
        return sources


client = OpenAI()
assistant = ResearchAssistant(client)

result = assistant.research(
    "What are the current leading approaches to grid-scale energy storage, "
    "and what tradeoffs do they involve?"
)

print(result.answer)
print()
for source in result.sources:
    print(f"- {source['title']}: {source['url']}")

Several design choices here are worth calling out explicitly, since each reflects a lesson from earlier in this unit rather than an arbitrary style preference.

The ResearchResult dataclass exists so that a question, its answer, and its sources always travel together as one object. This matters because it is easy, without this structure, to accidentally pass an answer around without its sources — for example, logging just response.output_text somewhere and losing the attribution trail discussed in Lesson 4. Using field(default_factory=list) for the sources field, rather than a plain sources: list = [], avoids a classic Python bug: a mutable default argument (or dataclass field) shared across every instance if written incorrectly. default_factory ensures each ResearchResult gets its own fresh list.

The ResearchAssistant class takes the OpenAI client as a constructor argument rather than creating one internally. This is a deliberate dependency-injection pattern: it means the assistant does not need real API credentials to be constructed for a unit test, since a test can pass in a fake object standing in for the client, as shown in the testing example below.

The prompt constructed inside research() explicitly asks the model to consult multiple sources and state disagreement — this connects directly to Lesson 3's point that prompt wording is one of your main levers for shaping search depth, and it previews Lesson 7's discussion of conflicting sources.

_extract_sources is the same de-duplication logic from Lesson 4, now living as a static method on the class instead of a free function, since it is conceptually part of how this assistant processes its own responses rather than a general-purpose utility used elsewhere.

Testing the Assistant Without Calling the API

Because ResearchAssistant takes its client as a constructor parameter, you can test its behavior — prompt construction, source extraction, result packaging — without ever making a network call, by substituting a fake client that returns a pre-built fake response.

class FakeAnnotation:
    def __init__(self, url, title=None):
        self.url = url
        self.title = title


class FakeContentBlock:
    def __init__(self, text, annotations=None):
        self.text = text
        self.annotations = annotations or []


class FakeMessageItem:
    def __init__(self, content):
        self.type = "message"
        self.content = content


class FakeResponse:
    def __init__(self, output_text, output):
        self.output_text = output_text
        self.output = output


class FakeResponsesAPI:
    def __init__(self, fake_response):
        self._fake_response = fake_response
        self.last_kwargs = None

    def create(self, **kwargs):
        self.last_kwargs = kwargs
        return self._fake_response


class FakeClient:
    def __init__(self, fake_response):
        self.responses = FakeResponsesAPI(fake_response)


def test_research_assistant_returns_answer_and_sources():
    fake_response = FakeResponse(
        output_text="Battery storage and pumped hydro are the two leading approaches.",
        output=[
            FakeMessageItem(content=[
                FakeContentBlock(
                    text="Battery storage and pumped hydro are the two leading approaches.",
                    annotations=[FakeAnnotation(url="https://example.org/storage", title="Grid Storage Overview")],
                ),
            ]),
        ],
    )
    fake_client = FakeClient(fake_response)
    assistant = ResearchAssistant(fake_client, model="gpt-5.6-terra")

    result = assistant.research("What are the leading grid storage approaches?")

    assert result.question == "What are the leading grid storage approaches?"
    assert "pumped hydro" in result.answer
    assert len(result.sources) == 1
    assert result.sources[0]["url"] == "https://example.org/storage"

    sent_kwargs = fake_client.responses.last_kwargs
    assert sent_kwargs["model"] == "gpt-5.6-terra"
    assert sent_kwargs["tools"] == [{"type": "web_search"}]
    assert "leading grid storage approaches" in sent_kwargs["input"]

    print("PASS: ResearchAssistant returns paired answer and sources, and calls the API correctly")


test_research_assistant_returns_answer_and_sources()

The FakeClient and FakeResponsesAPI classes mimic just enough of the real OpenAI client's shape — specifically, a .responses.create(**kwargs) call — for ResearchAssistant to run against them unmodified. FakeResponsesAPI also records the keyword arguments it was called with, in self.last_kwargs, which lets the test verify not just the output of research() but also what was actually sent to the (fake) API — confirming the right model, the right tool configuration, and that the question text was correctly embedded in the prompt. This is a more thorough test than only checking the return value, because it catches bugs in prompt construction that would otherwise only surface as a mysterious behavior change when running against the real API.

Note that none of this test file makes a real network request. Every object is a plain Python class built specifically to stand in for the real SDK's shape. This is fast, free, deterministic, and — critically — will not silently break your test suite just because the live web happened to return different search results on a given day.

Extending the Assistant

A few natural extensions, left as directions rather than full implementations, since the goal here is to establish the pattern:

  • Structured output combined with search, so the assistant returns a typed object (a list of findings, each with its own confidence level) rather than free text — covered fully in Lesson 6.
  • Conflict detection, where the assistant explicitly flags when its own sources disagree, rather than silently picking one — covered in Lesson 7.
  • A minimum source count requirement, rejecting an answer if fewer than a configured number of distinct sources were found, useful for research assistants where a single-source answer is considered insufficient for the application's standards.

Common Mistakes

Building the assistant around a single hardcoded question format, which causes it to fail or produce a poor prompt for research questions with a different shape than the one first tested. Keep the prompt template a well-tested part of the class, and consider adding parameters for tone or depth as real usage patterns emerge, rather than guessing all the variations upfront.

Constructing the OpenAI client inside the assistant class itself, which causes the class to require real API credentials to test at all. Pass the client in through the constructor, as shown here, so a fake client can be substituted for tests.

Losing the pairing between an answer and its sources by returning them separately, which causes downstream code to eventually display an answer without its supporting citations, or mismatch an answer with the wrong source list after some refactor. Package them together in a single result object like ResearchResult.

Best Practices

Model your application's tool calls behind a small class with an injectable client, exactly as ResearchAssistant does here, so unit tests never need real network access or API keys.

Always test both the returned value and the arguments sent to the (fake) API, since a passing test on output alone can hide a bug in prompt construction that a real user would notice immediately.

Keep the first version of any assistant narrow in scope. It is much easier to add conflict detection, structured output, or caching to a small, well-understood class than to untangle those concerns from a single sprawling function written to do everything at once.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Building a Research Assistant with Web Search and get answers drawn from it.

Signed-in readers only.