diff --git a/samples/pydantic-ai-extended/.gitignore b/samples/pydantic-ai-extended/.gitignore
new file mode 100644
index 00000000..d328f571
--- /dev/null
+++ b/samples/pydantic-ai-extended/.gitignore
@@ -0,0 +1,4 @@
+__pycache__/
+*.pyc
+.env
+.venv/
diff --git a/samples/pydantic-ai-extended/README.md b/samples/pydantic-ai-extended/README.md
new file mode 100644
index 00000000..48ac2432
--- /dev/null
+++ b/samples/pydantic-ai-extended/README.md
@@ -0,0 +1,98 @@
+# Pydantic AI Extended
+
+[](https://portal.defang.dev/redirect?url=https%3A%2F%2Fgithub.com%2Fnew%3Ftemplate_name%3Dsample-pydantic-ai-extended-template%26template_owner%3DDefangSamples)
+
+This sample shows a multi-service AI app built with [Pydantic AI](https://ai.pydantic.dev/), FastAPI, PostgreSQL (with pgvector), and Redis, deployed with Defang from a single Docker Compose file.
+
+A background worker generates sample support tickets and system alerts with an LLM, pushes them to a queue, then they get classified and stored with vector embeddings in PostgreSQL for semantic search. A Pydantic AI copilot agent uses tools to inspect the current state before answering questions.
+
+## Prerequisites
+
+1. Download [Defang CLI](https://github.com/DefangLabs/defang)
+2. (Optional) If you are using [Defang BYOC](https://docs.defang.io/docs/concepts/defang-byoc) authenticate with your cloud provider account
+3. (Optional for local development) [Docker CLI](https://docs.docker.com/engine/install/)
+
+## Development
+
+To run the application locally for development, use the development compose file:
+
+```bash
+docker compose -f compose.dev.yaml up --build
+```
+
+This will:
+
+- Start the FastAPI app on `http://localhost:8000`
+- Start PostgreSQL on port 5432
+- Start Redis on port 6379
+- Start a background worker for item classification
+- Start Docker model-provider services for chat + embeddings
+
+Local development uses:
+
+- `ai/qwen2.5:3B-Q4_K_M` for chat/tool-calling
+- `mxbai-embed-large` for embeddings
+
+This relies on Docker Model Runner / model-provider support being available in your local Docker installation. The first run will download both models, so startup can take a few minutes.
+If `docker compose -f compose.dev.yaml up` fails with `exec: "model": executable file not found in $PATH`, your local Docker installation does not have Docker Model Runner enabled yet.
+To keep iteration practical on CPU-only setups, `compose.dev.yaml` enables `LOCAL_FAST_DATA=true`, which uses deterministic sample generation and classification locally while still exercising the real chat and embedding services.
+
+In deployed environments, the app uses dedicated `chat` and `embedding` model services defined in `compose.yaml`. Defang injects OpenAI-compatible `CHAT_URL` / `CHAT_MODEL` and `EMBEDDING_URL` / `EMBEDDING_MODEL` environment variables automatically, so the application code stays platform-independent.
+
+## Configuration
+
+For this sample, you will need to provide the following [configuration](https://docs.defang.io/docs/concepts/configuration). Note that if you are using the 1-click deploy option, you can set these values as secrets in your GitHub repository and the action will automatically deploy them for you.
+
+### `POSTGRES_PASSWORD`
+
+The password for your PostgreSQL database. You need to set this before deploying for the first time.
+
+*You can easily set this to a random string using `defang config set POSTGRES_PASSWORD --random`*
+
+## Usage
+
+1. Open the app.
+2. Click **Generate sample items**.
+3. Watch the worker create 10 tickets and 10 alerts, then fan out per-item classify/embed jobs (progress updates in real time via polling).
+4. Ask questions like:
+ - `What should I look at first?`
+ - `Summarize the current tickets and alerts.`
+ - `Which items seem related?`
+ - `Find alerts similar to the payment outage.`
+
+## Deployment
+
+> [!NOTE]
+> Download [Defang CLI](https://github.com/DefangLabs/defang)
+
+### Defang Playground
+
+Deploy your application to the Defang Playground by opening up your terminal and typing:
+
+```bash
+defang compose up
+```
+
+### BYOC (Deploy to your own AWS or GCP cloud account)
+
+If you want to deploy to your own cloud account, you can [use Defang BYOC](https://docs.defang.io/docs/tutorials/deploy-to-your-cloud).
+
+The default sample uses Defang's managed model provider services:
+
+- `chat` uses `chat-default`
+- `embedding` uses `embedding-default`
+
+If you want to pin different models, edit the `provider.options.model` values in [compose.yaml](compose.yaml).
+
+> [!WARNING]
+> **Extended deployment time:** This sample creates a managed PostgreSQL database which may take upwards of 20 minutes to provision on first deployment. Subsequent deployments are much faster (2-5 minutes).
+
+---
+
+Title: Pydantic AI Extended
+
+Short Description: A Defang sample where background jobs classify and embed support tickets and system alerts, and a Pydantic AI copilot answers questions with tools.
+
+Tags: Pydantic AI, FastAPI, PostgreSQL, Redis, AI, Agents
+
+Languages: Python, Docker
diff --git a/samples/pydantic-ai-extended/app/.dockerignore b/samples/pydantic-ai-extended/app/.dockerignore
new file mode 100644
index 00000000..309de576
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/.dockerignore
@@ -0,0 +1,3 @@
+__pycache__
+*.pyc
+.venv
diff --git a/samples/pydantic-ai-extended/app/Dockerfile b/samples/pydantic-ai-extended/app/Dockerfile
new file mode 100644
index 00000000..96de05da
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.12-slim-bookworm
+
+RUN apt-get update -qq \
+ && apt-get install -y curl \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt ./
+
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/samples/pydantic-ai-extended/app/agents/__init__.py b/samples/pydantic-ai-extended/app/agents/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/samples/pydantic-ai-extended/app/agents/copilot.py b/samples/pydantic-ai-extended/app/agents/copilot.py
new file mode 100644
index 00000000..b049c740
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/agents/copilot.py
@@ -0,0 +1,163 @@
+"""
+Pydantic AI copilot agent with tools for inspecting tickets, alerts, tags,
+and semantic matches before answering questions.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pydantic_ai import Agent, RunContext
+
+from lib.ai import embed_text_for_search
+from lib.items import (
+ ItemFilters,
+ get_available_tags,
+ get_items_by_type,
+ search_items_by_embedding,
+)
+from lib.model import get_chat_model
+
+
+@dataclass
+class CopilotDeps:
+ """No external deps needed; tools query the database directly."""
+ pass
+
+
+copilot_agent = Agent(
+ get_chat_model,
+ deps_type=CopilotDeps,
+ instructions="""
+ You are the copilot for a demo app that tracks support tickets and system alerts.
+ Tickets are customer issues from tools like Zendesk, Intercom, Jira, Linear, and GitHub Issues.
+ Alerts are system notifications from tools like Datadog, PagerDuty, Sentry, Vercel, and Stripe.
+
+ Use tools before answering:
+ - Use get_tickets for questions about customer issues, bugs, blockers, status, owners, priorities, categories, or tags.
+ - Use get_alerts for questions about system alerts, incidents, deploys, monitoring, sources, categories, or tags.
+ - Use get_tags to discover the classification vocabulary before filtering by tags or explaining patterns.
+ - Use search_items when the user asks about similar issues, related items, or semantic matches.
+
+ Investigation pattern:
+ 1. Start with the smallest useful tool call.
+ 2. If the first result is broad or ambiguous, refine with status, source, priority, category, tag, assignee, or query filters.
+ 3. For pattern questions, inspect tags first, then query tickets/alerts or search similar items.
+ 4. For cross-cutting questions, use at least two tools when it materially improves the answer.
+ 5. Stop once the evidence is sufficient; do not call tools just to use every tool.
+
+ Final answer rules:
+ - Base every final answer on tool output only.
+ - Mention exact ticket/alert titles, owners or sources, priorities, categories, and tags when relevant.
+ - If the user asks which items match a status or priority, name the exact matching items.
+ - Keep the final answer concise and practical.
+ If the system has no items yet, tell the user to generate sample items first.
+ """,
+)
+
+
+@copilot_agent.tool
+async def get_tickets(
+ ctx: RunContext[CopilotDeps],
+ status: str | None = None,
+ assignee: str | None = None,
+ source: str | None = None,
+ category: str | None = None,
+ priority: str | None = None,
+ tag: str | None = None,
+ query: str | None = None,
+ limit: int = 10,
+) -> list[dict]:
+ """Fetch support tickets with optional filters."""
+ filters = ItemFilters(
+ status=status,
+ assignee=assignee,
+ source=source,
+ category=category,
+ priority=priority,
+ tag=tag,
+ query=query,
+ )
+ items = await get_items_by_type("ticket", limit=limit, filters=filters)
+ return [
+ {
+ "id": i.id,
+ "source": i.source,
+ "title": i.title,
+ "body": i.body,
+ "status": i.status,
+ "assignee": i.assignee,
+ "category": i.category,
+ "priority": i.priority,
+ "tags": i.tags,
+ }
+ for i in items
+ ]
+
+
+@copilot_agent.tool
+async def get_alerts(
+ ctx: RunContext[CopilotDeps],
+ source: str | None = None,
+ category: str | None = None,
+ priority: str | None = None,
+ tag: str | None = None,
+ query: str | None = None,
+ limit: int = 10,
+) -> list[dict]:
+ """Fetch system alerts with optional filters."""
+ filters = ItemFilters(
+ source=source,
+ category=category,
+ priority=priority,
+ tag=tag,
+ query=query,
+ )
+ items = await get_items_by_type("alert", limit=limit, filters=filters)
+ return [
+ {
+ "id": i.id,
+ "source": i.source,
+ "title": i.title,
+ "body": i.body,
+ "category": i.category,
+ "priority": i.priority,
+ "tags": i.tags,
+ }
+ for i in items
+ ]
+
+
+@copilot_agent.tool
+async def get_tags(
+ ctx: RunContext[CopilotDeps],
+ item_type: str | None = None,
+) -> list[dict]:
+ """Get all classification tags with their counts. Optionally filter by item_type ('ticket' or 'alert')."""
+ t = item_type if item_type in ("ticket", "alert") else None
+ return await get_available_tags(t)
+
+
+@copilot_agent.tool
+async def search_items(
+ ctx: RunContext[CopilotDeps],
+ search_query: str,
+ item_type: str | None = None,
+ limit: int = 5,
+) -> list[dict]:
+ """Semantic search across tickets and alerts. Returns the most similar items."""
+ embedding = await embed_text_for_search(search_query)
+ t = item_type if item_type in ("ticket", "alert") else None
+ results = await search_items_by_embedding(embedding, item_type=t, limit=limit)
+ return [
+ {
+ "title": r["item"].title,
+ "source": r["item"].source,
+ "body": r["item"].body,
+ "category": r["item"].category,
+ "priority": r["item"].priority,
+ "tags": r["item"].tags,
+ "score": r["score"],
+ }
+ for r in results
+ ]
diff --git a/samples/pydantic-ai-extended/app/lib/__init__.py b/samples/pydantic-ai-extended/app/lib/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/samples/pydantic-ai-extended/app/lib/ai.py b/samples/pydantic-ai-extended/app/lib/ai.py
new file mode 100644
index 00000000..69a14156
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/ai.py
@@ -0,0 +1,284 @@
+"""LLM communication: seed generation, classification, and embedding."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import os
+import re
+
+import httpx
+
+from lib.items import ItemClassification, ItemType, RawItemSeed
+from lib.model import get_chat_model, get_embedding_config, has_chat_access, has_embedding_access
+from lib.seed_data import fallback_alerts, fallback_tickets
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _has_llm_access() -> bool:
+ return has_chat_access()
+
+
+def _use_fast_local_data() -> bool:
+ return os.environ.get("LOCAL_FAST_DATA") == "true"
+
+
+def _parse_json_from_text(text: str) -> object:
+ """Extracts JSON from LLM output, handling markdown fences."""
+ text = text.strip()
+ match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
+ candidate = match.group(1).strip() if match else text
+ try:
+ return json.loads(candidate)
+ except json.JSONDecodeError:
+ # Try to find a JSON object or array
+ for start, end in [("{", "}"), ("[", "]")]:
+ first = candidate.find(start)
+ last = candidate.rfind(end)
+ if first >= 0 and last > first:
+ return json.loads(candidate[first : last + 1])
+ raise ValueError("Invalid JSON payload returned by model")
+
+
+# ---------------------------------------------------------------------------
+# Pydantic AI agent for generation
+# ---------------------------------------------------------------------------
+
+async def _run_chat_json(system_prompt: str, user_prompt: str) -> object | None:
+ if not _has_llm_access() or os.environ.get("MOCK_AGENT") == "true":
+ return None
+ from pydantic_ai import Agent
+
+ model = get_chat_model()
+ agent = Agent(model, instructions=system_prompt)
+ result = await agent.run(user_prompt)
+ return _parse_json_from_text(result.output)
+
+
+# ---------------------------------------------------------------------------
+# Embeddings
+# ---------------------------------------------------------------------------
+
+def _deterministic_embedding(text: str, dimensions: int = 192) -> list[float]:
+ """Generates a stable fake embedding from text using SHA-256."""
+ buckets = [0.0] * dimensions
+ digest = hashlib.sha256(text.encode()).digest()
+ for i, ch in enumerate(text):
+ code = ord(ch)
+ bucket = (code + digest[i % len(digest)]) % dimensions
+ direction = 1 if digest[(i * 7) % len(digest)] % 2 == 0 else -1
+ buckets[bucket] += direction * ((code % 13) + 1)
+ magnitude = math.sqrt(sum(v * v for v in buckets)) or 1.0
+ return [v / magnitude for v in buckets]
+
+
+async def embed_text_for_search(text: str) -> list[float]:
+ if os.environ.get("MOCK_AGENT") == "true" or not has_embedding_access():
+ return _deterministic_embedding(text)
+ try:
+ base_url, model_id, api_key = get_embedding_config()
+ url = f"{base_url.rstrip('/')}/embeddings"
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(
+ url,
+ json={"input": text, "model": model_id},
+ headers={"Authorization": f"Bearer {api_key}"},
+ )
+ resp.raise_for_status()
+ return resp.json()["data"][0]["embedding"]
+ except Exception:
+ return _deterministic_embedding(text)
+
+
+# ---------------------------------------------------------------------------
+# Classification (with regex fallbacks)
+# ---------------------------------------------------------------------------
+
+def _fallback_category(text: str) -> str:
+ t = text.lower()
+ if re.search(r"(deploy|release|workflow|rollback)", t):
+ return "delivery"
+ if re.search(r"(payment|invoice|billing|subscription)", t):
+ return "billing"
+ if re.search(r"(latency|timeout|slow|memory|performance)", t):
+ return "performance"
+ if re.search(r"(import|sync|webhook|integration|duplicate)", t):
+ return "integration"
+ if re.search(r"(error|exception|incident|alert|500)", t):
+ return "incident"
+ if re.search(r"(auth|login|sso|password|token|403)", t):
+ return "authentication"
+ return "operations"
+
+
+def _fallback_priority(text: str) -> str:
+ t = text.lower()
+ if re.search(r"(critical|rollback|paged|blocked|failing|unresponsive)", t):
+ return "critical"
+ if re.search(r"(error|duplicate|latency|failed|degraded|spike)", t):
+ return "high"
+ if re.search(r"(planned|review|verify|update|warning)", t):
+ return "medium"
+ return "low"
+
+
+def _fallback_tags(text: str) -> list[str]:
+ t = text.lower()
+ tags: set[str] = set()
+ if re.search(r"(api|latency|timeout|performance|memory)", t):
+ tags.add("performance")
+ if re.search(r"(deploy|release|workflow|rollback)", t):
+ tags.add("delivery")
+ if re.search(r"(payment|invoice|billing|subscription|stripe)", t):
+ tags.add("billing")
+ if re.search(r"(import|sync|webhook|integration)", t):
+ tags.add("integration")
+ if re.search(r"(customer|support|report)", t):
+ tags.add("customer")
+ if re.search(r"(bug|error|exception|incident|alert|500)", t):
+ tags.add("incident")
+ if re.search(r"(auth|login|sso|password|token|certificate)", t):
+ tags.add("auth")
+ if len(tags) < 2:
+ tags.add("ops")
+ tags.add("triage")
+ return list(tags)[:4]
+
+
+async def classify_item(
+ item: RawItemSeed | ItemClassification | dict,
+) -> ItemClassification:
+ # Accept dict-like or dataclass
+ if isinstance(item, dict):
+ source = item.get("source", "")
+ title = item.get("title", "")
+ body = item.get("body", "")
+ item_type = item.get("item_type", "ticket")
+ else:
+ source = getattr(item, "source", "")
+ title = getattr(item, "title", "")
+ body = getattr(item, "body", "")
+ item_type = getattr(item, "item_type", "ticket")
+
+ if _use_fast_local_data() or not _has_llm_access() or os.environ.get("MOCK_AGENT") == "true":
+ text = f"{source} {title} {body}"
+ return ItemClassification(
+ category=_fallback_category(text),
+ priority=_fallback_priority(text),
+ tags=_fallback_tags(text),
+ )
+
+ payload = await _run_chat_json(
+ "You classify incoming support tickets and system alerts. Return valid JSON only.",
+ "\n".join([
+ f"Item type: {item_type}",
+ f"Source: {source}",
+ f"Title: {title}",
+ f"Body: {body}",
+ "Return this exact shape:",
+ '{"category":"...","priority":"low|medium|high|critical","tags":["tag-one","tag-two"]}',
+ "Keep category short and practical, like incident, delivery, billing, performance, integration, authentication, or support.",
+ "Return 2 to 4 tags.",
+ "Do not include markdown.",
+ ]),
+ )
+
+ if payload and isinstance(payload, dict):
+ return ItemClassification(
+ category=str(payload.get("category", "operations")),
+ priority=str(payload.get("priority", "medium")),
+ tags=[str(t) for t in payload.get("tags", ["ops", "triage"])][:4],
+ )
+
+ text = f"{source} {title} {body}"
+ return ItemClassification(
+ category=_fallback_category(text),
+ priority=_fallback_priority(text),
+ tags=_fallback_tags(text),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Seed item generation
+# ---------------------------------------------------------------------------
+
+async def generate_seed_items() -> list[RawItemSeed]:
+ if _use_fast_local_data() or not _has_llm_access() or os.environ.get("MOCK_AGENT") == "true":
+ return fallback_tickets + fallback_alerts
+
+ tickets_payload = await _run_chat_json(
+ "You generate realistic support ticket records. Return valid JSON only.",
+ "\n".join([
+ "Generate exactly 10 support ticket records for a SaaS product team.",
+ "These tickets should look like real issues from tools like Zendesk, Intercom, Jira, Linear, and GitHub Issues.",
+ "Avoid fake enterprise jargon. Keep them concrete and easy to understand.",
+ "Return this exact shape:",
+ '{"tickets":[{"source":"Zendesk","title":"...","body":"...","status":"open|in progress|blocked|planned","assignee":"..."}]}',
+ "Do not include markdown.",
+ ]),
+ )
+
+ alerts_payload = await _run_chat_json(
+ "You generate realistic system alert records. Return valid JSON only.",
+ "\n".join([
+ "Generate exactly 10 alert records for a SaaS product team.",
+ "These alerts should look like notifications from tools like Datadog, PagerDuty, Sentry, Vercel, Stripe, GitHub Actions, and AWS CloudWatch.",
+ "Avoid fake enterprise jargon. Keep them concrete and easy to understand.",
+ "Return this exact shape:",
+ '{"alerts":[{"source":"Datadog","title":"...","body":"..."}]}',
+ "Do not include markdown.",
+ ]),
+ )
+
+ items: list[RawItemSeed] = []
+
+ if tickets_payload and isinstance(tickets_payload, dict):
+ for t in tickets_payload.get("tickets", [])[:10]:
+ items.append(RawItemSeed(
+ item_type="ticket",
+ source=str(t.get("source", "Unknown")),
+ title=str(t.get("title", "Untitled")),
+ body=str(t.get("body", "")),
+ status=t.get("status"),
+ assignee=t.get("assignee"),
+ ))
+
+ if alerts_payload and isinstance(alerts_payload, dict):
+ for a in alerts_payload.get("alerts", [])[:10]:
+ items.append(RawItemSeed(
+ item_type="alert",
+ source=str(a.get("source", "Unknown")),
+ title=str(a.get("title", "Untitled")),
+ body=str(a.get("body", "")),
+ ))
+
+ if not items:
+ return fallback_tickets + fallback_alerts
+
+ return items
+
+
+# ---------------------------------------------------------------------------
+# Embedding text builder
+# ---------------------------------------------------------------------------
+
+def text_for_embedding(
+ item: RawItemSeed | dict,
+ classification: ItemClassification,
+) -> str:
+ if isinstance(item, dict):
+ parts = [
+ item.get("item_type", ""),
+ item.get("source", ""),
+ item.get("title", ""),
+ item.get("body", ""),
+ ]
+ else:
+ parts = [item.item_type, item.source, item.title, item.body]
+ parts.extend([classification.category, classification.priority])
+ parts.append(" ".join(classification.tags))
+ return "\n".join(parts)
diff --git a/samples/pydantic-ai-extended/app/lib/db.py b/samples/pydantic-ai-extended/app/lib/db.py
new file mode 100644
index 00000000..2636ce0b
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/db.py
@@ -0,0 +1,79 @@
+"""PostgreSQL connection pool and schema migration using asyncpg."""
+
+from __future__ import annotations
+
+import os
+
+import asyncpg
+
+_pool: asyncpg.Pool | None = None
+
+
+async def get_pool() -> asyncpg.Pool:
+ global _pool
+ if _pool is None:
+ dsn = os.environ.get("DATABASE_URL")
+ if not dsn:
+ raise RuntimeError("DATABASE_URL is not configured")
+ _pool = await asyncpg.create_pool(dsn, min_size=2, max_size=10)
+ return _pool
+
+
+_schema_ready = False
+
+
+async def ensure_schema() -> None:
+ global _schema_ready
+ if _schema_ready:
+ return
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS seed_runs (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ total_items INTEGER NOT NULL DEFAULT 20,
+ processed_items INTEGER NOT NULL DEFAULT 0,
+ summary TEXT,
+ error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """)
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS items (
+ id BIGSERIAL PRIMARY KEY,
+ run_id TEXT REFERENCES seed_runs(id) ON DELETE SET NULL,
+ item_type TEXT NOT NULL,
+ source TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ status TEXT,
+ assignee TEXT,
+ category TEXT,
+ priority TEXT,
+ tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
+ embedding vector,
+ processed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT items_item_type_check CHECK (item_type IN ('ticket', 'alert'))
+ )
+ """)
+ await conn.execute("""
+ CREATE INDEX IF NOT EXISTS items_item_type_created_idx
+ ON items (item_type, created_at DESC)
+ """)
+ await conn.execute("""
+ CREATE INDEX IF NOT EXISTS items_run_id_idx ON items (run_id)
+ """)
+ _schema_ready = True
+
+
+async def close_pool() -> None:
+ global _pool, _schema_ready
+ if _pool:
+ await _pool.close()
+ _pool = None
+ _schema_ready = False
diff --git a/samples/pydantic-ai-extended/app/lib/items.py b/samples/pydantic-ai-extended/app/lib/items.py
new file mode 100644
index 00000000..7349a3e7
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/items.py
@@ -0,0 +1,347 @@
+"""Item CRUD operations against PostgreSQL."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+from lib.db import get_pool
+
+ItemType = Literal["ticket", "alert"]
+
+
+@dataclass
+class SeedRun:
+ id: str
+ status: str
+ total_items: int
+ processed_items: int
+ summary: str | None
+ error: str | None
+ created_at: str
+ updated_at: str
+
+
+@dataclass
+class ItemRecord:
+ id: int
+ run_id: str | None
+ item_type: ItemType
+ source: str
+ title: str
+ body: str
+ status: str | None
+ assignee: str | None
+ category: str | None
+ priority: str | None
+ tags: list[str]
+ processed_at: str | None
+ created_at: str
+ updated_at: str
+
+
+@dataclass
+class ItemClassification:
+ category: str
+ priority: str
+ tags: list[str]
+
+
+@dataclass
+class RawItemSeed:
+ item_type: ItemType
+ source: str
+ title: str
+ body: str
+ status: str | None = None
+ assignee: str | None = None
+
+
+@dataclass
+class ItemFilters:
+ status: str | None = None
+ assignee: str | None = None
+ source: str | None = None
+ category: str | None = None
+ priority: str | None = None
+ tag: str | None = None
+ query: str | None = None
+
+
+def _map_run(row: dict) -> SeedRun:
+ return SeedRun(
+ id=str(row["id"]),
+ status=str(row["status"]),
+ total_items=int(row["total_items"]),
+ processed_items=int(row["processed_items"]),
+ summary=row.get("summary"),
+ error=row.get("error"),
+ created_at=str(row["created_at"]),
+ updated_at=str(row["updated_at"]),
+ )
+
+
+def _map_item(row: dict) -> ItemRecord:
+ return ItemRecord(
+ id=int(row["id"]),
+ run_id=row.get("run_id"),
+ item_type=row["item_type"],
+ source=str(row["source"]),
+ title=str(row["title"]),
+ body=str(row["body"]),
+ status=row.get("status"),
+ assignee=row.get("assignee"),
+ category=row.get("category"),
+ priority=row.get("priority"),
+ tags=list(row.get("tags") or []),
+ processed_at=str(row["processed_at"]) if row.get("processed_at") else None,
+ created_at=str(row["created_at"]),
+ updated_at=str(row["updated_at"]),
+ )
+
+
+async def create_seed_run(run_id: str, total_items: int = 20) -> None:
+ pool = await get_pool()
+ await pool.execute(
+ """
+ INSERT INTO seed_runs (id, status, total_items, processed_items, summary, error)
+ VALUES ($1, 'queued', $2, 0, 'Queued item generation job', NULL)
+ """,
+ run_id,
+ total_items,
+ )
+
+
+async def start_seed_run(run_id: str, summary: str) -> None:
+ pool = await get_pool()
+ await pool.execute(
+ """
+ UPDATE seed_runs
+ SET status = 'running', summary = $2, error = NULL, updated_at = NOW()
+ WHERE id = $1
+ """,
+ run_id,
+ summary,
+ )
+
+
+async def finish_seed_run(run_id: str, summary: str) -> None:
+ pool = await get_pool()
+ await pool.execute(
+ """
+ UPDATE seed_runs
+ SET status = 'completed', summary = $2, updated_at = NOW()
+ WHERE id = $1
+ """,
+ run_id,
+ summary,
+ )
+
+
+async def fail_seed_run(run_id: str, error: str) -> None:
+ pool = await get_pool()
+ await pool.execute(
+ """
+ UPDATE seed_runs
+ SET status = 'failed', error = $2, summary = 'Background processing failed',
+ updated_at = NOW()
+ WHERE id = $1
+ """,
+ run_id,
+ error,
+ )
+
+
+async def get_latest_run() -> SeedRun | None:
+ pool = await get_pool()
+ row = await pool.fetchrow(
+ "SELECT * FROM seed_runs ORDER BY created_at DESC LIMIT 1"
+ )
+ return _map_run(row) if row else None
+
+
+async def insert_seed_items(run_id: str, items: list[RawItemSeed]) -> list[ItemRecord]:
+ pool = await get_pool()
+ inserted: list[ItemRecord] = []
+ for item in items:
+ row = await pool.fetchrow(
+ """
+ INSERT INTO items (run_id, item_type, source, title, body, status, assignee)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING *
+ """,
+ run_id,
+ item.item_type,
+ item.source,
+ item.title,
+ item.body,
+ item.status,
+ item.assignee,
+ )
+ inserted.append(_map_item(row))
+ return inserted
+
+
+async def get_item_by_id(item_id: int) -> ItemRecord | None:
+ pool = await get_pool()
+ row = await pool.fetchrow("SELECT * FROM items WHERE id = $1", item_id)
+ return _map_item(row) if row else None
+
+
+async def update_processed_item(
+ item_id: int, classification: ItemClassification, embedding: list[float]
+) -> ItemRecord | None:
+ pool = await get_pool()
+ vector_literal = f"[{','.join(str(v) for v in embedding)}]"
+ row = await pool.fetchrow(
+ """
+ UPDATE items
+ SET category = $2, priority = $3, tags = $4,
+ embedding = $5::vector, processed_at = NOW(), updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ """,
+ item_id,
+ classification.category,
+ classification.priority,
+ classification.tags,
+ vector_literal,
+ )
+ return _map_item(row) if row else None
+
+
+async def mark_item_processed(run_id: str) -> SeedRun | None:
+ pool = await get_pool()
+ row = await pool.fetchrow(
+ """
+ UPDATE seed_runs
+ SET processed_items = processed_items + 1, updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ """,
+ run_id,
+ )
+ return _map_run(row) if row else None
+
+
+def _apply_filters(
+ clauses: list[str], values: list, filters: ItemFilters | None = None
+) -> None:
+ if not filters:
+ return
+ if filters.status:
+ values.append(filters.status)
+ clauses.append(f"LOWER(status) = LOWER(${len(values)})")
+ if filters.assignee:
+ values.append(f"%{filters.assignee}%")
+ clauses.append(f"assignee ILIKE ${len(values)}")
+ if filters.source:
+ values.append(f"%{filters.source}%")
+ clauses.append(f"source ILIKE ${len(values)}")
+ if filters.category:
+ values.append(filters.category)
+ clauses.append(f"LOWER(category) = LOWER(${len(values)})")
+ if filters.priority:
+ values.append(filters.priority)
+ clauses.append(f"LOWER(priority) = LOWER(${len(values)})")
+ if filters.tag:
+ values.append(filters.tag)
+ clauses.append(
+ f"EXISTS (SELECT 1 FROM unnest(tags) AS t WHERE LOWER(t) = LOWER(${len(values)}))"
+ )
+ if filters.query:
+ values.append(f"%{filters.query}%")
+ idx = len(values)
+ clauses.append(
+ f"(title ILIKE ${idx} OR body ILIKE ${idx} OR source ILIKE ${idx} "
+ f"OR category ILIKE ${idx} "
+ f"OR EXISTS (SELECT 1 FROM unnest(tags) AS t WHERE t ILIKE ${idx}))"
+ )
+
+
+async def get_items_by_type(
+ item_type: ItemType, limit: int = 10, filters: ItemFilters | None = None
+) -> list[ItemRecord]:
+ pool = await get_pool()
+ clauses = ["item_type = $1"]
+ values: list = [item_type]
+ _apply_filters(clauses, values, filters)
+ values.append(limit)
+ sql = f"""
+ SELECT * FROM items
+ WHERE {' AND '.join(clauses)}
+ ORDER BY created_at DESC, id DESC
+ LIMIT ${len(values)}
+ """
+ rows = await pool.fetch(sql, *values)
+ return [_map_item(r) for r in rows]
+
+
+async def get_item_counts() -> dict:
+ pool = await get_pool()
+ row = await pool.fetchrow("""
+ SELECT
+ COUNT(*) FILTER (WHERE item_type = 'ticket')::int AS ticket_count,
+ COUNT(*) FILTER (WHERE item_type = 'alert')::int AS alert_count,
+ COUNT(*) FILTER (WHERE processed_at IS NOT NULL)::int AS classified_count
+ FROM items
+ """)
+ return {
+ "ticketCount": int(row["ticket_count"] or 0),
+ "alertCount": int(row["alert_count"] or 0),
+ "classifiedCount": int(row["classified_count"] or 0),
+ }
+
+
+async def get_available_tags(item_type: ItemType | None = None) -> list[dict]:
+ pool = await get_pool()
+ if item_type:
+ rows = await pool.fetch(
+ """
+ SELECT t AS tag, COUNT(*)::int AS count
+ FROM items CROSS JOIN LATERAL unnest(tags) AS t
+ WHERE item_type = $1
+ GROUP BY t ORDER BY count DESC, t ASC
+ """,
+ item_type,
+ )
+ else:
+ rows = await pool.fetch("""
+ SELECT t AS tag, COUNT(*)::int AS count
+ FROM items CROSS JOIN LATERAL unnest(tags) AS t
+ GROUP BY t ORDER BY count DESC, t ASC
+ """)
+ return [{"tag": str(r["tag"]), "count": int(r["count"])} for r in rows]
+
+
+async def search_items_by_embedding(
+ embedding: list[float],
+ item_type: ItemType | None = None,
+ limit: int = 5,
+ filters: ItemFilters | None = None,
+) -> list[dict]:
+ pool = await get_pool()
+ vector_literal = f"[{','.join(str(v) for v in embedding)}]"
+ clauses = ["processed_at IS NOT NULL", "embedding IS NOT NULL"]
+ values: list = [vector_literal, limit]
+
+ if item_type:
+ values.append(item_type)
+ clauses.append(f"item_type = ${len(values)}")
+
+ _apply_filters(clauses, values, filters)
+
+ rows = await pool.fetch(
+ f"""
+ SELECT *, 1 - (embedding <=> $1::vector) AS score
+ FROM items
+ WHERE {' AND '.join(clauses)}
+ ORDER BY embedding <=> $1::vector
+ LIMIT $2
+ """,
+ *values,
+ )
+ return [
+ {"item": _map_item(r), "score": round(float(r["score"]), 4)}
+ for r in rows
+ ]
diff --git a/samples/pydantic-ai-extended/app/lib/mock_agent.py b/samples/pydantic-ai-extended/app/lib/mock_agent.py
new file mode 100644
index 00000000..81acfbd7
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/mock_agent.py
@@ -0,0 +1,55 @@
+"""Lightweight mock copilot that queries the database directly."""
+
+from __future__ import annotations
+
+from lib.items import get_item_counts, get_items_by_type
+
+
+async def get_mock_reply(message: str) -> str:
+ counts = await get_item_counts()
+ tickets = await get_items_by_type("ticket", limit=5)
+ alerts = await get_items_by_type("alert", limit=5)
+
+ total = counts["ticketCount"] + counts["alertCount"]
+ if total == 0:
+ return (
+ "There are no items in the system yet. "
+ "Click **Generate sample items** to create some tickets and alerts, "
+ "then ask me about them."
+ )
+
+ msg = message.lower()
+
+ if "first" in msg or "urgent" in msg or "priority" in msg or "look at" in msg:
+ critical = [t for t in tickets if t.priority in ("critical", "high")]
+ if critical:
+ top = critical[0]
+ return (
+ f"I'd start with **{top.title}** (priority: {top.priority}, "
+ f"source: {top.source}). It's assigned to {top.assignee or 'unassigned'}."
+ )
+ return "No critical or high-priority tickets at the moment. Things look calm."
+
+ if "summar" in msg:
+ return (
+ f"There are **{counts['ticketCount']} tickets** and "
+ f"**{counts['alertCount']} alerts**. "
+ f"{counts['classifiedCount']} items have been classified so far."
+ )
+
+ if "related" in msg or "similar" in msg or "pattern" in msg:
+ tags_seen: set[str] = set()
+ for item in tickets + alerts:
+ tags_seen.update(item.tags)
+ if tags_seen:
+ return (
+ f"Common themes across items: {', '.join(sorted(tags_seen)[:8])}. "
+ "Try asking about a specific tag to drill in."
+ )
+ return "Items haven't been classified yet. Wait for the worker to finish, then ask again."
+
+ return (
+ f"The system currently has {counts['ticketCount']} tickets and "
+ f"{counts['alertCount']} alerts. Ask me to summarize, find urgent items, "
+ "or look for patterns."
+ )
diff --git a/samples/pydantic-ai-extended/app/lib/model.py b/samples/pydantic-ai-extended/app/lib/model.py
new file mode 100644
index 00000000..bdafe278
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/model.py
@@ -0,0 +1,52 @@
+"""
+Model resolution for Defang's OpenAI-compatible `provider: model` services.
+
+The application never talks to Bedrock or Vertex directly. Instead, Compose
+defines dedicated `chat` and `embedding` services, and Defang injects:
+ - CHAT_URL / CHAT_MODEL
+ - EMBEDDING_URL / EMBEDDING_MODEL
+
+Those endpoints stay stable across local Docker Model Runner, Playground,
+AWS, and GCP. The app code only sees OpenAI-compatible URLs plus model IDs.
+"""
+
+from __future__ import annotations
+
+import os
+
+from pydantic_ai.models.openai import OpenAIChatModel
+from pydantic_ai.providers.openai import OpenAIProvider
+
+
+def _require_env(name: str) -> str:
+ value = os.environ.get(name)
+ if not value:
+ raise RuntimeError(f"{name} is not configured")
+ return value
+
+
+def has_chat_access() -> bool:
+ return bool(os.environ.get("CHAT_URL") and os.environ.get("CHAT_MODEL"))
+
+
+def has_embedding_access() -> bool:
+ return bool(os.environ.get("EMBEDDING_URL") and os.environ.get("EMBEDDING_MODEL"))
+
+
+def get_chat_model() -> OpenAIChatModel:
+ return OpenAIChatModel(
+ _require_env("CHAT_MODEL"),
+ provider=OpenAIProvider(
+ base_url=_require_env("CHAT_URL"),
+ api_key=os.environ.get("OPENAI_API_KEY", "defang"),
+ ),
+ )
+
+
+def get_embedding_config() -> tuple[str, str, str]:
+ """Returns (base_url, model_id, api_key) for the embedding service."""
+ return (
+ _require_env("EMBEDDING_URL"),
+ _require_env("EMBEDDING_MODEL"),
+ os.environ.get("OPENAI_API_KEY", "defang"),
+ )
diff --git a/samples/pydantic-ai-extended/app/lib/queue.py b/samples/pydantic-ai-extended/app/lib/queue.py
new file mode 100644
index 00000000..a1dad63c
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/queue.py
@@ -0,0 +1,42 @@
+"""Simple Redis-based job queue using lists."""
+
+from __future__ import annotations
+
+import json
+import os
+
+import redis.asyncio as redis
+
+QUEUE_NAME = os.environ.get("QUEUE_NAME", "ticket-sync")
+
+_redis_conn: redis.Redis | None = None
+
+
+def get_redis() -> redis.Redis:
+ global _redis_conn
+ if _redis_conn is None:
+ url = os.environ.get("REDIS_URL")
+ if not url:
+ raise RuntimeError("REDIS_URL is not configured")
+ _redis_conn = redis.from_url(url, decode_responses=True)
+ return _redis_conn
+
+
+async def enqueue_job(job_name: str, data: dict) -> None:
+ r = get_redis()
+ payload = json.dumps({"name": job_name, "data": data})
+ await r.lpush(QUEUE_NAME, payload)
+
+
+async def dequeue_job(timeout: int = 5) -> dict | None:
+ r = get_redis()
+ result = await r.brpop(QUEUE_NAME, timeout=timeout)
+ if result is None:
+ return None
+ _, payload = result
+ return json.loads(payload)
+
+
+async def get_queue_length() -> int:
+ r = get_redis()
+ return await r.llen(QUEUE_NAME)
diff --git a/samples/pydantic-ai-extended/app/lib/seed_data.py b/samples/pydantic-ai-extended/app/lib/seed_data.py
new file mode 100644
index 00000000..a290e1b9
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/lib/seed_data.py
@@ -0,0 +1,151 @@
+"""Deterministic fallback seed data used when LLM is unavailable or LOCAL_FAST_DATA is enabled."""
+
+from __future__ import annotations
+
+from lib.items import RawItemSeed
+
+fallback_tickets: list[RawItemSeed] = [
+ RawItemSeed(
+ item_type="ticket",
+ source="Zendesk",
+ title="Cannot reset password after email change",
+ body="Customer changed their email address last week and now the password reset flow sends the link to the old email. They've tried clearing cookies and using incognito mode.",
+ status="open",
+ assignee="Maya Chen",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="GitHub Issues",
+ title="SDK returns 403 after token refresh",
+ body="The Python SDK throws a 403 Forbidden error immediately after refreshing the access token. This started after upgrading to v2.4.0. Rolling back to v2.3.1 fixes it.",
+ status="open",
+ assignee="Alex Rivera",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Intercom",
+ title="Billing page shows wrong subscription tier",
+ body="Enterprise customer reports their billing page shows the Pro tier instead of Enterprise. They're being charged correctly according to Stripe, but the UI is wrong.",
+ status="in progress",
+ assignee="Jordan Lee",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Jira",
+ title="CSV export times out for large datasets",
+ body="Exporting more than 50k rows as CSV causes a gateway timeout. The export job runs for over 5 minutes before nginx kills the connection.",
+ status="open",
+ assignee="Sam Patel",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Linear",
+ title="Webhook delivery fails silently for deleted endpoints",
+ body="When a webhook endpoint is deleted, pending deliveries fail without any error in the dashboard. Users expect to see failed delivery attempts in the logs.",
+ status="planned",
+ assignee="Maya Chen",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Zendesk",
+ title="SSO login loop on Safari",
+ body="Safari users get stuck in an infinite redirect loop when trying to sign in via SSO. Works fine on Chrome and Firefox. Likely related to ITP cookie restrictions.",
+ status="open",
+ assignee="Alex Rivera",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Slack",
+ title="Dashboard charts render blank after timezone change",
+ body="After switching the account timezone from UTC to PST, all dashboard charts show blank. Refreshing the page and clearing cache doesn't help.",
+ status="open",
+ assignee="Jordan Lee",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="GitHub Issues",
+ title="Rate limiter counts preflight OPTIONS requests",
+ body="CORS preflight OPTIONS requests are being counted against the API rate limit. This causes legitimate POST requests to fail for browser-based clients.",
+ status="in progress",
+ assignee="Sam Patel",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Intercom",
+ title="File uploads fail above 25MB even though limit is 100MB",
+ body="The file upload endpoint returns a 413 error for files above 25MB. The docs say the limit is 100MB. The load balancer might have a lower limit configured.",
+ status="blocked",
+ assignee="Alex Rivera",
+ ),
+ RawItemSeed(
+ item_type="ticket",
+ source="Linear",
+ title="Search indexing lags behind by 10+ minutes",
+ body="Newly created records don't appear in search results for at least 10 minutes. The search index job seems to be running on a fixed schedule rather than processing events in real time.",
+ status="planned",
+ assignee="Maya Chen",
+ ),
+]
+
+fallback_alerts: list[RawItemSeed] = [
+ RawItemSeed(
+ item_type="alert",
+ source="Datadog",
+ title="API p99 latency above 2s for 15 minutes",
+ body="The /api/v2/search endpoint p99 latency has been above 2 seconds for the last 15 minutes. Normal baseline is 400ms. The database connection pool is at 95% utilization.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="PagerDuty",
+ title="Payment processing service unresponsive",
+ body="Health checks for the payment service have been failing for 3 minutes. Last successful transaction was at 14:32 UTC. Stripe webhook deliveries are queuing up.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="Sentry",
+ title="Unhandled TypeError in checkout flow",
+ body="TypeError: Cannot read properties of undefined (reading 'price') at CheckoutPage.calculateTotal. 847 occurrences in the last hour affecting 312 users.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="GitHub Actions",
+ title="Deploy to production failed: image pull error",
+ body="The production deploy workflow failed at the image pull step. The container registry returned a 429 Too Many Requests error. Three retries all failed.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="Vercel",
+ title="Edge function cold start times elevated",
+ body="Cold start times for edge functions in the us-east-1 region are 3x higher than normal. Warm function performance is unaffected. Vercel status page shows no incidents.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="Stripe",
+ title="Webhook signature verification failures spike",
+ body="43 webhook signature verification failures in the last 30 minutes. All are for the invoice.payment_succeeded event type. The signing secret may have been rotated.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="Datadog",
+ title="Redis memory usage at 89% of max",
+ body="The production Redis instance memory usage has been climbing steadily and is now at 89% of the configured maxmemory. Key eviction has started.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="Sentry",
+ title="Rate of 500 errors doubled in the last hour",
+ body="Internal server errors across all API endpoints have increased from a baseline of ~20/min to ~45/min. No recent deploys. The error distribution is spread across multiple endpoints.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="AWS CloudWatch",
+ title="RDS connection count approaching limit",
+ body="The RDS instance has 180 active connections out of a maximum 200. This is the highest it has been in 30 days. Several connection pool warnings in the application logs.",
+ ),
+ RawItemSeed(
+ item_type="alert",
+ source="PagerDuty",
+ title="Certificate expiry warning: api.example.com",
+ body="The TLS certificate for api.example.com expires in 7 days. Auto-renewal via Let's Encrypt failed with a DNS challenge verification error.",
+ ),
+]
diff --git a/samples/pydantic-ai-extended/app/main.py b/samples/pydantic-ai-extended/app/main.py
new file mode 100644
index 00000000..7eed938d
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/main.py
@@ -0,0 +1,225 @@
+"""
+FastAPI application serving the UI and API endpoints.
+
+Routes:
+ GET / - Serves the HTML UI
+ GET /api/health - Health check
+ GET /api/dashboard - Dashboard data (counts, latest run, queue)
+ GET /api/items - List tickets and alerts
+ POST /api/items/seed - Kick off seed generation
+ POST /api/chat - Copilot endpoint (streaming NDJSON or JSON)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import uuid
+
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+from agents.copilot import CopilotDeps, copilot_agent
+from lib.db import close_pool, ensure_schema
+from lib.items import get_item_counts, get_items_by_type, get_latest_run
+from lib.mock_agent import get_mock_reply
+from lib.queue import enqueue_job, get_queue_length
+
+app = FastAPI()
+app.mount("/static", StaticFiles(directory="static"), name="static")
+templates = Jinja2Templates(directory="templates")
+
+
+@app.on_event("startup")
+async def startup() -> None:
+ await ensure_schema()
+
+
+@app.on_event("shutdown")
+async def shutdown() -> None:
+ await close_pool()
+
+
+# ---------------------------------------------------------------------------
+# Health
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/health")
+async def health() -> dict:
+ return {"status": "ok"}
+
+
+# ---------------------------------------------------------------------------
+# Dashboard
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/dashboard")
+async def dashboard() -> dict:
+ latest_run = await get_latest_run()
+ counts = await get_item_counts()
+ queue_len = await get_queue_length()
+ return {
+ "latestRun": {
+ "id": latest_run.id,
+ "status": latest_run.status,
+ "totalItems": latest_run.total_items,
+ "processedItems": latest_run.processed_items,
+ "summary": latest_run.summary,
+ "error": latest_run.error,
+ "updatedAt": latest_run.updated_at,
+ }
+ if latest_run
+ else None,
+ "counts": counts,
+ "queue": {"pending": queue_len},
+ }
+
+
+# ---------------------------------------------------------------------------
+# Items
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/items")
+async def list_items() -> dict:
+ tickets = await get_items_by_type("ticket", limit=10)
+ alerts = await get_items_by_type("alert", limit=10)
+ return {
+ "tickets": [
+ {
+ "id": t.id,
+ "source": t.source,
+ "title": t.title,
+ "body": t.body,
+ "status": t.status,
+ "assignee": t.assignee,
+ "category": t.category,
+ "priority": t.priority,
+ "tags": t.tags,
+ "processedAt": t.processed_at,
+ }
+ for t in tickets
+ ],
+ "alerts": [
+ {
+ "id": a.id,
+ "source": a.source,
+ "title": a.title,
+ "body": a.body,
+ "category": a.category,
+ "priority": a.priority,
+ "tags": a.tags,
+ "processedAt": a.processed_at,
+ }
+ for a in alerts
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Seed
+# ---------------------------------------------------------------------------
+
+
+@app.post("/api/items/seed")
+async def seed_items() -> dict:
+ from lib.items import create_seed_run
+
+ run_id = str(uuid.uuid4())
+ await create_seed_run(run_id)
+ await enqueue_job("seed-batch", {"runId": run_id})
+ return {"runId": run_id}
+
+
+# ---------------------------------------------------------------------------
+# Chat
+# ---------------------------------------------------------------------------
+
+
+@app.post("/api/chat")
+async def chat(request: Request) -> StreamingResponse | JSONResponse:
+ await ensure_schema()
+ body = await request.json()
+ message = body.get("message", "")
+ stream = body.get("stream", False)
+ thread_id = body.get("threadId", str(uuid.uuid4()))
+
+ if not message:
+ return JSONResponse(
+ {"error": "A message is required."}, status_code=400
+ )
+
+ if stream:
+ return _streaming_response(message, thread_id)
+ else:
+ return await _non_streaming_response(message, thread_id)
+
+
+def _streaming_response(message: str, thread_id: str) -> StreamingResponse:
+ async def generate():
+ yield json.dumps({"type": "meta", "threadId": thread_id}) + "\n"
+
+ if os.environ.get("MOCK_AGENT") == "true":
+ reply = await get_mock_reply(message)
+ for word in reply.split():
+ yield json.dumps({"type": "delta", "text": word + " "}) + "\n"
+ yield json.dumps({"type": "done"}) + "\n"
+ return
+
+ try:
+ async with copilot_agent.run_stream(
+ message, deps=CopilotDeps(), message_history=[]
+ ) as response:
+ async for text in response.stream_text(delta=True):
+ if text:
+ yield json.dumps({"type": "delta", "text": text}) + "\n"
+ yield json.dumps({"type": "done"}) + "\n"
+ except Exception as exc:
+ # Fallback to mock on agent failure
+ try:
+ reply = await get_mock_reply(message)
+ for word in reply.split():
+ yield json.dumps({"type": "delta", "text": word + " "}) + "\n"
+ yield json.dumps({"type": "done"}) + "\n"
+ except Exception:
+ yield json.dumps({"type": "error", "message": str(exc)}) + "\n"
+
+ return StreamingResponse(
+ generate(),
+ media_type="application/x-ndjson",
+ headers={"Cache-Control": "no-cache, no-transform"},
+ )
+
+
+async def _non_streaming_response(
+ message: str, thread_id: str
+) -> JSONResponse:
+ if os.environ.get("MOCK_AGENT") == "true":
+ reply = await get_mock_reply(message)
+ return JSONResponse({"reply": reply, "threadId": thread_id})
+
+ try:
+ result = await copilot_agent.run(
+ message, deps=CopilotDeps(), message_history=[]
+ )
+ reply = result.output
+ if not reply:
+ raise ValueError("Agent returned no text")
+ return JSONResponse({"reply": reply, "threadId": thread_id})
+ except Exception:
+ reply = await get_mock_reply(message)
+ return JSONResponse({"reply": reply, "threadId": thread_id})
+
+
+# ---------------------------------------------------------------------------
+# UI
+# ---------------------------------------------------------------------------
+
+
+@app.get("/", response_class=HTMLResponse)
+async def index(request: Request) -> HTMLResponse:
+ return templates.TemplateResponse("index.html", {"request": request})
diff --git a/samples/pydantic-ai-extended/app/requirements.txt b/samples/pydantic-ai-extended/app/requirements.txt
new file mode 100644
index 00000000..3d968c44
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/requirements.txt
@@ -0,0 +1,7 @@
+fastapi[standard]==0.115.12
+uvicorn[standard]==0.34.3
+pydantic-ai[openai]>=1.0,<2
+jinja2==3.1.6
+asyncpg==0.30.0
+redis[hiredis]==5.3.0
+httpx==0.28.1
diff --git a/samples/pydantic-ai-extended/app/static/app.js b/samples/pydantic-ai-extended/app/static/app.js
new file mode 100644
index 00000000..89aa2c99
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/static/app.js
@@ -0,0 +1,266 @@
+/* global fetch */
+(function () {
+ "use strict";
+
+ // ---------------------------------------------------------------------------
+ // DOM refs
+ // ---------------------------------------------------------------------------
+ const seedBtn = document.getElementById("seed-btn");
+ const seedStatus = document.getElementById("seed-status");
+ const seedUpdated = document.getElementById("seed-updated");
+ const statTickets = document.getElementById("stat-tickets");
+ const statAlerts = document.getElementById("stat-alerts");
+ const statClassified = document.getElementById("stat-classified");
+ const statQueue = document.getElementById("stat-queue");
+ const ticketList = document.getElementById("ticket-list");
+ const alertList = document.getElementById("alert-list");
+ const chatLog = document.getElementById("chat-log");
+ const chatForm = document.getElementById("chat-form");
+ const chatInput = document.getElementById("chat-input");
+ const chatSend = document.getElementById("chat-send");
+ const suggestions = document.getElementById("suggestions");
+
+ let sending = false;
+ const messages = [];
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+ function formatTimestamp(ts) {
+ if (!ts) return "";
+ try {
+ const d = new Date(ts);
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ } catch {
+ return "";
+ }
+ }
+
+ function priorityClass(p) {
+ return "status-" + (p || "low");
+ }
+
+ function escapeHtml(str) {
+ const div = document.createElement("div");
+ div.textContent = str;
+ return div.innerHTML;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Item card rendering
+ // ---------------------------------------------------------------------------
+ function renderItemCard(item, showAssignee) {
+ const tags = (item.tags || [])
+ .map(function (t) {
+ return '' + escapeHtml(t) + " ";
+ })
+ .join("");
+
+ return (
+ '
' +
+ '
' +
+ '' +
+ escapeHtml(item.source) +
+ " " +
+ (item.priority
+ ? '' +
+ escapeHtml(item.priority) +
+ " "
+ : "") +
+ "
" +
+ "
" +
+ escapeHtml(item.title) +
+ " " +
+ '
' +
+ escapeHtml(item.body) +
+ "
" +
+ '
' +
+ (item.category
+ ? "" + escapeHtml(item.category) + " "
+ : "") +
+ (showAssignee && item.assignee
+ ? "" + escapeHtml(item.assignee) + " "
+ : "") +
+ (item.status ? "" + escapeHtml(item.status) + " " : "") +
+ "
" +
+ (tags ? '
' + tags + "
" : "") +
+ "
"
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Dashboard polling
+ // ---------------------------------------------------------------------------
+ let pollTimer = null;
+
+ async function refreshDashboard() {
+ try {
+ const [dash, items] = await Promise.all([
+ fetch("/api/dashboard").then(function (r) {
+ return r.json();
+ }),
+ fetch("/api/items").then(function (r) {
+ return r.json();
+ }),
+ ]);
+
+ var run = dash.latestRun;
+ if (run) {
+ if (run.status === "running" || run.status === "queued") {
+ seedStatus.textContent =
+ "Processing " + run.processedItems + "/" + run.totalItems;
+ } else if (run.status === "completed") {
+ seedStatus.textContent = "Ready \u00b7 " + run.totalItems + " items";
+ } else {
+ seedStatus.textContent = "Run failed";
+ }
+ seedUpdated.textContent = "Updated " + formatTimestamp(run.updatedAt);
+ }
+
+ statTickets.textContent = dash.counts.ticketCount;
+ statAlerts.textContent = dash.counts.alertCount;
+ statClassified.textContent = dash.counts.classifiedCount;
+ statQueue.textContent = dash.queue.pending;
+
+ if (items.tickets.length > 0) {
+ ticketList.innerHTML = items.tickets
+ .map(function (t) {
+ return renderItemCard(t, true);
+ })
+ .join("");
+ }
+
+ if (items.alerts.length > 0) {
+ alertList.innerHTML = items.alerts
+ .map(function (a) {
+ return renderItemCard(a, false);
+ })
+ .join("");
+ }
+ } catch (e) {
+ /* ignore transient errors */
+ }
+ }
+
+ function startPolling() {
+ if (pollTimer) return;
+ pollTimer = setInterval(refreshDashboard, 3000);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Seed
+ // ---------------------------------------------------------------------------
+ seedBtn.addEventListener("click", async function () {
+ seedBtn.disabled = true;
+ seedBtn.textContent = "Queueing\u2026";
+ try {
+ await fetch("/api/items/seed", { method: "POST" });
+ seedStatus.textContent = "Queued";
+ startPolling();
+ } catch (e) {
+ seedStatus.textContent = "Failed to queue";
+ } finally {
+ seedBtn.disabled = false;
+ seedBtn.textContent = "Generate sample items";
+ }
+ });
+
+ // ---------------------------------------------------------------------------
+ // Chat
+ // ---------------------------------------------------------------------------
+ function renderMessages() {
+ if (messages.length === 0) return;
+ if (suggestions) suggestions.style.display = "none";
+
+ chatLog.innerHTML = messages
+ .map(function (m) {
+ var cls = "chat-message " + m.role;
+ if (m.error) cls += " error";
+ return '' + escapeHtml(m.text) + "
";
+ })
+ .join("");
+
+ chatLog.scrollTop = chatLog.scrollHeight;
+ }
+
+ function updateSendButton() {
+ chatSend.disabled = sending || !chatInput.value.trim();
+ }
+
+ chatInput.addEventListener("input", updateSendButton);
+
+ // Suggestion chips
+ document.querySelectorAll(".suggestion-chip").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ chatInput.value = btn.getAttribute("data-prompt");
+ updateSendButton();
+ });
+ });
+
+ chatForm.addEventListener("submit", async function (e) {
+ e.preventDefault();
+ var text = chatInput.value.trim();
+ if (!text || sending) return;
+ sending = true;
+ updateSendButton();
+ chatInput.value = "";
+
+ messages.push({ role: "user", text: text });
+ messages.push({ role: "assistant", text: "", streaming: true });
+ renderMessages();
+
+ try {
+ var resp = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: text, stream: true }),
+ });
+
+ var reader = resp.body.getReader();
+ var decoder = new TextDecoder();
+ var buffer = "";
+ var assistantMsg = messages[messages.length - 1];
+
+ while (true) {
+ var result = await reader.read();
+ if (result.done) break;
+ buffer += decoder.decode(result.value, { stream: true });
+ var lines = buffer.split("\n");
+ buffer = lines.pop();
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim();
+ if (!line) continue;
+ try {
+ var event = JSON.parse(line);
+ if (event.type === "delta") {
+ assistantMsg.text += event.text;
+ } else if (event.type === "error") {
+ assistantMsg.text += "[Error: " + event.message + "]";
+ assistantMsg.error = true;
+ }
+ } catch (parseErr) {
+ /* skip malformed lines */
+ }
+ }
+ renderMessages();
+ }
+
+ assistantMsg.streaming = false;
+ } catch (err) {
+ var last = messages[messages.length - 1];
+ last.text = "Failed to get a response: " + err.message;
+ last.error = true;
+ }
+
+ sending = false;
+ updateSendButton();
+ renderMessages();
+ });
+
+ // Initial load
+ refreshDashboard();
+ startPolling();
+})();
diff --git a/samples/pydantic-ai-extended/app/static/style.css b/samples/pydantic-ai-extended/app/static/style.css
new file mode 100644
index 00000000..7b85016a
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/static/style.css
@@ -0,0 +1,334 @@
+:root {
+ color-scheme: light;
+ --bg: #f6f2e9;
+ --surface: rgba(255, 255, 255, 0.92);
+ --surface-strong: rgba(255, 255, 255, 0.98);
+ --border: #d8d0c4;
+ --text: #1f2c38;
+ --muted: #62707d;
+ --brand: #0f4c5c;
+ --brand-strong: #0a3442;
+ --accent: #cc7a32;
+ --critical: #b42318;
+ --high: #b54708;
+ --medium: #1769aa;
+ --low: #667085;
+ --chat-width: 360px;
+ --gutter: 20px;
+ --font-display: "Sora", "Space Grotesk", "Avenir Next", sans-serif;
+ --font-body: "Source Sans 3", "Segoe UI", sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html, body { margin: 0; padding: 0; min-height: 100%; }
+
+body {
+ color: var(--text);
+ font-family: var(--font-body);
+ background:
+ radial-gradient(circle at 10% 10%, rgba(15, 76, 92, 0.12), transparent 36%),
+ radial-gradient(circle at 90% 8%, rgba(204, 122, 50, 0.1), transparent 28%),
+ linear-gradient(180deg, #fbf8f2 0%, var(--bg) 52%, #f8f2e8 100%);
+}
+
+button, textarea, input { font: inherit; }
+h1, h2, h3, p { margin: 0; }
+
+.app-shell { min-height: 100vh; }
+
+.chat-rail {
+ position: fixed;
+ inset: 0 auto 0 0;
+ width: calc(var(--chat-width) + (var(--gutter) * 2));
+ padding: var(--gutter);
+}
+
+.content-shell {
+ width: min(1100px, calc(100vw - var(--chat-width) - (var(--gutter) * 4)));
+ margin-left: calc(var(--chat-width) + (var(--gutter) * 2));
+ margin-right: auto;
+ padding: 22px var(--gutter) 56px;
+}
+
+.card {
+ border: 1px solid var(--border);
+ border-radius: 24px;
+ background: var(--surface);
+ box-shadow: 0 18px 36px rgba(25, 34, 41, 0.06);
+ backdrop-filter: blur(6px);
+}
+
+.hero-card {
+ display: flex;
+ gap: 18px;
+ justify-content: space-between;
+ align-items: flex-start;
+ padding: 22px;
+}
+
+.hero-copy { max-width: 640px; }
+
+.eyebrow {
+ display: inline-flex;
+ padding: 7px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(15, 76, 92, 0.24);
+ background: rgba(15, 76, 92, 0.08);
+ color: var(--brand);
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.hero-card h2, .chat-title {
+ margin-top: 12px;
+ font-family: var(--font-display);
+ line-height: 1.02;
+ letter-spacing: -0.04em;
+}
+
+.hero-card h2 { font-size: clamp(2rem, 3vw, 2.8rem); }
+.chat-title { font-size: clamp(1.6rem, 2.2vw, 2rem); }
+.muted-text { color: var(--muted); line-height: 1.45; }
+.hero-copy .muted-text, .chat-intro { margin-top: 10px; }
+.hero-actions { display: grid; gap: 12px; min-width: min(320px, 100%); }
+
+.primary-button, .suggestion-chip {
+ border-radius: 999px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: transform 140ms ease, opacity 140ms ease, background-color 140ms ease;
+}
+
+.primary-button {
+ padding: 11px 16px;
+ background: linear-gradient(135deg, var(--brand) 0%, var(--brand-strong) 100%);
+ color: white;
+ font-weight: 700;
+ box-shadow: 0 16px 30px rgba(10, 52, 66, 0.22);
+}
+
+.primary-button:hover, .suggestion-chip:hover { transform: translateY(-1px); }
+.primary-button[disabled], .suggestion-chip[disabled] { opacity: 0.6; cursor: not-allowed; transform: none; }
+
+.status-panel {
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--surface-strong);
+ padding: 12px;
+ display: grid;
+ gap: 6px;
+}
+
+.status-label, .stat-label { display: block; font-size: 0.8rem; color: var(--muted); }
+
+.stats-row {
+ margin-top: 16px;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.stat-card {
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--surface);
+ padding: 12px;
+}
+
+.stat-card strong, .status-panel strong {
+ display: block;
+ margin-top: 7px;
+ font-family: var(--font-display);
+ font-size: 1.7rem;
+ line-height: 1;
+}
+
+.columns {
+ margin-top: 16px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.list-card { padding: 18px; }
+.list-header { margin-bottom: 14px; }
+.list-header .muted-text { margin-top: 8px; }
+.item-list { display: grid; gap: 10px; }
+
+.item-card {
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--surface-strong);
+ padding: 14px;
+}
+
+.item-card h3 {
+ margin-top: 8px;
+ font-family: var(--font-display);
+ font-size: 1.02rem;
+ letter-spacing: -0.02em;
+}
+
+.item-topline, .meta-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.item-body { margin-top: 8px; color: var(--muted); line-height: 1.45; }
+.meta-row { margin-top: 10px; color: var(--muted); font-size: 0.92rem; }
+
+.source-pill, .priority-pill, .tag-pill {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
+.source-pill {
+ border: 1px solid rgba(15, 76, 92, 0.22);
+ background: rgba(15, 76, 92, 0.08);
+ color: var(--brand);
+ padding: 5px 10px;
+}
+
+.priority-pill {
+ border: 1px solid var(--border);
+ background: white;
+ color: var(--low);
+ padding: 5px 10px;
+ text-transform: capitalize;
+}
+
+.status-critical { color: var(--critical); }
+.status-high { color: var(--high); }
+.status-medium { color: var(--medium); }
+.status-low { color: var(--low); }
+
+.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+
+.tag-pill {
+ border: 1px solid rgba(15, 76, 92, 0.2);
+ background: rgba(15, 76, 92, 0.06);
+ color: var(--brand);
+ padding: 3px 8px;
+}
+
+.empty-list, .chat-empty-state {
+ border: 1px dashed rgba(15, 76, 92, 0.26);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.68);
+ color: var(--muted);
+ padding: 14px;
+ line-height: 1.45;
+}
+
+.chat-card {
+ display: grid;
+ grid-template-rows: auto auto 1fr auto;
+ height: 100%;
+ min-height: calc(100vh - (var(--gutter) * 2));
+ padding: 18px;
+ gap: 12px;
+}
+
+.suggestion-row { display: flex; flex-wrap: wrap; gap: 8px; }
+
+.suggestion-chip {
+ padding: 8px 12px;
+ border-color: rgba(15, 76, 92, 0.24);
+ background: rgba(255, 255, 255, 0.88);
+ color: var(--brand-strong);
+ font-size: 0.82rem;
+}
+
+.chat-log {
+ display: grid;
+ gap: 10px;
+ align-content: start;
+ min-height: 0;
+ overflow: auto;
+ padding-right: 4px;
+}
+
+.chat-message {
+ border-radius: 18px;
+ padding: 11px 13px;
+ white-space: pre-wrap;
+ line-height: 1.45;
+}
+
+.chat-message.user {
+ margin-left: 18px;
+ background: linear-gradient(135deg, rgba(204, 122, 50, 0.16), rgba(204, 122, 50, 0.08));
+}
+
+.chat-message.assistant {
+ margin-right: 18px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.95);
+}
+
+.chat-message.assistant.error {
+ border-color: rgba(180, 35, 24, 0.34);
+ background: rgba(180, 35, 24, 0.08);
+}
+
+.stream-cursor {
+ display: inline-block;
+ width: 9px;
+ height: 1.05em;
+ margin-left: 4px;
+ transform: translateY(1px);
+ border-radius: 2px;
+ background: var(--brand);
+ animation: cursorBlink 900ms steps(1, end) infinite;
+}
+
+.chat-form { display: grid; }
+
+.chat-compose {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: end;
+}
+
+.chat-input {
+ width: 100%;
+ height: 88px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: white;
+ color: var(--text);
+ padding: 12px;
+ resize: none;
+}
+
+.chat-send-button {
+ min-width: 88px;
+ height: 88px;
+}
+
+@keyframes cursorBlink {
+ 0%, 49% { opacity: 1; }
+ 50%, 100% { opacity: 0; }
+}
+
+@media (max-width: 1040px) {
+ .chat-rail { position: static; width: auto; padding: 18px 18px 0; }
+ .content-shell { width: auto; max-width: 1100px; margin: 0 auto; padding: 18px 18px 56px; }
+ .hero-card, .columns, .stats-row { grid-template-columns: 1fr; }
+ .hero-card { display: grid; }
+ .chat-card { min-height: 0; height: auto; }
+}
+
+@media (max-width: 720px) {
+ .chat-compose, .columns, .stats-row { grid-template-columns: 1fr; }
+ .primary-button, .chat-send-button { width: 100%; }
+}
diff --git a/samples/pydantic-ai-extended/app/templates/index.html b/samples/pydantic-ai-extended/app/templates/index.html
new file mode 100644
index 00000000..2ab3bb19
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/templates/index.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+ Pydantic AI Extended
+
+
+
+
+
+
+
+
+
+
Tickets and alerts
+
Background jobs classify incoming tickets and system alerts.
+
+ Click one button to generate 10 tickets and 10 alerts. The worker fans that out into per-item jobs, stores the results in Postgres, and builds embeddings for semantic lookup.
+
+
+
+
Generate sample items
+
+
+ Status
+ No items yet
+
+
+
+
+
+
+
+ Tickets 0
+ Alerts 0
+ Classified 0
+ Queue 0
+
+
+
+
+
+
+
+
diff --git a/samples/pydantic-ai-extended/app/worker.py b/samples/pydantic-ai-extended/app/worker.py
new file mode 100644
index 00000000..b0093d86
--- /dev/null
+++ b/samples/pydantic-ai-extended/app/worker.py
@@ -0,0 +1,104 @@
+"""
+Background worker that processes jobs from the Redis queue.
+
+Job types:
+ - seed-batch: Generate sample tickets and alerts, insert them, then fan out
+ per-item classify jobs.
+ - classify-item: Classify a single item and generate its embedding.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+import traceback
+
+from lib.ai import classify_item, embed_text_for_search, generate_seed_items, text_for_embedding
+from lib.db import ensure_schema
+from lib.items import (
+ ItemClassification,
+ fail_seed_run,
+ finish_seed_run,
+ get_item_by_id,
+ insert_seed_items,
+ mark_item_processed,
+ start_seed_run,
+ update_processed_item,
+)
+from lib.queue import dequeue_job, enqueue_job
+
+
+async def handle_seed_batch(run_id: str) -> None:
+ await start_seed_run(run_id, "Generating 10 tickets and 10 alerts with the LLM")
+
+ raw_items = await generate_seed_items()
+ inserted = await insert_seed_items(run_id, raw_items)
+
+ for item in inserted:
+ await enqueue_job("classify-item", {"runId": run_id, "itemId": item.id})
+
+ await start_seed_run(
+ run_id, f"Queued {len(inserted)} background classification jobs"
+ )
+
+
+async def handle_classify_item(run_id: str, item_id: int) -> None:
+ item = await get_item_by_id(item_id)
+ if item is None:
+ raise ValueError(f"Item {item_id} not found")
+
+ item_dict = {
+ "item_type": item.item_type,
+ "source": item.source,
+ "title": item.title,
+ "body": item.body,
+ }
+ classification = await classify_item(item_dict)
+ embedding = await embed_text_for_search(text_for_embedding(item_dict, classification))
+ await update_processed_item(item.id, classification, embedding)
+ run = await mark_item_processed(run_id)
+
+ if run and run.processed_items >= run.total_items:
+ await finish_seed_run(
+ run_id,
+ f"Generated {run.total_items} items and classified every one of them",
+ )
+
+
+async def main() -> None:
+ await ensure_schema()
+ print("ticket-sync worker is running", flush=True)
+
+ while True:
+ try:
+ job = await dequeue_job(timeout=5)
+ if job is None:
+ continue
+
+ name = job.get("name")
+ data = job.get("data", {})
+
+ if name == "seed-batch":
+ await handle_seed_batch(data["runId"])
+ elif name == "classify-item":
+ await handle_classify_item(data["runId"], data["itemId"])
+ else:
+ print(f"Unknown job type: {name}", flush=True)
+
+ except Exception:
+ traceback.print_exc()
+ run_id = None
+ if isinstance(job, dict):
+ run_id = job.get("data", {}).get("runId")
+ if run_id:
+ try:
+ await fail_seed_run(run_id, traceback.format_exc()[-200:])
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ sys.exit(0)
diff --git a/samples/pydantic-ai-extended/compose.dev.yaml b/samples/pydantic-ai-extended/compose.dev.yaml
new file mode 100644
index 00000000..405f6628
--- /dev/null
+++ b/samples/pydantic-ai-extended/compose.dev.yaml
@@ -0,0 +1,88 @@
+name: pydantic-ai-extended-dev
+services:
+ app:
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ ports:
+ - 8000:8000
+ environment:
+ DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
+ REDIS_URL: redis://redis:6379
+ QUEUE_NAME: ticket-sync
+ LOCAL_FAST_DATA: "true"
+ MOCK_AGENT: "false"
+ OPENAI_API_KEY: defang
+ depends_on:
+ chat:
+ condition: service_started
+ embedding:
+ condition: service_started
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_started
+
+ worker:
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ command: python worker.py
+ environment:
+ DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
+ REDIS_URL: redis://redis:6379
+ QUEUE_NAME: ticket-sync
+ LOCAL_FAST_DATA: "true"
+ MOCK_AGENT: "false"
+ OPENAI_API_KEY: defang
+ depends_on:
+ chat:
+ condition: service_started
+ embedding:
+ condition: service_started
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_started
+
+ postgres:
+ image: pgvector/pgvector:pg16
+ environment:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ POSTGRES_USER: postgres
+ ports:
+ - 5432:5432
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - pg_isready -U postgres -d postgres
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ redis:
+ image: redis:6.2
+ command: redis-server --save 60 1 --loglevel warning --maxmemory-policy noeviction
+ ports:
+ - 6379:6379
+
+ chat:
+ provider:
+ type: model
+ options:
+ model: ai/qwen2.5:3B-Q4_K_M
+ x-defang-llm: true
+
+ embedding:
+ provider:
+ type: model
+ options:
+ model: ai/mxbai-embed-large
+ x-defang-llm: true
+
+volumes:
+ pgdata:
diff --git a/samples/pydantic-ai-extended/compose.yaml b/samples/pydantic-ai-extended/compose.yaml
new file mode 100644
index 00000000..2ea8a515
--- /dev/null
+++ b/samples/pydantic-ai-extended/compose.yaml
@@ -0,0 +1,127 @@
+name: pydantic-ai-extended
+services:
+ app:
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ ports:
+ - mode: ingress
+ target: 8000
+ published: 8000
+ environment:
+ DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres?sslmode=require
+ REDIS_URL: redis://redis:6379
+ QUEUE_NAME: ticket-sync
+ MOCK_AGENT: "false"
+ depends_on:
+ chat:
+ condition: service_started
+ embedding:
+ condition: service_started
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ healthcheck:
+ test:
+ - CMD
+ - curl
+ - -f
+ - http://localhost:8000/api/health
+ interval: 15s
+ timeout: 5s
+ retries: 10
+ start_period: 15s
+ deploy:
+ resources:
+ reservations:
+ cpus: "0.5"
+ memory: 512M
+ worker:
+ build:
+ context: ./app
+ dockerfile: Dockerfile
+ command: python worker.py
+ environment:
+ DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres?sslmode=require
+ REDIS_URL: redis://redis:6379
+ QUEUE_NAME: ticket-sync
+ MOCK_AGENT: "false"
+ depends_on:
+ chat:
+ condition: service_started
+ embedding:
+ condition: service_started
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ deploy:
+ resources:
+ reservations:
+ cpus: "0.5"
+ memory: 512M
+ postgres:
+ image: postgres:16
+ x-defang-postgres: true
+ restart: always
+ environment:
+ POSTGRES_PASSWORD:
+ POSTGRES_DB: postgres
+ POSTGRES_USER: postgres
+ ports:
+ - mode: host
+ target: 5432
+ published: 5432
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - pg_isready -U postgres -d postgres
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ deploy:
+ resources:
+ reservations:
+ cpus: "0.5"
+ memory: 512M
+ redis:
+ image: redis:6.2
+ x-defang-redis: true
+ restart: always
+ ports:
+ - mode: host
+ target: 6379
+ published: 6379
+ deploy:
+ resources:
+ reservations:
+ cpus: "0.25"
+ memory: 256M
+ chat:
+ provider:
+ type: model
+ options:
+ model: chat-default
+ x-defang-llm: true
+ environment:
+ OPENAI_API_KEY: defang
+ deploy:
+ resources:
+ reservations:
+ cpus: "0.5"
+ memory: 512M
+ embedding:
+ provider:
+ type: model
+ options:
+ model: embedding-default
+ x-defang-llm: true
+ environment:
+ OPENAI_API_KEY: defang
+ deploy:
+ resources:
+ reservations:
+ cpus: "0.5"
+ memory: 512M