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 + +[![1-click-deploy](https://raw.githubusercontent.com/DefangLabs/defang-assets/main/Logos/Buttons/SVG/deploy-with-defang.svg)](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. +

+
+
+ +
+
+ Status + No items yet +
+ +
+
+
+ +
+
Tickets0
+
Alerts0
+
Classified0
+
Queue0
+
+ +
+
+
+ Open tickets +

Support tickets from tools like Zendesk, Jira, and GitHub Issues.

+
+
+
No tickets yet.
+
+
+ +
+
+ Recent alerts +

System alerts from monitoring, deploys, CI, and payment services.

+
+
+
No alerts yet.
+
+
+
+
+
+ + + 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