Skip to content

a. Overview

In this lab

  • Add five AI steps to the BoxOffice pipeline of lab 11: a pull-request reviewer, a test generator checked by mutation testing, an agent that proposes a fix when CI fails, release notes on every tag, and an explained canary verdict.
  • Run every step through one small, provider-neutral client: a local model with Ollama (free, the default), Claude through the official SDK, or any OpenAI-compatible endpoint through LiteLLM.
  • Harden the workflows: permissions, triggers, secrets, and a prompt-injection test that the reviewer must survive.
  • Measure it: review precision, cost per PR, CI minutes and time to merge over a week of pull requests.

Lab 11 gave BoxOffice a pipeline that builds, tests, scans, signs and rolls out each change behind a canary. Lab 12 put a language model inside the product. This lab puts language models inside the pipeline β€” and treats them with the same discipline as any other dependency: least privilege, budgets, validation of every output, and numbers that say whether they help. In the story of the course it builds chapter 14: the decisions of code review, AI-generated tests, agents in CI, release and operate and security & governance.

The rule for the whole lab: AI proposes, rules and humans dispose. No step in this lab can merge, approve, deploy or roll back on its own. The model writes comments, patches and explanations; deterministic checks (tests, mutation score, policy thresholds) and people decide.

Before you start

Time 6–8 hours for parts 0–6 (one part per sitting is fine), plus ~15 minutes a day for one week in part 7
You need the tools of lab 0; a GitHub account (the repository on GitHub, the gh CLI logged in); for the free model, ~6 GB of free RAM and ~5 GB of disk (8 GB machines: see Troubleshooting)
Directory every command runs from the root of your boxoffice/ repository
Costs zero on the default path: the model runs on your machine (Ollama) or on the GitHub runner. What can cost money is listed in What costs money
git --version                 # git 2.40+
gh --version                  # gh 2.x
gh auth status                # "Logged in to github.com account <you>"
uv --version                  # uv 0.5+ (runs every Python script of this lab)
java -version                 # 21 or newer
mvn -v                        # Apache Maven 3.9+
docker info > /dev/null && echo "Docker is running"   # mvn verify starts Testcontainers (parts 2-3)
ollama --version              # installed in step 0.1

Start state β€” the end of lab 12: your boxoffice repository, pushed to GitHub, with

boxoffice/ at the start of lab 13
πŸ“ boxoffice/
β”œβ”€β”€ YAML .github/workflows/ci.yaml   # lab 11: "name: CI", runs `mvn -B verify` for each service
β”œβ”€β”€ YAML docker-compose.yaml  infra/  load/  scripts/  results/
β”œβ”€β”€ πŸ“ services/catalog/  services/inventory/  services/auth/  services/gateway/  ...
└── πŸ“ k8s/ ...                    # lab 10-11 (only needed for the optional live canary, step 5.4)

This lab uses mostly services/catalog (a Spring Boot 4.1.1 service on port 8082, see lab 0) and the CI workflow of lab 11. The Kubernetes cluster and the LiteLLM proxy of lab 12 are optional.

Catch up

Skipped or broke lab 12? Download boxoffice-after-lab-12.zip, unzip it and cd boxoffice. Then put it on GitHub β€” the AI steps run in GitHub Actions:

git init -b main && git add -A && git commit -m "boxoffice after lab 12"
gh repo create boxoffice --public --source . --push

This lab adds .github/ai/ (scripts, golden set, canary snapshots), .github/actions/ollama/, five ai-*.yaml workflows, CONVENTIONS.md, docs/adr/0001-schema-per-service.md, Dockerfile.ai-tools, k8s/catalog/analysis-explained.yaml and a unit test of catalog, and replaces services/catalog/pom.xml. All are shown in full on these pages and are all in boxoffice-after-lab-13.zip. If your CI workflow is not named CI, change workflows: [CI] in ai-fix.yaml (part 3).

Acronyms used in this lab

PR pull request Β· CI continuous integration Β· LLM large language model Β· ADR architecture decision record Β· PIT the mutation-testing tool for Java (pitest.org) Β· SHA the hash that names a Git commit Β· pp percentage points (5 % β†’ 10 % is +5 pp) Β· SDK software development kit.

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

Theory connection

This lab pairs with the class AI-Assisted Delivery. Read its Code review and Security & governance pages before parts 1 and 6. It builds on the Delivery page of the cloud-native class and on the LLM gateway of the AI systems class.

What you will build

