# Install required packages
!pip install --upgrade --quiet ipywidgets 'pydantic-ai-slim[openrouter,web-fetch]' python-dotenv pymupdf requests
import os
import urllib.request
import zipfile
# Download and extract data files
url = 'https://github.com/jsoma/workshop-ai-agents/raw/main/docs/03-pydantic-ai-orchestration/03-pydantic-ai-orchestration-data.zip'
print(f'Downloading data from {url}...')
urllib.request.urlretrieve(url, '03-pydantic-ai-orchestration-data.zip')
print('Extracting 03-pydantic-ai-orchestration-data.zip...')
with zipfile.ZipFile('03-pydantic-ai-orchestration-data.zip', 'r') as zip_ref:
zip_ref.extractall('.')
os.remove('03-pydantic-ai-orchestration-data.zip')
print('✓ Data files extracted!')
import os
from getpass import getpass
from dotenv import load_dotenv
from pydantic_ai import Agent
from pydantic_ai.capabilities import WebFetch, WebSearch
load_dotenv()
if not os.getenv("OPENROUTER_API_KEY"):
os.environ["OPENROUTER_API_KEY"] = getpass("OpenRouter API key: ")
if not os.getenv("BRAINTRUST_API_KEY"):
os.environ["BRAINTRUST_API_KEY"] = getpass("Braintrust API key: ")
print("Initialized.")
If we zoom out from "give an LLM a lot of toys to play with," we have to examine the question of how much control do we really want to give up? Do we let the LLM control the process start to finish, or do we set up stages, checkpoints and roles?
This is agent orchestration. In the example below, we're going to leave behind the single-agent setup and move into a world of multiple agents that each have specialized roles.
Let's get a little wild. Let's say I'm reporting on zoning appeals in a small area in Virginia. How can I use agents to help speed up my work?
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
MODEL = 'openrouter:anthropic/claude-sonnet-4-5'
class Motion(BaseModel):
related_case_id: str | None = None
text: str
vote: str | None = Field(
default=None,
description="Compact vote result, e.g. '6-0', '5-1', or 'passed unanimously'",
)
outcome: str | None = None
class ZoningCase(BaseModel):
case_id: str | None = Field(
default=None,
description="Example: V25-0002, A24-0002",
)
case_type: Literal["variance", "appeal", "minutes", "election", "other"]
applicants: list[str] = []
property_address: str | None = None
parcel_id: str | None = None
voting_district: str | None = None
request: str
staff_recommendation: str | None = None
public_conflict: str | None = Field(
default=None,
description="Brief summary of major support/opposition or dispute",
)
outcome: str | None = None
class MeetingDetails(BaseModel):
meeting_date: date
location: str | None = None
members_present: list[str] = []
staff_present: list[str] = []
cases: list[ZoningCase]
motions: list[Motion]
summary: str
meeting_agent = Agent(
MODEL,
output_type=MeetingDetails,
instructions="""
Extract meeting details from the provided minutes.
""",
)
import pymupdf
doc = pymupdf.open("20250715 BZA min.pdf")
text = "\n\n".join(page.get_text() for page in doc)
response = await meeting_agent.run(text)
details = response.output.model_dump()
import requests
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, Field
ASSESSMENT_URL = 'https://apps.spotsylvaniacountyva.gov/assessment/assessment/results.cfm'
def search_assessments_by_parcel_id(parcel_id: str) -> str:
"""
Search for assessments related to a given parcel ID.
If initial search fails, try appending a "-" on the end of the parcel ID and searching again.
"""
data = {
'streetnum': '',
'streetname': '',
'parcelid': parcel_id,
'submitsearch': 'Go',
}
response = requests.post(ASSESSMENT_URL, data=data)
cleaned = response.text.replace("Info.cfm?", "https://apps.spotsylvaniacountyva.gov/assessment/assessment/Info.cfm?")
return cleaned
def search_assessments_by_address(street_num: str, street_name: str) -> str:
"""
Search for assessments related to a given street address.
Street name should be only the name and must NOT include RD, DR, ST, AVE
"""
data = {
'streetnum': street_num,
'streetname': street_name,
'parcelid': '',
'submitsearch': 'Go'
}
response = requests.post(ASSESSMENT_URL, data=data)
cleaned = response.text.replace("Info.cfm?", "https://apps.spotsylvaniacountyva.gov/assessment/assessment/Info.cfm?")
return cleaned
class RealEstateRecord(BaseModel):
parcel_id: str
physical_address: str
legal_description: str
deeded_square_feet: Decimal | None = None
assessment_year: int
land_value: Decimal
building_value: Decimal
improvement_value: Decimal
total_value: Decimal
building_id: str | None = None
year_built: int | None = Field(
default=None,
description="Use None if the source says 0 or unknown",
)
building_type: str | None = None
total_area_sqft: Decimal | None = None
bedrooms: int | None = None
full_bathrooms: int | None = None
half_bathrooms: int | None = None
has_composition_shingle: bool | None = None
has_frame_siding_vinyl: bool | None = None
has_heat_pump: bool | None = None
wood_deck_sqft: Decimal | None = None
owner_name: str
mailing_street: str
mailing_city: str
mailing_state: str
mailing_zip: str
sales_as_of_date: date | None = None
date_of_transfer: date | None = None
transfer_type: str | None = None
instrument_number: str | None = None
sale_price: Decimal | None = None
google_maps_url: str | None = None
assessment_agent = Agent(
MODEL,
output_type=RealEstateRecord,
capabilities=[WebFetch(local=True, native=False)],
tools=[
search_assessments_by_parcel_id,
search_assessments_by_address,
],
instructions="""
Extract one property assessment record from county assessment pages.
REMEMBER: Assessment records are located at https://apps.spotsylvaniacountyva.gov/assessment/assessment/Info.cfm?OID=XXXXXX
""",
)
class ResearchResult(BaseModel):
research_topic: str
results_summary: str | None = None
source_ledger: list[str]
research_agent = Agent(
MODEL,
output_type=ResearchResult,
capabilities=[WebSearch(native=False, local=True), WebFetch(native=False, local=True)],
instructions="""
Act as an investigative journalist researching a zoning case.
Research the applicant(s) and provide a brief summary of who they are,
what they do, and any relevant background information that might be
helpful for understanding their perspective in a zoning case.
""",
)
for case in details['cases']:
print("Researching case:", case['case_id'])
# Researching the assessment
prompt = """
Acquire the property assessment record for the following parcel:
- Parcel ID: {parcel_id}
- Address: {property_address}
""".format(**case)
response = await assessment_agent.run(prompt)
case['assessment_record'] = response.output.model_dump()
# Researching the people
names = ", ".join(case['applicants'])
prompt = """
Applicant names: {names}
Property address: {property_address}
Zoning issue: {request}
Public conflict summary: {public_conflict}
""".format(**case, names=names)
if 'assessment_record' in case and case['assessment_record']:
prompt += """
Owner name from assessment record: {owner_name}
Owner full address from assessment record:
{mailing_street}
{mailing_city} {mailing_state}
{mailing_zip}
""".format(**case['assessment_record'])
response = await research_agent.run(prompt)
case['applicant_research'] = response.output.model_dump()
details
Todo lists, worker-supervisor, planner-executor...