Skip to content

12. AI Assistant

Goal

Add the BoxOffice support assistant: a Python 3.12 + FastAPI service, managed with uv, that answers fans' questions with an LLM behind a small LLM gateway (LiteLLM: routes, fallback, timeouts, costs); grounds its answers in the catalog with RAG on pgvector; looks up live data through a tool registry in the shape of MCP, inside a bounded agent loop; protects the model and the users with guardrails; exports tokens, cost and time-to-first-token to the Grafana of lab 8; and is load-tested where it hurts β€” the first token.

On sale day, support receives thousands of the same questions: when is the show? how much? where is my order? An assistant can answer them in seconds, at 3 a.m., in Portuguese and English β€” if it does not invent shows, leak data or cost more than the tickets. This lab builds one with the architecture of the AI systems class, running entirely on your machine with a local open model (Ollama), and optionally on Claude for comparison. In the story of the course, this lab builds chapter 13: it implements the decisions of the LLM gateway (routes and a fallback), the agent loop and its read-only tools, retrieval with pgvector and the safety mesh.

Slides

Click the slides and use ← β†’ to move through them; F goes full screen and P opens the presenter view, with the notes. Open the slides in a new tab

Before you start

Time about 6 hours, including model downloads and slow local inference
Tools Docker with Compose v2; uv (uv --version, from lab 0); curl, jq. Python is managed by uv: no pip, no manual virtual environments
Docker resources at least 4 CPUs and 8 GB free for this lab (β‰ˆ 1.5 GB of models); on macOS, plan to run Ollama natively (step 1)
Disk β‰ˆ 3 GB for the images and the models
Accounts and costs none required: everything runs locally and free. Optional: an Anthropic API key for the Claude route (pay-as-you-go, β‰ˆ US$ 0.006 per question with claude-opus-5-5)

Every command runs from the root of your boxoffice repository, in the state lab 11 left it:

Start state (end of lab 11)
boxoffice/
β”œβ”€β”€ docker-compose.yaml     # the Compose stack of labs 1-9 (catalog behind gateway, order, ...)
β”œβ”€β”€ .github/  k8s/          # labs 10-11
β”œβ”€β”€ infra/  load/  scripts/
└── services/               # auth, catalog, gateway, inventory, notification, order, payment

Catch up

Skipped or broke lab 11? Download boxoffice-after-lab-11.zip, unzip it, cd boxoffice, sh scripts/get-otel-agent.sh, and continue from here. This lab only needs the Compose stack (gateway, catalog, order, db, otel-lgtm), not the cluster.

Every file of this lab is shown in full on these pages and published under https://insper.github.io/platform/2027.1/hands-on/12/code/<path>.

Architecture

flowchart LR
    fan[fan / chat UI] -->|"POST /assistant/chat/stream"| api[assistant: FastAPI]
    api --> gin{input guard}
    gin --> orch[agent loop]
    orch -->|"search_query: ..."| emb[embedding model]
    emb --> vdb[("pgvector: assistant.documents")]
    vdb -->|top-k events| orch
    orch -->|LiteLLM router| gw[LLM gateway]
    gw -->|local| ollama["Ollama: qwen2.5:1.5b"]
    gw -.->|"optional, fallback to local"| claude[Claude API]
    orch -->|tool calls| reg[tool registry]
    reg -->|GET /catalog/events| cat[gateway -> catalog]
    reg -->|"GET /orders/id + X-User-Email"| ord[order]
    orch --> gout{output guard}
    gout -->|SSE tokens + done| fan
    api -.->|OTLP metrics| lgtm[otel-lgtm]

BoxOffice components touched

Palette component In this lab
LLM Gateway LiteLLM Router: routes, fallback, timeouts, cost accounting
Orchestrator the bounded agent loop of agent.py
Tool Registry three read-only tools with JSON schemas (MCP-style)
Memory Fabric RAG: embeddings of catalog events in pgvector
Vector DB PostgreSQL 17 + pgvector, HNSW index
Safety Mesh input and output guardrails, PII redaction

What you will measure

Question Metric Tool
How long until the fan sees the first word? TTFT p50/p95 under concurrency scripts/assistant_load.py
How fast are tokens produced? tokens/s, requests/min same
Does retrieval find the right events? hit@5, MRR scripts/eval_retrieval.py
Are answers grounded? share of grounded: true the API, the guard metric
What does an answer cost? tokens and US$ per request the API, assistant_llm_cost_usd_total

Prerequisites

Steps

  1. The service and the LLM gateway β€” Ollama, pgvector, the uv project, LiteLLM routes and fallback, the optional Claude route.
  2. RAG with pgvector β€” embeddings, the HNSW index, ingestion of the catalog, retrieval evaluation.
  3. Tools and the agent loop β€” the tool registry, the bounded loop, streaming, the API.
  4. Guardrails and metrics β€” injection and PII on the way in, grounding on the way out; tokens, cost and TTFT in Grafana.
  5. Measure it β€” TTFT under load, retrieval and answer quality, model choice.

Try it in the playground