flowchart LR
    dev([Developer]) -->|"opens PR"| pr["Pull request"]
    pr --> rev["1 Β· AI review<br/>inline comments + cost"]
    pr -->|"label ai-tests"| gen["2 Β· AI tests<br/>PIT must improve"]
    pr --> ci["CI (lab 11)<br/>build Β· test Β· scan"]
    ci -->|"failure"| fix["3 Β· AI fix agent<br/>draft PR"]
    ci -->|"merge + tag"| rel["4 Β· AI release notes<br/>draft release"]
    rel --> canary["Canary (lab 11)"]
    canary --> expl["5 Β· Policy verdict<br/>+ AI explanation"]
    expl -->|"promote / rollback"| prod([Production])
    sec["6 Β· Hardening<br/>permissions Β· injection test"] -.-> rev
    sec -.-> fix
    meas["7 Β· Measure it<br/>precision Β· $/PR Β· minutes"] -.-> rev

Every AI step has the same shape β€” a trigger, context gathering, one model call (or a bounded agent loop) with structured output, validation, and an action with the smallest possible permission:

Part Trigger Context Output Validated by Action (permission)
1. AI review pull_request numbered diff, CONVENTIONS.md, ADRs JSON findings added lines only, thresholds, dedup review comments (pull-requests: write)
2. AI tests label ai-tests class, neighbours, existing tests a test class compiles, passes twice, PIT score rises draft PR (separate job)
3. Fix agent workflow_run failed failed log, repository a patch mvn verify, no test removed, src/ only draft PR (separate job)
4. Release notes tag v* commits, diffstat, migrations JSON items with refs every item cites a real commit draft release (contents: write)
5. Canary analysis step Prometheus numbers explanation must repeat the policy verdict none: the policy decides
6. Security β€” β€” β€” golden set + injection cases β€”
7. Measure it weekly PR history report β€” β€”

0. Setup: one client, three ways to reach a model

All scripts live in .github/ai/ of your repository and import one module, llm.py. It hides the provider behind a single function, complete_json(system, user, schema), that returns JSON validated against a schema plus the token usage and its cost. The provider is chosen with environment variables β€” the same names locally and in GitHub Actions (as repository variables):

Variable Default Meaning
LLM_PROVIDER ollama ollama (local, free), anthropic (Claude) or openai (any OpenAI-compatible endpoint, e.g. LiteLLM)
LLM_MODEL qwen2.5-coder-16k on ollama, claude-opus-5-5 on anthropic the model id
LLM_BASE_URL http://localhost:11434/v1 on ollama endpoint of ollama / openai
LLM_API_KEY, ANTHROPIC_API_KEY β€” secrets, only for the paid or remote paths

0.1 Pick a model

  1. Install Ollama and start it:

    # macOS
    brew install ollama && brew services start ollama
    # Linux
    curl -fsSL https://ollama.com/install.sh | sh
    

    Check that the server answers: curl -s http://localhost:11434/api/version prints {"version":"..."}. On Linux without systemd, start it by hand in another terminal with ollama serve.

  2. Get the model β€” after you saved the files of step 0.2 (the script is one of them):

    bash .github/ai/ollama-model.sh
    

    It downloads qwen2.5-coder:7b (4.7 GB, once) and creates qwen2.5-coder-16k, the same model with a 16,384-token context window. Ollama's default window is 4,096 tokens, and a longer prompt is cut silently β€” the reviewer would lose its instructions on a big diff. You should see, at the end:

    success
    ready: LLM_PROVIDER=ollama LLM_MODEL=qwen2.5-coder-16k (context 16384 tokens)
    

Nothing else to configure: ollama is the default provider. In GitHub Actions the workflows install Ollama on the runner with the composite action .github/actions/ollama (below) β€” free, but slow: a CPU-only runner needs minutes per call, where your laptop needs seconds. Expect lower review quality than a frontier model; part 6 measures how much lower.

  1. Create an API key in the Claude Console and set a monthly spend limit for its workspace (a few dollars is plenty for this lab).
  2. Locally: export LLM_PROVIDER=anthropic and export ANTHROPIC_API_KEY=<the key you just created> (never commit it; the lab 0 .gitignore already ignores .env).
  3. In GitHub: Settings β†’ Secrets and variables β†’ Actions, or with gh:

    gh secret set ANTHROPIC_API_KEY          # paste the key when asked
    gh variable set LLM_PROVIDER --body anthropic
    gh variable set LLM_MODEL --body claude-opus-5-5   # or claude-sonnet-5-5 / claude-haiku-4-5: cheaper
    

