Project: A Research Assistant
What This Project Builds
This project combines every built-in tool from this unit into a single research assistant: given a research question, it searches the live web for current information (Lesson 1), searches a curated internal document collection for relevant background material (Lesson 2), and, when the question requires it, performs actual calculations or data analysis over any numeric information it gathers (Lesson 3) — synthesizing all of it into one structured, well-cited answer. This is meant as a capstone exercise for the unit: rather than exercising each built-in tool in isolation, it shows how they combine within a single coherent feature, with the model itself deciding which combination of tools a given question actually needs.
Step 1: Setting Up the Vector Store
Following Lesson 2's pattern, the assistant's internal knowledge base is a vector store populated ahead of time — in this project, a small collection of background research notes and internal reports the assistant should draw on alongside live web results.
research_notes_store = client.vector_stores.create(name="Research Notes")
for filename in ["market_analysis_2025.pdf", "prior_research_summary.pdf", "internal_benchmarks.pdf"]:
with open(filename, "rb") as f:
client.vector_stores.files.upload(vector_store_id=research_notes_store.id, file=f)
This is a one-time setup step, run whenever the underlying document collection changes, entirely separate from any individual research query — exactly the separation of concerns Lesson 2 established between indexing (done once, ahead of time) and querying (done per request, drawing on whatever is currently indexed).
Step 2: Defining the Combined Toolset
research_tools = [
{"type": "web_search"},
{"type": "file_search", "vector_store_ids": [research_notes_store.id]},
{"type": "code_interpreter", "container": {"type": "auto"}},
]
All three built-in tools are registered together, letting the model choose per-question, and even per sub-question within a single research request, which combination is relevant — a question purely about current events might only trigger web search, a question about internal historical context might only trigger file search, and a question requiring a comparison or calculation across data gathered from either source might additionally trigger Code Interpreter. Nothing about this registration forces any particular tool to be used; as Lesson 1 and Lesson 3 both emphasized, the model uses each tool only when the specific question actually calls for it.
Step 3: The Response Schema
Following this course's now-familiar structured-output pattern (Unit 6, and the tiered-confidence design Unit 7, Lesson 5 and Unit 9, Lesson 2 both used), the assistant's final output is a structured research summary rather than an unstructured block of text, making its findings, sources, and confidence machine-readable for whatever application ultimately displays or acts on them.
from pydantic import BaseModel
from enum import Enum
class SourceType(str, Enum):
WEB = "web"
INTERNAL_DOCUMENT = "internal_document"
CALCULATION = "calculation"
class ResearchFinding(BaseModel):
claim: str
source_type: SourceType
source_reference: str
class ResearchSummary(BaseModel):
question: str
summary: str
findings: list[ResearchFinding]
limitations: str
model_config = {"extra": "forbid"}
SourceType as an enum (Unit 6, Lesson 1's guidance applied here) lets each individual finding be tagged with exactly where it came from — a live web result, an internal document, or a computed value — which is what lets a calling application later render, say, web-sourced findings with an external-link icon and internal findings with a different one, or apply different trust weighting to each category. The limitations field exists specifically to give the model an honest place to note gaps — a question partially unanswered because neither web search nor the internal documents addressed some part of it — following the same "make honest uncertainty representable in the schema" principle Unit 6 and Unit 7, Lesson 5 both established for tiered-confidence designs.
Step 4: The Research Function
def run_research_query(client, question: str) -> ResearchSummary:
response = client.responses.parse(
model="gpt-5.6-terra",
instructions=(
"You are a research assistant. Use web search for current events and live facts, "
"file search for internal background context, and code execution for any "
"calculations or data analysis needed. Cite every claim's source type and "
"specific reference. If some part of the question can't be answered from "
"available sources, say so explicitly in the limitations field rather than guessing."
),
input=question,
tools=research_tools,
text_format=ResearchSummary,
)
return response.output_parsed
summary = run_research_query(client, "How does our internal benchmark data compare to the current industry average latency reported this year, and what's the percentage difference?")
print(summary.summary)
for finding in summary.findings:
print(f"- [{finding.source_type.value}] {finding.claim} (source: {finding.source_reference})")
This single function call is doing considerably more than earlier single-tool examples in this unit: for a question like the one shown, the model likely needs to retrieve the internal benchmark figure from internal_benchmarks.pdf via file search, retrieve the current industry-average figure via web search, and then compute the percentage difference between the two via Code Interpreter — all within one request, with client.responses.parse() (Unit 6) ensuring the final synthesized answer comes back as a validated ResearchSummary object rather than an unstructured block of text mixing sourced claims with a bare computed number with no indication of which is which.
Step 5: Handling the Case Where Parsing Fails
Following Unit 6, Lesson 4's refusal-handling guidance, a production version of this function needs to account for the model declining to produce a valid structured summary — plausible here given how much is being asked of a single request (multiple tools, several sub-questions, a nontrivial schema).
def run_research_query_safely(client, question: str) -> dict:
try:
response = client.responses.parse(
model="gpt-5.6-terra",
instructions=(
"You are a research assistant. Use web search for current events and live facts, "
"file search for internal background context, and code execution for any "
"calculations or data analysis needed. Cite every claim's source type and "
"specific reference. If some part of the question can't be answered from "
"available sources, say so explicitly in the limitations field rather than guessing."
),
input=question,
tools=research_tools,
text_format=ResearchSummary,
)
except Exception as e:
return {"status": "request_failed", "error": str(e)}
if response.output_parsed is None:
return {"status": "refused", "reason": getattr(response, "refusal", "No summary was returned.")}
return {"status": "success", "summary": response.output_parsed}
This mirrors the same three-tier failure handling Unit 6, Lesson 4 and Unit 7, Lesson 5 both applied to their own structured-output functions, adapted here to a request that also happens to use three built-in tools rather than none — the failure-handling logic itself doesn't actually change based on which tools were involved, since client.responses.parse() presents the same success/refusal/exception possibilities regardless of what happened inside the request to produce the final answer.
Step 6: Inspecting Which Tools Were Actually Used
For debugging and for understanding how the assistant is actually behaving in practice, it's worth inspecting which tools a given request invoked, following the same output-item inspection pattern each of this unit's earlier lessons introduced for its own specific tool.
def summarize_tool_usage(response) -> dict:
usage = {"web_search_calls": 0, "file_search_calls": 0, "code_interpreter_calls": 0}
for item in response.output:
if item.type == "web_search_call":
usage["web_search_calls"] += 1
elif item.type == "file_search_call":
usage["file_search_calls"] += 1
elif item.type == "code_interpreter_call":
usage["code_interpreter_calls"] += 1
return usage
response = client.responses.create(
model="gpt-5.6-terra",
input="What's the current state of the internal benchmark documentation?",
tools=research_tools,
)
print(summarize_tool_usage(response))
Logging this kind of tool-usage summary across a batch of representative test questions is a practical, low-effort way to sanity-check the assistant's behavior during development — a question that's purely about internal documents but that consistently triggers an unnecessary web search (or vice versa) is a signal worth investigating, following the same tool-selection diagnostic Unit 8, Lesson 6 applied to its own weather assistant project, now extended across three built-in tools instead of three custom functions.
Step 7: Testing Without Real Tool Calls
Following this course's dependency-injection pattern, the schema-processing and tool-usage-summarizing logic can be tested with fake response objects, entirely independent of real web search, file search, or Code Interpreter calls.
class FakeToolCallItem:
def __init__(self, item_type):
self.type = item_type
class FakeResponse:
def __init__(self, output):
self.output = output
def test_summarize_tool_usage_counts_correctly():
fake_response = FakeResponse(output=[
FakeToolCallItem("web_search_call"),
FakeToolCallItem("web_search_call"),
FakeToolCallItem("file_search_call"),
FakeToolCallItem("message"),
])
usage = summarize_tool_usage(fake_response)
assert usage["web_search_calls"] == 2
assert usage["file_search_calls"] == 1
assert usage["code_interpreter_calls"] == 0
print("PASS: summarize_tool_usage correctly counts each tool type from a fake response")
test_summarize_tool_usage_counts_correctly()
This test covers the counting logic in complete isolation from any real API cost or the inherent variability of which tools a real model call happens to invoke for a given question — exactly the same rationale behind every fake-response test this unit has introduced for its individual tools, now applied to logic that spans all three at once.
Cost Awareness for a Multi-Tool Assistant
A single request to this assistant can, in the worst case, invoke web search, file search, and Code Interpreter all within one call — each of which, as Lessons 1 through 3 covered individually, adds its own cost and latency beyond a plain text request. Stacked together, a single research question can end up considerably more expensive than any single-tool request examined earlier in this unit, which is worth being deliberate about rather than discovering by surprise in a usage bill.
def estimate_worst_case_tool_cost(base_request_cost: float, web_search_cost: float, file_search_cost: float, code_interpreter_cost: float) -> float:
"""Illustrative — confirm actual current per-tool pricing against official
documentation, since it varies by usage volume and by which tools a given
request actually invokes."""
return base_request_cost + web_search_cost + file_search_cost + code_interpreter_cost
worst_case = estimate_worst_case_tool_cost(0.01, 0.03, 0.02, 0.04)
print(f"Worst-case estimated cost per research query: ${worst_case:.2f}")
Thinking through a worst-case estimate like this before deploying a multi-tool assistant at any real scale is worth the small upfront effort: for an application serving many research queries, restricting research_tools to a narrower set for a specific deployment (only file_search, say, for a use case that never actually needs live web results) following Lesson 4's least-privilege reasoning for tool restriction, can meaningfully reduce typical per-query cost without giving up any capability the deployment actually uses.
Troubleshooting Checklist
When this assistant's output seems off in practice, this checklist tends to isolate the cause quickly:
- Is
output_parsedcoming backNoneunexpectedly? A question spanning too many sub-parts or tools at once may be a case where the model struggles to produce a single coherent structured summary — consider whether the question should be broken into smaller, separately-run research queries. - Are findings missing their
source_reference? If a finding's claim looks plausible but its reference is vague or missing, revisit theinstructionswording asking the model to cite specific sources, following Lesson 1 and Lesson 2's citation guidance. - Is a tool being invoked when it shouldn't be, or skipped when it should be used? Run
summarize_tool_usage()against a batch of representative test questions (mirroring Unit 8, Lesson 6's tool-selection diagnostic) to check for a systematic pattern before assuming an isolated fluke. - Is the vector store behind file search stale? Following Lesson 2's guidance, confirm
research_notes_storereflects the current set of internal documents, since an outdated internal reference can silently produce a confidently wrong internal-document-sourced finding. - Is the
limitationsfield consistently empty even for genuinely incomplete answers? If so, theinstructionsmay need to more explicitly and forcefully request that gap be reported, since a model given a complex, multi-part question does not always volunteer an honest account of what it couldn't fully answer without being asked directly.
Extending the Project
Natural next steps for this project, each building on techniques from across the course: adding a caching layer that stores previous research summaries and checks whether a new question substantially overlaps with one already answered, avoiding redundant tool calls for closely related questions; combining this assistant's structured output with the text-to-speech capability from Unit 7, Lesson 4 to produce a spoken research briefing; and extending the ResearchSummary schema with a follow_up_questions field, prompting the model to suggest what additional research would meaningfully extend the current findings — a natural fit for the kind of open-ended research work this project is designed to support.