Web Research Assistant
Project 3: Build a Web Research Assistant
This project builds an assistant that answers questions using live web search rather than a static knowledge base, drawing on Unit 15's web search and grounded-answer tooling and Unit 6's structured outputs to keep citations machine-checkable rather than embedded loosely in prose.
Scope and Design Decisions
The assistant takes a research question — "What are the current best practices for X" or "What happened with Y this week" — searches the web, and returns a synthesized answer with a structured list of the sources it drew from. It is intentionally scoped to single-question research, not multi-step autonomous browsing; that broader scenario belongs to an agent framework and is closer to what Project 9 builds.
Two decisions matter most:
- Search grounding forces citation, not just search. Using the built-in web search tool means the model retrieves real, current content, but nothing stops it from summarizing that content without attribution unless the output schema requires sources. This project makes citations structurally mandatory.
- Confidence and recency are tracked explicitly. Web research answers age faster than document-grounded ones. The system records how many sources agreed and how recent they were, so downstream consumers can judge reliability rather than treating every answer as equally certain.
Grounded Search with Structured Citations
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class Citation(BaseModel):
url: str
title: str
published_date: str | None
class ResearchAnswer(BaseModel):
summary: str
key_points: list[str]
citations: list[Citation]
confidence: str # "high", "medium", "low"
def research_question(question: str) -> ResearchAnswer:
response = client.responses.parse(
model="gpt-5.6-terra",
input=[
{
"role": "system",
"content": (
"You are a research assistant. Use web search to answer the "
"question with current information. Every claim in key_points "
"must be traceable to at least one citation. Set confidence to "
"'low' if sources disagree or are sparse."
),
},
{"role": "user", "content": question},
],
tools=[{"type": "web_search"}],
text_format=ResearchAnswer,
)
return response.output_parsed
Citation captures a URL, a title, and an optional published date — the date is optional because not every web page exposes reliable publication metadata, and forcing the field to be required would push the model to fabricate a plausible-looking date rather than admit it is unknown. confidence is a free enum-like string rather than a numeric score because a numeric confidence score from a language model tends to imply more precision than the underlying signal actually has; a three-way qualitative bucket is honest about the granularity that is actually available.
The system prompt explicitly instructs the model to only make claims traceable to citations. This is a soft constraint — nothing in the API enforces it mechanically the way a schema enforces field types — so it is reinforced with a verification step below rather than trusted blindly.
Note: The exact tool name and parameters for web search (
{"type": "web_search"}here) are part of the built-in tools surface covered in Unit 15 and Unit 9, and argument shapes can change between API versions. Confirm the current tool schema against the SDK's tool definitions before deploying.
Verifying Citations Actually Support the Claims
def verify_citation_coverage(answer: ResearchAnswer) -> list[str]:
warnings = []
if not answer.citations:
warnings.append("No citations were returned for a research answer.")
if len(answer.citations) < 2 and answer.confidence == "high":
warnings.append("High confidence claimed with fewer than two independent sources.")
seen_domains = {_domain(c.url) for c in answer.citations}
if len(seen_domains) == 1 and len(answer.citations) > 1:
warnings.append("All citations come from a single domain; corroboration is weak.")
return warnings
def _domain(url: str) -> str:
from urllib.parse import urlparse
return urlparse(url).netloc.lower()
This function does not verify the citations point to real, live pages — that would require a separate HTTP request per citation, which is a reasonable addition for a high-stakes deployment but out of scope here. What it does check is structural: an empty citation list paired with a synthesized answer is a red flag, high confidence backed by a single source is a red flag, and unanimous agreement from a single domain is weaker evidence than the same claim appearing across independent domains. These are heuristics, not proofs, but they catch a meaningful share of over-confident answers cheaply.
Handling Follow-Up Questions
def research_conversation(questions: list[str]) -> list[ResearchAnswer]:
results = []
context_summary = ""
for question in questions:
contextualized = (
f"Prior research summary: {context_summary}\n\nNew question: {question}"
if context_summary
else question
)
answer = research_question(contextualized)
results.append(answer)
context_summary = answer.summary
return results
Web research often happens as a sequence of narrowing questions — "What are the leading approaches to X" followed by "How does the second approach handle edge case Y." Passing the previous summary as context, rather than the full conversation history, keeps each search focused: the model is reminded of what has already been established without re-triggering a broad search on tangential parts of an earlier answer. This is a lighter-weight alternative to full conversation state (Unit 4) that fits research workflows better, since each turn is closer to an independent query than a continuation of a single dialogue.
Formatting a Report
def format_report(answer: ResearchAnswer) -> str:
lines = [answer.summary, ""]
lines.append("Key points:")
for point in answer.key_points:
lines.append(f"- {point}")
lines.append("")
lines.append(f"Confidence: {answer.confidence}")
lines.append("Sources:")
for i, citation in enumerate(answer.citations, start=1):
date_part = f" ({citation.published_date})" if citation.published_date else ""
lines.append(f"[{i}] {citation.title}{date_part} — {citation.url}")
return "\n".join(lines)
Separating the structured ResearchAnswer object from its text rendering means the same data can be displayed as a formatted report, converted to HTML for a web page, or serialized to JSON for an API response, without touching the research logic itself. This mirrors the general principle from Unit 6 of keeping structured data and presentation as separate concerns.
Testing the Verification Logic
def test_flags_high_confidence_single_source():
answer = ResearchAnswer(
summary="Test summary",
key_points=["A claim"],
citations=[Citation(url="https://example.com/a", title="A", published_date=None)],
confidence="high",
)
warnings = verify_citation_coverage(answer)
assert any("fewer than two" in w for w in warnings)
print("PASS: high confidence with one source is flagged")
def test_no_warnings_for_well_supported_answer():
answer = ResearchAnswer(
summary="Test summary",
key_points=["A claim"],
citations=[
Citation(url="https://a.example.com/x", title="A", published_date="2026-01-01"),
Citation(url="https://b.example.org/y", title="B", published_date="2026-02-01"),
],
confidence="medium",
)
warnings = verify_citation_coverage(answer)
assert warnings == []
print("PASS: two independent, agreeing sources produce no warnings")
test_flags_high_confidence_single_source()
test_no_warnings_for_well_supported_answer()
Both tests build ResearchAnswer and Citation objects directly as plain Pydantic models — no API call, no real web search — and check verify_citation_coverage's output against known-good and known-bad inputs. Testing the verification function in isolation from research_question is important because the verification logic is exactly the part of this project most likely to need tuning after real-world use, and it should be safe to adjust without needing a live model call to check each change.
Extending This Project
Add a live URL-liveness check that fetches each citation and confirms a 200 response before including it in the final report, and add domain-authority weighting so that citations from established, high-authority sources influence the confidence rating more than an anonymous blog.
Common Mistakes
- Treating a system-prompt instruction to cite sources as a guarantee. Prompt instructions are strong nudges, not enforcement; pair them with a structured
citationsfield and a verification pass like the one in this project. - Assigning numeric confidence scores from the model's own self-assessment. Language models are not well-calibrated at producing precise probabilities; a small number of qualitative buckets is more honest and more useful downstream.
- Re-sending full conversation history for every follow-up research question. This drags earlier, possibly irrelevant search results into new queries and can bias the search toward the wrong angle. Summarize and carry forward only what is still relevant.
Best Practices
- Make citations structurally required, not optional. A field that must be populated is far more reliable than an instruction that citations should be included.
- Separate structured research data from its presentation. Keep
ResearchAnswerfree of formatting concerns so the same data can drive multiple output formats. - Apply cheap heuristic checks before trusting high-confidence answers. Source count, source diversity, and citation presence catch a meaningful share of over-confident results without an expensive verification pipeline.