The client calls the Messages API through the official anthropic SDK with structured outputs (output_config.format with a JSON schema), prompt caching of the system prompt, and the server-side refusal fallback (a beta) on models that support it. Check the models overview for current model ids and prices.

The LiteLLM proxy of lab 12 exposes an OpenAI-compatible endpoint in front of any provider. Locally: export LLM_PROVIDER=openai LLM_BASE_URL=http://localhost:4000/v1 LLM_MODEL=<a model alias of your proxy> and export LLM_API_KEY=<your LiteLLM virtual key>; curl -s -H "Authorization: Bearer $LLM_API_KEY" $LLM_BASE_URL/models lists the aliases.

GitHub-hosted runners cannot reach localhost on your laptop: in CI this path needs a LiteLLM proxy reachable from the internet (or a self-hosted runner). Set the repository variables LLM_PROVIDER=openai, LLM_BASE_URL, LLM_MODEL and the secret LLM_API_KEY (a virtual key with its own budget).

What about GitHub Models?

Earlier versions of this lab could use GitHub Models, a free inference API for Actions. GitHub retired it on 30 July 2026 (GitHub docs), so the free path is now Ollama.

What costs money

Item Cost
Ollama on your machine free (electricity, ~5 GB of disk)
GitHub Actions minutes free for public repositories; in a private repository they count against your plan's monthly minutes (GitHub billing) β€” the Ollama path uses several runner minutes per AI job
Claude API (LLM_PROVIDER=anthropic, and path A of part 3) paid per token; protect it with a workspace spend limit. Each job prints its cost in US$
A LiteLLM proxy on the internet whatever its upstream provider charges

Course policy for this lab

Use your own key or a local model, never a teammate's or a company's. Never paste secrets, real customer data or personal data into prompts. Any commit that contains AI-generated code carries the trailer AI-assisted: <tool> (<model>) and every AI-opened PR carries the label ai-generated.

0.2 The shared client

Save the files below at the paths in their titles (the folder .github/ai/ is new). To avoid copying twenty files by hand, you can also take them from boxoffice-after-lab-13.zip β€” the state at the end of this lab β€” and copy only what you need:

curl -fsSL -o /tmp/lab13.zip https://insper.github.io/platform/2027.1/hands-on/13/boxoffice-after-lab-13.zip
unzip -oq /tmp/lab13.zip -d /tmp/lab13      # outside your repository: nothing to clean up in git status
cp -R /tmp/lab13/boxoffice/.github/ai /tmp/lab13/boxoffice/.github/actions .github/
.github/ai/requirements.txt
anthropic>=1.0
openai>=2.0
jsonschema>=4.0
.github/ai/ollama-model.sh
#!/usr/bin/env bash
# Prepare the free local model of lab 13 (run it from the repository root):
#
#     bash .github/ai/ollama-model.sh
#
# Pulls qwen2.5-coder:7b (4.7 GB) and creates `qwen2.5-coder-16k`: the same weights with a
# 16,384-token context window. Ollama's default window is 4,096 tokens, and a longer prompt
# (conventions + a big diff, or an agent's growing conversation) is cut SILENTLY.
set -euo pipefail

BASE="${OLLAMA_BASE_MODEL:-qwen2.5-coder:7b}"
NAME="${OLLAMA_MODEL_NAME:-qwen2.5-coder-16k}"
CTX="${OLLAMA_NUM_CTX:-16384}"

ollama pull "$BASE"
modelfile="$(mktemp)"
printf 'FROM %s\nPARAMETER num_ctx %s\n' "$BASE" "$CTX" > "$modelfile"
ollama create "$NAME" -f "$modelfile"
rm -f "$modelfile"
echo "ready: LLM_PROVIDER=ollama LLM_MODEL=$NAME (context $CTX tokens)"
.github/actions/ollama/action.yml
name: Local model (Ollama)
description: >-
  Free path of lab 13. Installs Ollama on the runner and prepares qwen2.5-coder-16k.
  CPU only: expect minutes per call, not seconds.
runs:
  using: composite
  steps:
    - name: Install and start Ollama
      shell: bash
      run: |
        curl -fsSL https://ollama.com/install.sh | sh
        if ! curl -sf http://localhost:11434/api/version > /dev/null; then
          (ollama serve > "$RUNNER_TEMP/ollama.log" 2>&1 &)
        fi
        for _ in $(seq 1 30); do
          curl -sf http://localhost:11434/api/version && break
          sleep 1
        done
    - name: Prepare the model
      shell: bash
      run: bash .github/ai/ollama-model.sh
