Building a Research Assistant with Web Search

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

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.out 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 Mem 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
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.