Try it: run the ai-assistant scenario with the load profile. Raise the orchestrator's steps from 1 to 4 and watch the latency and the cost per request multiply β€” every step is another LLM call. Then enable the semantic cache of the LLM gateway and see which share of the traffic never reaches the model.

The full simulator is in the playground.

Measure it

The Measure it step has the scripts and the results table to keep in results/lab12.md.

Troubleshooting

Symptom Likely cause Fix
answers take minutes the model runs on a CPU (always the case in Docker on macOS) run Ollama natively and set OLLAMA_URL=http://host.docker.internal:11434; close other stacks; ask shorter questions
litellm.APIConnectionError … Connection refused in the logs Ollama is not running or OLLAMA_URL is wrong docker compose ps ollama / ollama list; check the variable in docker-compose.yaml
model "qwen2.5:1.5b" not found the model was not pulled docker compose up ollama-init (or ollama pull qwen2.5:1.5b natively)
startup fails with type "vector" does not exist db still runs the plain postgres:17 image docker compose up -d db after updating the compose file; docker compose exec db psql -U boxoffice -c "\dx" lists vector
/assistant/ingest returns Internal Server Error or {"ingested":0} the gateway is not running (Name or service not known in docker compose logs assistant), or the catalog behind it is down docker compose up -d --wait gateway; docker compose ps gateway catalog-1; docker compose exec assistant python -c "import urllib.request;print(urllib.request.urlopen('http://gateway:8080/catalog/events/1').status)"
uv sync --frozen fails in the build uv.lock does not match pyproject.toml run uv lock in services/assistant (on your machine) and rebuild
no assistant_* metrics in Grafana OTEL_EXPORTER_OTLP_ENDPOINT not set, or no question answered yet check the variables of assistant; metrics are exported every 10 s
Bind for 0.0.0.0:11434 failed: port is already allocated Ollama already runs natively on your machine, and docker compose up -d started the ollama container too keep the native one: docker compose stop ollama ollama-init, and start services by name (docker compose up -d assistant) with OLLAMA_URL exported
litellm.Timeout … Timeout passed=120.0 in the logs; errors in the load test the model runs on a CPU and the requests queue in front of it for more than the gateway's 120 s lower the concurrency (--concurrency 1 2 --requests 2), or run Ollama natively; timeout=120 is in app/llm.py
422 blocked for a normal question a false positive of the injection patterns rephrase, and note it: false positives are part of the guardrail's measured quality

Best practices in this lab

  • Routes instead of model names, with a fallback β€” app/llm.py asks for local or claude, and claude falls back to local; why: LLM Gateway.
  • A bounded loop that always ends with an answer β€” app/agent.py stops at MAX_STEPS = 4 and makes the last call without tools; why: the agent loop.
  • Read-only tools, identity from the platform β€” app/tools.py takes the fan from X-User-Email and never writes; why: tools are side effects.
  • Retrieval measured on its own β€” scripts/eval_retrieval.py scores hit@k and MRR before any model answers; why: RAG & Memory.
  • Redact before the model, check citations after it, measure TTFT in seconds β€” app/guardrails.py and app/metrics.py; why: Safety, Evals & Observability.

Clean up

docker compose stop assistant ollama        # free the CPU/RAM of the model
docker compose down -v                      # or remove everything (including the downloaded models)

docker compose down -v also deletes the ollama-models volume: the next run downloads the models again. Keep it if you continue to lab 13, which can use the same local model. With native Ollama, ollama stop qwen2.5:1.5b frees the model's memory at once (it also unloads after 5 idle minutes), and ollama rm qwen2.5:1.5b nomic-embed-text deletes the models when you no longer need them.

End state

End state (what this lab added or changed)
boxoffice/
β”œβ”€β”€ .gitignore              # + __pycache__/, .venv/
β”œβ”€β”€ docker-compose.yaml     # db/db-replica -> pgvector image; + ollama, ollama-init, assistant
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ assistant_load.py   # TTFT load test (uv inline script)
β”‚   └── eval_retrieval.py   # hit@k / MRR (uv inline script)
β”œβ”€β”€ results/lab12.md
└── services/assistant/     # new: pyproject.toml, uv.lock, .python-version, Dockerfile, .dockerignore,
    └── app/                # __init__, config, llm, db, rag, tools, agent, guardrails, metrics, main

Commit and tag: git add -A && git commit -m "lab 12: AI assistant" && git tag lab12.

Checklist

  • ollama, ollama-init and assistant run in docker-compose.yaml (or Ollama natively, with OLLAMA_URL); db uses the pgvector image
  • services/assistant is a uv project with pyproject.toml and uv.lock committed; the image builds with uv sync --frozen
  • /assistant/ingest loaded at least 300 events; scripts/eval_retrieval.py reports hit@5 and MRR
  • /assistant/chat and /assistant/chat/stream answer with citations checked by the output guard; an injection attempt returns 422
  • get_order_status only answers for the user in X-User-Email
  • assistant_* metrics (tokens, cost, TTFT) visible in Grafana
  • results table filled in results/lab12.md, including where the model ran
  • self-check: docker compose up -d --build assistant from a clean clone, then curl localhost:8088/assistant/health returns "status":"UP"; pushed with the tag lab12

Start: The service and the LLM gateway