.github/ai/llm.py
"""Provider-neutral LLM client for the BoxOffice CI jobs.

LLM_PROVIDER=ollama     (default) a local model through Ollama's OpenAI-compatible API: free, no key
                        (LLM_BASE_URL defaults to http://localhost:11434/v1)
LLM_PROVIDER=anthropic  Claude Messages API through the official SDK (needs ANTHROPIC_API_KEY)
LLM_PROVIDER=openai     any other OpenAI-compatible endpoint, e.g. a LiteLLM proxy
                        (needs LLM_BASE_URL; LLM_API_KEY if the endpoint wants one)
LLM_MODEL               model id: qwen2.5-coder-16k (see ollama-model.sh), claude-opus-5-5, ...

Every call returns parsed JSON that validates against a JSON Schema, plus the token
usage, so each job can print what it cost.
"""
from __future__ import annotations

import json
import os
import sys
import time
from dataclasses import dataclass

import jsonschema

# US$ per million tokens: (input, output, cache read). List prices when this lab was
# written -- illustrative; check the provider's pricing page. Unknown models cost 0 here
# (a local Ollama model costs runner minutes, not tokens).
PRICES = {
    "claude-opus-5-5": (4.00, 20.00, 0.20),
    "claude-sonnet-5-5": (2.00, 10.00, 0.20),
    "claude-haiku-4-5": (1.00, 5.00, 0.10),
}
CACHE_WRITE = 1.25  # a 5-minute cache write costs 1.25x the input price
# Models that accept the server-side refusal fallback (beta); see the Claude docs.
FALLBACK_MODELS = {"claude-opus-5-5", "claude-sonnet-5-5"}


class LLMError(RuntimeError):
    """The model did not return a usable answer (refusal, truncation, invalid JSON)."""


@dataclass
class Usage:
    model: str
    input_tokens: int = 0        # uncached input
    cache_read_tokens: int = 0
    cache_write_tokens: int = 0
    output_tokens: int = 0       # includes thinking tokens, billed as output
    calls: int = 0
    seconds: float = 0.0

    def add(self, other: "Usage") -> None:
        for f in ("input_tokens", "cache_read_tokens", "cache_write_tokens",
                  "output_tokens", "calls", "seconds"):
            setattr(self, f, getattr(self, f) + getattr(other, f))

    @property
    def usd(self) -> float:
        price = PRICES.get(self.model)
        if price is None:
            return 0.0
        p_in, p_out, p_read = price
        return (self.input_tokens * p_in + self.cache_write_tokens * p_in * CACHE_WRITE
                + self.cache_read_tokens * p_read + self.output_tokens * p_out) / 1e6

    def as_dict(self) -> dict:
        return {"model": self.model, "calls": self.calls, "in": self.input_tokens,
                "cache_read": self.cache_read_tokens, "cache_write": self.cache_write_tokens,
                "out": self.output_tokens, "seconds": round(self.seconds, 1),
                "usd": round(self.usd, 4)}

    def markdown(self) -> str:
        price = "n/a (local or unknown model)" if self.model not in PRICES else f"US$ {self.usd:.4f}"
        return ("| Model | Calls | Input | Cache read | Cache write | Output | Time | Cost |\n"
                "|---|---|---|---|---|---|---|---|\n"
                f"| `{self.model}` | {self.calls} | {self.input_tokens:,} | {self.cache_read_tokens:,}"
                f" | {self.cache_write_tokens:,} | {self.output_tokens:,} | {self.seconds:.1f} s"
                f" | {price} |\n")


def provider() -> str:
    return os.environ.get("LLM_PROVIDER") or "ollama"     # the free path is the default


def model() -> str:
    default = "qwen2.5-coder-16k" if provider() == "ollama" else "claude-opus-5-5"
    return os.environ.get("LLM_MODEL") or default


def base_url() -> str:
    """Endpoint of the OpenAI-compatible providers (ollama, openai)."""
    url = os.environ.get("LLM_BASE_URL") or (
        "http://localhost:11434/v1" if provider() == "ollama" else "")
    if not url:
        raise LLMError("LLM_PROVIDER=openai needs LLM_BASE_URL (e.g. http://localhost:4000/v1)")
    return url


