PydanticAI Example Notebook: Atlas Support Assistant (E2E)
%load_ext autoreload
%autoreload 2
import logging
import helpers.hnotebook as ut
ut.config_notebook()
# Initialize logger.
logging.basicConfig(level=logging.INFO)
_LOG = logging.getLogger(__name__)import pydanticai_example_utils as utilsMODEL_ID: openai:gpt-5-2025-08-07
OPENAI_API_KEY set: True
PydanticAI Example Notebook: Atlas Support Assistant (E2E)¶
This notebook builds a small “support assistant” for a synthetic product called Atlas.
We will:
- Generate a synthetic knowledge base (Markdown docs)
- Load + chunk the docs
- Build a simple local embedding index (no external embedding service required)
- Add retrieval as a PydanticAI tool
- Use structured outputs (Pydantic schema) with citations
- Add validators to enforce rules like “citations required”
- Add optional guardrails and personalization
The result is an end-to-end pattern you can reuse for real RAG assistants.
Setup¶
This cell initializes the environment and imports all required libraries.
PydanticAI agents need:
- a model identifier (for example
openai:gpt-4o-mini) - a provider API key (for example
OPENAI_API_KEY)
Everything else in this notebook is local and self-contained.
!pip install -q pydantic-aiimport os
import functools
from pathlib import Path
from dataclasses import dataclass
from typing import Optional
import nest_asyncio
nest_asyncio.apply()
from pydantic import BaseModel, Field
from pydantic_ai import Agent
MODEL_ID = os.getenv("PYDANTIC_AI_MODEL", "openai:gpt-4o-mini")
print("MODEL_ID:", MODEL_ID)
print("OPENAI_API_KEY set:", bool(os.getenv("OPENAI_API_KEY")))Data and Scenario¶
We build a tiny product docs corpus to keep the tutorial self-contained.
We will build a tiny documentation set for an imaginary product called Atlas.
What this cell does¶
- Creates a local folder
example_dataset/and writes a small set of synthetic product/support documents as Markdown files. - Each file represents a support knowledge-base article (billing, troubleshooting, security, limits, etc.).
- The dataset is intentionally small but diverse so retrieval can return the right document depending on the question.
Importance¶
PydanticAI becomes most useful when the agent is grounded in external context (RAG-style). These documents act as that context. In the next steps, we will:
- Load these Markdown files into memory
- Retrieve relevant chunks for a user query
- Use a PydanticAI agent + tools to answer using retrieved text
- Return a structured output with citations
DOCS_DIR = Path("example_dataset/")
DOCS_DIR.mkdir(parents=True, exist_ok=True)
DOCS = {
"overview.md": """
# Atlas Overview
Atlas is a data sync service for small teams. It connects to CSV files and cloud buckets and keeps datasets up to date.
Getting started
- Create a workspace.
- Add a data source.
- Run the first sync.
Limits
- File uploads up to 50 MB.
- Up to 5 data sources on the Starter plan.
""",
"billing.md": """
# Billing and Plans
Plans
- Starter: $20 per month, 5 data sources, email support.
- Team: $80 per month, 25 data sources, priority email support.
- Enterprise: custom pricing, SSO, dedicated success manager.
Invoices
- Invoices are issued on the first of each month.
- You can download invoices from Settings > Billing.
""",
"troubleshooting.md": """
# Troubleshooting
Common issues
- Sync stuck at 0%: check your source credentials and try again.
- CSV upload fails: ensure the file is under 50 MB and encoded in UTF-8.
- Duplicate rows: enable the "deduplicate" toggle on the source.
""",
"security.md": """
# Security
Authentication
- Atlas supports two-factor authentication (2FA) for Team and Enterprise plans.
- Enable it under Settings > Security.
Data retention
- Deleted sources are retained for 30 days.
""",
"limits.md": """
# Usage Limits
Rate limits
- API requests are limited to 120 per minute on Team.
- Starter is limited to 30 per minute.
Storage
- Starter: 10 GB total storage.
- Team: 200 GB total storage.
""",
"support.md": """
# Support
Support channels
- Starter: email support, replies within 2 business days.
- Team: priority email support, replies within 4 business hours.
- Enterprise: dedicated success manager and 24/7 support.
Escalations
- Use the support portal to open a ticket.
""",
}
for name, text in DOCS.items():
path = DOCS_DIR / name
if not path.exists():
path.write_text(text.strip() + "\n")
print("Docs directory:", DOCS_DIR)
print("Files:", [p.name for p in DOCS_DIR.glob("*.md")])Docs directory: example_dataset
Files: ['limits.md', 'support.md', 'api.md', 'overview.md', 'billing.md', 'troubleshooting.md', 'security.md', 'integrations.md']
We load all Markdown files into a standard in-memory format:
doc_id: stable identifier for citationstitle: human-readable nametext: document content
A consistent document schema makes it easy to:
- pass documents into dependencies (
deps) - build retrieval tools
- return structured citations in the agent output
Chunking and Local Embeddings¶
We split each document into chunks and compute a deterministic vector for each chunk.
Why this approach¶
- It is fully local and reproducible (no external embedding API required)
- It is good enough to demonstrate retrieval and grounding
Importance¶
PydanticAI agents become far more reliable when they can retrieve relevant context via tools instead of guessing.
@dataclass
class DocChunk:
doc_id: str
chunk_id: int
text: str
vector: list[float]
docs = utils.load_docs(DOCS_DIR)
chunks = utils.chunk_docs(docs, DocChunk, max_chars=700)
print("Chunks:", len(chunks))
print("Example:", chunks[0].doc_id, chunks[0].chunk_id)Chunks: 8
Example: api 0
Build a lightweight search index / Retrieval¶
We search the chunk index for the most relevant pieces of text for a query.
class DocMatch(BaseModel):
doc_id: str
chunk_id: int
score: float
text: str
preview = utils.search_chunks(
chunks, "How do I download invoices?", DocMatch, top_k=3
)
print("Preview matches:")
for m in preview:
print(m.doc_id, "chunk", m.chunk_id, "score=", round(m.score, 4))Preview matches:
billing chunk 0 score= 0.1964
api chunk 0 score= 0.0
integrations chunk 0 score= 0.0
Importance¶
- We represent each document chunk as a vector and compute similarity with a query vector using dot product.
search_chunks(...)ranks chunks by similarity and returns the top matches.
Dependencies and Output Schema¶
Dependencies (DocDeps)¶
Dependencies are runtime context passed into the agent at execution time. Here we store:
- the chunk index
- an optional user profile (for personalization)
Output schema (AnswerWithSources)¶
The agent output is forced into a structured format:
answer: the response textsources: citations withdoc_id,chunk_id, and a short quotefollow_up_questions: optional list to support guardrails
Structured outputs eliminate brittle parsing and make results usable in real applications.
@dataclass
class DocDeps:
chunks: list[DocChunk]
user: Optional["UserProfile"] = None # optional personalization
class SourceRef(BaseModel):
doc_id: str
chunk_id: int
quote: str
class AnswerWithSources(BaseModel):
answer: str
sources: list[SourceRef] = Field(default_factory=list)
follow_up_questions: list[str] = Field(
default_factory=list
) # enables guardrails section later
@dataclass
class UserProfile:
plan: str
region: strRetrieval Tool¶
We wrap retrieval into a tool so the agent can call it during reasoning. Tools are the bridge between an LLM and real functionality. Here the tool provides grounded context for RAG-style answers.
search_docs_tool = functools.partial(
utils.search_docs,
doc_match_cls=DocMatch,
)Agent Configuration and Validation¶
This agent has:
- tools: retrieval
- deps: chunk store and optional user profile
- structured output: answer plus citations
- validator: enforces citation rules and triggers retry
The schema ensures output structure, and the validator ensures output quality. Together they turn a chatty model into a reliable system component.
agent = Agent(
MODEL_ID,
deps_type=DocDeps,
tools=[search_docs_tool],
output_type=AnswerWithSources,
instructions=(
"You are Atlas Support. "
"Use the `search_docs` tool to find relevant text. "
"Answer briefly. If you use document info, include 1-3 sources with doc_id, chunk_id, and short quotes."
),
)
agent.output_validator(utils.enforce_sources)End-to-End Query¶
We run the agent asynchronously using await (notebook-safe).
What happened¶
- The agent can call
search_docsto retrieve relevant text - The model generates a structured response
- The validator ensures citations exist if docs were referenced
This is the full pattern: RAG grounding plus structured outputs plus reliability checks.
deps = DocDeps(chunks=chunks)
out = await utils.ask("How do I download invoices?", deps, agent)
outAnswerWithSources(answer='Go to Settings > Billing in Atlas, find the invoice you need, and click Download. Invoices are issued on the first of each month, so you’ll find monthly invoices there.', sources=[SourceRef(doc_id='billing', chunk_id=0, quote='Invoices are issued on the first of each month.\nYou can download invoices from Settings > Billing.')], follow_up_questions=['Do you need help finding a specific month’s invoice?'])print("Answer:\n", out.answer)
print("\nSources:")
for s in out.sources:
print(
f"- {s.doc_id} (chunk {s.chunk_id}): {s.quote[:120].replace('\\n', ' ')}"
)
if out.follow_up_questions:
print("\nFollow-ups:")
for q in out.follow_up_questions:
print("-", q)Answer:
Go to Settings > Billing in Atlas, find the invoice you need, and click Download. Invoices are issued on the first of each month, so you’ll find monthly invoices there.
Sources:
- billing (chunk 0): Invoices are issued on the first of each month.
You can download invoices from Settings > Billing.
Follow-ups:
- Do you need help finding a specific month’s invoice?
Consuming Structured Output¶
We print the answer and citations from the structured result object. Downstream systems can store citations, audit answers, and render sources cleanly without parsing raw text.
try:
utils.enforce_sources(
AnswerWithSources(answer="According to the policy...", sources=[])
)
except Exception as e:
print("Validator failure example:", e)Validator failure example: You referenced docs/policies but did not include sources.
What happened (and why PydanticAI helps)¶
This shows the validator catching an invalid output.
In a real run, ModelRetry tells PydanticAI to retry until the output meets the citation rules.
Streaming Output¶
Streaming returns tokens progressively, which improves perceived latency in chat interfaces.
Streaming is useful for UI experiences and interactive assistants, especially when responses are longer.
stream_agent = Agent(
MODEL_ID, instructions="Write one short paragraph about unit tests."
)
await utils.stream_demo(stream_agent)Unit tests are small, automated checks that verify a single, isolated piece of code—usually a functionUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended.Unit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly andUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions earlyUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions early and enabling safe refactoring. Effective unit tests areUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions early and enabling safe refactoring. Effective unit tests are deterministic, focus on one behaviorUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions early and enabling safe refactoring. Effective unit tests are deterministic, focus on one behavior, and isolate external dependencies with mocks or stubsUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions early and enabling safe refactoring. Effective unit tests are deterministic, focus on one behavior, and isolate external dependencies with mocks or stubs. A solid unit test suite documents intended behavior, improves codeUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions early and enabling safe refactoring. Effective unit tests are deterministic, focus on one behavior, and isolate external dependencies with mocks or stubs. A solid unit test suite documents intended behavior, improves code quality, and speeds up developmentUnit tests are small, automated checks that verify a single, isolated piece of code—usually a function or class—behaves as intended. Written and run by developers, they execute quickly and often, catching regressions early and enabling safe refactoring. Effective unit tests are deterministic, focus on one behavior, and isolate external dependencies with mocks or stubs. A solid unit test suite documents intended behavior, improves code quality, and speeds up development.
Conversation memory (multi-turn)¶
Reuse message history to keep context across turns.
deps = DocDeps(chunks=chunks)
first = await agent.run("Where do I enable 2FA?", deps=deps)
utils.enforce_sources(first.output)
follow_up = await agent.run(
"Does that work on the Starter plan?",
deps=deps,
message_history=first.new_messages(),
)
utils.enforce_sources(follow_up.output)
print(follow_up.output)answer='No—2FA is only available on Team and Enterprise plans.' sources=[SourceRef(doc_id='security', chunk_id=0, quote='Atlas supports two-factor authentication (2FA) for Team and Enterprise plans.'), SourceRef(doc_id='billing', chunk_id=0, quote='Plans - Starter: $20 per month... - Team: $80 per month... - Enterprise: custom pricing, SSO, dedicated success manager.')] follow_up_questions=['Would you like help upgrading to the Team plan to enable 2FA?']
Guardrails (lightweight)¶
Reject out-of-scope questions without calling the model.
guarded = await utils.run_guarded(
"Write me a poem about the ocean.",
DocDeps(chunks=chunks),
agent,
AnswerWithSources,
)
print(guarded)answer='I can only help with Atlas product documentation and support questions.' sources=[] follow_up_questions=['Do you have a question about Atlas setup, billing, or support?']
Dynamic updates¶
Add new docs, rebuild the index, and query again.
from pathlib import Pathfrom pathlib import Path
# 1) Add the new doc
new_doc = DOCS_DIR / "integrations.md"
new_doc.write_text(
"""
# Integrations
Atlas supports S3 and Google Cloud Storage as data sources.
SFTP sources are available on Enterprise plans.
""".strip()
+ "\n",
encoding="utf-8",
)
# 2) Reload docs in the expected dict format
docs = utils.load_docs(DOCS_DIR) # must return list[dict] with doc_id/title/text
chunks = utils.chunk_docs(docs, DocChunk, max_chars=700)
# 3) Run the agent (notebook-safe)
deps = DocDeps(chunks=chunks)
res = await agent.run("Do you support S3?", deps=deps)
out = res.output
print("Answer:\n", out.answer)
print("\nSources:")
for s in out.sources:
print(
f"- {s.doc_id} (chunk {s.chunk_id}): {s.quote[:120].replace('\\n', ' ')}"
)<class 'list'> 8
First item type: <class 'dict'>
First item preview: {'doc_id': 'api', 'title': 'Api', 'text': '# API Access\n\nAPI keys\n- Create API keys under Settings > Developer.\n- Ke
Answer:
Yes—Atlas supports Amazon S3 as a data source.
Sources:
- integrations (chunk 0): Atlas supports S3 and Google Cloud Storage as data sources.
Personalization via Dependencies¶
We pass a UserProfile through dependencies so the agent can tailor answers. Dependencies are the clean way to inject user context, tenant context, and configuration into tools and agent behavior without global state or prompt hacks.
personalized_deps = DocDeps(
chunks=chunks,
user=UserProfile(plan="Starter", region="US"),
)
personalized = await utils.ask(
"What are my rate limits and storage limits?",
personalized_deps,
agent,
)
personalizedAnswerWithSources(answer='Here are the current Atlas limits by plan:\n- Starter: 30 API requests per minute; 10 GB total storage.\n- Team: 120 API requests per minute; 200 GB total storage.\n\nWhich plan are you on? I can confirm the exact limits for your workspace.', sources=[SourceRef(doc_id='limits', chunk_id=0, quote='API requests are limited to 120 per minute on Team. Starter is limited to 30 per minute. Starter: 10 GB total storage. Team: 200 GB total storage.')], follow_up_questions=['Which plan is your workspace on (Starter or Team)?'])Summary¶
You built a grounded support assistant using:
- a synthetic knowledge base
- deterministic local embeddings for retrieval
- PydanticAI tools to fetch context
- structured outputs with citations
- validators to enforce reliability
- optional guardrails and personalization
This is the core E2E pattern for building production-grade assistants with PydanticAI.