Building a Research Assistant with Web Search
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:
- Accept a research question from the caller.
- 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.
- Extract both the synthesized answer and the list of sources used.
- 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.