def complete_json(system: str, user: str, schema: dict, *, max_tokens: int = 16000,
                  effort: str = "medium") -> tuple[dict, Usage]:
    """One model call that must answer with JSON valid against `schema`.

    `system` should be stable across runs (it is cached); `user` carries the data
    of this run (diff, logs, metrics), which is untrusted.
    """
    start = time.monotonic()
    try:
        if provider() == "anthropic":
            data, usage = _anthropic(system, user, schema, max_tokens, effort)
        else:
            data, usage = _openai_compatible(system, user, schema, max_tokens)
    except LLMError:
        raise
    except Exception as err:  # no key, connection refused, quota... -> the caller fails open
        raise LLMError(f"{type(err).__name__}: {str(err)[:300]}") from err
    usage.seconds = time.monotonic() - start
    return data, usage


def _anthropic(system, user, schema, max_tokens, effort):
    import anthropic

    client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY
    name = model()
    output_config = {"format": {"type": "json_schema", "schema": schema}}
    if "haiku" not in name:                 # the effort parameter is not available on Haiku 4.5
        output_config["effort"] = effort
    request = dict(
        model=name,
        max_tokens=max_tokens,
        # The system prompt (instructions + conventions) is the same on every run:
        # mark it for prompt caching so repeated runs pay the cheap cache-read price.
        system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user", "content": user}],
        output_config=output_config,
    )
    if name in FALLBACK_MODELS:
        # If a safety classifier declines the request, the API re-runs it on a fallback model.
        response = client.beta.messages.create(
            betas=["server-side-fallback-2026-07-01"], fallbacks="default", **request)
    else:
        response = client.messages.create(**request)

    if response.stop_reason == "refusal":
        raise LLMError("the model declined the request")
    if response.stop_reason == "max_tokens":
        raise LLMError("answer truncated: raise max_tokens or send less context")
    text = next((b.text for b in response.content if b.type == "text"), "")
    data = _parse(text, schema)
    u = response.usage
    usage = Usage(name, input_tokens=u.input_tokens,
                  cache_read_tokens=u.cache_read_input_tokens or 0,
                  cache_write_tokens=u.cache_creation_input_tokens or 0,
                  output_tokens=u.output_tokens, calls=1)
    return data, usage


def _openai_compatible(system, user, schema, max_tokens):
    from openai import OpenAI

    client = OpenAI(base_url=base_url(), api_key=os.environ.get("LLM_API_KEY") or "not-needed")
    name = model()
    # JSON mode is widely supported (LiteLLM, Ollama, vLLM); the schema goes in the
    # prompt and the answer is validated here, with one repair attempt.
    instructions = (system + "\n\nAnswer with ONE JSON object, and nothing else, that validates "
                    "against this JSON Schema:\n" + json.dumps(schema))
    messages = [{"role": "system", "content": instructions}, {"role": "user", "content": user}]
    usage = Usage(name)
    for attempt in range(2):
        response = client.chat.completions.create(
            model=name, max_tokens=max_tokens, temperature=0,
            response_format={"type": "json_object"}, messages=messages)
        usage.calls += 1
        if response.usage:
            usage.input_tokens += response.usage.prompt_tokens
            usage.output_tokens += response.usage.completion_tokens
        text = response.choices[0].message.content or ""
        try:
            return _parse(text, schema), usage
        except LLMError as err:
            if attempt == 1:
                raise
            messages += [{"role": "assistant", "content": text},
                         {"role": "user", "content": f"Invalid answer: {err}. Reply again with "
                                                     "only the JSON object."}]
    raise AssertionError("unreachable")


def _parse(text: str, schema: dict) -> dict:
    try:
        data = json.loads(text)
        jsonschema.validate(data, schema)  # defence in depth, even with structured outputs
    except (json.JSONDecodeError, jsonschema.ValidationError) as err:
        raise LLMError(f"answer is not valid JSON for the schema: {str(err)[:200]}") from err
    return data


def step_summary(markdown: str) -> None:
    """Append Markdown to the job summary page (or print it when running locally)."""
    path = os.environ.get("GITHUB_STEP_SUMMARY")
    if path:
        with open(path, "a", encoding="utf-8") as f:
            f.write(markdown + "\n")
    else:
        print(markdown, file=sys.stderr)


