chore: import upstream snapshot with attribution
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Pydantic Services Inc. 2024 to present
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Pydantic AI Examples
|
||||
|
||||
[](https://github.com/pydantic/pydantic-ai/actions/workflows/ci.yml?query=branch%3Amain)
|
||||
[](https://coverage-badge.samuelcolvin.workers.dev/redirect/pydantic/pydantic-ai)
|
||||
[](https://pypi.python.org/pypi/pydantic-ai)
|
||||
[](https://github.com/pydantic/pydantic-ai)
|
||||
[](https://github.com/pydantic/pydantic-ai/blob/main/LICENSE)
|
||||
|
||||
Examples of how to use Pydantic AI and what it can do.
|
||||
|
||||
For full documentation of these examples and how to run them, see [ai.pydantic.dev/examples/](https://ai.pydantic.dev/examples/).
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Very simply CLI to aid in copying examples code to a new directory.
|
||||
|
||||
To run examples in place, run:
|
||||
|
||||
uv run -m pydantic_ai_examples.<example_module_name>
|
||||
|
||||
For examples:
|
||||
|
||||
uv run -m pydantic_ai_examples.pydantic_model
|
||||
|
||||
To copy all examples to a new directory, run:
|
||||
|
||||
uv run -m pydantic_ai_examples --copy-to <destination_path>
|
||||
|
||||
See https://ai.pydantic.dev/examples/ for more information.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def cli():
|
||||
this_dir = Path(__file__).parent
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='pydantic_ai_examples',
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='store_true', help='show the version and exit'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--copy-to', dest='DEST', help='Copy all examples to a new directory'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.version:
|
||||
from pydantic_ai import __version__
|
||||
|
||||
print(f'pydantic_ai v{__version__}')
|
||||
elif args.DEST:
|
||||
copy_to(this_dir, Path(args.DEST))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def copy_to(this_dir: Path, dst: Path):
|
||||
if dst.exists():
|
||||
print(f'Error: destination path "{dst}" already exists', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
dst.mkdir(parents=True)
|
||||
|
||||
count = 0
|
||||
for file in this_dir.glob('*.*'):
|
||||
with open(file, 'rb') as src_file:
|
||||
with open(dst / file.name, 'wb') as dst_file:
|
||||
dst_file.write(src_file.read())
|
||||
count += 1
|
||||
|
||||
print(f'Copied {count} example files to "{dst}"')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Example usage of the AG-UI adapter for Pydantic AI.
|
||||
|
||||
This provides a FastAPI application that demonstrates how to use the
|
||||
Pydantic AI agent with the AG-UI protocol. It includes examples for
|
||||
each of the AG-UI dojo features:
|
||||
- Agentic Chat
|
||||
- Human in the Loop
|
||||
- Agentic Generative UI
|
||||
- Tool Based Generative UI
|
||||
- Shared State
|
||||
- Predictive State Updates
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .api import (
|
||||
agentic_chat_app,
|
||||
agentic_generative_ui_app,
|
||||
human_in_the_loop_app,
|
||||
predictive_state_updates_app,
|
||||
shared_state_app,
|
||||
tool_approval_app,
|
||||
tool_based_generative_ui_app,
|
||||
)
|
||||
|
||||
app = FastAPI(title='Pydantic AI AG-UI server')
|
||||
app.mount('/agentic_chat', agentic_chat_app, 'Agentic Chat')
|
||||
app.mount('/agentic_generative_ui', agentic_generative_ui_app, 'Agentic Generative UI')
|
||||
app.mount('/human_in_the_loop', human_in_the_loop_app, 'Human in the Loop')
|
||||
app.mount(
|
||||
'/predictive_state_updates',
|
||||
predictive_state_updates_app,
|
||||
'Predictive State Updates',
|
||||
)
|
||||
app.mount('/shared_state', shared_state_app, 'Shared State')
|
||||
app.mount('/tool_approval', tool_approval_app, 'Tool Approval (interrupts)')
|
||||
app.mount(
|
||||
'/tool_based_generative_ui',
|
||||
tool_based_generative_ui_app,
|
||||
'Tool Based Generative UI',
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Very simply CLI to run the AG-UI example.
|
||||
|
||||
See https://ai.pydantic.dev/examples/ag-ui/ for more information.
|
||||
"""
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run('pydantic_ai_examples.ag_ui:app', port=9000)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Example API for a AG-UI compatible Pydantic AI Agent UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .agentic_chat import app as agentic_chat_app
|
||||
from .agentic_generative_ui import app as agentic_generative_ui_app
|
||||
from .human_in_the_loop import app as human_in_the_loop_app
|
||||
from .predictive_state_updates import app as predictive_state_updates_app
|
||||
from .shared_state import app as shared_state_app
|
||||
from .tool_approval import app as tool_approval_app
|
||||
from .tool_based_generative_ui import app as tool_based_generative_ui_app
|
||||
|
||||
__all__ = [
|
||||
'agentic_chat_app',
|
||||
'agentic_generative_ui_app',
|
||||
'human_in_the_loop_app',
|
||||
'predictive_state_updates_app',
|
||||
'shared_state_app',
|
||||
'tool_approval_app',
|
||||
'tool_based_generative_ui_app',
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Agentic Chat feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
agent = Agent('openai:gpt-5-mini')
|
||||
|
||||
|
||||
@agent.tool_plain
|
||||
async def current_time(timezone: str = 'UTC') -> str:
|
||||
"""Get the current time in ISO format.
|
||||
|
||||
Args:
|
||||
timezone: The timezone to use.
|
||||
|
||||
Returns:
|
||||
The current time in ISO format string.
|
||||
"""
|
||||
tz: ZoneInfo = ZoneInfo(timezone)
|
||||
return datetime.now(tz=tz).isoformat()
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent)
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Agentic Generative UI feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from ag_ui.core import EventType, StateDeltaEvent, StateSnapshotEvent
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
StepStatus = Literal['pending', 'completed']
|
||||
|
||||
|
||||
class Step(BaseModel):
|
||||
"""Represents a step in a plan."""
|
||||
|
||||
description: str = Field(description='The description of the step')
|
||||
status: StepStatus = Field(
|
||||
default='pending',
|
||||
description='The status of the step (e.g., pending, completed)',
|
||||
)
|
||||
|
||||
|
||||
class Plan(BaseModel):
|
||||
"""Represents a plan with multiple steps."""
|
||||
|
||||
steps: list[Step] = Field(
|
||||
default_factory=list[Step], description='The steps in the plan'
|
||||
)
|
||||
|
||||
|
||||
class JSONPatchOp(BaseModel):
|
||||
"""A class representing a JSON Patch operation (RFC 6902)."""
|
||||
|
||||
op: Literal['add', 'remove', 'replace', 'move', 'copy', 'test'] = Field(
|
||||
description='The operation to perform: add, remove, replace, move, copy, or test',
|
||||
)
|
||||
path: str = Field(description='JSON Pointer (RFC 6901) to the target location')
|
||||
value: Any = Field(
|
||||
default=None,
|
||||
description='The value to apply (for add, replace operations)',
|
||||
)
|
||||
from_: str | None = Field(
|
||||
default=None,
|
||||
alias='from',
|
||||
description='Source path (for move, copy operations)',
|
||||
)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
'openai:gpt-5-mini',
|
||||
instructions=dedent(
|
||||
"""
|
||||
When planning use tools only, without any other messages.
|
||||
IMPORTANT:
|
||||
- Use the `create_plan` tool to set the initial state of the steps
|
||||
- Use the `update_plan_step` tool to update the status of each step
|
||||
- Do NOT repeat the plan or summarise it in a message
|
||||
- Do NOT confirm the creation or updates in a message
|
||||
- Do NOT ask the user for additional information or next steps
|
||||
|
||||
Only one plan can be active at a time, so do not call the `create_plan` tool
|
||||
again until all the steps in current plan are completed.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@agent.tool_plain
|
||||
async def create_plan(steps: list[str]) -> StateSnapshotEvent:
|
||||
"""Create a plan with multiple steps.
|
||||
|
||||
Args:
|
||||
steps: List of step descriptions to create the plan.
|
||||
|
||||
Returns:
|
||||
StateSnapshotEvent containing the initial state of the steps.
|
||||
"""
|
||||
plan: Plan = Plan(
|
||||
steps=[Step(description=step) for step in steps],
|
||||
)
|
||||
return StateSnapshotEvent(
|
||||
type=EventType.STATE_SNAPSHOT,
|
||||
snapshot=plan.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@agent.tool_plain
|
||||
async def update_plan_step(
|
||||
index: int, description: str | None = None, status: StepStatus | None = None
|
||||
) -> StateDeltaEvent:
|
||||
"""Update the plan with new steps or changes.
|
||||
|
||||
Args:
|
||||
index: The index of the step to update.
|
||||
description: The new description for the step.
|
||||
status: The new status for the step.
|
||||
|
||||
Returns:
|
||||
StateDeltaEvent containing the changes made to the plan.
|
||||
"""
|
||||
changes: list[JSONPatchOp] = []
|
||||
if description is not None:
|
||||
changes.append(
|
||||
JSONPatchOp(
|
||||
op='replace', path=f'/steps/{index}/description', value=description
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
changes.append(
|
||||
JSONPatchOp(op='replace', path=f'/steps/{index}/status', value=status)
|
||||
)
|
||||
return StateDeltaEvent(
|
||||
type=EventType.STATE_DELTA,
|
||||
delta=changes,
|
||||
)
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent)
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Human in the Loop Feature.
|
||||
|
||||
No special handling is required for this feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
agent = Agent(
|
||||
'openai:gpt-5-mini',
|
||||
instructions=dedent(
|
||||
"""
|
||||
When planning tasks use tools only, without any other messages.
|
||||
IMPORTANT:
|
||||
- Use the `generate_task_steps` tool to display the suggested steps to the user
|
||||
- Never repeat the plan, or send a message detailing steps
|
||||
- If accepted, confirm the creation of the plan and the number of selected (enabled) steps only
|
||||
- If not accepted, ask the user for more information, DO NOT use the `generate_task_steps` tool again
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent)
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Predictive State feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from textwrap import dedent
|
||||
|
||||
from pydantic import BaseModel
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from ag_ui.core import CustomEvent, EventType
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.ui import StateDeps
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
|
||||
class DocumentState(BaseModel):
|
||||
"""State for the document being written."""
|
||||
|
||||
document: str = ''
|
||||
|
||||
|
||||
agent = Agent('openai:gpt-5-mini', deps_type=StateDeps[DocumentState])
|
||||
|
||||
|
||||
# Tools which return AG-UI events will be sent to the client as part of the
|
||||
# event stream, single events and iterables of events are supported.
|
||||
@agent.tool_plain
|
||||
async def document_predict_state() -> list[CustomEvent]:
|
||||
"""Enable document state prediction.
|
||||
|
||||
Returns:
|
||||
CustomEvent containing the event to enable state prediction.
|
||||
"""
|
||||
return [
|
||||
CustomEvent(
|
||||
type=EventType.CUSTOM,
|
||||
name='PredictState',
|
||||
value=[
|
||||
{
|
||||
'state_key': 'document',
|
||||
'tool': 'write_document',
|
||||
'tool_argument': 'document',
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@agent.instructions()
|
||||
async def story_instructions(ctx: RunContext[StateDeps[DocumentState]]) -> str:
|
||||
"""Provide instructions for writing document if present.
|
||||
|
||||
Args:
|
||||
ctx: The run context containing document state information.
|
||||
|
||||
Returns:
|
||||
Instructions string for the document writing agent.
|
||||
"""
|
||||
return dedent(
|
||||
f"""You are a helpful assistant for writing documents.
|
||||
|
||||
Before you start writing, you MUST call the `document_predict_state`
|
||||
tool to enable state prediction.
|
||||
|
||||
To present the document to the user for review, you MUST use the
|
||||
`write_document` tool.
|
||||
|
||||
When you have written the document, DO NOT repeat it as a message.
|
||||
If accepted briefly summarize the changes you made, 2 sentences
|
||||
max, otherwise ask the user to clarify what they want to change.
|
||||
|
||||
This is the current document:
|
||||
|
||||
{ctx.deps.state.document}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
deps = StateDeps(DocumentState())
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
# `dispatch_request` mutates `deps.state` from the request, so give each request its own copy.
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent, deps=replace(deps))
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Shared State feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from enum import Enum
|
||||
from textwrap import dedent
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from ag_ui.core import EventType, StateSnapshotEvent
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.ui import StateDeps
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
|
||||
class SkillLevel(str, Enum):
|
||||
"""The level of skill required for the recipe."""
|
||||
|
||||
BEGINNER = 'Beginner'
|
||||
INTERMEDIATE = 'Intermediate'
|
||||
ADVANCED = 'Advanced'
|
||||
|
||||
|
||||
class SpecialPreferences(str, Enum):
|
||||
"""Special preferences for the recipe."""
|
||||
|
||||
HIGH_PROTEIN = 'High Protein'
|
||||
LOW_CARB = 'Low Carb'
|
||||
SPICY = 'Spicy'
|
||||
BUDGET_FRIENDLY = 'Budget-Friendly'
|
||||
ONE_POT_MEAL = 'One-Pot Meal'
|
||||
VEGETARIAN = 'Vegetarian'
|
||||
VEGAN = 'Vegan'
|
||||
|
||||
|
||||
class CookingTime(str, Enum):
|
||||
"""The cooking time of the recipe."""
|
||||
|
||||
FIVE_MIN = '5 min'
|
||||
FIFTEEN_MIN = '15 min'
|
||||
THIRTY_MIN = '30 min'
|
||||
FORTY_FIVE_MIN = '45 min'
|
||||
SIXTY_PLUS_MIN = '60+ min'
|
||||
|
||||
|
||||
class Ingredient(BaseModel):
|
||||
"""A class representing an ingredient in a recipe."""
|
||||
|
||||
icon: str = Field(
|
||||
default='ingredient',
|
||||
description="The icon emoji (not emoji code like '\x1f35e', but the actual emoji like 🥕) of the ingredient",
|
||||
)
|
||||
name: str
|
||||
amount: str
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
"""A class representing a recipe."""
|
||||
|
||||
skill_level: SkillLevel = Field(
|
||||
default=SkillLevel.BEGINNER,
|
||||
description='The skill level required for the recipe',
|
||||
)
|
||||
special_preferences: list[SpecialPreferences] = Field(
|
||||
default_factory=list[SpecialPreferences],
|
||||
description='Any special preferences for the recipe',
|
||||
)
|
||||
cooking_time: CookingTime = Field(
|
||||
default=CookingTime.FIVE_MIN, description='The cooking time of the recipe'
|
||||
)
|
||||
ingredients: list[Ingredient] = Field(
|
||||
default_factory=list[Ingredient],
|
||||
description='Ingredients for the recipe',
|
||||
)
|
||||
instructions: list[str] = Field(
|
||||
default_factory=list[str], description='Instructions for the recipe'
|
||||
)
|
||||
|
||||
|
||||
class RecipeSnapshot(BaseModel):
|
||||
"""A class representing the state of the recipe."""
|
||||
|
||||
recipe: Recipe = Field(
|
||||
default_factory=Recipe, description='The current state of the recipe'
|
||||
)
|
||||
|
||||
|
||||
agent = Agent('openai:gpt-5-mini', deps_type=StateDeps[RecipeSnapshot])
|
||||
|
||||
|
||||
@agent.tool_plain
|
||||
async def display_recipe(recipe: Recipe) -> StateSnapshotEvent:
|
||||
"""Display the recipe to the user.
|
||||
|
||||
Args:
|
||||
recipe: The recipe to display.
|
||||
|
||||
Returns:
|
||||
StateSnapshotEvent containing the recipe snapshot.
|
||||
"""
|
||||
return StateSnapshotEvent(
|
||||
type=EventType.STATE_SNAPSHOT,
|
||||
snapshot={'recipe': recipe},
|
||||
)
|
||||
|
||||
|
||||
@agent.instructions
|
||||
async def recipe_instructions(ctx: RunContext[StateDeps[RecipeSnapshot]]) -> str:
|
||||
"""Instructions for the recipe generation agent.
|
||||
|
||||
Args:
|
||||
ctx: The run context containing recipe state information.
|
||||
|
||||
Returns:
|
||||
Instructions string for the recipe generation agent.
|
||||
"""
|
||||
return dedent(
|
||||
f"""
|
||||
You are a helpful assistant for creating recipes.
|
||||
|
||||
IMPORTANT:
|
||||
- Create a complete recipe using the existing ingredients
|
||||
- Append new ingredients to the existing ones
|
||||
- Use the `display_recipe` tool to present the recipe to the user
|
||||
- Do NOT repeat the recipe in the message, use the tool instead
|
||||
- Do NOT run the `display_recipe` tool multiple times in a row
|
||||
|
||||
Once you have created the updated recipe and displayed it to the user,
|
||||
summarise the changes in one sentence, don't describe the recipe in
|
||||
detail or send it as a message to the user.
|
||||
|
||||
The current state of the recipe is:
|
||||
|
||||
{ctx.deps.state.recipe.model_dump_json(indent=2)}
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
deps = StateDeps(RecipeSnapshot())
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
# `dispatch_request` mutates `deps.state` from the request, so give each request its own copy.
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent, deps=replace(deps))
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tool approval with AG-UI interrupts.
|
||||
|
||||
Demonstrates the AG-UI interrupt lifecycle: a tool declared with `requires_approval=True`
|
||||
pauses the run when the model proposes a call. The adapter emits `RUN_FINISHED` with
|
||||
`outcome.type == "interrupt"`; the client renders an approval UI from `outcome.interrupts[]`
|
||||
and posts a follow-up `RunAgentInput` carrying `resume[]` to approve, deny, or edit the call.
|
||||
|
||||
Requires `ag-ui-protocol >= 0.1.19`. See https://docs.ag-ui.com/concepts/interrupts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.tools import DeferredToolRequests
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
# `output_type` must include `DeferredToolRequests` so the run can pause on a pending approval
|
||||
# instead of erroring when the model proposes a `requires_approval=True` tool.
|
||||
agent = Agent('openai:gpt-5-mini', output_type=[str, DeferredToolRequests])
|
||||
|
||||
|
||||
@agent.tool_plain(requires_approval=True)
|
||||
def delete_file(path: str) -> str:
|
||||
"""Delete a file. The run pauses here and waits for the user to approve before executing."""
|
||||
# Real implementation would actually delete the file.
|
||||
return f'deleted {path}'
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent)
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Tool Based Generative UI feature.
|
||||
|
||||
No special handling is required for this feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
||||
|
||||
agent = Agent('openai:gpt-5-mini')
|
||||
|
||||
|
||||
async def run_agent(request: Request) -> Response:
|
||||
return await AGUIAdapter.dispatch_request(request, agent=agent)
|
||||
|
||||
|
||||
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Small but complete example of using Pydantic AI to build a support agent for a bank.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.bank_support
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConn:
|
||||
"""A wrapper over the SQLite connection."""
|
||||
|
||||
sqlite_conn: sqlite3.Connection
|
||||
|
||||
async def customer_name(self, *, id: int) -> str | None:
|
||||
res = cur.execute('SELECT name FROM customers WHERE id=?', (id,))
|
||||
row = res.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
return None
|
||||
|
||||
async def customer_balance(self, *, id: int) -> float:
|
||||
res = cur.execute('SELECT balance FROM customers WHERE id=?', (id,))
|
||||
row = res.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
else:
|
||||
raise ValueError('Customer not found')
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupportDependencies:
|
||||
customer_id: int
|
||||
db: DatabaseConn
|
||||
|
||||
|
||||
class SupportOutput(BaseModel):
|
||||
support_advice: str
|
||||
"""Advice returned to the customer"""
|
||||
block_card: bool
|
||||
"""Whether to block their card or not"""
|
||||
risk: int
|
||||
"""Risk level of query"""
|
||||
|
||||
|
||||
support_agent = Agent(
|
||||
'openai:gpt-5.2',
|
||||
deps_type=SupportDependencies,
|
||||
output_type=SupportOutput,
|
||||
instructions=(
|
||||
'You are a support agent in our bank, give the '
|
||||
'customer support and judge the risk level of their query. '
|
||||
"Reply using the customer's name."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@support_agent.instructions
|
||||
async def add_customer_name(ctx: RunContext[SupportDependencies]) -> str:
|
||||
customer_name = await ctx.deps.db.customer_name(id=ctx.deps.customer_id)
|
||||
return f"The customer's name is {customer_name!r}"
|
||||
|
||||
|
||||
@support_agent.tool
|
||||
async def customer_balance(ctx: RunContext[SupportDependencies]) -> str:
|
||||
"""Returns the customer's current account balance."""
|
||||
balance = await ctx.deps.db.customer_balance(
|
||||
id=ctx.deps.customer_id,
|
||||
)
|
||||
return f'${balance:.2f}'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with sqlite3.connect(':memory:') as con:
|
||||
cur = con.cursor()
|
||||
cur.execute('CREATE TABLE customers(id, name, balance)')
|
||||
cur.execute("""
|
||||
INSERT INTO customers VALUES
|
||||
(123, 'John', 123.45)
|
||||
""")
|
||||
con.commit()
|
||||
|
||||
deps = SupportDependencies(customer_id=123, db=DatabaseConn(sqlite_conn=con))
|
||||
result = support_agent.run_sync('What is my balance?', deps=deps)
|
||||
print(result.output)
|
||||
"""
|
||||
support_advice='Hello John, your current account balance, including pending transactions, is $123.45.' block_card=False risk=1
|
||||
"""
|
||||
|
||||
result = support_agent.run_sync('I just lost my card!', deps=deps)
|
||||
print(result.output)
|
||||
"""
|
||||
support_advice="I'm sorry to hear that, John. We are temporarily blocking your card to prevent unauthorized transactions." block_card=True risk=8
|
||||
"""
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat App</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
main {
|
||||
max-width: 700px;
|
||||
}
|
||||
#conversation .user::before {
|
||||
content: 'You asked: ';
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
#conversation .model::before {
|
||||
content: 'AI Response: ';
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
#spinner {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-in;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid #222;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
#spinner.active {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="border rounded mx-auto my-5 p-4">
|
||||
<h1>Chat App</h1>
|
||||
<p>Ask me anything...</p>
|
||||
<div id="conversation" class="px-2"></div>
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<div id="spinner"></div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<input id="prompt-input" name="prompt" class="form-control"/>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-primary mt-2">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="error" class="d-none text-danger">
|
||||
Error occurred, check the browser developer console for more information.
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/typescript/5.6.3/typescript.min.js" integrity="sha512-TvPkf1JgpB7FBf8dpYVD+2FXCy+Wn4sHIIWyTQijjOOt8Z20BAbwm0Si991w2k++oXFHp5NlOfWYud/R1sJUNA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script type="module">
|
||||
// to let me write TypeScript, without adding the burden of npm we do a dirty, non-production-ready hack
|
||||
// and transpile the TypeScript code in the browser
|
||||
// this is (arguably) A neat demo trick, but not suitable for production!
|
||||
async function loadTs() {
|
||||
const response = await fetch('/chat_app.ts');
|
||||
const tsCode = await response.text();
|
||||
const jsCode = window.ts.transpile(tsCode, { target: "es2015" });
|
||||
let script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.text = jsCode;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
loadTs().catch((e) => {
|
||||
console.error(e);
|
||||
document.getElementById('error').classList.remove('d-none');
|
||||
document.getElementById('spinner').classList.remove('active');
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Simple chat app example build with FastAPI.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.chat_app
|
||||
"""
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal, TypeVar
|
||||
|
||||
import fastapi
|
||||
import logfire
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from typing_extensions import LiteralString, ParamSpec, TypedDict
|
||||
|
||||
from pydantic_ai import (
|
||||
Agent,
|
||||
ModelMessage,
|
||||
ModelMessagesTypeAdapter,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
TextPart,
|
||||
UnexpectedModelBehavior,
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
agent = Agent('openai:gpt-5.2')
|
||||
THIS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: fastapi.FastAPI):
|
||||
async with Database.connect() as db:
|
||||
yield {'db': db}
|
||||
|
||||
|
||||
app = fastapi.FastAPI(lifespan=lifespan)
|
||||
logfire.instrument_fastapi(app)
|
||||
|
||||
|
||||
@app.get('/')
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse((THIS_DIR / 'chat_app.html'), media_type='text/html')
|
||||
|
||||
|
||||
@app.get('/chat_app.ts')
|
||||
async def main_ts() -> FileResponse:
|
||||
"""Get the raw typescript code, it's compiled in the browser, forgive me."""
|
||||
return FileResponse((THIS_DIR / 'chat_app.ts'), media_type='text/plain')
|
||||
|
||||
|
||||
async def get_db(request: Request) -> Database:
|
||||
return request.state.db
|
||||
|
||||
|
||||
@app.get('/chat/')
|
||||
async def get_chat(database: Database = Depends(get_db)) -> Response:
|
||||
msgs = await database.get_messages()
|
||||
return Response(
|
||||
b'\n'.join(json.dumps(to_chat_message(m)).encode('utf-8') for m in msgs),
|
||||
media_type='text/plain',
|
||||
)
|
||||
|
||||
|
||||
class ChatMessage(TypedDict):
|
||||
"""Format of messages sent to the browser."""
|
||||
|
||||
role: Literal['user', 'model']
|
||||
timestamp: str
|
||||
content: str
|
||||
|
||||
|
||||
def to_chat_message(m: ModelMessage) -> ChatMessage:
|
||||
first_part = m.parts[0]
|
||||
if isinstance(m, ModelRequest):
|
||||
if isinstance(first_part, UserPromptPart):
|
||||
assert isinstance(first_part.content, str)
|
||||
return {
|
||||
'role': 'user',
|
||||
'timestamp': first_part.timestamp.isoformat(),
|
||||
'content': first_part.content,
|
||||
}
|
||||
elif isinstance(m, ModelResponse):
|
||||
if isinstance(first_part, TextPart):
|
||||
return {
|
||||
'role': 'model',
|
||||
'timestamp': m.timestamp.isoformat(),
|
||||
'content': first_part.content,
|
||||
}
|
||||
raise UnexpectedModelBehavior(f'Unexpected message type for chat app: {m}')
|
||||
|
||||
|
||||
@app.post('/chat/')
|
||||
async def post_chat(
|
||||
prompt: Annotated[str, fastapi.Form()], database: Database = Depends(get_db)
|
||||
) -> StreamingResponse:
|
||||
async def stream_messages():
|
||||
"""Streams new line delimited JSON `Message`s to the client."""
|
||||
# stream the user prompt so that can be displayed straight away
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
'role': 'user',
|
||||
'timestamp': datetime.now(tz=timezone.utc).isoformat(),
|
||||
'content': prompt,
|
||||
}
|
||||
).encode('utf-8')
|
||||
+ b'\n'
|
||||
)
|
||||
# get the chat history so far to pass as context to the agent
|
||||
messages = await database.get_messages()
|
||||
# run the agent with the user prompt and the chat history
|
||||
async with agent.run_stream(prompt, message_history=messages) as result:
|
||||
async for text in result.stream_output(debounce_by=0.01):
|
||||
# text here is a `str` and the frontend wants
|
||||
# JSON encoded ModelResponse, so we create one
|
||||
m = ModelResponse(parts=[TextPart(text)], timestamp=result.timestamp)
|
||||
yield json.dumps(to_chat_message(m)).encode('utf-8') + b'\n'
|
||||
|
||||
# add new messages (e.g. the user prompt and the agent response in this case) to the database
|
||||
await database.add_messages(result.new_messages_json())
|
||||
|
||||
return StreamingResponse(stream_messages(), media_type='text/plain')
|
||||
|
||||
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Database:
|
||||
"""Rudimentary database to store chat messages in SQLite.
|
||||
|
||||
The SQLite standard library package is synchronous, so we
|
||||
use a thread pool executor to run queries asynchronously.
|
||||
"""
|
||||
|
||||
con: sqlite3.Connection
|
||||
_loop: asyncio.AbstractEventLoop
|
||||
_executor: ThreadPoolExecutor
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def connect(
|
||||
cls, file: Path = THIS_DIR / '.chat_app_messages.sqlite'
|
||||
) -> AsyncGenerator[Database]:
|
||||
with logfire.span('connect to DB'):
|
||||
loop = asyncio.get_running_loop()
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
con = await loop.run_in_executor(executor, cls._connect, file)
|
||||
slf = cls(con, loop, executor)
|
||||
try:
|
||||
yield slf
|
||||
finally:
|
||||
await slf._asyncify(con.close)
|
||||
|
||||
@staticmethod
|
||||
def _connect(file: Path) -> sqlite3.Connection:
|
||||
con = sqlite3.connect(str(file))
|
||||
con = logfire.instrument_sqlite3(con)
|
||||
cur = con.cursor()
|
||||
cur.execute(
|
||||
'CREATE TABLE IF NOT EXISTS messages (id INT PRIMARY KEY, message_list TEXT);'
|
||||
)
|
||||
con.commit()
|
||||
return con
|
||||
|
||||
async def add_messages(self, messages: bytes):
|
||||
await self._asyncify(
|
||||
self._execute,
|
||||
'INSERT INTO messages (message_list) VALUES (?);',
|
||||
messages,
|
||||
commit=True,
|
||||
)
|
||||
await self._asyncify(self.con.commit)
|
||||
|
||||
async def get_messages(self) -> list[ModelMessage]:
|
||||
c = await self._asyncify(
|
||||
self._execute, 'SELECT message_list FROM messages order by id'
|
||||
)
|
||||
rows = await self._asyncify(c.fetchall)
|
||||
messages: list[ModelMessage] = []
|
||||
for row in rows:
|
||||
messages.extend(ModelMessagesTypeAdapter.validate_json(row[0]))
|
||||
return messages
|
||||
|
||||
def _execute(
|
||||
self, sql: LiteralString, *args: Any, commit: bool = False
|
||||
) -> sqlite3.Cursor:
|
||||
cur = self.con.cursor()
|
||||
cur.execute(sql, args)
|
||||
if commit:
|
||||
self.con.commit()
|
||||
return cur
|
||||
|
||||
async def _asyncify(
|
||||
self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs
|
||||
) -> R:
|
||||
return await self._loop.run_in_executor( # type: ignore
|
||||
self._executor,
|
||||
partial(func, **kwargs),
|
||||
*args, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
'pydantic_ai_examples.chat_app:app', reload=True, reload_dirs=[str(THIS_DIR)]
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
// BIG FAT WARNING: to avoid the complexity of npm, this typescript is compiled in the browser
|
||||
// there's currently no static type checking
|
||||
|
||||
import { marked } from 'https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.0/lib/marked.esm.js'
|
||||
const convElement = document.getElementById('conversation')
|
||||
|
||||
const promptInput = document.getElementById('prompt-input') as HTMLInputElement
|
||||
const spinner = document.getElementById('spinner')
|
||||
|
||||
// stream the response and render messages as each chunk is received
|
||||
// data is sent as newline-delimited JSON
|
||||
async function onFetchResponse(response: Response): Promise<void> {
|
||||
let text = ''
|
||||
let decoder = new TextDecoder()
|
||||
if (response.ok) {
|
||||
const reader = response.body.getReader()
|
||||
while (true) {
|
||||
const {done, value} = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
text += decoder.decode(value)
|
||||
addMessages(text)
|
||||
spinner.classList.remove('active')
|
||||
}
|
||||
addMessages(text)
|
||||
promptInput.disabled = false
|
||||
promptInput.focus()
|
||||
} else {
|
||||
const text = await response.text()
|
||||
console.error(`Unexpected response: ${response.status}`, {response, text})
|
||||
throw new Error(`Unexpected response: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
// The format of messages, this matches pydantic-ai both for brevity and understanding
|
||||
// in production, you might not want to keep this format all the way to the frontend
|
||||
interface Message {
|
||||
role: string
|
||||
content: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// take raw response text and render messages into the `#conversation` element
|
||||
// Message timestamp is assumed to be a unique identifier of a message, and is used to deduplicate
|
||||
// hence you can send data about the same message multiple times, and it will be updated
|
||||
// instead of creating a new message elements
|
||||
function addMessages(responseText: string) {
|
||||
const lines = responseText.split('\n')
|
||||
const messages: Message[] = lines.filter(line => line.length > 1).map(j => JSON.parse(j))
|
||||
for (const message of messages) {
|
||||
// we use the timestamp as a crude element id
|
||||
const {timestamp, role, content} = message
|
||||
const id = `msg-${timestamp}`
|
||||
let msgDiv = document.getElementById(id)
|
||||
if (!msgDiv) {
|
||||
msgDiv = document.createElement('div')
|
||||
msgDiv.id = id
|
||||
msgDiv.title = `${role} at ${timestamp}`
|
||||
msgDiv.classList.add('border-top', 'pt-2', role)
|
||||
convElement.appendChild(msgDiv)
|
||||
}
|
||||
msgDiv.innerHTML = marked.parse(content)
|
||||
}
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function onError(error: any) {
|
||||
console.error(error)
|
||||
document.getElementById('error').classList.remove('d-none')
|
||||
document.getElementById('spinner').classList.remove('active')
|
||||
}
|
||||
|
||||
async function onSubmit(e: SubmitEvent): Promise<void> {
|
||||
e.preventDefault()
|
||||
spinner.classList.add('active')
|
||||
const body = new FormData(e.target as HTMLFormElement)
|
||||
|
||||
promptInput.value = ''
|
||||
promptInput.disabled = true
|
||||
|
||||
const response = await fetch('/chat/', {method: 'POST', body})
|
||||
await onFetchResponse(response)
|
||||
}
|
||||
|
||||
// call onSubmit when the form is submitted (e.g. user clicks the send button or hits Enter)
|
||||
document.querySelector('form').addEventListener('submit', (e) => onSubmit(e).catch(onError))
|
||||
|
||||
// load messages on page load
|
||||
fetch('/chat/').then(onFetchResponse).catch(onError)
|
||||
@@ -0,0 +1,107 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import datasets
|
||||
import duckdb
|
||||
import pandas as pd
|
||||
|
||||
from pydantic_ai import Agent, ModelRetry, RunContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalystAgentDeps:
|
||||
output: dict[str, pd.DataFrame] = field(default_factory=dict[str, pd.DataFrame])
|
||||
|
||||
def store(self, value: pd.DataFrame) -> str:
|
||||
"""Store the output in deps and return the reference such as Out[1] to be used by the LLM."""
|
||||
ref = f'Out[{len(self.output) + 1}]'
|
||||
self.output[ref] = value
|
||||
return ref
|
||||
|
||||
def get(self, ref: str) -> pd.DataFrame:
|
||||
if ref not in self.output:
|
||||
raise ModelRetry(
|
||||
f'Error: {ref} is not a valid variable reference. Check the previous messages and try again.'
|
||||
)
|
||||
return self.output[ref]
|
||||
|
||||
|
||||
analyst_agent = Agent(
|
||||
'openai:gpt-5.2',
|
||||
deps_type=AnalystAgentDeps,
|
||||
instructions='You are a data analyst and your job is to analyze the data according to the user request.',
|
||||
)
|
||||
|
||||
|
||||
@analyst_agent.tool
|
||||
def load_dataset(
|
||||
ctx: RunContext[AnalystAgentDeps],
|
||||
path: str,
|
||||
split: str = 'train',
|
||||
) -> str:
|
||||
"""Load the `split` of dataset `dataset_name` from huggingface.
|
||||
|
||||
Args:
|
||||
ctx: Pydantic AI agent RunContext
|
||||
path: name of the dataset in the form of `<user_name>/<dataset_name>`
|
||||
split: load the split of the dataset (default: "train")
|
||||
"""
|
||||
# begin load data from hf
|
||||
builder = datasets.load_dataset_builder(path) # pyright: ignore[reportUnknownMemberType]
|
||||
splits: dict[str, datasets.SplitInfo] = builder.info.splits or {}
|
||||
if split not in splits:
|
||||
raise ModelRetry(
|
||||
f'{split} is not valid for dataset {path}. Valid splits are {",".join(splits.keys())}'
|
||||
)
|
||||
|
||||
builder.download_and_prepare() # pyright: ignore[reportUnknownMemberType]
|
||||
dataset = builder.as_dataset(split=split)
|
||||
assert isinstance(dataset, datasets.Dataset)
|
||||
dataframe = dataset.to_pandas()
|
||||
assert isinstance(dataframe, pd.DataFrame)
|
||||
# end load data from hf
|
||||
|
||||
# store the dataframe in the deps and get a ref like "Out[1]"
|
||||
ref = ctx.deps.store(dataframe)
|
||||
# construct a summary of the loaded dataset
|
||||
output = [
|
||||
f'Loaded the dataset as `{ref}`.',
|
||||
f'Description: {dataset.info.description}'
|
||||
if dataset.info.description
|
||||
else None,
|
||||
f'Features: {dataset.info.features!r}' if dataset.info.features else None,
|
||||
]
|
||||
return '\n'.join(filter(None, output))
|
||||
|
||||
|
||||
@analyst_agent.tool
|
||||
def run_duckdb(ctx: RunContext[AnalystAgentDeps], dataset: str, sql: str) -> str:
|
||||
"""Run DuckDB SQL query on the DataFrame.
|
||||
|
||||
Note that the virtual table name used in DuckDB SQL must be `dataset`.
|
||||
|
||||
Args:
|
||||
ctx: Pydantic AI agent RunContext
|
||||
dataset: reference string to the DataFrame
|
||||
sql: the query to be executed using DuckDB
|
||||
"""
|
||||
data = ctx.deps.get(dataset)
|
||||
result = duckdb.query_df(df=data, virtual_table_name='dataset', sql_query=sql)
|
||||
# pass the result as ref (because DuckDB SQL can select many rows, creating another huge dataframe)
|
||||
ref = ctx.deps.store(result.df())
|
||||
return f'Executed SQL, result is `{ref}`'
|
||||
|
||||
|
||||
@analyst_agent.tool
|
||||
def display(ctx: RunContext[AnalystAgentDeps], name: str) -> str:
|
||||
"""Display at most 5 rows of the dataframe."""
|
||||
dataset = ctx.deps.get(name)
|
||||
return dataset.head().to_string() # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
deps = AnalystAgentDeps()
|
||||
result = analyst_agent.run_sync(
|
||||
user_prompt='Count how many negative comments are there in the dataset `cornell-movie-review-data/rotten_tomatoes`',
|
||||
deps=deps,
|
||||
)
|
||||
print(result.output)
|
||||
@@ -0,0 +1,2 @@
|
||||
from .agent import infer_time_range as infer_time_range
|
||||
from .models import TimeRangeResponse as TimeRangeResponse
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
from .models import TimeRangeInputs, TimeRangeResponse
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeRangeDeps:
|
||||
"""Dependencies for the time range inference agent.
|
||||
|
||||
While we could just get the current time using datetime.now() directly in the tools or system prompt, passing it
|
||||
via deps makes it easier to use a repeatable value during testing. While there are packages like `time-machine`
|
||||
that can do this for you, that kind of monkey-patching approach can become unwieldy as things get more complex.
|
||||
"""
|
||||
|
||||
now: datetime = field(default_factory=lambda: datetime.now().astimezone())
|
||||
|
||||
|
||||
time_range_agent = Agent[TimeRangeDeps, TimeRangeResponse](
|
||||
'gpt-5.2',
|
||||
output_type=TimeRangeResponse, # type: ignore # we can't yet annotate something as receiving a TypeForm
|
||||
deps_type=TimeRangeDeps,
|
||||
system_prompt="Convert the user's request into a structured time range.",
|
||||
retries=1,
|
||||
instrument=True,
|
||||
)
|
||||
|
||||
|
||||
@time_range_agent.tool
|
||||
def get_current_time(ctx: RunContext[TimeRangeDeps]) -> str:
|
||||
"""Get the user's current time and timezone in the format 'Friday, November 22, 2024 11:15:14 PST'."""
|
||||
# (The following comment is not in the docstring because the tool docstring is included in model requests.)
|
||||
# In practice, you might unconditionally include this in the system prompt, but using a tool for this helps
|
||||
# demonstrate some evaluation capabilities, such as checking whether a specific tool was called (or wasn't).
|
||||
now_str = ctx.deps.now.strftime(
|
||||
'%A, %B %d, %Y %H:%M:%S %Z'
|
||||
) # Format like: Friday, November 22, 2024 11:15:14 PST
|
||||
return f"The user's current time is {now_str}."
|
||||
|
||||
|
||||
async def infer_time_range(inputs: TimeRangeInputs) -> TimeRangeResponse:
|
||||
"""Infer a time range from a user prompt."""
|
||||
deps = TimeRangeDeps(now=inputs['now'])
|
||||
return (await time_range_agent.run(inputs['prompt'], deps=deps)).output
|
||||
@@ -0,0 +1,69 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from pydantic_ai_examples.evals.models import (
|
||||
TimeRangeBuilderSuccess,
|
||||
TimeRangeInputs,
|
||||
TimeRangeResponse,
|
||||
)
|
||||
from pydantic_evals.evaluators import (
|
||||
Evaluator,
|
||||
EvaluatorContext,
|
||||
EvaluatorOutput,
|
||||
)
|
||||
from pydantic_evals.otel import SpanQuery
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidateTimeRange(Evaluator[TimeRangeInputs, TimeRangeResponse]):
|
||||
def evaluate(
|
||||
self, ctx: EvaluatorContext[TimeRangeInputs, TimeRangeResponse]
|
||||
) -> EvaluatorOutput:
|
||||
if isinstance(ctx.output, TimeRangeBuilderSuccess):
|
||||
window_end = ctx.output.max_timestamp_with_offset
|
||||
window_size = window_end - ctx.output.min_timestamp_with_offset
|
||||
return {
|
||||
'window_is_not_too_long': window_size <= timedelta(days=30),
|
||||
'window_is_not_in_the_future': window_end <= ctx.inputs['now'],
|
||||
}
|
||||
|
||||
return {} # No evaluation needed for errors
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserMessageIsConcise(Evaluator[TimeRangeInputs, TimeRangeResponse]):
|
||||
async def evaluate(
|
||||
self,
|
||||
ctx: EvaluatorContext[TimeRangeInputs, TimeRangeResponse],
|
||||
) -> EvaluatorOutput:
|
||||
if isinstance(ctx.output, TimeRangeBuilderSuccess):
|
||||
user_facing_message = ctx.output.explanation
|
||||
else:
|
||||
user_facing_message = ctx.output.error_message
|
||||
|
||||
if user_facing_message is not None:
|
||||
return len(user_facing_message.split()) < 50
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentCalledTool(Evaluator[object, object, object]):
|
||||
agent_name: str
|
||||
tool_name: str
|
||||
|
||||
def evaluate(self, ctx: EvaluatorContext[object, object, object]) -> bool:
|
||||
return ctx.span_tree.any(
|
||||
SpanQuery(
|
||||
name_equals='agent run',
|
||||
has_attributes={'agent_name': self.agent_name},
|
||||
stop_recursing_when=SpanQuery(name_equals='agent run'),
|
||||
some_descendant_has=SpanQuery(
|
||||
name_equals='running tool',
|
||||
has_attributes={'gen_ai.tool.name': self.tool_name},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
CUSTOM_EVALUATOR_TYPES = (ValidateTimeRange, UserMessageIsConcise, AgentCalledTool)
|
||||
@@ -0,0 +1,107 @@
|
||||
# yaml-language-server: $schema=time_range_v1_schema.json
|
||||
cases:
|
||||
- name: Single day mention
|
||||
inputs:
|
||||
prompt: I want to see logs from 2021-05-08
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2021-05-08T00:00:00Z'
|
||||
max_timestamp_with_offset: '2021-05-08T23:59:59Z'
|
||||
explanation: You mentioned a single day (2021-05-08). The entire day is used.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Ambiguous mention
|
||||
inputs:
|
||||
prompt: Check logs from last week or so, around early May
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-10-21T09:30:00Z'
|
||||
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
|
||||
explanation: We interpret the mention of early May as extraneous, focusing on
|
||||
'last week or so' from the current time.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- LLMJudge: We want to interpret conflicting references by default to the more recent
|
||||
timeframe; confirm the explanation addresses ignoring early May.
|
||||
- name: Single datetime mention
|
||||
inputs:
|
||||
prompt: Show me the logs at 2023-10-27 2:00pm
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-10-27T13:50:00Z'
|
||||
max_timestamp_with_offset: '2023-10-27T14:10:00Z'
|
||||
explanation: You only mentioned a single point in time, so a 10-minute window
|
||||
around that time is used.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Relative mention without date
|
||||
inputs:
|
||||
prompt: Check logs from 2 hours ago
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-10-28T07:30:00Z'
|
||||
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
|
||||
explanation: You requested logs starting from 2 hours prior to the current time.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Impossible range
|
||||
inputs:
|
||||
prompt: Check logs from 2025, but make sure they are also from 2020
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
error_message: 'Conflicting time instructions: 2025 and 2020 cannot both apply.'
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderError
|
||||
- name: No mention
|
||||
inputs:
|
||||
prompt: Show me some logs
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
error_message: No timeframe could be inferred from your request.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderError
|
||||
- name: Ambiguous elliptical mention
|
||||
inputs:
|
||||
prompt: Check logs from around the start of last quarter
|
||||
now: '2023-07-15T08:00:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-04-01T00:00:00Z'
|
||||
max_timestamp_with_offset: '2023-04-05T23:59:59Z'
|
||||
explanation: We interpret 'around the start of last quarter' as the first few
|
||||
days of Q2 2023.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Far future mention
|
||||
inputs:
|
||||
prompt: Check logs from January 3050
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '3050-01-01T00:00:00Z'
|
||||
max_timestamp_with_offset: '3050-01-31T23:59:59Z'
|
||||
explanation: You requested logs from January 3050. The entire month is used.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Confusing relative references
|
||||
inputs:
|
||||
prompt: Check logs from yesterday but also last year
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
error_message: 'Conflicting instructions: ''yesterday'' versus ''last year'' could
|
||||
not be reconciled.'
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderError
|
||||
- name: Range from speech
|
||||
inputs:
|
||||
prompt: I want the logs from December 25th to December 26th, so I can see what
|
||||
happened on Christmas day. But also it might be earlier.
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-12-25T00:00:00Z'
|
||||
max_timestamp_with_offset: '2023-12-26T23:59:59Z'
|
||||
explanation: You asked specifically for December 25th to December 26th. The mention
|
||||
of an earlier date is ignored since a range was provided.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
evaluators:
|
||||
- LLMJudge: Ensure the explanation or error_message fields are truly appropriate for
|
||||
user display, in a second-person or friendly style.
|
||||
@@ -0,0 +1,697 @@
|
||||
{
|
||||
"$defs": {
|
||||
"Case": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Name"
|
||||
},
|
||||
"inputs": {
|
||||
"$ref": "#/$defs/TimeRangeInputs"
|
||||
},
|
||||
"metadata": {
|
||||
"default": null,
|
||||
"title": "Metadata",
|
||||
"type": "null"
|
||||
},
|
||||
"expected_output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/TimeRangeBuilderSuccess"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/TimeRangeBuilderError"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Expected Output"
|
||||
},
|
||||
"evaluators": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Equals"
|
||||
},
|
||||
{
|
||||
"const": "EqualsExpected",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_IsInstance"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_MaxDuration"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Evaluators",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputs"
|
||||
],
|
||||
"title": "Case",
|
||||
"type": "object"
|
||||
},
|
||||
"KnownModelName": {
|
||||
"enum": [
|
||||
"anthropic:claude-3-7-sonnet-latest",
|
||||
"anthropic:claude-3-5-haiku-latest",
|
||||
"anthropic:claude-3-5-sonnet-latest",
|
||||
"anthropic:claude-3-opus-latest",
|
||||
"claude-3-7-sonnet-latest",
|
||||
"claude-3-5-haiku-latest",
|
||||
"bedrock:amazon.titan-tg1-large",
|
||||
"bedrock:amazon.titan-text-lite-v1",
|
||||
"bedrock:amazon.titan-text-express-v1",
|
||||
"bedrock:us.amazon.nova-pro-v1:0",
|
||||
"bedrock:us.amazon.nova-lite-v1:0",
|
||||
"bedrock:us.amazon.nova-micro-v1:0",
|
||||
"bedrock:anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"bedrock:us.anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"bedrock:anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"bedrock:anthropic.claude-instant-v1",
|
||||
"bedrock:anthropic.claude-v2:1",
|
||||
"bedrock:anthropic.claude-v2",
|
||||
"bedrock:anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"bedrock:anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"bedrock:anthropic.claude-3-opus-20240229-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-opus-20240229-v1:0",
|
||||
"bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"bedrock:anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"bedrock:cohere.command-text-v14",
|
||||
"bedrock:cohere.command-r-v1:0",
|
||||
"bedrock:cohere.command-r-plus-v1:0",
|
||||
"bedrock:cohere.command-light-text-v14",
|
||||
"bedrock:meta.llama3-8b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-70b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-1-8b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-1-8b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-1-70b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-1-70b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-1-405b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-11b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-90b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-1b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-3b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-3-70b-instruct-v1:0",
|
||||
"bedrock:mistral.mistral-7b-instruct-v0:2",
|
||||
"bedrock:mistral.mixtral-8x7b-instruct-v0:1",
|
||||
"bedrock:mistral.mistral-large-2402-v1:0",
|
||||
"bedrock:mistral.mistral-large-2407-v1:0",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest",
|
||||
"cohere:c4ai-aya-expanse-32b",
|
||||
"cohere:c4ai-aya-expanse-8b",
|
||||
"cohere:command",
|
||||
"cohere:command-light",
|
||||
"cohere:command-light-nightly",
|
||||
"cohere:command-nightly",
|
||||
"cohere:command-r",
|
||||
"cohere:command-r-03-2024",
|
||||
"cohere:command-r-08-2024",
|
||||
"cohere:command-r-plus",
|
||||
"cohere:command-r-plus-04-2024",
|
||||
"cohere:command-r-plus-08-2024",
|
||||
"cohere:command-r7b-12-2024",
|
||||
"deepseek:deepseek-chat",
|
||||
"deepseek:deepseek-reasoner",
|
||||
"google-gla:gemini-1.0-pro",
|
||||
"google-gla:gemini-1.5-flash",
|
||||
"google-gla:gemini-1.5-flash-8b",
|
||||
"google-gla:gemini-1.5-pro",
|
||||
"google-gla:gemini-2.0-flash-exp",
|
||||
"google-gla:gemini-2.0-flash-thinking-exp-01-21",
|
||||
"google-gla:gemini-exp-1206",
|
||||
"google-gla:gemini-2.0-flash",
|
||||
"google-gla:gemini-2.0-flash-lite-preview-02-05",
|
||||
"google-gla:gemini-2.0-pro-exp-02-05",
|
||||
"google-vertex:gemini-1.0-pro",
|
||||
"google-vertex:gemini-1.5-flash",
|
||||
"google-vertex:gemini-1.5-flash-8b",
|
||||
"google-vertex:gemini-1.5-pro",
|
||||
"google-vertex:gemini-2.0-flash-exp",
|
||||
"google-vertex:gemini-2.0-flash-thinking-exp-01-21",
|
||||
"google-vertex:gemini-exp-1206",
|
||||
"google-vertex:gemini-2.0-flash",
|
||||
"google-vertex:gemini-2.0-flash-lite-preview-02-05",
|
||||
"google-vertex:gemini-2.0-pro-exp-02-05",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-3.5-turbo-0301",
|
||||
"gpt-3.5-turbo-0613",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-16k",
|
||||
"gpt-3.5-turbo-16k-0613",
|
||||
"gpt-4",
|
||||
"gpt-4-0125-preview",
|
||||
"gpt-4-0314",
|
||||
"gpt-4-0613",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4-32k",
|
||||
"gpt-4-32k-0314",
|
||||
"gpt-4-32k-0613",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-vision-preview",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-4o-audio-preview-2024-10-01",
|
||||
"gpt-4o-audio-preview-2024-12-17",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4o-mini-audio-preview",
|
||||
"gpt-4o-mini-audio-preview-2024-12-17",
|
||||
"gpt-4o-mini-search-preview",
|
||||
"gpt-4o-mini-search-preview-2025-03-11",
|
||||
"gpt-4o-search-preview",
|
||||
"gpt-4o-search-preview-2025-03-11",
|
||||
"groq:distil-whisper-large-v3-en",
|
||||
"groq:gemma2-9b-it",
|
||||
"groq:llama-3.3-70b-versatile",
|
||||
"groq:llama-3.1-8b-instant",
|
||||
"groq:llama-guard-3-8b",
|
||||
"groq:llama3-70b-8192",
|
||||
"groq:llama3-8b-8192",
|
||||
"groq:whisper-large-v3",
|
||||
"groq:whisper-large-v3-turbo",
|
||||
"groq:playai-tts",
|
||||
"groq:playai-tts-arabic",
|
||||
"groq:qwen-qwq-32b",
|
||||
"groq:mistral-saba-24b",
|
||||
"groq:qwen-2.5-coder-32b",
|
||||
"groq:qwen-2.5-32b",
|
||||
"groq:deepseek-r1-distill-qwen-32b",
|
||||
"groq:deepseek-r1-distill-llama-70b",
|
||||
"groq:llama-3.3-70b-specdec",
|
||||
"groq:llama-3.2-1b-preview",
|
||||
"groq:llama-3.2-3b-preview",
|
||||
"groq:llama-3.2-11b-vision-preview",
|
||||
"groq:llama-3.2-90b-vision-preview",
|
||||
"mistral:codestral-latest",
|
||||
"mistral:mistral-large-latest",
|
||||
"mistral:mistral-moderation-latest",
|
||||
"mistral:mistral-small-latest",
|
||||
"o1",
|
||||
"o1-2024-12-17",
|
||||
"o1-mini",
|
||||
"o1-mini-2024-09-12",
|
||||
"o1-preview",
|
||||
"o1-preview-2024-09-12",
|
||||
"o3-mini",
|
||||
"o3-mini-2025-01-31",
|
||||
"openai:chatgpt-4o-latest",
|
||||
"openai:gpt-3.5-turbo",
|
||||
"openai:gpt-3.5-turbo-0125",
|
||||
"openai:gpt-3.5-turbo-0301",
|
||||
"openai:gpt-3.5-turbo-0613",
|
||||
"openai:gpt-3.5-turbo-1106",
|
||||
"openai:gpt-3.5-turbo-16k",
|
||||
"openai:gpt-3.5-turbo-16k-0613",
|
||||
"openai:gpt-4",
|
||||
"openai:gpt-4-0125-preview",
|
||||
"openai:gpt-4-0314",
|
||||
"openai:gpt-4-0613",
|
||||
"openai:gpt-4-1106-preview",
|
||||
"openai:gpt-4-32k",
|
||||
"openai:gpt-4-32k-0314",
|
||||
"openai:gpt-4-32k-0613",
|
||||
"openai:gpt-4-turbo",
|
||||
"openai:gpt-4-turbo-2024-04-09",
|
||||
"openai:gpt-4-turbo-preview",
|
||||
"openai:gpt-4-vision-preview",
|
||||
"openai:gpt-4o",
|
||||
"openai:gpt-4o-2024-05-13",
|
||||
"openai:gpt-4o-2024-08-06",
|
||||
"openai:gpt-4o-2024-11-20",
|
||||
"openai:gpt-4o-audio-preview",
|
||||
"openai:gpt-4o-audio-preview-2024-10-01",
|
||||
"openai:gpt-4o-audio-preview-2024-12-17",
|
||||
"openai:gpt-4o-mini",
|
||||
"openai:gpt-4o-mini-2024-07-18",
|
||||
"openai:gpt-4o-mini-audio-preview",
|
||||
"openai:gpt-4o-mini-audio-preview-2024-12-17",
|
||||
"openai:gpt-4o-mini-search-preview",
|
||||
"openai:gpt-4o-mini-search-preview-2025-03-11",
|
||||
"openai:gpt-4o-search-preview",
|
||||
"openai:gpt-4o-search-preview-2025-03-11",
|
||||
"openai:o1",
|
||||
"openai:o1-2024-12-17",
|
||||
"openai:o1-mini",
|
||||
"openai:o1-mini-2024-09-12",
|
||||
"openai:o1-preview",
|
||||
"openai:o1-preview-2024-09-12",
|
||||
"openai:o3-mini",
|
||||
"openai:o3-mini-2025-01-31",
|
||||
"test"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SpanQuery": {
|
||||
"description": "A serializable query for filtering SpanNodes based on various conditions.\n\nAll fields are optional and combined with AND logic by default.",
|
||||
"properties": {
|
||||
"name_equals": {
|
||||
"title": "Name Equals",
|
||||
"type": "string"
|
||||
},
|
||||
"name_contains": {
|
||||
"title": "Name Contains",
|
||||
"type": "string"
|
||||
},
|
||||
"name_matches_regex": {
|
||||
"title": "Name Matches Regex",
|
||||
"type": "string"
|
||||
},
|
||||
"has_attributes": {
|
||||
"title": "Has Attributes",
|
||||
"type": "object"
|
||||
},
|
||||
"has_attribute_keys": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Has Attribute Keys",
|
||||
"type": "array"
|
||||
},
|
||||
"min_duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "duration",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"title": "Min Duration"
|
||||
},
|
||||
"max_duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "duration",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"title": "Max Duration"
|
||||
},
|
||||
"not_": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"and_": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"title": "And",
|
||||
"type": "array"
|
||||
},
|
||||
"or_": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"title": "Or",
|
||||
"type": "array"
|
||||
},
|
||||
"min_child_count": {
|
||||
"title": "Min Child Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_child_count": {
|
||||
"title": "Max Child Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"some_child_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"all_children_have": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"no_child_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"stop_recursing_when": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"min_descendant_count": {
|
||||
"title": "Min Descendant Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_descendant_count": {
|
||||
"title": "Max Descendant Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"some_descendant_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"all_descendants_have": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"no_descendant_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"min_depth": {
|
||||
"title": "Min Depth",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_depth": {
|
||||
"title": "Max Depth",
|
||||
"type": "integer"
|
||||
},
|
||||
"some_ancestor_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"all_ancestors_have": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"no_ancestor_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
}
|
||||
},
|
||||
"title": "SpanQuery",
|
||||
"type": "object"
|
||||
},
|
||||
"TimeRangeBuilderError": {
|
||||
"description": "Response when a time range cannot not be generated.",
|
||||
"properties": {
|
||||
"error_message": {
|
||||
"title": "Error Message",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error_message"
|
||||
],
|
||||
"title": "TimeRangeBuilderError",
|
||||
"type": "object"
|
||||
},
|
||||
"TimeRangeBuilderSuccess": {
|
||||
"description": "Response when a time range could be successfully generated.",
|
||||
"properties": {
|
||||
"min_timestamp_with_offset": {
|
||||
"description": "A datetime in ISO format with timezone offset.",
|
||||
"format": "date-time",
|
||||
"title": "Min Timestamp With Offset",
|
||||
"type": "string"
|
||||
},
|
||||
"max_timestamp_with_offset": {
|
||||
"description": "A datetime in ISO format with timezone offset.",
|
||||
"format": "date-time",
|
||||
"title": "Max Timestamp With Offset",
|
||||
"type": "string"
|
||||
},
|
||||
"explanation": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "A brief explanation of the time range that was selected.\n\nFor example, if a user only mentions a specific point in time, you might explain that you selected a 10 minute\nwindow around that time.",
|
||||
"title": "Explanation"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"min_timestamp_with_offset",
|
||||
"max_timestamp_with_offset",
|
||||
"explanation"
|
||||
],
|
||||
"title": "TimeRangeBuilderSuccess",
|
||||
"type": "object"
|
||||
},
|
||||
"TimeRangeInputs": {
|
||||
"description": "The inputs for the time range inference agent.",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"type": "string"
|
||||
},
|
||||
"now": {
|
||||
"format": "date-time",
|
||||
"title": "Now",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"prompt",
|
||||
"now"
|
||||
],
|
||||
"title": "TimeRangeInputs",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_Contains": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Contains": {
|
||||
"$ref": "#/$defs/evaluator_params_Contains"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Contains"
|
||||
],
|
||||
"title": "evaluator_Contains",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_LLMJudge": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"LLMJudge": {
|
||||
"$ref": "#/$defs/evaluator_params_LLMJudge"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"LLMJudge"
|
||||
],
|
||||
"title": "evaluator_LLMJudge",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_params_Contains": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {
|
||||
"title": "Value"
|
||||
},
|
||||
"case_sensitive": {
|
||||
"title": "Case Sensitive",
|
||||
"type": "boolean"
|
||||
},
|
||||
"as_strings": {
|
||||
"title": "As Strings",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"value"
|
||||
],
|
||||
"title": "evaluator_params_Contains",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_params_LLMJudge": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rubric": {
|
||||
"title": "Rubric",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/$defs/KnownModelName",
|
||||
"title": "Model"
|
||||
},
|
||||
"include_input": {
|
||||
"title": "Include Input",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rubric"
|
||||
],
|
||||
"title": "evaluator_params_LLMJudge",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_Contains": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Contains": {
|
||||
"title": "Contains"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Contains"
|
||||
],
|
||||
"title": "short_evaluator_Contains",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_Equals": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Equals": {
|
||||
"title": "Equals"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Equals"
|
||||
],
|
||||
"title": "short_evaluator_Equals",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_HasMatchingSpan": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"HasMatchingSpan": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"HasMatchingSpan"
|
||||
],
|
||||
"title": "short_evaluator_HasMatchingSpan",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_IsInstance": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"IsInstance": {
|
||||
"title": "Isinstance",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"IsInstance"
|
||||
],
|
||||
"title": "short_evaluator_IsInstance",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_LLMJudge": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"LLMJudge": {
|
||||
"title": "Llmjudge",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"LLMJudge"
|
||||
],
|
||||
"title": "short_evaluator_LLMJudge",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_MaxDuration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"MaxDuration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"format": "duration",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"title": "Maxduration"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"MaxDuration"
|
||||
],
|
||||
"title": "short_evaluator_MaxDuration",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cases": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/Case"
|
||||
},
|
||||
"title": "Cases",
|
||||
"type": "array"
|
||||
},
|
||||
"evaluators": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Equals"
|
||||
},
|
||||
{
|
||||
"const": "EqualsExpected",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_IsInstance"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_MaxDuration"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Evaluators",
|
||||
"type": "array"
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cases"
|
||||
],
|
||||
"title": "Dataset",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# yaml-language-server: $schema=time_range_v2_schema.json
|
||||
cases:
|
||||
- name: Single day mention
|
||||
inputs:
|
||||
prompt: I want to see logs from 2021-05-08
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2021-05-08T00:00:00Z'
|
||||
max_timestamp_with_offset: '2021-05-08T23:59:59Z'
|
||||
explanation: You mentioned a single day (2021-05-08). The entire day is used.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Ambiguous mention
|
||||
inputs:
|
||||
prompt: Check logs from last week or so, around early May
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-10-21T09:30:00Z'
|
||||
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
|
||||
explanation: We interpret the mention of early May as extraneous, focusing on
|
||||
'last week or so' from the current time.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- LLMJudge: We want to interpret conflicting references by default to the more recent
|
||||
timeframe; confirm the explanation addresses ignoring early May.
|
||||
- name: Single datetime mention
|
||||
inputs:
|
||||
prompt: Show me the logs at 2023-10-27 2:00pm
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-10-27T13:50:00Z'
|
||||
max_timestamp_with_offset: '2023-10-27T14:10:00Z'
|
||||
explanation: You only mentioned a single point in time, so a 10-minute window
|
||||
around that time is used.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Relative mention without date
|
||||
inputs:
|
||||
prompt: Check logs from 2 hours ago
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-10-28T07:30:00Z'
|
||||
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
|
||||
explanation: You requested logs starting from 2 hours prior to the current time.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- AgentCalledTool:
|
||||
agent_name: time_range_agent
|
||||
tool_name: get_current_time
|
||||
- name: Impossible range
|
||||
inputs:
|
||||
prompt: Check logs from 2025, but make sure they are also from 2020
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
error_message: 'Conflicting time instructions: 2025 and 2020 cannot both apply.'
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderError
|
||||
- name: No mention
|
||||
inputs:
|
||||
prompt: Show me some logs
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
error_message: No timeframe could be inferred from your request.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderError
|
||||
- name: Ambiguous elliptical mention
|
||||
inputs:
|
||||
prompt: Check logs from around the start of last quarter
|
||||
now: '2023-07-15T08:00:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-04-01T00:00:00Z'
|
||||
max_timestamp_with_offset: '2023-04-05T23:59:59Z'
|
||||
explanation: We interpret 'around the start of last quarter' as the first few
|
||||
days of Q2 2023.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Far future mention
|
||||
inputs:
|
||||
prompt: Check logs from January 3050
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '3050-01-01T00:00:00Z'
|
||||
max_timestamp_with_offset: '3050-01-31T23:59:59Z'
|
||||
explanation: You requested logs from January 3050. The entire month is used.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
- name: Confusing relative references
|
||||
inputs:
|
||||
prompt: Check logs from yesterday but also last year
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
error_message: 'Conflicting instructions: ''yesterday'' versus ''last year'' could
|
||||
not be reconciled.'
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderError
|
||||
- name: Range from speech
|
||||
inputs:
|
||||
prompt: I want the logs from December 25th to December 26th, so I can see what
|
||||
happened on Christmas day. But also it might be earlier.
|
||||
now: '2023-10-28T09:30:00Z'
|
||||
expected_output:
|
||||
min_timestamp_with_offset: '2023-12-25T00:00:00Z'
|
||||
max_timestamp_with_offset: '2023-12-26T23:59:59Z'
|
||||
explanation: You asked specifically for December 25th to December 26th. The mention
|
||||
of an earlier date is ignored since a range was provided.
|
||||
evaluators:
|
||||
- IsInstance: TimeRangeBuilderSuccess
|
||||
evaluators:
|
||||
- LLMJudge: Ensure the explanation or error_message fields are truly appropriate for
|
||||
user display, in a second-person or friendly style.
|
||||
- ValidateTimeRange
|
||||
- UserMessageIsConcise
|
||||
@@ -0,0 +1,751 @@
|
||||
{
|
||||
"$defs": {
|
||||
"Case": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Name"
|
||||
},
|
||||
"inputs": {
|
||||
"$ref": "#/$defs/TimeRangeInputs"
|
||||
},
|
||||
"metadata": {
|
||||
"default": null,
|
||||
"title": "Metadata",
|
||||
"type": "null"
|
||||
},
|
||||
"expected_output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/TimeRangeBuilderSuccess"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/TimeRangeBuilderError"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Expected Output"
|
||||
},
|
||||
"evaluators": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "ValidateTimeRange",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "UserMessageIsConcise",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_AgentCalledTool"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Equals"
|
||||
},
|
||||
{
|
||||
"const": "EqualsExpected",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_IsInstance"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_MaxDuration"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Evaluators",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputs"
|
||||
],
|
||||
"title": "Case",
|
||||
"type": "object"
|
||||
},
|
||||
"KnownModelName": {
|
||||
"enum": [
|
||||
"anthropic:claude-3-7-sonnet-latest",
|
||||
"anthropic:claude-3-5-haiku-latest",
|
||||
"anthropic:claude-3-5-sonnet-latest",
|
||||
"anthropic:claude-3-opus-latest",
|
||||
"claude-3-7-sonnet-latest",
|
||||
"claude-3-5-haiku-latest",
|
||||
"bedrock:amazon.titan-tg1-large",
|
||||
"bedrock:amazon.titan-text-lite-v1",
|
||||
"bedrock:amazon.titan-text-express-v1",
|
||||
"bedrock:us.amazon.nova-pro-v1:0",
|
||||
"bedrock:us.amazon.nova-lite-v1:0",
|
||||
"bedrock:us.amazon.nova-micro-v1:0",
|
||||
"bedrock:anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"bedrock:us.anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"bedrock:anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"bedrock:anthropic.claude-instant-v1",
|
||||
"bedrock:anthropic.claude-v2:1",
|
||||
"bedrock:anthropic.claude-v2",
|
||||
"bedrock:anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"bedrock:anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"bedrock:anthropic.claude-3-opus-20240229-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-opus-20240229-v1:0",
|
||||
"bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"bedrock:anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"bedrock:us.anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"bedrock:cohere.command-text-v14",
|
||||
"bedrock:cohere.command-r-v1:0",
|
||||
"bedrock:cohere.command-r-plus-v1:0",
|
||||
"bedrock:cohere.command-light-text-v14",
|
||||
"bedrock:meta.llama3-8b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-70b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-1-8b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-1-8b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-1-70b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-1-70b-instruct-v1:0",
|
||||
"bedrock:meta.llama3-1-405b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-11b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-90b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-1b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-2-3b-instruct-v1:0",
|
||||
"bedrock:us.meta.llama3-3-70b-instruct-v1:0",
|
||||
"bedrock:mistral.mistral-7b-instruct-v0:2",
|
||||
"bedrock:mistral.mixtral-8x7b-instruct-v0:1",
|
||||
"bedrock:mistral.mistral-large-2402-v1:0",
|
||||
"bedrock:mistral.mistral-large-2407-v1:0",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest",
|
||||
"cohere:c4ai-aya-expanse-32b",
|
||||
"cohere:c4ai-aya-expanse-8b",
|
||||
"cohere:command",
|
||||
"cohere:command-light",
|
||||
"cohere:command-light-nightly",
|
||||
"cohere:command-nightly",
|
||||
"cohere:command-r",
|
||||
"cohere:command-r-03-2024",
|
||||
"cohere:command-r-08-2024",
|
||||
"cohere:command-r-plus",
|
||||
"cohere:command-r-plus-04-2024",
|
||||
"cohere:command-r-plus-08-2024",
|
||||
"cohere:command-r7b-12-2024",
|
||||
"deepseek:deepseek-chat",
|
||||
"deepseek:deepseek-reasoner",
|
||||
"google-gla:gemini-1.0-pro",
|
||||
"google-gla:gemini-1.5-flash",
|
||||
"google-gla:gemini-1.5-flash-8b",
|
||||
"google-gla:gemini-1.5-pro",
|
||||
"google-gla:gemini-2.0-flash-exp",
|
||||
"google-gla:gemini-2.0-flash-thinking-exp-01-21",
|
||||
"google-gla:gemini-exp-1206",
|
||||
"google-gla:gemini-2.0-flash",
|
||||
"google-gla:gemini-2.0-flash-lite-preview-02-05",
|
||||
"google-gla:gemini-2.0-pro-exp-02-05",
|
||||
"google-vertex:gemini-1.0-pro",
|
||||
"google-vertex:gemini-1.5-flash",
|
||||
"google-vertex:gemini-1.5-flash-8b",
|
||||
"google-vertex:gemini-1.5-pro",
|
||||
"google-vertex:gemini-2.0-flash-exp",
|
||||
"google-vertex:gemini-2.0-flash-thinking-exp-01-21",
|
||||
"google-vertex:gemini-exp-1206",
|
||||
"google-vertex:gemini-2.0-flash",
|
||||
"google-vertex:gemini-2.0-flash-lite-preview-02-05",
|
||||
"google-vertex:gemini-2.0-pro-exp-02-05",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-3.5-turbo-0301",
|
||||
"gpt-3.5-turbo-0613",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-16k",
|
||||
"gpt-3.5-turbo-16k-0613",
|
||||
"gpt-4",
|
||||
"gpt-4-0125-preview",
|
||||
"gpt-4-0314",
|
||||
"gpt-4-0613",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4-32k",
|
||||
"gpt-4-32k-0314",
|
||||
"gpt-4-32k-0613",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-vision-preview",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-4o-audio-preview-2024-10-01",
|
||||
"gpt-4o-audio-preview-2024-12-17",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4o-mini-audio-preview",
|
||||
"gpt-4o-mini-audio-preview-2024-12-17",
|
||||
"gpt-4o-mini-search-preview",
|
||||
"gpt-4o-mini-search-preview-2025-03-11",
|
||||
"gpt-4o-search-preview",
|
||||
"gpt-4o-search-preview-2025-03-11",
|
||||
"groq:distil-whisper-large-v3-en",
|
||||
"groq:gemma2-9b-it",
|
||||
"groq:llama-3.3-70b-versatile",
|
||||
"groq:llama-3.1-8b-instant",
|
||||
"groq:llama-guard-3-8b",
|
||||
"groq:llama3-70b-8192",
|
||||
"groq:llama3-8b-8192",
|
||||
"groq:whisper-large-v3",
|
||||
"groq:whisper-large-v3-turbo",
|
||||
"groq:playai-tts",
|
||||
"groq:playai-tts-arabic",
|
||||
"groq:qwen-qwq-32b",
|
||||
"groq:mistral-saba-24b",
|
||||
"groq:qwen-2.5-coder-32b",
|
||||
"groq:qwen-2.5-32b",
|
||||
"groq:deepseek-r1-distill-qwen-32b",
|
||||
"groq:deepseek-r1-distill-llama-70b",
|
||||
"groq:llama-3.3-70b-specdec",
|
||||
"groq:llama-3.2-1b-preview",
|
||||
"groq:llama-3.2-3b-preview",
|
||||
"groq:llama-3.2-11b-vision-preview",
|
||||
"groq:llama-3.2-90b-vision-preview",
|
||||
"mistral:codestral-latest",
|
||||
"mistral:mistral-large-latest",
|
||||
"mistral:mistral-moderation-latest",
|
||||
"mistral:mistral-small-latest",
|
||||
"o1",
|
||||
"o1-2024-12-17",
|
||||
"o1-mini",
|
||||
"o1-mini-2024-09-12",
|
||||
"o1-preview",
|
||||
"o1-preview-2024-09-12",
|
||||
"o3-mini",
|
||||
"o3-mini-2025-01-31",
|
||||
"openai:chatgpt-4o-latest",
|
||||
"openai:gpt-3.5-turbo",
|
||||
"openai:gpt-3.5-turbo-0125",
|
||||
"openai:gpt-3.5-turbo-0301",
|
||||
"openai:gpt-3.5-turbo-0613",
|
||||
"openai:gpt-3.5-turbo-1106",
|
||||
"openai:gpt-3.5-turbo-16k",
|
||||
"openai:gpt-3.5-turbo-16k-0613",
|
||||
"openai:gpt-4",
|
||||
"openai:gpt-4-0125-preview",
|
||||
"openai:gpt-4-0314",
|
||||
"openai:gpt-4-0613",
|
||||
"openai:gpt-4-1106-preview",
|
||||
"openai:gpt-4-32k",
|
||||
"openai:gpt-4-32k-0314",
|
||||
"openai:gpt-4-32k-0613",
|
||||
"openai:gpt-4-turbo",
|
||||
"openai:gpt-4-turbo-2024-04-09",
|
||||
"openai:gpt-4-turbo-preview",
|
||||
"openai:gpt-4-vision-preview",
|
||||
"openai:gpt-4o",
|
||||
"openai:gpt-4o-2024-05-13",
|
||||
"openai:gpt-4o-2024-08-06",
|
||||
"openai:gpt-4o-2024-11-20",
|
||||
"openai:gpt-4o-audio-preview",
|
||||
"openai:gpt-4o-audio-preview-2024-10-01",
|
||||
"openai:gpt-4o-audio-preview-2024-12-17",
|
||||
"openai:gpt-4o-mini",
|
||||
"openai:gpt-4o-mini-2024-07-18",
|
||||
"openai:gpt-4o-mini-audio-preview",
|
||||
"openai:gpt-4o-mini-audio-preview-2024-12-17",
|
||||
"openai:gpt-4o-mini-search-preview",
|
||||
"openai:gpt-4o-mini-search-preview-2025-03-11",
|
||||
"openai:gpt-4o-search-preview",
|
||||
"openai:gpt-4o-search-preview-2025-03-11",
|
||||
"openai:o1",
|
||||
"openai:o1-2024-12-17",
|
||||
"openai:o1-mini",
|
||||
"openai:o1-mini-2024-09-12",
|
||||
"openai:o1-preview",
|
||||
"openai:o1-preview-2024-09-12",
|
||||
"openai:o3-mini",
|
||||
"openai:o3-mini-2025-01-31",
|
||||
"test"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SpanQuery": {
|
||||
"description": "A serializable query for filtering SpanNodes based on various conditions.\n\nAll fields are optional and combined with AND logic by default.",
|
||||
"properties": {
|
||||
"name_equals": {
|
||||
"title": "Name Equals",
|
||||
"type": "string"
|
||||
},
|
||||
"name_contains": {
|
||||
"title": "Name Contains",
|
||||
"type": "string"
|
||||
},
|
||||
"name_matches_regex": {
|
||||
"title": "Name Matches Regex",
|
||||
"type": "string"
|
||||
},
|
||||
"has_attributes": {
|
||||
"title": "Has Attributes",
|
||||
"type": "object"
|
||||
},
|
||||
"has_attribute_keys": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Has Attribute Keys",
|
||||
"type": "array"
|
||||
},
|
||||
"min_duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "duration",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"title": "Min Duration"
|
||||
},
|
||||
"max_duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "duration",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"title": "Max Duration"
|
||||
},
|
||||
"not_": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"and_": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"title": "And",
|
||||
"type": "array"
|
||||
},
|
||||
"or_": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"title": "Or",
|
||||
"type": "array"
|
||||
},
|
||||
"min_child_count": {
|
||||
"title": "Min Child Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_child_count": {
|
||||
"title": "Max Child Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"some_child_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"all_children_have": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"no_child_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"stop_recursing_when": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"min_descendant_count": {
|
||||
"title": "Min Descendant Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_descendant_count": {
|
||||
"title": "Max Descendant Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"some_descendant_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"all_descendants_have": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"no_descendant_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"min_depth": {
|
||||
"title": "Min Depth",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_depth": {
|
||||
"title": "Max Depth",
|
||||
"type": "integer"
|
||||
},
|
||||
"some_ancestor_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"all_ancestors_have": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
},
|
||||
"no_ancestor_has": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
}
|
||||
},
|
||||
"title": "SpanQuery",
|
||||
"type": "object"
|
||||
},
|
||||
"TimeRangeBuilderError": {
|
||||
"description": "Response when a time range cannot not be generated.",
|
||||
"properties": {
|
||||
"error_message": {
|
||||
"title": "Error Message",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error_message"
|
||||
],
|
||||
"title": "TimeRangeBuilderError",
|
||||
"type": "object"
|
||||
},
|
||||
"TimeRangeBuilderSuccess": {
|
||||
"description": "Response when a time range could be successfully generated.",
|
||||
"properties": {
|
||||
"min_timestamp_with_offset": {
|
||||
"description": "A datetime in ISO format with timezone offset.",
|
||||
"format": "date-time",
|
||||
"title": "Min Timestamp With Offset",
|
||||
"type": "string"
|
||||
},
|
||||
"max_timestamp_with_offset": {
|
||||
"description": "A datetime in ISO format with timezone offset.",
|
||||
"format": "date-time",
|
||||
"title": "Max Timestamp With Offset",
|
||||
"type": "string"
|
||||
},
|
||||
"explanation": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "A brief explanation of the time range that was selected.\n\nFor example, if a user only mentions a specific point in time, you might explain that you selected a 10 minute\nwindow around that time.",
|
||||
"title": "Explanation"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"min_timestamp_with_offset",
|
||||
"max_timestamp_with_offset",
|
||||
"explanation"
|
||||
],
|
||||
"title": "TimeRangeBuilderSuccess",
|
||||
"type": "object"
|
||||
},
|
||||
"TimeRangeInputs": {
|
||||
"description": "The inputs for the time range inference agent.",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"type": "string"
|
||||
},
|
||||
"now": {
|
||||
"format": "date-time",
|
||||
"title": "Now",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"prompt",
|
||||
"now"
|
||||
],
|
||||
"title": "TimeRangeInputs",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_AgentCalledTool": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"AgentCalledTool": {
|
||||
"$ref": "#/$defs/evaluator_params_AgentCalledTool"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"AgentCalledTool"
|
||||
],
|
||||
"title": "evaluator_AgentCalledTool",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_Contains": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Contains": {
|
||||
"$ref": "#/$defs/evaluator_params_Contains"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Contains"
|
||||
],
|
||||
"title": "evaluator_Contains",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_LLMJudge": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"LLMJudge": {
|
||||
"$ref": "#/$defs/evaluator_params_LLMJudge"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"LLMJudge"
|
||||
],
|
||||
"title": "evaluator_LLMJudge",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_params_AgentCalledTool": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"title": "Agent Name",
|
||||
"type": "string"
|
||||
},
|
||||
"tool_name": {
|
||||
"title": "Tool Name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_name",
|
||||
"tool_name"
|
||||
],
|
||||
"title": "evaluator_params_AgentCalledTool",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_params_Contains": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {
|
||||
"title": "Value"
|
||||
},
|
||||
"case_sensitive": {
|
||||
"title": "Case Sensitive",
|
||||
"type": "boolean"
|
||||
},
|
||||
"as_strings": {
|
||||
"title": "As Strings",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"value"
|
||||
],
|
||||
"title": "evaluator_params_Contains",
|
||||
"type": "object"
|
||||
},
|
||||
"evaluator_params_LLMJudge": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rubric": {
|
||||
"title": "Rubric",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/$defs/KnownModelName",
|
||||
"title": "Model"
|
||||
},
|
||||
"include_input": {
|
||||
"title": "Include Input",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rubric"
|
||||
],
|
||||
"title": "evaluator_params_LLMJudge",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_Contains": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Contains": {
|
||||
"title": "Contains"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Contains"
|
||||
],
|
||||
"title": "short_evaluator_Contains",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_Equals": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"Equals": {
|
||||
"title": "Equals"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Equals"
|
||||
],
|
||||
"title": "short_evaluator_Equals",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_HasMatchingSpan": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"HasMatchingSpan": {
|
||||
"$ref": "#/$defs/SpanQuery"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"HasMatchingSpan"
|
||||
],
|
||||
"title": "short_evaluator_HasMatchingSpan",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_IsInstance": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"IsInstance": {
|
||||
"title": "Isinstance",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"IsInstance"
|
||||
],
|
||||
"title": "short_evaluator_IsInstance",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_LLMJudge": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"LLMJudge": {
|
||||
"title": "Llmjudge",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"LLMJudge"
|
||||
],
|
||||
"title": "short_evaluator_LLMJudge",
|
||||
"type": "object"
|
||||
},
|
||||
"short_evaluator_MaxDuration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"MaxDuration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"format": "duration",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"title": "Maxduration"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"MaxDuration"
|
||||
],
|
||||
"title": "short_evaluator_MaxDuration",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cases": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/Case"
|
||||
},
|
||||
"title": "Cases",
|
||||
"type": "array"
|
||||
},
|
||||
"evaluators": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "ValidateTimeRange",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "UserMessageIsConcise",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_AgentCalledTool"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Equals"
|
||||
},
|
||||
{
|
||||
"const": "EqualsExpected",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_Contains"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_IsInstance"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_MaxDuration"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/evaluator_LLMJudge"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Evaluators",
|
||||
"type": "array"
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cases"
|
||||
],
|
||||
"title": "Dataset",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import NoneType
|
||||
|
||||
from pydantic_ai_examples.evals.models import TimeRangeInputs, TimeRangeResponse
|
||||
from pydantic_evals import Dataset
|
||||
from pydantic_evals.generation import generate_dataset
|
||||
|
||||
|
||||
async def main():
|
||||
dataset = await generate_dataset(
|
||||
dataset_type=Dataset[TimeRangeInputs, TimeRangeResponse, NoneType],
|
||||
model='openai:gpt-5.2', # Use a smarter model since this is a more complex task that is only run once
|
||||
n_examples=10,
|
||||
extra_instructions="""
|
||||
Generate a dataset of test cases for the time range inference agent.
|
||||
|
||||
Include a variety of inputs that might be given to the agent, including some where the only
|
||||
reasonable response is a `TimeRangeBuilderError`, and some where a `TimeRangeBuilderSuccess` is
|
||||
expected. Make use of the `IsInstance` evaluator to ensure that the inputs and outputs are of the appropriate
|
||||
type.
|
||||
|
||||
When appropriate, use the `LLMJudge` evaluator to provide a more precise description of the time range the
|
||||
agent should have inferred. In particular, it's good if the example user inputs are somewhat ambiguous, to
|
||||
reflect realistic (difficult-to-handle) user questions, but the LLMJudge evaluator can help ensure that the
|
||||
agent's output is still judged based on precisely what the desired behavior is even for somewhat ambiguous
|
||||
user questions. You do not need to include LLMJudge evaluations for all cases (in particular, for cases where
|
||||
the expected output is unambiguous from the user's question), but you should include at least one or two
|
||||
examples that do benefit from an LLMJudge evaluation (and include it).
|
||||
|
||||
To be clear, the LLMJudge rubrics should be concise and reflect only information that is NOT ALREADY PRESENT
|
||||
in the user prompt for the example.
|
||||
|
||||
Leave the model and include_input arguments to LLMJudge as their default values (null).
|
||||
|
||||
Also add a dataset-wide LLMJudge evaluator to ensure that the 'explanation' or 'error_message' fields are
|
||||
appropriate to be displayed to the user (e.g., written in second person, etc.).
|
||||
""",
|
||||
)
|
||||
|
||||
dataset.to_file(
|
||||
Path(__file__).parent / 'datasets' / 'time_range_v1.yaml',
|
||||
fmt='yaml',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
from types import NoneType
|
||||
|
||||
from pydantic_ai_examples.evals.custom_evaluators import (
|
||||
CUSTOM_EVALUATOR_TYPES,
|
||||
AgentCalledTool,
|
||||
UserMessageIsConcise,
|
||||
ValidateTimeRange,
|
||||
)
|
||||
from pydantic_ai_examples.evals.models import (
|
||||
TimeRangeInputs,
|
||||
TimeRangeResponse,
|
||||
)
|
||||
from pydantic_evals import Dataset
|
||||
|
||||
|
||||
def main():
|
||||
dataset_path = Path(__file__).parent / 'datasets' / 'time_range_v1.yaml'
|
||||
dataset = Dataset[TimeRangeInputs, TimeRangeResponse, NoneType].from_file(
|
||||
dataset_path
|
||||
)
|
||||
dataset.add_evaluator(ValidateTimeRange())
|
||||
dataset.add_evaluator(UserMessageIsConcise())
|
||||
dataset.add_evaluator(
|
||||
AgentCalledTool('time_range_agent', 'get_current_time'),
|
||||
specific_case='Relative mention without date',
|
||||
)
|
||||
dataset.to_file(
|
||||
Path(__file__).parent / 'datasets' / 'time_range_v2.yaml',
|
||||
custom_evaluator_types=CUSTOM_EVALUATOR_TYPES,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
from types import NoneType
|
||||
|
||||
import logfire
|
||||
|
||||
from pydantic_ai_examples.evals import infer_time_range
|
||||
from pydantic_ai_examples.evals.custom_evaluators import (
|
||||
CUSTOM_EVALUATOR_TYPES,
|
||||
)
|
||||
from pydantic_ai_examples.evals.models import (
|
||||
TimeRangeInputs,
|
||||
TimeRangeResponse,
|
||||
)
|
||||
from pydantic_evals import Dataset
|
||||
|
||||
logfire.configure(
|
||||
send_to_logfire='if-token-present',
|
||||
environment='development',
|
||||
service_name='evals',
|
||||
)
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
def evaluate_dataset():
|
||||
dataset_path = Path(__file__).parent / 'datasets' / 'time_range_v2.yaml'
|
||||
dataset = Dataset[TimeRangeInputs, TimeRangeResponse, NoneType].from_file(
|
||||
dataset_path, custom_evaluator_types=CUSTOM_EVALUATOR_TYPES
|
||||
)
|
||||
report = dataset.evaluate_sync(infer_time_range)
|
||||
print(report)
|
||||
|
||||
averages = report.averages()
|
||||
assert averages is not None
|
||||
assertion_pass_rate = averages.assertions
|
||||
assert assertion_pass_rate is not None, 'There should be at least one assertion'
|
||||
assert assertion_pass_rate > 0.9, (
|
||||
f'The assertion pass rate was {assertion_pass_rate:.1%}; it should be above 90%.'
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
evaluate_dataset()
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
from types import NoneType
|
||||
|
||||
import logfire
|
||||
|
||||
from pydantic_ai_examples.evals import infer_time_range
|
||||
from pydantic_ai_examples.evals.agent import time_range_agent
|
||||
from pydantic_ai_examples.evals.custom_evaluators import (
|
||||
CUSTOM_EVALUATOR_TYPES,
|
||||
)
|
||||
from pydantic_ai_examples.evals.models import (
|
||||
TimeRangeInputs,
|
||||
TimeRangeResponse,
|
||||
)
|
||||
from pydantic_evals import Dataset
|
||||
|
||||
logfire.configure(
|
||||
send_to_logfire='if-token-present',
|
||||
environment='development',
|
||||
service_name='evals',
|
||||
)
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
def compare_models():
|
||||
dataset_path = Path(__file__).parent / 'datasets' / 'time_range_v2.yaml'
|
||||
dataset = Dataset[TimeRangeInputs, TimeRangeResponse, NoneType].from_file(
|
||||
dataset_path, custom_evaluator_types=CUSTOM_EVALUATOR_TYPES
|
||||
)
|
||||
with logfire.span('Comparing different models for time_range_agent'):
|
||||
with time_range_agent.override(model='openai:gpt-5.1'):
|
||||
dataset.evaluate_sync(infer_time_range, name='openai:gpt-5.1')
|
||||
with time_range_agent.override(model='openai:gpt-5.2'):
|
||||
dataset.evaluate_sync(infer_time_range, name='openai:gpt-5.2')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
compare_models()
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class TimeRangeBuilderSuccess(BaseModel, use_attribute_docstrings=True):
|
||||
"""Response when a time range could be successfully generated."""
|
||||
|
||||
min_timestamp_with_offset: AwareDatetime
|
||||
"""A datetime in ISO format with timezone offset."""
|
||||
|
||||
max_timestamp_with_offset: AwareDatetime
|
||||
"""A datetime in ISO format with timezone offset."""
|
||||
|
||||
explanation: str | None
|
||||
"""
|
||||
A brief explanation of the time range that was selected.
|
||||
|
||||
For example, if a user only mentions a specific point in time, you might explain that you selected a 10 minute
|
||||
window around that time.
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
readable_min_timestamp = self.min_timestamp_with_offset.strftime(
|
||||
'%A, %B %d, %Y %H:%M:%S %Z'
|
||||
)
|
||||
readable_max_timestamp = self.max_timestamp_with_offset.strftime(
|
||||
'%A, %B %d, %Y %H:%M:%S %Z'
|
||||
)
|
||||
lines = [
|
||||
'TimeRangeBuilderSuccess:',
|
||||
f'* min_timestamp_with_offset: {readable_min_timestamp}',
|
||||
f'* max_timestamp_with_offset: {readable_max_timestamp}',
|
||||
]
|
||||
if self.explanation is not None:
|
||||
lines.append(f'* explanation: {self.explanation}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class TimeRangeBuilderError(BaseModel):
|
||||
"""Response when a time range cannot not be generated."""
|
||||
|
||||
error_message: str
|
||||
|
||||
def __str__(self):
|
||||
return f'TimeRangeBuilderError:\n* {self.error_message}'
|
||||
|
||||
|
||||
TimeRangeResponse = TimeRangeBuilderSuccess | TimeRangeBuilderError
|
||||
|
||||
|
||||
class TimeRangeInputs(TypedDict):
|
||||
"""The inputs for the time range inference agent."""
|
||||
|
||||
prompt: str
|
||||
now: AwareDatetime
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Example of a multi-agent flow where one agent delegates work to another.
|
||||
|
||||
In this scenario, a group of agents work together to find flights for a user.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
import logfire
|
||||
from pydantic import BaseModel, Field
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from pydantic_ai import (
|
||||
Agent,
|
||||
ModelMessage,
|
||||
ModelRetry,
|
||||
RunContext,
|
||||
RunUsage,
|
||||
UsageLimits,
|
||||
)
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
class FlightDetails(BaseModel):
|
||||
"""Details of the most suitable flight."""
|
||||
|
||||
flight_number: str
|
||||
price: int
|
||||
origin: str = Field(description='Three-letter airport code')
|
||||
destination: str = Field(description='Three-letter airport code')
|
||||
date: datetime.date
|
||||
|
||||
|
||||
class NoFlightFound(BaseModel):
|
||||
"""When no valid flight is found."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
web_page_text: str
|
||||
req_origin: str
|
||||
req_destination: str
|
||||
req_date: datetime.date
|
||||
|
||||
|
||||
# This agent is responsible for controlling the flow of the conversation.
|
||||
search_agent = Agent[Deps, FlightDetails | NoFlightFound](
|
||||
'openai:gpt-5.2',
|
||||
output_type=FlightDetails | NoFlightFound, # type: ignore
|
||||
retries=4,
|
||||
system_prompt=(
|
||||
'Your job is to find the cheapest flight for the user on the given date. '
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# This agent is responsible for extracting flight details from web page text.
|
||||
extraction_agent = Agent(
|
||||
'openai:gpt-5.2',
|
||||
output_type=list[FlightDetails],
|
||||
system_prompt='Extract all the flight details from the given text.',
|
||||
)
|
||||
|
||||
|
||||
@search_agent.tool
|
||||
async def extract_flights(ctx: RunContext[Deps]) -> list[FlightDetails]:
|
||||
"""Get details of all flights."""
|
||||
# we pass the usage to the search agent so requests within this agent are counted
|
||||
result = await extraction_agent.run(ctx.deps.web_page_text, usage=ctx.usage)
|
||||
logfire.info('found {flight_count} flights', flight_count=len(result.output))
|
||||
return result.output
|
||||
|
||||
|
||||
@search_agent.output_validator
|
||||
async def validate_output(
|
||||
ctx: RunContext[Deps], output: FlightDetails | NoFlightFound
|
||||
) -> FlightDetails | NoFlightFound:
|
||||
"""Procedural validation that the flight meets the constraints."""
|
||||
if isinstance(output, NoFlightFound):
|
||||
return output
|
||||
|
||||
errors: list[str] = []
|
||||
if output.origin != ctx.deps.req_origin:
|
||||
errors.append(
|
||||
f'Flight should have origin {ctx.deps.req_origin}, not {output.origin}'
|
||||
)
|
||||
if output.destination != ctx.deps.req_destination:
|
||||
errors.append(
|
||||
f'Flight should have destination {ctx.deps.req_destination}, not {output.destination}'
|
||||
)
|
||||
if output.date != ctx.deps.req_date:
|
||||
errors.append(f'Flight should be on {ctx.deps.req_date}, not {output.date}')
|
||||
|
||||
if errors:
|
||||
raise ModelRetry('\n'.join(errors))
|
||||
else:
|
||||
return output
|
||||
|
||||
|
||||
class SeatPreference(BaseModel):
|
||||
row: int = Field(ge=1, le=30)
|
||||
seat: Literal['A', 'B', 'C', 'D', 'E', 'F']
|
||||
|
||||
|
||||
class Failed(BaseModel):
|
||||
"""Unable to extract a seat selection."""
|
||||
|
||||
|
||||
# This agent is responsible for extracting the user's seat selection
|
||||
seat_preference_agent = Agent[object, SeatPreference | Failed](
|
||||
'openai:gpt-5.2',
|
||||
output_type=SeatPreference | Failed,
|
||||
system_prompt=(
|
||||
"Extract the user's seat preference. "
|
||||
'Seats A and F are window seats. '
|
||||
'Row 1 is the front row and has extra leg room. '
|
||||
'Rows 14, and 20 also have extra leg room. '
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# in reality this would be downloaded from a booking site,
|
||||
# potentially using another agent to navigate the site
|
||||
flights_web_page = """
|
||||
1. Flight SFO-AK123
|
||||
- Price: $350
|
||||
- Origin: San Francisco International Airport (SFO)
|
||||
- Destination: Ted Stevens Anchorage International Airport (ANC)
|
||||
- Date: January 10, 2025
|
||||
|
||||
2. Flight SFO-AK456
|
||||
- Price: $370
|
||||
- Origin: San Francisco International Airport (SFO)
|
||||
- Destination: Fairbanks International Airport (FAI)
|
||||
- Date: January 10, 2025
|
||||
|
||||
3. Flight SFO-AK789
|
||||
- Price: $400
|
||||
- Origin: San Francisco International Airport (SFO)
|
||||
- Destination: Juneau International Airport (JNU)
|
||||
- Date: January 20, 2025
|
||||
|
||||
4. Flight NYC-LA101
|
||||
- Price: $250
|
||||
- Origin: San Francisco International Airport (SFO)
|
||||
- Destination: Ted Stevens Anchorage International Airport (ANC)
|
||||
- Date: January 10, 2025
|
||||
|
||||
5. Flight CHI-MIA202
|
||||
- Price: $200
|
||||
- Origin: Chicago O'Hare International Airport (ORD)
|
||||
- Destination: Miami International Airport (MIA)
|
||||
- Date: January 12, 2025
|
||||
|
||||
6. Flight BOS-SEA303
|
||||
- Price: $120
|
||||
- Origin: Boston Logan International Airport (BOS)
|
||||
- Destination: Ted Stevens Anchorage International Airport (ANC)
|
||||
- Date: January 12, 2025
|
||||
|
||||
7. Flight DFW-DEN404
|
||||
- Price: $150
|
||||
- Origin: Dallas/Fort Worth International Airport (DFW)
|
||||
- Destination: Denver International Airport (DEN)
|
||||
- Date: January 10, 2025
|
||||
|
||||
8. Flight ATL-HOU505
|
||||
- Price: $180
|
||||
- Origin: Hartsfield-Jackson Atlanta International Airport (ATL)
|
||||
- Destination: George Bush Intercontinental Airport (IAH)
|
||||
- Date: January 10, 2025
|
||||
"""
|
||||
|
||||
# restrict how many requests this app can make to the LLM
|
||||
usage_limits = UsageLimits(request_limit=15)
|
||||
|
||||
|
||||
async def main():
|
||||
deps = Deps(
|
||||
web_page_text=flights_web_page,
|
||||
req_origin='SFO',
|
||||
req_destination='ANC',
|
||||
req_date=datetime.date(2025, 1, 10),
|
||||
)
|
||||
message_history: list[ModelMessage] | None = None
|
||||
usage: RunUsage = RunUsage()
|
||||
# run the agent until a satisfactory flight is found
|
||||
while True:
|
||||
result = await search_agent.run(
|
||||
f'Find me a flight from {deps.req_origin} to {deps.req_destination} on {deps.req_date}',
|
||||
deps=deps,
|
||||
usage=usage,
|
||||
message_history=message_history,
|
||||
usage_limits=usage_limits,
|
||||
)
|
||||
if isinstance(result.output, NoFlightFound):
|
||||
print('No flight found')
|
||||
break
|
||||
else:
|
||||
flight = result.output
|
||||
print(f'Flight found: {flight}')
|
||||
answer = Prompt.ask(
|
||||
'Do you want to buy this flight, or keep searching? (buy/*search)',
|
||||
choices=['buy', 'search', ''],
|
||||
show_choices=False,
|
||||
)
|
||||
if answer == 'buy':
|
||||
seat = await find_seat(usage)
|
||||
await buy_tickets(flight, seat)
|
||||
break
|
||||
else:
|
||||
message_history = result.all_messages(
|
||||
output_tool_return_content='Please suggest another flight'
|
||||
)
|
||||
|
||||
|
||||
async def find_seat(usage: RunUsage) -> SeatPreference:
|
||||
message_history: list[ModelMessage] | None = None
|
||||
while True:
|
||||
answer = Prompt.ask('What seat would you like?')
|
||||
|
||||
result = await seat_preference_agent.run(
|
||||
answer,
|
||||
message_history=message_history,
|
||||
usage=usage,
|
||||
usage_limits=usage_limits,
|
||||
)
|
||||
if isinstance(result.output, SeatPreference):
|
||||
return result.output
|
||||
else:
|
||||
print('Could not understand seat preference. Please try again.')
|
||||
message_history = result.all_messages()
|
||||
|
||||
|
||||
async def buy_tickets(flight_details: FlightDetails, seat: SeatPreference):
|
||||
print(f'Purchasing flight {flight_details=!r} {seat=!r}...')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Medical Triage System with Agent Delegation.
|
||||
|
||||
The `triage_agent` acts as the central decision-maker and orchestrator.
|
||||
The instructions helps it to "call tools to consult specialists or a senior doctor."
|
||||
It delegates the actual medical work (diagnosis or treatment planning) to other agents.
|
||||
|
||||
The two core functions act as the delegation mechanism:
|
||||
|
||||
- consult_specialist: This tool routes the complaint to a specific Specialist Agent
|
||||
(cardiology_agent, neurology_agent, etc.). This is Level 1 Delegation: Routing to expertise.
|
||||
|
||||
- consult_senior_doctor: This tool routes the complaint to a Senior Agent (senior_doctor_agent).
|
||||
This is Level 2 Delegation: Escalation for critical decision-making.
|
||||
|
||||
Demonstrates:
|
||||
- Master agent coordinating specialized sub-agents
|
||||
- Dynamic routing and delegation based on symptom analysis
|
||||
- Structured output
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.medical_agent_delegation
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from pydantic_ai import Agent, ModelHTTPError, RunContext
|
||||
|
||||
MODEL = 'openai:gpt-5.2'
|
||||
|
||||
|
||||
# Structured Outputs
|
||||
class Specialty(str, Enum):
|
||||
general = 'general'
|
||||
cardiology = 'cardiology'
|
||||
neurology = 'neurology'
|
||||
|
||||
|
||||
class MedicalReport(BaseModel):
|
||||
diagnosis: list[str]
|
||||
differential: list[str]
|
||||
recommended_tests: list[str]
|
||||
immediate_actions: list[str]
|
||||
estimated_time_minutes: int
|
||||
|
||||
|
||||
class TreatmentPlan(BaseModel):
|
||||
plan_summary: str = Field(
|
||||
description='The structured treatment plan from the senior doctor'
|
||||
)
|
||||
refer_to_specialist: Specialty | None = Field(
|
||||
description='Specialty to route the patient to for further treatment, if necessary'
|
||||
)
|
||||
follow_up_days: int
|
||||
|
||||
|
||||
class TriageFinalOutput(BaseModel):
|
||||
"""The final structured output containing the result of the entire flow."""
|
||||
|
||||
specialty: Specialty | None = None
|
||||
final_report: MedicalReport | None = None
|
||||
treatment_plan: TreatmentPlan | None = None
|
||||
final_status: str = Field(
|
||||
..., description="Status: 'resolved_by_specialist' or 'escalated'"
|
||||
)
|
||||
|
||||
|
||||
# Shared Dependency
|
||||
@dataclass
|
||||
class PatientInfo:
|
||||
patient_id: str
|
||||
age: int
|
||||
known_conditions: list[str]
|
||||
|
||||
|
||||
class TestPatient(TypedDict):
|
||||
complaint: str
|
||||
patient: PatientInfo
|
||||
|
||||
|
||||
class MedicalHistoryRecord(TypedDict):
|
||||
timestamp: str
|
||||
patient_id: str
|
||||
path: str
|
||||
specialty: Specialty | None
|
||||
report_summary: list[str] | str
|
||||
treatment_summary: str
|
||||
|
||||
|
||||
# Specialist and Senior Agents
|
||||
gp_agent = Agent(
|
||||
MODEL,
|
||||
output_type=MedicalReport,
|
||||
deps_type=PatientInfo,
|
||||
instructions=dedent(
|
||||
"""
|
||||
You are a general practitioner.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
cardiology_agent = Agent(
|
||||
MODEL,
|
||||
output_type=MedicalReport,
|
||||
deps_type=PatientInfo,
|
||||
instructions=dedent(
|
||||
"""
|
||||
You are a cardiology specialist.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
neurology_agent = Agent(
|
||||
MODEL,
|
||||
output_type=MedicalReport,
|
||||
deps_type=PatientInfo,
|
||||
instructions=dedent(
|
||||
"""
|
||||
You are a neurology specialist.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
senior_doctor_agent = Agent(
|
||||
MODEL,
|
||||
output_type=TreatmentPlan,
|
||||
deps_type=PatientInfo,
|
||||
instructions=dedent(
|
||||
"""
|
||||
You are a senior clinician overseeing complex or ambiguous cases.
|
||||
Integrate all prior findings to produce a clear treatment plan.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
SPECIALIST_MAP = {
|
||||
'general': gp_agent,
|
||||
'cardiology': cardiology_agent,
|
||||
'neurology': neurology_agent,
|
||||
}
|
||||
|
||||
# Agent-as-Orchestrator: triage_agent with Delegation Tools
|
||||
triage_agent = Agent(
|
||||
MODEL,
|
||||
output_type=TriageFinalOutput,
|
||||
deps_type=PatientInfo,
|
||||
instructions=dedent(
|
||||
"""
|
||||
You are a triage clinician coordinating medical workflow.
|
||||
You can call tools to consult specialists or a senior doctor.
|
||||
|
||||
AVAILABLE SPECIALTIES:
|
||||
- "general": General practitioner for common issues
|
||||
- "cardiology": For heart, chest pain, cardiac symptoms
|
||||
- "neurology": For brain, nerve, stroke, headache symptoms
|
||||
|
||||
ESCALATION RULES — call consult_senior_doctor immediately if ANY of:
|
||||
- "Worst headache of my life" (possible subarachnoid hemorrhage)
|
||||
- Sudden weakness, numbness, or paralysis in limbs (possible stroke)
|
||||
- Sudden vision loss or blurry vision alongside other symptoms
|
||||
- Loss of consciousness or repeated fainting
|
||||
- Multi-system symptoms (e.g. neuro + cardiac combined)
|
||||
- You are uncertain which specialist is appropriate
|
||||
|
||||
For all other cases, route to the appropriate specialist via consult_specialist.
|
||||
Always produce a structured TriageFinalOutput.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@triage_agent.tool
|
||||
async def consult_specialist(
|
||||
ctx: RunContext[PatientInfo],
|
||||
specialty: Specialty,
|
||||
question: str,
|
||||
) -> TriageFinalOutput | str:
|
||||
"""Consult the appropriate specialist for expert consultation."""
|
||||
specialist_agent = SPECIALIST_MAP.get(specialty)
|
||||
print(f'Proceed with specialist - {specialty}')
|
||||
if not specialist_agent:
|
||||
print('Selected specialist does not exists!')
|
||||
return f'No specialist found for {specialty.name}.'
|
||||
|
||||
result = await specialist_agent.run(f'Consultation: {question}', deps=ctx.deps)
|
||||
report: MedicalReport = result.output
|
||||
|
||||
return TriageFinalOutput(
|
||||
final_status='resolved_by_specialist',
|
||||
specialty=specialty,
|
||||
final_report=report,
|
||||
)
|
||||
|
||||
|
||||
@triage_agent.tool
|
||||
async def consult_senior_doctor(
|
||||
ctx: RunContext[PatientInfo], reason_for_escalation: str, initial_complaint: str
|
||||
) -> TriageFinalOutput:
|
||||
"""Consult senior doctor in case of escalation and emergency cases.
|
||||
|
||||
Immediately escalates the case to the senior clinician for severe cases and for a final TreatmentPlan.
|
||||
Use this for high severity, critical, or ambiguous cases.
|
||||
|
||||
Args:
|
||||
ctx: Pydantic AI agent RunContext
|
||||
reason_for_escalation: Summary of why the case must be escalated (e.g., "Severe pain, possible cardiac event").
|
||||
initial_complaint: The patient's original complaint.
|
||||
"""
|
||||
patient = ctx.deps
|
||||
senior_note = f'Reason: {reason_for_escalation}\nComplaint and context:\n{initial_complaint}\nPatient: {patient.patient_id}, age {patient.age}\n'
|
||||
|
||||
print('Direct escalation triggered by Triage LLM.')
|
||||
treatment_plan = None
|
||||
try:
|
||||
result = await senior_doctor_agent.run(
|
||||
f'Consultation for: {senior_note}', deps=ctx.deps
|
||||
)
|
||||
treatment_plan = result.output
|
||||
except ModelHTTPError as e:
|
||||
# Handle case where LLM fails to provide TreatmentPlan structure
|
||||
treatment_plan = TreatmentPlan(
|
||||
plan_summary=f'Consultation failed due to API error: {e.status_code}. Requires manual review.',
|
||||
refer_to_specialist=None,
|
||||
follow_up_days=1,
|
||||
)
|
||||
|
||||
return TriageFinalOutput(
|
||||
final_status='escalated',
|
||||
treatment_plan=treatment_plan,
|
||||
)
|
||||
|
||||
|
||||
# Coordinator System
|
||||
class MedicalTriageSystem:
|
||||
"""Coordinator that invokes triage_agent as the orchestrator."""
|
||||
|
||||
def __init__(self):
|
||||
self.triage = triage_agent
|
||||
self.medical_history: list[MedicalHistoryRecord] = []
|
||||
|
||||
async def handle_patient(
|
||||
self, complaint: str, patient: PatientInfo
|
||||
) -> dict[str, Any]:
|
||||
timestamp = datetime.now(tz=timezone.utc).isoformat()
|
||||
print(f'\n[{timestamp}] Processing complaint: {complaint}')
|
||||
|
||||
triage_prompt = (
|
||||
f'Patient {patient.patient_id}, age {patient.age}\n'
|
||||
f'Complaint: {complaint}\n'
|
||||
f'Known conditions: {patient.known_conditions}\n'
|
||||
f'If necessary, use your tools to consult specialists or senior doctor.'
|
||||
)
|
||||
|
||||
triage_result = await self.triage.run(triage_prompt, deps=patient)
|
||||
final_output: TriageFinalOutput = triage_result.output
|
||||
|
||||
record: MedicalHistoryRecord = {
|
||||
'timestamp': timestamp,
|
||||
'patient_id': patient.patient_id,
|
||||
'path': final_output.final_status,
|
||||
'specialty': final_output.specialty,
|
||||
'report_summary': final_output.final_report.diagnosis
|
||||
if final_output.final_report
|
||||
else 'N/A',
|
||||
'treatment_summary': final_output.treatment_plan.plan_summary
|
||||
if final_output.treatment_plan
|
||||
else 'N/A',
|
||||
}
|
||||
self.medical_history.append(record)
|
||||
|
||||
return final_output.model_dump()
|
||||
|
||||
|
||||
async def demo_medical_triage():
|
||||
system = MedicalTriageSystem()
|
||||
|
||||
test_patients: list[TestPatient] = [
|
||||
{
|
||||
'complaint': 'Sudden severe chest pain radiating to left arm and shortness of breath.',
|
||||
'patient': PatientInfo(
|
||||
patient_id='P001', age=64, known_conditions=['hypertension']
|
||||
),
|
||||
},
|
||||
{
|
||||
'complaint': 'Intermittent headaches for 2 weeks, mild nausea, no weakness.',
|
||||
'patient': PatientInfo(patient_id='P002', age=34, known_conditions=[]),
|
||||
},
|
||||
{
|
||||
'complaint': 'Unresponsive patient, suspected multi-organ failure, unknown history.',
|
||||
'patient': PatientInfo(
|
||||
patient_id='P004',
|
||||
age=85,
|
||||
known_conditions=['heart failure', 'renal failure'],
|
||||
),
|
||||
},
|
||||
{
|
||||
'complaint': 'Hard to breath and faint every few minutes.',
|
||||
'patient': PatientInfo(patient_id='P003', age=71, known_conditions=[]),
|
||||
},
|
||||
{
|
||||
'complaint': "Sudden onset of the worst headache of my life, followed by blurry vision and now I can't feel my left leg. I took aspirin an hour ago.",
|
||||
'patient': PatientInfo(
|
||||
patient_id='P003',
|
||||
age=71,
|
||||
known_conditions=['Type 2 Diabetes', 'Chronic Migraines'],
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
for entry in test_patients:
|
||||
print(f'Processing patient {entry["patient"].patient_id}')
|
||||
result = await system.handle_patient(entry['complaint'], entry['patient'])
|
||||
print('Result:', result)
|
||||
|
||||
print('\nMEDICAL HISTORY SUMMARY:')
|
||||
for history in system.medical_history:
|
||||
print(
|
||||
f'- {history["timestamp"]} | Patient {history["patient_id"]} | Path: {history["path"]}'
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(demo_medical_triage())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Simple example of using Pydantic AI to construct a Pydantic model from a text input.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.pydantic_model
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import logfire
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
city: str
|
||||
country: str
|
||||
|
||||
|
||||
model = os.getenv('PYDANTIC_AI_MODEL', 'openai:gpt-5.2')
|
||||
print(f'Using model: {model}')
|
||||
agent = Agent(model, output_type=MyModel)
|
||||
|
||||
if __name__ == '__main__':
|
||||
result = agent.run_sync('The windy city in the US of A.')
|
||||
print(result.output)
|
||||
print(result.usage)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""RAG example with pydantic-ai — using vector search to augment a chat agent.
|
||||
|
||||
Run pgvector with:
|
||||
|
||||
mkdir postgres-data
|
||||
docker run --rm -e POSTGRES_PASSWORD=postgres \
|
||||
-p 54320:5432 \
|
||||
-v `pwd`/postgres-data:/var/lib/postgresql/data \
|
||||
pgvector/pgvector:pg17
|
||||
|
||||
Build the search DB with:
|
||||
|
||||
uv run -m pydantic_ai_examples.rag build
|
||||
|
||||
Ask the agent a question with:
|
||||
|
||||
uv run -m pydantic_ai_examples.rag search "How do I configure logfire to work with FastAPI?"
|
||||
"""
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
import logfire
|
||||
import pydantic_core
|
||||
from anyio import create_task_group
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import AsyncGenerator
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_asyncpg()
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
openai: AsyncOpenAI
|
||||
pool: asyncpg.Pool
|
||||
|
||||
|
||||
agent = Agent('openai:gpt-5.2', deps_type=Deps)
|
||||
|
||||
|
||||
@agent.tool
|
||||
async def retrieve(context: RunContext[Deps], search_query: str) -> str:
|
||||
"""Retrieve documentation sections based on a search query.
|
||||
|
||||
Args:
|
||||
context: The call context.
|
||||
search_query: The search query.
|
||||
"""
|
||||
with logfire.span(
|
||||
'create embedding for {search_query=}', search_query=search_query
|
||||
):
|
||||
embedding = await context.deps.openai.embeddings.create(
|
||||
input=search_query,
|
||||
model='text-embedding-3-small',
|
||||
)
|
||||
|
||||
assert len(embedding.data) == 1, (
|
||||
f'Expected 1 embedding, got {len(embedding.data)}, doc query: {search_query!r}'
|
||||
)
|
||||
embedding = embedding.data[0].embedding
|
||||
embedding_json = pydantic_core.to_json(embedding).decode()
|
||||
rows = await context.deps.pool.fetch(
|
||||
'SELECT url, title, content FROM doc_sections ORDER BY embedding <-> $1 LIMIT 8',
|
||||
embedding_json,
|
||||
)
|
||||
return '\n\n'.join(
|
||||
f'# {row["title"]}\nDocumentation URL:{row["url"]}\n\n{row["content"]}\n'
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
async def run_agent(question: str):
|
||||
"""Entry point to run the agent and perform RAG based question answering."""
|
||||
openai = AsyncOpenAI()
|
||||
logfire.instrument_openai(openai)
|
||||
|
||||
logfire.info('Asking "{question}"', question=question)
|
||||
|
||||
async with database_connect(False) as pool:
|
||||
deps = Deps(openai=openai, pool=pool)
|
||||
answer = await agent.run(question, deps=deps)
|
||||
print(answer.output)
|
||||
|
||||
|
||||
#######################################################
|
||||
# The rest of this file is dedicated to preparing the #
|
||||
# search database, and some utilities. #
|
||||
#######################################################
|
||||
|
||||
# JSON document from
|
||||
# https://gist.github.com/samuelcolvin/4b5bb9bb163b1122ff17e29e48c10992
|
||||
DOCS_JSON = (
|
||||
'https://gist.githubusercontent.com/'
|
||||
'samuelcolvin/4b5bb9bb163b1122ff17e29e48c10992/raw/'
|
||||
'80c5925c42f1442c24963aaf5eb1a324d47afe95/logfire_docs.json'
|
||||
)
|
||||
|
||||
|
||||
async def build_search_db():
|
||||
"""Build the search database."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(DOCS_JSON)
|
||||
response.raise_for_status()
|
||||
sections = sections_ta.validate_json(response.content)
|
||||
|
||||
openai = AsyncOpenAI()
|
||||
logfire.instrument_openai(openai)
|
||||
|
||||
async with database_connect(True) as pool:
|
||||
with logfire.span('create schema'):
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.execute(DB_SCHEMA)
|
||||
|
||||
sem = asyncio.Semaphore(10)
|
||||
async with create_task_group() as tg:
|
||||
for section in sections:
|
||||
tg.start_soon(insert_doc_section, sem, openai, pool, section)
|
||||
|
||||
|
||||
async def insert_doc_section(
|
||||
sem: asyncio.Semaphore,
|
||||
openai: AsyncOpenAI,
|
||||
pool: asyncpg.Pool,
|
||||
section: DocsSection,
|
||||
) -> None:
|
||||
async with sem:
|
||||
url = section.url()
|
||||
exists = await pool.fetchval('SELECT 1 FROM doc_sections WHERE url = $1', url)
|
||||
if exists:
|
||||
logfire.info('Skipping {url=}', url=url)
|
||||
return
|
||||
|
||||
with logfire.span('create embedding for {url=}', url=url):
|
||||
embedding = await openai.embeddings.create(
|
||||
input=section.embedding_content(),
|
||||
model='text-embedding-3-small',
|
||||
)
|
||||
assert len(embedding.data) == 1, (
|
||||
f'Expected 1 embedding, got {len(embedding.data)}, doc section: {section}'
|
||||
)
|
||||
embedding = embedding.data[0].embedding
|
||||
embedding_json = pydantic_core.to_json(embedding).decode()
|
||||
await pool.execute(
|
||||
'INSERT INTO doc_sections (url, title, content, embedding) VALUES ($1, $2, $3, $4)',
|
||||
url,
|
||||
section.title,
|
||||
section.content,
|
||||
embedding_json,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocsSection:
|
||||
id: int
|
||||
parent: int | None
|
||||
path: str
|
||||
level: int
|
||||
title: str
|
||||
content: str
|
||||
|
||||
def url(self) -> str:
|
||||
url_path = re.sub(r'\.md$', '', self.path)
|
||||
return (
|
||||
f'https://logfire.pydantic.dev/docs/{url_path}/#{slugify(self.title, "-")}'
|
||||
)
|
||||
|
||||
def embedding_content(self) -> str:
|
||||
return '\n\n'.join((f'path: {self.path}', f'title: {self.title}', self.content))
|
||||
|
||||
|
||||
sections_ta = TypeAdapter(list[DocsSection])
|
||||
|
||||
|
||||
# pyright: reportUnknownMemberType=false
|
||||
# pyright: reportUnknownVariableType=false
|
||||
@asynccontextmanager
|
||||
async def database_connect(
|
||||
create_db: bool = False,
|
||||
) -> AsyncGenerator[asyncpg.Pool, None]:
|
||||
server_dsn, database = (
|
||||
'postgresql://postgres:postgres@localhost:54320',
|
||||
'pydantic_ai_rag',
|
||||
)
|
||||
if create_db:
|
||||
with logfire.span('check and create DB'):
|
||||
conn = await asyncpg.connect(server_dsn)
|
||||
try:
|
||||
db_exists = await conn.fetchval(
|
||||
'SELECT 1 FROM pg_database WHERE datname = $1', database
|
||||
)
|
||||
if not db_exists:
|
||||
await conn.execute(f'CREATE DATABASE {database}')
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
pool = await asyncpg.create_pool(f'{server_dsn}/{database}')
|
||||
try:
|
||||
yield pool
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
DB_SCHEMA = """
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_sections (
|
||||
id serial PRIMARY KEY,
|
||||
url text NOT NULL UNIQUE,
|
||||
title text NOT NULL,
|
||||
content text NOT NULL,
|
||||
-- text-embedding-3-small returns a vector of 1536 floats
|
||||
embedding vector(1536) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_sections_embedding ON doc_sections USING hnsw (embedding vector_l2_ops);
|
||||
"""
|
||||
|
||||
|
||||
def slugify(value: str, separator: str, unicode: bool = False) -> str:
|
||||
"""Slugify a string, to make it URL friendly."""
|
||||
# Taken unchanged from https://github.com/Python-Markdown/markdown/blob/3.7/markdown/extensions/toc.py#L38
|
||||
if not unicode:
|
||||
# Replace Extended Latin characters with ASCII, i.e. `žlutý` => `zluty`
|
||||
value = unicodedata.normalize('NFKD', value)
|
||||
value = value.encode('ascii', 'ignore').decode('ascii')
|
||||
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
|
||||
return re.sub(rf'[{separator}\s]+', separator, value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
if action == 'build':
|
||||
asyncio.run(build_search_db())
|
||||
elif action == 'search':
|
||||
if len(sys.argv) == 3:
|
||||
q = sys.argv[2]
|
||||
else:
|
||||
q = 'How do I configure logfire to work with FastAPI?'
|
||||
asyncio.run(run_agent(q))
|
||||
else:
|
||||
print(
|
||||
'uv run --extra examples -m pydantic_ai_examples.rag build|search',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Example demonstrating how to use Pydantic AI to create a simple roulette game.
|
||||
|
||||
Run with:
|
||||
uv run -m pydantic_ai_examples.roulette_wheel
|
||||
"""
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
|
||||
# Define the dependencies class
|
||||
@dataclass
|
||||
class Deps:
|
||||
winning_number: int
|
||||
|
||||
|
||||
# Create the agent with proper typing
|
||||
roulette_agent = Agent(
|
||||
'groq:llama-3.3-70b-versatile',
|
||||
deps_type=Deps,
|
||||
retries=3,
|
||||
output_type=bool,
|
||||
system_prompt=(
|
||||
'Use the `roulette_wheel` function to determine if the customer has won based on the number they bet on.'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@roulette_agent.tool
|
||||
async def roulette_wheel(
|
||||
ctx: RunContext[Deps], square: int
|
||||
) -> Literal['winner', 'loser']:
|
||||
"""Check if the bet square is a winner.
|
||||
|
||||
Args:
|
||||
ctx: The context containing the winning number.
|
||||
square: The number the player bet on.
|
||||
"""
|
||||
return 'winner' if square == ctx.deps.winning_number else 'loser'
|
||||
|
||||
|
||||
async def main():
|
||||
# Set up dependencies
|
||||
winning_number = 18
|
||||
deps = Deps(winning_number=winning_number)
|
||||
|
||||
# Run some example bets using streaming
|
||||
async with roulette_agent.run_stream(
|
||||
'Put my money on square eighteen', deps=deps
|
||||
) as response:
|
||||
result = await response.get_output()
|
||||
print('Bet on 18:', result)
|
||||
|
||||
async with roulette_agent.run_stream(
|
||||
'I bet five is the winner', deps=deps
|
||||
) as response:
|
||||
result = await response.get_output()
|
||||
print('Bet on 5:', result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
from textwrap import dedent
|
||||
from types import NoneType
|
||||
|
||||
import logfire
|
||||
|
||||
### [imports]
|
||||
from pydantic_ai import Agent, NativeOutput
|
||||
from pydantic_ai.common_tools.duckduckgo import duckduckgo_search_tool ### [/imports]
|
||||
|
||||
from .models import Analysis, Profile
|
||||
|
||||
### [agent]
|
||||
agent = Agent(
|
||||
'openai:gpt-5.2',
|
||||
instructions=dedent(
|
||||
"""
|
||||
When a new person joins our public Slack, please put together a brief snapshot so we can be most useful to them.
|
||||
|
||||
**What to include**
|
||||
|
||||
1. **Who they are:** Any details about their professional role or projects (e.g. LinkedIn, GitHub, company bio).
|
||||
2. **Where they work:** Name of the organisation and its domain.
|
||||
3. **How we can help:** On a scale of 1–5, estimate how likely they are to benefit from **Pydantic Logfire**
|
||||
(our paid observability tool) based on factors such as company size, product maturity, or AI usage.
|
||||
*1 = probably not relevant, 5 = very strong fit.*
|
||||
|
||||
**Our products (for context only)**
|
||||
• **Pydantic Validation** – Python data-validation (open source)
|
||||
• **Pydantic AI** – Python agent framework (open source)
|
||||
• **Pydantic Logfire** – Observability for traces, logs & metrics with first-class AI support (commercial)
|
||||
|
||||
**How to research**
|
||||
|
||||
• Use the provided DuckDuckGo search tool to research the person and the organization they work for, based on the email domain or what you find on e.g. LinkedIn and GitHub.
|
||||
• If you can't find enough to form a reasonable view, return **None**.
|
||||
"""
|
||||
),
|
||||
tools=[duckduckgo_search_tool()],
|
||||
output_type=NativeOutput([Analysis, NoneType]),
|
||||
) ### [/agent]
|
||||
|
||||
|
||||
### [analyze_profile]
|
||||
@logfire.instrument('Analyze profile')
|
||||
async def analyze_profile(profile: Profile) -> Analysis | None:
|
||||
result = await agent.run(profile.as_prompt())
|
||||
return result.output ### [/analyze_profile]
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import Any
|
||||
|
||||
import logfire
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from logfire.propagate import get_context
|
||||
|
||||
from .models import Profile
|
||||
|
||||
|
||||
### [process_slack_member]
|
||||
def process_slack_member(profile: Profile):
|
||||
from .modal import process_slack_member as _process_slack_member
|
||||
|
||||
_process_slack_member.spawn(
|
||||
profile.model_dump(), logfire_ctx=get_context()
|
||||
) ### [/process_slack_member]
|
||||
|
||||
|
||||
### [app]
|
||||
app = FastAPI()
|
||||
logfire.instrument_fastapi(app, capture_headers=True)
|
||||
|
||||
|
||||
@app.post('/')
|
||||
async def process_webhook(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if payload['type'] == 'url_verification':
|
||||
return {'challenge': payload['challenge']}
|
||||
elif (
|
||||
payload['type'] == 'event_callback' and payload['event']['type'] == 'team_join'
|
||||
):
|
||||
profile = Profile.model_validate(payload['event']['user']['profile'])
|
||||
|
||||
process_slack_member(profile)
|
||||
return {'status': 'OK'}
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) ### [/app]
|
||||
@@ -0,0 +1,85 @@
|
||||
import logfire
|
||||
|
||||
### [imports]
|
||||
from .agent import analyze_profile
|
||||
from .models import Profile
|
||||
|
||||
### [imports-daily_summary]
|
||||
from .slack import send_slack_message
|
||||
from .store import AnalysisStore ### [/imports,/imports-daily_summary]
|
||||
|
||||
### [constant-new_lead_channel]
|
||||
NEW_LEAD_CHANNEL = '#new-slack-leads'
|
||||
### [/constant-new_lead_channel]
|
||||
### [constant-daily_summary_channel]
|
||||
DAILY_SUMMARY_CHANNEL = '#daily-slack-leads-summary'
|
||||
### [/constant-daily_summary_channel]
|
||||
|
||||
|
||||
### [process_slack_member]
|
||||
@logfire.instrument('Process Slack member')
|
||||
async def process_slack_member(profile: Profile):
|
||||
analysis = await analyze_profile(profile)
|
||||
logfire.info('Analysis', analysis=analysis)
|
||||
|
||||
if analysis is None:
|
||||
return
|
||||
|
||||
await AnalysisStore().add(analysis)
|
||||
|
||||
await send_slack_message(
|
||||
NEW_LEAD_CHANNEL,
|
||||
[
|
||||
{
|
||||
'type': 'header',
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': f'New Slack member with score {analysis.relevance}/5',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'divider',
|
||||
},
|
||||
*analysis.as_slack_blocks(),
|
||||
],
|
||||
) ### [/process_slack_member]
|
||||
|
||||
|
||||
### [send_daily_summary]
|
||||
@logfire.instrument('Send daily summary')
|
||||
async def send_daily_summary():
|
||||
analyses = await AnalysisStore().list()
|
||||
logfire.info('Analyses', analyses=analyses)
|
||||
|
||||
if len(analyses) == 0:
|
||||
return
|
||||
|
||||
sorted_analyses = sorted(analyses, key=lambda x: x.relevance, reverse=True)
|
||||
top_analyses = sorted_analyses[:5]
|
||||
|
||||
blocks = [
|
||||
{
|
||||
'type': 'header',
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': f'Top {len(top_analyses)} new Slack members from the last 24 hours',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for analysis in top_analyses:
|
||||
blocks.extend(
|
||||
[
|
||||
{
|
||||
'type': 'divider',
|
||||
},
|
||||
*analysis.as_slack_blocks(include_relevance=True),
|
||||
]
|
||||
)
|
||||
|
||||
await send_slack_message(
|
||||
DAILY_SUMMARY_CHANNEL,
|
||||
blocks,
|
||||
)
|
||||
|
||||
await AnalysisStore().clear() ### [/send_daily_summary]
|
||||
@@ -0,0 +1,66 @@
|
||||
from typing import Any
|
||||
|
||||
### [setup_modal]
|
||||
import modal
|
||||
|
||||
image = modal.Image.debian_slim(python_version='3.13').pip_install(
|
||||
'pydantic',
|
||||
'pydantic_ai_slim[openai,duckduckgo]',
|
||||
'logfire[httpx,fastapi]',
|
||||
'fastapi[standard]',
|
||||
'httpx',
|
||||
)
|
||||
app = modal.App(
|
||||
name='slack-lead-qualifier',
|
||||
image=image,
|
||||
secrets=[
|
||||
modal.Secret.from_name('logfire'),
|
||||
modal.Secret.from_name('openai'),
|
||||
modal.Secret.from_name('slack'),
|
||||
],
|
||||
) ### [/setup_modal]
|
||||
|
||||
|
||||
### [setup_logfire]
|
||||
def setup_logfire():
|
||||
import logfire
|
||||
|
||||
logfire.configure(service_name=app.name)
|
||||
logfire.instrument_pydantic_ai()
|
||||
logfire.instrument_httpx(capture_all=True) ### [/setup_logfire]
|
||||
|
||||
|
||||
### [web_app]
|
||||
@app.function(min_containers=1)
|
||||
@modal.asgi_app() # type: ignore
|
||||
def web_app():
|
||||
setup_logfire()
|
||||
|
||||
from .app import app as _app
|
||||
|
||||
return _app ### [/web_app]
|
||||
|
||||
|
||||
### [process_slack_member]
|
||||
@app.function()
|
||||
async def process_slack_member(profile_raw: dict[str, Any], logfire_ctx: Any):
|
||||
setup_logfire()
|
||||
|
||||
from logfire.propagate import attach_context
|
||||
|
||||
from .functions import process_slack_member as _process_slack_member
|
||||
from .models import Profile
|
||||
|
||||
with attach_context(logfire_ctx):
|
||||
profile = Profile.model_validate(profile_raw)
|
||||
await _process_slack_member(profile) ### [/process_slack_member]
|
||||
|
||||
|
||||
### [send_daily_summary]
|
||||
@app.function(schedule=modal.Cron('0 8 * * *')) # Every day at 8am UTC
|
||||
async def send_daily_summary():
|
||||
setup_logfire()
|
||||
|
||||
from .functions import send_daily_summary as _send_daily_summary
|
||||
|
||||
await _send_daily_summary() ### [/send_daily_summary]
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
from annotated_types import Ge, Le
|
||||
from pydantic import BaseModel
|
||||
|
||||
### [import-format_as_xml]
|
||||
from pydantic_ai import format_as_xml ### [/import-format_as_xml]
|
||||
|
||||
|
||||
### [profile,profile-intro]
|
||||
class Profile(BaseModel): ### [/profile-intro]
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
display_name: str | None = None
|
||||
email: str ### [/profile]
|
||||
|
||||
### [profile-as_prompt]
|
||||
def as_prompt(self) -> str:
|
||||
return format_as_xml(self, root_tag='profile') ### [/profile-as_prompt]
|
||||
|
||||
|
||||
### [analysis,analysis-intro]
|
||||
class Analysis(BaseModel): ### [/analysis-intro]
|
||||
profile: Profile
|
||||
organization_name: str
|
||||
organization_domain: str
|
||||
job_title: str
|
||||
relevance: Annotated[int, Ge(1), Le(5)]
|
||||
"""Estimated fit for Pydantic Logfire: 1 = low, 5 = high"""
|
||||
summary: str
|
||||
"""One-sentence welcome note summarising who they are and how we might help""" ### [/analysis]
|
||||
|
||||
### [analysis-as_slack_blocks]
|
||||
def as_slack_blocks(self, include_relevance: bool = False) -> list[dict[str, Any]]:
|
||||
profile = self.profile
|
||||
relevance = f'({self.relevance}/5)' if include_relevance else ''
|
||||
return [
|
||||
{
|
||||
'type': 'markdown',
|
||||
'text': f'[{profile.display_name}](mailto:{profile.email}), {self.job_title} at [**{self.organization_name}**](https://{self.organization_domain}) {relevance}',
|
||||
},
|
||||
{
|
||||
'type': 'markdown',
|
||||
'text': self.summary,
|
||||
},
|
||||
] ### [/analysis-as_slack_blocks]
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import logfire
|
||||
|
||||
### [send_slack_message]
|
||||
API_KEY = os.getenv('SLACK_API_KEY')
|
||||
assert API_KEY, 'SLACK_API_KEY is not set'
|
||||
|
||||
|
||||
@logfire.instrument('Send Slack message')
|
||||
async def send_slack_message(channel: str, blocks: list[dict[str, Any]]):
|
||||
client = httpx.AsyncClient()
|
||||
response = await client.post(
|
||||
'https://slack.com/api/chat.postMessage',
|
||||
json={
|
||||
'channel': channel,
|
||||
'blocks': blocks,
|
||||
},
|
||||
headers={
|
||||
'Authorization': f'Bearer {API_KEY}',
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if not result.get('ok', False):
|
||||
error = result.get('error', 'Unknown error')
|
||||
raise Exception(f'Failed to send to Slack: {error}') ### [/send_slack_message]
|
||||
@@ -0,0 +1,31 @@
|
||||
import logfire
|
||||
|
||||
### [import-modal]
|
||||
import modal ### [/import-modal]
|
||||
|
||||
from .models import Analysis
|
||||
|
||||
|
||||
### [analysis_store]
|
||||
class AnalysisStore:
|
||||
@classmethod
|
||||
@logfire.instrument('Add analysis to store')
|
||||
async def add(cls, analysis: Analysis):
|
||||
await cls._get_store().put.aio(analysis.profile.email, analysis.model_dump())
|
||||
|
||||
@classmethod
|
||||
@logfire.instrument('List analyses from store')
|
||||
async def list(cls) -> list[Analysis]:
|
||||
return [
|
||||
Analysis.model_validate(analysis)
|
||||
async for analysis in cls._get_store().values.aio()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@logfire.instrument('Clear analyses from store')
|
||||
async def clear(cls):
|
||||
await cls._get_store().clear.aio()
|
||||
|
||||
@classmethod
|
||||
def _get_store(cls) -> modal.Dict:
|
||||
return modal.Dict.from_name('analyses', create_if_missing=True) # type: ignore ### [/analysis_store]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Example demonstrating how to use Pydantic AI to generate SQL queries based on user input.
|
||||
|
||||
Run postgres with:
|
||||
|
||||
mkdir postgres-data
|
||||
docker run --rm -e POSTGRES_PASSWORD=postgres -p 54320:5432 postgres
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.sql_gen "show me logs from yesterday, with level 'error'"
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Annotated, Any, TypeAlias
|
||||
|
||||
import asyncpg
|
||||
import logfire
|
||||
from annotated_types import MinLen
|
||||
from devtools import debug
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from pydantic_ai import Agent, ModelRetry, RunContext, format_as_xml
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_asyncpg()
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
DB_SCHEMA = """
|
||||
CREATE TABLE records (
|
||||
created_at timestamptz,
|
||||
start_timestamp timestamptz,
|
||||
end_timestamp timestamptz,
|
||||
trace_id text,
|
||||
span_id text,
|
||||
parent_span_id text,
|
||||
level log_level,
|
||||
span_name text,
|
||||
message text,
|
||||
attributes_json_schema text,
|
||||
attributes jsonb,
|
||||
tags text[],
|
||||
is_exception boolean,
|
||||
otel_status_message text,
|
||||
service_name text
|
||||
);
|
||||
"""
|
||||
SQL_EXAMPLES = [
|
||||
{
|
||||
'request': 'show me records where foobar is false',
|
||||
'response': "SELECT * FROM records WHERE attributes->>'foobar' = false",
|
||||
},
|
||||
{
|
||||
'request': 'show me records where attributes include the key "foobar"',
|
||||
'response': "SELECT * FROM records WHERE attributes ? 'foobar'",
|
||||
},
|
||||
{
|
||||
'request': 'show me records from yesterday',
|
||||
'response': "SELECT * FROM records WHERE start_timestamp::date > CURRENT_TIMESTAMP - INTERVAL '1 day'",
|
||||
},
|
||||
{
|
||||
'request': 'show me error records with the tag "foobar"',
|
||||
'response': "SELECT * FROM records WHERE level = 'error' and 'foobar' = ANY(tags)",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
conn: asyncpg.Connection
|
||||
|
||||
|
||||
class Success(BaseModel):
|
||||
"""Response when SQL could be successfully generated."""
|
||||
|
||||
sql_query: Annotated[str, MinLen(1)]
|
||||
explanation: str = Field(
|
||||
'', description='Explanation of the SQL query, as markdown'
|
||||
)
|
||||
|
||||
|
||||
class InvalidRequest(BaseModel):
|
||||
"""Response the user input didn't include enough information to generate SQL."""
|
||||
|
||||
error_message: str
|
||||
|
||||
|
||||
Response: TypeAlias = Success | InvalidRequest
|
||||
agent = Agent[Deps, Response](
|
||||
'google:gemini-3-flash-preview',
|
||||
# Type ignore while we wait for PEP-0747, nonetheless unions will work fine everywhere else
|
||||
output_type=Response, # type: ignore
|
||||
deps_type=Deps,
|
||||
)
|
||||
|
||||
|
||||
@agent.system_prompt
|
||||
async def system_prompt() -> str:
|
||||
return f"""\
|
||||
Given the following PostgreSQL table of records, your job is to
|
||||
write a SQL query that suits the user's request.
|
||||
|
||||
Database schema:
|
||||
|
||||
{DB_SCHEMA}
|
||||
|
||||
today's date = {date.today()}
|
||||
|
||||
{format_as_xml(SQL_EXAMPLES)}
|
||||
"""
|
||||
|
||||
|
||||
@agent.output_validator
|
||||
async def validate_output(ctx: RunContext[Deps], output: Response) -> Response:
|
||||
if isinstance(output, InvalidRequest):
|
||||
return output
|
||||
|
||||
# gemini often adds extraneous backslashes to SQL
|
||||
output.sql_query = output.sql_query.replace('\\', '')
|
||||
if not output.sql_query.upper().startswith('SELECT'):
|
||||
raise ModelRetry('Please create a SELECT query')
|
||||
|
||||
try:
|
||||
await ctx.deps.conn.execute(f'EXPLAIN {output.sql_query}')
|
||||
except asyncpg.exceptions.PostgresError as e:
|
||||
raise ModelRetry(f'Invalid query: {e}') from e
|
||||
else:
|
||||
return output
|
||||
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) == 1:
|
||||
prompt = 'show me logs from yesterday, with level "error"'
|
||||
else:
|
||||
prompt = sys.argv[1]
|
||||
|
||||
async with database_connect(
|
||||
'postgresql://postgres:postgres@localhost:54320', 'pydantic_ai_sql_gen'
|
||||
) as conn:
|
||||
deps = Deps(conn)
|
||||
result = await agent.run(prompt, deps=deps)
|
||||
debug(result.output)
|
||||
|
||||
|
||||
# pyright: reportUnknownMemberType=false
|
||||
# pyright: reportUnknownVariableType=false
|
||||
@asynccontextmanager
|
||||
async def database_connect(server_dsn: str, database: str) -> AsyncGenerator[Any, None]:
|
||||
with logfire.span('check and create DB'):
|
||||
conn = await asyncpg.connect(server_dsn)
|
||||
try:
|
||||
db_exists = await conn.fetchval(
|
||||
'SELECT 1 FROM pg_database WHERE datname = $1', database
|
||||
)
|
||||
if not db_exists:
|
||||
await conn.execute(f'CREATE DATABASE {database}')
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
conn = await asyncpg.connect(f'{server_dsn}/{database}')
|
||||
try:
|
||||
with logfire.span('create schema'):
|
||||
async with conn.transaction():
|
||||
if not db_exists:
|
||||
await conn.execute(
|
||||
"CREATE TYPE log_level AS ENUM ('debug', 'info', 'warning', 'error', 'critical')"
|
||||
)
|
||||
await conn.execute(DB_SCHEMA)
|
||||
yield conn
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
"""This example shows how to stream markdown from an agent, using the `rich` library to display the markdown.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.stream_markdown
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import logfire
|
||||
from rich.console import Console, ConsoleOptions, RenderResult
|
||||
from rich.live import Live
|
||||
from rich.markdown import CodeBlock, Markdown
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models import KnownModelName
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
agent = Agent()
|
||||
|
||||
# models to try, and the appropriate env var
|
||||
models: list[tuple[KnownModelName, str]] = [
|
||||
('google:gemini-3-flash-preview', 'GEMINI_API_KEY'),
|
||||
('openai:gpt-5-mini', 'OPENAI_API_KEY'),
|
||||
('groq:llama-3.3-70b-versatile', 'GROQ_API_KEY'),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
prettier_code_blocks()
|
||||
console = Console()
|
||||
prompt = 'Show me a short example of using Pydantic.'
|
||||
console.log(f'Asking: {prompt}...', style='cyan')
|
||||
for model, env_var in models:
|
||||
if env_var in os.environ:
|
||||
console.log(f'Using model: {model}')
|
||||
with Live('', console=console, vertical_overflow='visible') as live:
|
||||
async with agent.run_stream(prompt, model=model) as result:
|
||||
async for message in result.stream_output():
|
||||
live.update(Markdown(message))
|
||||
console.log(result.usage)
|
||||
else:
|
||||
console.log(f'{model} requires {env_var} to be set.')
|
||||
|
||||
|
||||
def prettier_code_blocks():
|
||||
"""Make rich code blocks prettier and easier to copy.
|
||||
|
||||
From https://github.com/samuelcolvin/aicli/blob/v0.8.0/samuelcolvin_aicli.py#L22
|
||||
"""
|
||||
|
||||
class SimpleCodeBlock(CodeBlock):
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
code = str(self.text).rstrip()
|
||||
yield Text(self.lexer_name, style='dim')
|
||||
yield Syntax(
|
||||
code,
|
||||
self.lexer_name,
|
||||
theme=self.theme,
|
||||
background_color='default',
|
||||
word_wrap=True,
|
||||
)
|
||||
yield Text(f'/{self.lexer_name}', style='dim')
|
||||
|
||||
Markdown.elements['fence'] = SimpleCodeBlock
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Information about whales — an example of streamed structured response validation.
|
||||
|
||||
This script streams structured responses about whales, validates the data
|
||||
and displays it as a dynamic table using Rich as the data is received.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.stream_whales
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import logfire
|
||||
from pydantic import Field
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.table import Table
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
class Whale(TypedDict):
|
||||
name: str
|
||||
length: Annotated[
|
||||
float, Field(description='Average length of an adult whale in meters.')
|
||||
]
|
||||
weight: NotRequired[
|
||||
Annotated[
|
||||
float,
|
||||
Field(description='Average weight of an adult whale in kilograms.', ge=50),
|
||||
]
|
||||
]
|
||||
ocean: NotRequired[str]
|
||||
description: NotRequired[Annotated[str, Field(description='Short Description')]]
|
||||
|
||||
|
||||
agent = Agent('openai:gpt-5.2', output_type=list[Whale])
|
||||
|
||||
|
||||
async def main():
|
||||
console = Console()
|
||||
with Live('\n' * 36, console=console) as live:
|
||||
console.print('Requesting data...', style='cyan')
|
||||
async with agent.run_stream(
|
||||
'Generate me details of 5 species of Whale.'
|
||||
) as result:
|
||||
console.print('Response:', style='green')
|
||||
|
||||
async for whales in result.stream_output(debounce_by=0.01):
|
||||
table = Table(
|
||||
title='Species of Whale',
|
||||
caption='Streaming Structured responses from OpenAI',
|
||||
width=120,
|
||||
)
|
||||
table.add_column('ID', justify='right')
|
||||
table.add_column('Name')
|
||||
table.add_column('Avg. Length (m)', justify='right')
|
||||
table.add_column('Avg. Weight (kg)', justify='right')
|
||||
table.add_column('Ocean')
|
||||
table.add_column('Description', justify='right')
|
||||
|
||||
for wid, whale in enumerate(whales, start=1):
|
||||
table.add_row(
|
||||
str(wid),
|
||||
whale['name'],
|
||||
f'{whale["length"]:0.0f}',
|
||||
f'{w:0.0f}' if (w := whale.get('weight')) else '…',
|
||||
whale.get('ocean') or '…',
|
||||
whale.get('description') or '…',
|
||||
)
|
||||
live.update(table)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Example of a Pydantic AI agent that understands video using TwelveLabs Pegasus.
|
||||
|
||||
In this case the idea is a "video analyst" agent — the user can ask questions about a
|
||||
video (given its URL), and the agent will use the `analyze_video` tool to call
|
||||
[TwelveLabs](https://twelvelabs.io) Pegasus, a video-understanding model, to answer.
|
||||
|
||||
This shows how to wrap a third-party multimodal API as a Pydantic AI tool: the LLM
|
||||
decides *what* to ask about the video, and Pegasus does the actual video understanding.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.twelvelabs_video_agent
|
||||
"""
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import logfire
|
||||
from twelvelabs import AsyncTwelveLabs
|
||||
from twelvelabs.types import VideoContext_Url
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
# A public sample video used when the user doesn't provide one. The URL must point at a
|
||||
# video file TwelveLabs can fetch directly; set VIDEO_URL to use your own.
|
||||
DEFAULT_VIDEO_URL = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4'
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
twelvelabs: AsyncTwelveLabs
|
||||
video_url: str
|
||||
|
||||
|
||||
video_agent = Agent(
|
||||
'openai:gpt-5-mini',
|
||||
instructions=(
|
||||
'You help users understand a video. '
|
||||
'Use the `analyze_video` tool to ask the video-understanding model questions, '
|
||||
'then answer the user concisely based on what it returns.'
|
||||
),
|
||||
deps_type=Deps,
|
||||
retries=2,
|
||||
)
|
||||
|
||||
|
||||
@video_agent.tool
|
||||
async def analyze_video(ctx: RunContext[Deps], prompt: str) -> str:
|
||||
"""Analyze the video with TwelveLabs Pegasus and return a text answer.
|
||||
|
||||
Args:
|
||||
ctx: The context.
|
||||
prompt: What to ask about the video, e.g. "Summarize this video" or
|
||||
"What objects appear in the first 10 seconds?".
|
||||
"""
|
||||
response = await ctx.deps.twelvelabs.analyze(
|
||||
model_name='pegasus1.5',
|
||||
video=VideoContext_Url(url=ctx.deps.video_url),
|
||||
prompt=prompt,
|
||||
max_tokens=2048,
|
||||
)
|
||||
return response.data or ''
|
||||
|
||||
|
||||
async def main():
|
||||
api_key = os.environ.get('TWELVELABS_API_KEY')
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
'Set TWELVELABS_API_KEY to run this example. '
|
||||
'Grab a free key at https://twelvelabs.io.'
|
||||
)
|
||||
video_url = os.environ.get('VIDEO_URL', DEFAULT_VIDEO_URL)
|
||||
|
||||
async with AsyncTwelveLabs(api_key=api_key) as client:
|
||||
deps = Deps(twelvelabs=client, video_url=video_url)
|
||||
result = await video_agent.run(
|
||||
'Give me a one-sentence summary of this video.', deps=deps
|
||||
)
|
||||
print('Response:', result.output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Example of Pydantic AI with multiple tools which the LLM needs to call in turn to answer a question.
|
||||
|
||||
In this case the idea is a "weather" agent — the user can ask for the weather in multiple cities,
|
||||
the agent will use the `get_lat_lng` tool to get the latitude and longitude of the locations, then use
|
||||
the `get_weather` tool to get the weather.
|
||||
|
||||
Run with:
|
||||
|
||||
uv run -m pydantic_ai_examples.weather_agent
|
||||
"""
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import logfire
|
||||
from httpx import AsyncClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
|
||||
logfire.configure(send_to_logfire='if-token-present')
|
||||
logfire.instrument_pydantic_ai()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
client: AsyncClient
|
||||
|
||||
|
||||
weather_agent = Agent(
|
||||
'openai:gpt-5-mini',
|
||||
# 'Be concise, reply with one sentence.' is enough for some models (like openai) to use
|
||||
# the below tools appropriately, but others like anthropic and gemini require a bit more direction.
|
||||
instructions='Be concise, reply with one sentence.',
|
||||
deps_type=Deps,
|
||||
retries=2,
|
||||
)
|
||||
|
||||
|
||||
class LatLng(BaseModel):
|
||||
lat: float
|
||||
lng: float
|
||||
|
||||
|
||||
@weather_agent.tool
|
||||
async def get_lat_lng(ctx: RunContext[Deps], location_description: str) -> LatLng:
|
||||
"""Get the latitude and longitude of a location.
|
||||
|
||||
Args:
|
||||
ctx: The context.
|
||||
location_description: A description of a location.
|
||||
"""
|
||||
# NOTE: the response here will be random, and is not related to the location description.
|
||||
r = await ctx.deps.client.get(
|
||||
'https://demo-endpoints.pydantic.workers.dev/latlng',
|
||||
params={'location': location_description},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return LatLng.model_validate_json(r.content)
|
||||
|
||||
|
||||
@weather_agent.tool
|
||||
async def get_weather(ctx: RunContext[Deps], lat: float, lng: float) -> dict[str, Any]:
|
||||
"""Get the weather at a location.
|
||||
|
||||
Args:
|
||||
ctx: The context.
|
||||
lat: Latitude of the location.
|
||||
lng: Longitude of the location.
|
||||
"""
|
||||
# NOTE: the responses here will be random, and are not related to the lat and lng.
|
||||
temp_response, descr_response = await asyncio.gather(
|
||||
ctx.deps.client.get(
|
||||
'https://demo-endpoints.pydantic.workers.dev/number',
|
||||
params={'min': 10, 'max': 30},
|
||||
),
|
||||
ctx.deps.client.get(
|
||||
'https://demo-endpoints.pydantic.workers.dev/weather',
|
||||
params={'lat': lat, 'lng': lng},
|
||||
),
|
||||
)
|
||||
temp_response.raise_for_status()
|
||||
descr_response.raise_for_status()
|
||||
return {
|
||||
'temperature': f'{temp_response.text} °C',
|
||||
'description': descr_response.text,
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
async with AsyncClient() as client:
|
||||
logfire.instrument_httpx(client, capture_all=True)
|
||||
deps = Deps(client=client)
|
||||
result = await weather_agent.run(
|
||||
'What is the weather like in London and in Wiltshire?', deps=deps
|
||||
)
|
||||
print('Response:', result.output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import json
|
||||
|
||||
from httpx import AsyncClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pydantic_ai import ToolCallPart, ToolReturnPart
|
||||
from pydantic_ai_examples.weather_agent import Deps, weather_agent
|
||||
|
||||
try:
|
||||
import gradio as gr
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
'Please install gradio with `pip install gradio`. You must use python>=3.10.'
|
||||
) from e
|
||||
|
||||
TOOL_TO_DISPLAY_NAME = {'get_lat_lng': 'Geocoding API', 'get_weather': 'Weather API'}
|
||||
|
||||
client = AsyncClient()
|
||||
deps = Deps(client=client)
|
||||
|
||||
|
||||
async def stream_from_agent(prompt: str, chatbot: list[dict], past_messages: list):
|
||||
chatbot.append({'role': 'user', 'content': prompt})
|
||||
yield gr.Textbox(interactive=False, value=''), chatbot, gr.skip()
|
||||
async with weather_agent.run_stream(
|
||||
prompt, deps=deps, message_history=past_messages
|
||||
) as result:
|
||||
for message in result.new_messages():
|
||||
for call in message.parts:
|
||||
if isinstance(call, ToolCallPart):
|
||||
call_args = call.args_as_json_str()
|
||||
metadata = {
|
||||
'title': f'🛠️ Using {TOOL_TO_DISPLAY_NAME[call.tool_name]}',
|
||||
}
|
||||
if call.tool_call_id is not None:
|
||||
metadata['id'] = call.tool_call_id
|
||||
|
||||
gr_message = {
|
||||
'role': 'assistant',
|
||||
'content': 'Parameters: ' + call_args,
|
||||
'metadata': metadata,
|
||||
}
|
||||
chatbot.append(gr_message)
|
||||
if isinstance(call, ToolReturnPart):
|
||||
for gr_message in chatbot:
|
||||
if (gr_message.get('metadata') or {}).get(
|
||||
'id', ''
|
||||
) == call.tool_call_id:
|
||||
if isinstance(call.content, BaseModel):
|
||||
json_content = call.content.model_dump_json()
|
||||
else:
|
||||
json_content = json.dumps(call.content)
|
||||
gr_message['content'] += f'\nOutput: {json_content}'
|
||||
yield gr.skip(), chatbot, gr.skip()
|
||||
chatbot.append({'role': 'assistant', 'content': ''})
|
||||
async for message in result.stream_text():
|
||||
chatbot[-1]['content'] = message
|
||||
yield gr.skip(), chatbot, gr.skip()
|
||||
past_messages = result.all_messages()
|
||||
|
||||
yield gr.Textbox(interactive=True), gr.skip(), past_messages
|
||||
|
||||
|
||||
async def handle_retry(chatbot, past_messages: list, retry_data: gr.RetryData):
|
||||
new_history = chatbot[: retry_data.index]
|
||||
previous_prompt = chatbot[retry_data.index]['content']
|
||||
past_messages = past_messages[: retry_data.index]
|
||||
async for update in stream_from_agent(previous_prompt, new_history, past_messages):
|
||||
yield update
|
||||
|
||||
|
||||
def undo(chatbot, past_messages: list, undo_data: gr.UndoData):
|
||||
new_history = chatbot[: undo_data.index]
|
||||
past_messages = past_messages[: undo_data.index]
|
||||
return chatbot[undo_data.index]['content'], new_history, past_messages
|
||||
|
||||
|
||||
def select_data(message: gr.SelectData) -> str:
|
||||
return message.value['text']
|
||||
|
||||
|
||||
with gr.Blocks() as demo:
|
||||
gr.HTML(
|
||||
"""
|
||||
<div style="display: flex; justify-content: center; align-items: center; gap: 2rem; padding: 1rem; width: 100%">
|
||||
<img src="https://ai.pydantic.dev/img/logo-white.svg" style="max-width: 200px; height: auto">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 1rem 0">Weather Assistant</h1>
|
||||
<h3 style="margin: 0 0 0.5rem 0">
|
||||
This assistant answer your weather questions.
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
past_messages = gr.State([])
|
||||
chatbot = gr.Chatbot(
|
||||
label='Packing Assistant',
|
||||
avatar_images=(None, 'https://ai.pydantic.dev/img/logo-white.svg'),
|
||||
examples=[
|
||||
{'text': 'What is the weather like in Miami?'},
|
||||
{'text': 'What is the weather like in London?'},
|
||||
],
|
||||
)
|
||||
with gr.Row():
|
||||
prompt = gr.Textbox(
|
||||
lines=1,
|
||||
show_label=False,
|
||||
placeholder='What is the weather like in New York City?',
|
||||
)
|
||||
generation = prompt.submit(
|
||||
stream_from_agent,
|
||||
inputs=[prompt, chatbot, past_messages],
|
||||
outputs=[prompt, chatbot, past_messages],
|
||||
)
|
||||
chatbot.example_select(select_data, None, [prompt])
|
||||
chatbot.retry(
|
||||
handle_retry, [chatbot, past_messages], [prompt, chatbot, past_messages]
|
||||
)
|
||||
chatbot.undo(undo, [chatbot, past_messages], [prompt, chatbot, past_messages])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
demo.launch()
|
||||
@@ -0,0 +1,80 @@
|
||||
[build-system]
|
||||
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "uv-dynamic-versioning"
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
vcs = "git"
|
||||
style = "pep440"
|
||||
bump = true
|
||||
|
||||
[project]
|
||||
name = "pydantic-ai-examples"
|
||||
dynamic = ["version", "dependencies"]
|
||||
description = "Examples of how to use Pydantic AI and what it can do."
|
||||
authors = [
|
||||
{ name = "Samuel Colvin", email = "samuel@pydantic.dev" },
|
||||
{ name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" },
|
||||
{ name = "David Montague", email = "david@pydantic.dev" },
|
||||
{ name = "Alex Hall", email = "alex@pydantic.dev" },
|
||||
{ name = "Douwe Maan", email = "douwe@pydantic.dev" },
|
||||
]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Information Technology",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: Unix",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Environment :: Console",
|
||||
"Environment :: MacOS X",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
|
||||
dependencies = [
|
||||
"pydantic-ai-slim[openai,google,groq,anthropic,ag-ui]=={{ version }}",
|
||||
"pydantic-evals=={{ version }}",
|
||||
"asyncpg>=0.31.0",
|
||||
"fastapi>=0.117.0",
|
||||
"logfire[asyncpg,fastapi,sqlite3,httpx]>=3.14.1",
|
||||
"python-multipart>=0.0.17",
|
||||
"rich>=13.9.2",
|
||||
"uvicorn>=0.32.0",
|
||||
"devtools>=0.12.2",
|
||||
"gradio>=6.7.0",
|
||||
"mcp[cli]>=1.25.0,<2.0",
|
||||
"modal>=1.0.4",
|
||||
"duckdb>=1.4.2",
|
||||
"datasets>=4.0.0",
|
||||
"pandas>=2.3.3",
|
||||
"twelvelabs>=1.2.8",
|
||||
# On Python 3.14, the locked numpy 2.2.6 has no cp314 wheel and would be
|
||||
# source-built in CI. numpy 2.3.2+ ships cp314 wheels, so require it there.
|
||||
"numpy>=2.3.2 ; python_version >= '3.14'",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["pydantic_ai_examples"]
|
||||
|
||||
[tool.uv.sources]
|
||||
pydantic-ai-slim = { workspace = true }
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../pyproject.toml"
|
||||
line-length = 88
|
||||
Reference in New Issue
Block a user