Project: A Research Assistant

Ma Mahalakshmi V Updated 16 Sep 2026
10 min read ·Lesson 41 of 224

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:

  1. Is output_parsed coming back None unexpectedly? 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.
  2. Are findings missing their source_reference? If a finding's claim looks plausible but its reference is vague or missing, revisit the instructions wording asking the model to cite specific sources, following Lesson 1 and Lesson 2's citation guidance.
  3. 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.
  4. Is the vector store behind file search stale? Following Lesson 2's guidance, confirm research_notes_store reflects the current set of internal documents, since an outdated internal reference can silently produce a confidently wrong internal-document-sourced finding.
  5. Is the limitations field consistently empty even for genuinely incomplete answers? If so, the instructions may 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.

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 Project: A Research Assistant and get answers drawn from it.

Signed-in readers only.