if __name__ == "__main__":
    # Smoke test of the setup:  python .github/ai/llm.py
    print(f"provider={provider()} model={model()}", file=sys.stderr)
    try:
        answer, cost = complete_json(
            "You answer with JSON only.", "Greet the BoxOffice team in at most eight words.",
            {"type": "object", "properties": {"greeting": {"type": "string"}},
             "required": ["greeting"], "additionalProperties": False}, max_tokens=200)
    except LLMError as err:
        sys.exit(f"LLM error: {err}")
    print(json.dumps(answer, ensure_ascii=False))
    print(cost.markdown())

What to notice in llm.py:

Design decision Why
system is stable, user carries the run's data The system prompt (instructions + conventions) is identical on every run, so it can be cached: on Claude, cached input tokens are billed at a fraction of the normal price. The diff, logs and metrics change every run and go in user.
Structured output + schema validation Claude constrains the answer to the JSON schema (output_config.format); the OpenAI-compatible path (Ollama, LiteLLM) uses JSON mode and validates with one repair attempt. Either way the caller gets a dict it can check, never free text it must parse.
Every failure becomes an LLMError A refusal, a truncated answer (max_tokens), no API key, a model server that is down: each job decides explicitly what happens then (usually: fail open, skip the AI step and say so in the summary).
Usage with prices Every job prints tokens and dollars in its summary. You cannot manage a cost you do not see. The prices are list prices when this lab was written β€” update PRICES from the provider's page. A local model shows n/a.
No retries here The SDKs already retry 429 and 5xx with backoff; a second retry layer would multiply calls during an outage (the retry storm of Reliability).

Checkpoint. Run the smoke test at the bottom of llm.py (the first uv run downloads the Python packages, ~10 s):

uv run --no-project --python 3.12 --with-requirements .github/ai/requirements.txt python .github/ai/llm.py

You should see (the greeting varies; the first call also loads the model into memory, so it takes longer):

provider=ollama model=qwen2.5-coder-16k
{"greeting": "Hello, BoxOffice team!"}
| Model | Calls | Input | Cache read | Cache write | Output | Time | Cost |
|---|---|---|---|---|---|---|---|
| `qwen2.5-coder-16k` | 1 | 81 | 0 | 0 | 15 | 1.9 s | n/a (local or unknown model) |

LLM error: APIConnectionError: Connection error. means Ollama is not running; NotFoundError ... model 'qwen2.5-coder-16k' not found means step 0.1 was skipped. See Troubleshooting.

0.3 The context: conventions and decisions

The reviewer is only as good as what it knows about your system. A generic model knows Java; it does not know that order must never write to the inventory schema. Write that knowledge down once β€” for humans first β€” in CONVENTIONS.md at the root of the repository:

CONVENTIONS.md
# BoxOffice conventions

Read by humans and by the AI reviewer. Keep it short: every line costs tokens on every review.

## Architecture rules (blocker if violated)
- One PostgreSQL schema per service (`catalog`, `inventory`, `orders`, `payment`, ...).
  A service reads and writes ONLY its own schema. Another service's data is reached
  through its HTTP API or its events, never with SQL.
- Seat state (holds, sales) is owned by `inventory`. Any change to a seat goes through
  inventory, inside a transaction that locks the row (`SELECT ... FOR UPDATE`) or uses
  optimistic locking. Double booking is the worst bug this system can have.
- `order` publishes events through the transactional outbox, never directly to Kafka
  inside a database transaction.
- Every outbound HTTP call (service-to-service or to the payment provider) has a connect
  and a read timeout, and calls to `payment` are idempotent (Idempotency-Key header).
- Services trust identity only from the `X-User-*` headers set by the gateway.

## Code
- Java 21, Spring Boot 4.1, Spring Cloud 2025.1; DTOs are `record`s; no Lombok.
- Money is `long` cents or `BigDecimal`, never `double`.
- SQL uses bind parameters; never build SQL by string concatenation.
- No secrets in code or in application.yaml: read them from environment variables.
- Errors are RFC 9457 problem+json; `POST` that creates returns 201 with `Location`.
- Flyway migrations are append-only: never edit a migration that was merged.

## Tests
- Business rules in `inventory`, `order` and `payment` need unit tests for the error paths.
- No `Thread.sleep` in tests; inject a `Clock` for time.

Architecture decision records (ADRs) in docs/adr/ are also sent to the reviewer (the first 3,000 characters of each). An ADR is a short note of one decision: context, decision, consequences. Start with the one BoxOffice made in lab 1:

docs/adr/0001-schema-per-service.md
# ADR 0001 β€” One database schema per service

- Status: accepted
- Date: 2027-02-15

## Context
All BoxOffice services share one PostgreSQL instance (`db`, database `boxoffice`). Nothing in
PostgreSQL stops `order` from reading or updating `inventory.seats` directly, and a shortcut like
that would couple the two services' release cycles and bypass inventory's locking rules.

## Decision
Each service owns one schema (`catalog`, `inventory`, `orders`, `payment`, `notification`, `auth`)
and its Flyway migrations. A service reads and writes ONLY its own schema. Data owned by another
service is reached through that service's HTTP API or its events.

## Consequences
- A query across services becomes an API call or a read model built from events (slower, explicit).
- Each schema can move to its own database later without changing other services.
- Reviewers (human and AI) treat SQL that names another service's schema as a blocker.

0.4 Repository settings

Once per repository, from the repository root:

gh label create ai-generated --color 8250df --description "opened or written by an AI step"
gh label create ai-tests --color 0e8a16 --description "run the AI test generator on this PR"
# Workflow permissions: read-only by default, and allow Actions to open (draft) pull requests
gh api --method PUT "repos/{owner}/{repo}/actions/permissions/workflow" \
  -f default_workflow_permissions=read -F can_approve_pull_request_reviews=true

{owner}/{repo} is filled in by gh from the current repository. The last command is the same as Settings β†’ Actions β†’ General β†’ Workflow permissions: Read repository contents and Allow GitHub Actions to create and approve pull requests (GitHub has one checkbox for both; no workflow of this lab ever approves). Check it with gh api "repos/{owner}/{repo}/actions/permissions/workflow":

{"default_workflow_permissions":"read","can_approve_pull_request_reviews":true}

Parts

  1. AI pull-request review β€” structured findings as inline comments, noise control, cost in the job summary.
  2. AI-generated tests, judged by mutation testing β€” keep a generated test only if PIT says it catches more bugs.
  3. An agent that fixes the failing build β€” headless Claude Code or a 150-line agent loop; a draft PR for a human.
  4. AI release notes β€” grounded in commits, published as a draft.
  5. An explained canary verdict β€” the policy decides, the model explains.
  6. Security hardening β€” triggers, permissions, secrets, prompt injection, and a golden-set eval.
  7. Measure it β€” precision, $/PR, CI minutes and time to merge over a week.
  8. Quiz.

Troubleshooting

Each part has its own list for its specific errors; these are the ones that can appear anywhere.

Symptom Cause Fix
AI review skipped / LLM error: APIConnectionError: Connection error. Ollama is not running (or LLM_BASE_URL points elsewhere) curl -s http://localhost:11434/api/version; start it (brew services start ollama, sudo systemctl start ollama or ollama serve)
NotFoundError ... model 'qwen2.5-coder-16k' not found the 16k model was not created bash .github/ai/ollama-model.sh; ollama list must show it
A local call takes minutes, or the machine swaps not enough free RAM for a 7B model with a 16k window close other programs; on an 8 GB machine use the 3B model: OLLAMA_BASE_MODEL=qwen2.5-coder:3b bash .github/ai/ollama-model.sh (same name, smaller model, lower quality)
TypeError: Could not resolve authentication method LLM_PROVIDER=anthropic without ANTHROPIC_API_KEY export the key, or unset LLM_PROVIDER to go back to Ollama
uv: command not found / No interpreter found for Python 3.12 uv missing (lab 0) install uv (lab 0); uv python install 3.12
A workflow does not start at all the file is not on the default branch yet (workflow_run, labels), or Actions are disabled merge the workflows into main first; Settings β†’ Actions β†’ General β†’ Allow all actions
HTTP 403: Resource not accessible by integration the job lacks a permission, or the repository default is read-only and the workflow did not declare it keep the permissions: blocks of the files on these pages; run step 0.4
GitHub Actions is not permitted to create or approve pull requests step 0.4 skipped run the gh api --method PUT ... command of step 0.4
A CI job with Ollama hits its timeout CPU-only runner, large prompt (in a private repository the runner may also have less memory) smaller diffs; the 3B model (add env: { OLLAMA_BASE_MODEL: "qwen2.5-coder:3b" } to the step that uses ./.github/actions/ollama); or a paid provider for that job

Best practices in this lab

  • AI proposes, rules decide β€” .github/ai/review.py posts event: COMMENT, never an approval, and exits 0 when the model fails; why: Code review.
  • Privilege split by job β€” ai-fix.yaml runs the model with a read-only token and opens the draft PR from a job with no model; why: Agents in CI.
  • Generated tests prove their value β€” gen_tests.py keeps a test only if it passes twice and adds 5 pp of mutation score; why: Testing.
  • The verdict is code; the model only explains β€” canary_explain.py returns the exit code of the policy and discards an explanation that disagrees; why: Release & operate.
  • The AI tools have their own regression test β€” eval_review.py and ai-eval.yaml run the golden set when the prompt, conventions or model change; why: Security & governance.

Clean up

# stop the AI workflows from running on every PR (re-enable with `gh workflow enable`)
for w in ai-review.yaml ai-tests.yaml ai-fix.yaml ai-release-notes.yaml ai-eval.yaml; do gh workflow disable "$w"; done
# delete the branches the bots opened (their PRs were merged or closed)
git ls-remote --heads origin 'ai-fix/*' 'ai-tests/*' | awk '{print $2}' | sed 's|refs/heads/||' \
  | xargs -r -n1 git push origin --delete
ollama stop qwen2.5-coder-16k          # frees the RAM now; `ollama rm qwen2.5-coder-16k qwen2.5-coder:7b` frees the disk

If you used Claude, revoke the API key in the Claude Console (or keep it with its spend limit) and gh secret delete ANTHROPIC_API_KEY. Keep the files: they are part of your repository and of the team project.

Deliverable

  • .github/ai/ with llm.py, diffutil.py, review.py, gen_tests.py, fix_agent.py, release_notes.py, canary_explain.py, eval_review.py, metrics.py, ollama-model.sh, requirements.txt, the evals/ golden set and the canary/ snapshots.
  • CONVENTIONS.md with at least five rules specific to your BoxOffice, and at least one ADR in docs/adr/.
  • Workflows ai-review.yaml, ai-tests.yaml, ai-fix.yaml, ai-release-notes.yaml and ai-eval.yaml, each with an explicit permissions: block, passing actionlint (part 6).
  • One PR where the reviewer posted inline comments, with the cost table in the job summary.
  • One draft PR opened by the test generator with a higher mutation score, or the job summary explaining why the tests were rejected.
  • One draft PR opened by the fix agent for a build you broke on purpose (or the local run of part 3), reviewed (approved or rejected) by you with a written reason.
  • A draft release with AI notes for a v* tag.
  • The three canary verdicts (healthy, regression, low traffic) of part 5.
  • The injection PR of part 6 and an ai-eval run, with the eval table of your model.
  • results/lab13.md with the measurement table of part 7 and your conclusion: keep, tune or remove each AI step?
  • Tag the commit: git tag lab13 && git push --tags; quiz answered.

Self-check before you submit: git status is clean; gh workflow list shows the five AI workflows active; gh run list --limit 20 shows at least one successful run of AI review, AI tests, AI release notes and AI reviewer eval; git log -p | grep -c 'sk-ant-' prints 0 (no Claude key was ever committed).

End state

changed in lab 13
πŸ“ boxoffice/
β”œβ”€β”€ πŸ“ .github/ai/                       # new: llm.py, diffutil.py, review.py, gen_tests.py, fix_agent.py,
β”‚   β”œβ”€β”€  #      release_notes.py, canary_explain.py, eval_review.py, metrics.py,
β”‚   └──  #      ollama-model.sh, requirements.txt, evals/ (5 cases), canary/ (3 snapshots)
β”œβ”€β”€ YAML .github/actions/ollama/action.yml # new: the free model on the runner
β”œβ”€β”€ YAML .github/workflows/ai-review.yaml  ai-tests.yaml  ai-fix.yaml  ai-release-notes.yaml  ai-eval.yaml   # new
β”œβ”€β”€ Markdown CONVENTIONS.md                    # new
β”œβ”€β”€ Markdown docs/adr/0001-schema-per-service.md                                        # new
β”œβ”€β”€  Dockerfile.ai-tools               # new (optional live canary, step 5.4)
β”œβ”€β”€ YAML k8s/catalog/analysis-explained.yaml                                        # new (optional, step 5.4)
β”œβ”€β”€  services/catalog/pom.xml          # replaced: lab 11 pom + PIT plugin
β”œβ”€β”€  services/catalog/src/test/java/boxoffice/catalog/CatalogServiceTest.java   # new
└── Markdown results/lab13.md

This is the last lab: the same pipeline, conventions and measurements carry into the team project.

Start with part 1: AI review