Deploy an Incident Triage API

Deploy an AI API that converts incident reports into reliable triage decisions.

Introduction

30 Second Summary

Support teams often receive urgent incident reports as rushed paragraphs written in different styles. That inconsistency slows the decision about who should respond first.

In this project, you will build a customer-facing incident triage API with FastAPI. The finished Docker deployment converts OpenAI API model output into a validated decision contract.

What You'll Build

You paste an outage report into FastAPI's interactive documentation to receive a consistent triage decision ready for automated routing or human review.

By the end of this project, you'll have:

  • A working triage API that turns messy support prose into a stable routing decision with clear escalation guidance.
  • A supportable service with a health response plus request IDs that connect API responses to matching log entries.
  • A customer-ready handoff for a reproducible container deployment backed by acceptance scores from three representative incidents.
  • Secret Mission: Add a deterministic offline fallback that keeps basic triage available when the OpenAI API is unavailable or deliberately bypassed.

Are there any prerequisites?

Basic Python familiarity plus an internet connection are enough because OpenAI Platform access and API-key creation happen inside the project. Model requests are billed by token usage.

Before We Start

Start by locking in the customer problem that guides every decision in this deployment. Support agents need consistent operational triage decisions from incident reports written as inconsistent prose.

Set Up the Customer Deployment Workspace

Support agents need consistent triage decisions, so the deployment must behave the same way each time it starts.

A reproducible workspace pins the runtime through Docker Compose. It also pins every Python package before feature work begins.

Your live OpenAI API key stays in a local .env file. Git ignores that file.

Docker excludes the same credential file from the build context.

In this step, get ready to:
  • Start Docker Desktop and confirm that its client can reach the engine.
  • Create the incident-triage-api workspace in Visual Studio Code.
  • Protect your API key and add the pinned container configuration.
Prepare the Windows workspace

A Docker client can be installed while its engine is stopped. Starting Docker Desktop gives the later Compose commands a running server.

  • Start Docker Desktop from Windows search.
  • Select Accept if the Docker Subscription Service Agreement appears.
  • Wait for Docker Desktop to report that its engine is running.

Why does Docker need two parts?

The Docker client receives commands such as docker version. The Docker engine performs the container work.

A healthy local setup lets the client communicate with that engine.

The engine is ready. Now you can give every deployment file one predictable home.

  • Start Visual Studio Code from Windows search.
  • Select File from the top menu.
  • Select Open Folder....
  • Select your Desktop in the folder picker.
  • Select New Folder.
  • Enter incident-triage-api as the folder name.
  • Choose Select Folder.
  • Choose Yes, I trust the authors if the workspace trust prompt appears.

You should see incident-triage-api at the top of the Explorer sidebar.

Workspace folder missing?

Select File from the top menu. Use Open Folder... to select incident-triage-api from your Desktop.

Ask for help with the workspace

Protect the API credential

An environment template documents the required variable without exposing its value. The private copy receives your real credential.

  • Create .env.example inside incident-triage-api by selecting New File... in the Explorer.
  • Add the environment template by pasting the code below:
OPENAI_API_KEY=your-api-key-here

What does this template do?

  • OPENAI_API_KEY is the environment variable that the OpenAI Python SDK reads automatically.
  • your-api-key-here is a non-secret placeholder that makes the required format clear.
  • Save .env.example by pressing Ctrl+S.
  • Confirm that .env.example appears in the Explorer.
  • Create .gitignore inside incident-triage-api with the Explorer's New File... control.
  • Add the Git exclusions by pasting the code below:
.env
__pycache__/
*.pyc

What does Git ignore?

  • .env keeps the live API key out of version control.
  • __pycache__/ excludes generated Python cache folders.
  • *.pyc excludes compiled Python files.
  • Save .gitignore by pressing Ctrl+S.
  • Confirm that the file remains visible in the Explorer.
  • Create .dockerignore inside incident-triage-api with the Explorer's New File... control.
  • Add the Docker build exclusions by pasting the code below:
.env
.git/
__pycache__/
*.pyc

What stays out of the image?

  • .env prevents the live credential from entering the build context.
  • .git/ keeps repository history out of the image.
  • The Python patterns exclude generated cache files.
  • Save .dockerignore by pressing Ctrl+S.
  • Confirm that .env.example appears beside both ignore files in the Explorer.

The public template is ready. Your private .env file can now use the same variable name.

  • Create .env inside incident-triage-api with the Explorer's New File... control.
  • Copy the full OPENAI_API_KEY=your-api-key-here line from .env.example into .env.
  • Save .env by pressing Ctrl+S.

Why use two environment files?

.env.example is safe to share because it contains only a placeholder. .env stays local because it contains the live key.

This separation preserves a reproducible setup without exposing the credential.

Keep the key private

The full key is visible only when you create it. Paste it only into .env.

Keep the key out of screenshots. Never add it to .env.example.

  • Open the official OpenAI API-key page in your browser.
  • Sign up or log in to reach your OpenAI Platform settings.
  • Select your project in the organization settings.
  • Select API Keys.
  • Select + Create new secret key.
  • Enter incident-triage-api as the key name.
  • Select Create.
  • Copy the secret key while it is visible.

Why copy the key now?

OpenAI displays the full secret only when the key is created. A lost key must be replaced with a newly generated one.

  • Return to the open .env file in Visual Studio Code.
  • Replace your-api-key-here with the secret key you copied.
  • Save .env by pressing Ctrl+S.
  • Close the .env editor tab so the credential is no longer visible.

Good call keeping the boundary tight. Your live key now exists only in the local .env file.

Unable to create or save the key?

Confirm that your OpenAI account has access to the selected Platform project. Create a new key if the original secret is no longer visible.

Check that you pasted the key into .env instead of .env.example.

Ask for help with API-key setup

Add the container configuration

The deployment needs a Python package boundary before application code arrives. It also needs exact dependency versions so every image installs the same stack.

  • Create an app folder inside incident-triage-api with the Explorer's New Folder control.
  • Select the new app folder in the Explorer.
  • Create __init__.py inside app with the New File... control.
  • Leave app/__init__.py empty.
  • Save app/__init__.py by pressing Ctrl+S.

You should see an empty app/__init__.py file nested under the app folder.

  • Create requirements.txt inside incident-triage-api with the Explorer's New File... control.
  • Add the pinned dependencies by pasting the code below:
fastapi[standard]==0.141.1
openai==3.13.0
pydantic==2.13.5

What do these dependencies provide?

  • fastapi[standard]==0.141.1 provides the FastAPI web framework plus its standard runtime dependencies.
  • openai==3.13.0 provides the OpenAI Python SDK used for model requests.
  • pydantic==2.13.5 provides Pydantic models for request validation and structured responses.
  • Save requirements.txt by pressing Ctrl+S.
  • Confirm that the file contains exactly three dependency lines.

Dependency lines look different?

Compare each package name and version with the code block. Keep the square brackets around standard in the FastAPI requirement.

Ask for help with the dependency file

  • Create Dockerfile inside incident-triage-api with the Explorer's New File... control.
  • Add the image recipe by pasting the code below:
FROM python:3.14.7-slim-trixie

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./app /code/app
COPY ./eval_cases.py /code/eval_cases.py

CMD ["fastapi", "run", "app/main.py", "--port", "80"]

What does the image recipe define?

  • python:3.14.7-slim-trixie pins the Python container image.
  • /code gives the application a consistent location inside the image.
  • The dependency copy runs before the application copy so Docker can reuse the installation layer when only application files change.
  • The final command serves app/main.py on container port 80.
  • Save Dockerfile by pressing Ctrl+S.
  • Confirm that Dockerfile appears at the top level of the Explorer.

Dockerfile saved with the wrong name?

Confirm that the filename is exactly Dockerfile with no extension. Windows may otherwise save it as a text file.

Ask for help with the Dockerfile

  • Create compose.yaml inside incident-triage-api with the Explorer's New File... control.
  • Add the Compose service by pasting the code below:
services:
  api:
    build: .
    env_file: .env
    ports:
      - "127.0.0.1:8000:80"

What does this service configuration do?

  • api is the service name used by later Docker Compose commands.
  • build: . tells Compose to use the Dockerfile in incident-triage-api.
  • env_file: .env passes the API key into the container at runtime.
  • 127.0.0.1:8000:80 exposes container port 80 through local port 8000.
  • Save compose.yaml by pressing Ctrl+S.
  • Confirm that the YAML indentation matches the code block.

Compose file showing indentation warnings?

Use spaces for every indentation level. Keep the port mapping nested beneath ports.

Ask for help with Compose configuration

Use the tabs below to compare every tracked artifact before the final runtime check.

✔️ Awesome, I've got everything!

Great. Save every open file and keep .env closed so its credential remains hidden.

ⓧ I'd like to double check the full code

Compare each file with the references below. The private .env file is intentionally excluded because it contains your live key.

.env
.git/
__pycache__/
*.pyc
OPENAI_API_KEY=your-api-key-here
.env
__pycache__/
*.pyc
FROM python:3.14.7-slim-trixie

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./app /code/app
COPY ./eval_cases.py /code/eval_cases.py

CMD ["fastapi", "run", "app/main.py", "--port", "80"]
services:
  api:
    build: .
    env_file: .env
    ports:
      - "127.0.0.1:8000:80"
fastapi[standard]==0.141.1
openai==3.13.0
pydantic==2.13.5

The app/__init__.py file is empty.

Before you run the check, do you expect Docker to report only the local command-line client or both the client and the running engine?

  • Open PowerShell from Windows search.
  • Verify the Docker client and engine by running this command:
docker version

What does this check prove?

  • The Client section confirms that the Docker command-line client is available.
  • The Server section confirms that the Docker engine is running.

You should see both Client and Server sections in PowerShell. This confirms that Docker Desktop can receive container commands.

  • Return to the Visual Studio Code workspace.
  • Expand the app folder in the Explorer.
  • Confirm that the Explorer lists .dockerignore, .env, .env.example, .gitignore, Dockerfile, compose.yaml, requirements.txt, and app/__init__.py.

Missing the Server section?

Return to Docker Desktop and wait for its engine to finish starting. Run the version check again after Docker Desktop reports that it is ready.

Ask for help with Docker Desktop

The workspace now protects your key inside a reproducible container setup. Next, you'll ship the first working triage loop.

Ship the Fastest Possible Triage Prototype

Your protected workspace is ready. The support team can now move from setup to a working triage request.

The customer needs a working loop quickly. FastAPI accepts each incident report. The OpenAI API supplies the first triage answer.

Speed creates a deliberate gap here. The first response is one free-form string, so routing automation cannot depend on stable fields.

In this step, get ready to:
  • Create the API foundation for incoming incident reports.
  • Connect the triage route to the model.
  • Demonstrate the running prototype through Docker Compose and interactive documentation.
Create the FastAPI foundation

Pydantic gives the incoming request a known shape. The prototype accepts one incident description through IncidentRequest.

  • In VS Code's file sidebar, select the app folder.
  • Use the new-file control to create app/main.py inside the app folder.
  • Add the API foundation to app/main.py by pasting this code:
from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel

MODEL = "gpt-6-astra"

client = OpenAI()
app = FastAPI(title="Incident Triage API")


class IncidentRequest(BaseModel):
    description: str

What Does This Code Set Up?

  • FastAPI creates the web application that receives customer requests.
  • OpenAI() creates the model client. The SDK reads OPENAI_API_KEY from the container environment.
  • MODEL keeps the model identifier in one place.
  • IncidentRequest requires every request body to contain a text description field.
  • Save app/main.py.
  • Confirm that VS Code lists main.py beneath the app folder.

Can't Find main.py?

Check that main.py sits inside the existing app folder. Its full path must be app/main.py.

Help me check the file location.

Connect the route to the model

A path operation connects an HTTP request to Python code. This one sends the customer's description to the model before returning the answer directly.

  • In app/main.py, place your cursor below the IncidentRequest class.
  • Add the triage path operation by pasting this code:
@app.post("/triage")
def triage_incident(incident: IncidentRequest) -> dict[str, str]:
    response = client.responses.create(
        model=MODEL,
        input=incident.description,
    )
    return {"raw_output": response.output_text}

How Does the Triage Route Work?

  • @app.post("/triage") publishes a path that accepts incident reports.
  • triage_incident() receives the validated IncidentRequest body.
  • client.responses.create(...) sends incident.description to the selected model.
  • response.output_text contains the model's free-form answer.
  • raw_output places that answer inside one response field.
  • Save app/main.py.
  • Check that the final line returns response.output_text under raw_output.

Seeing Errors in the Route?

Check that triage_incident() starts at the left edge of the file. Keep the lines inside the function indented by four spaces.

Help me compare the route.

✔️ Awesome, I've got everything!

Your first triage route is ready. Double-check that you saved app/main.py.

ⓧ I'd like to double check the full code

  • Compare your saved app/main.py with this reference:
from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel

MODEL = "gpt-6-astra"

client = OpenAI()
app = FastAPI(title="Incident Triage API")


class IncidentRequest(BaseModel):
    description: str


@app.post("/triage")
def triage_incident(incident: IncidentRequest) -> dict[str, str]:
    response = client.responses.create(
        model=MODEL,
        input=incident.description,
    )
    return {"raw_output": response.output_text}
Run the prototype and expose the gap

Your container configuration packages the API with its pinned dependencies. The first build can take a few minutes while Docker downloads the Python image and installs those packages.

  • In VS Code, start an integrated PowerShell terminal from the top menu.
  • Start the API service by running:
docker compose up --build

You'll see build output followed by service logs. The terminal then stays attached to the running api service.

Build or Startup Failed?

Confirm that Docker Desktop is still running. Check that the terminal is inside the incident-triage-api folder containing compose.yaml.

If the logs mention authentication, confirm that your local .env contains the API key you created earlier. Keep the key hidden when asking for help.

Help me diagnose the container startup.

  • Press the Windows key to open Windows search.
  • Type the name of your web browser and press Enter.
  • Enter http://127.0.0.1:8000/docs in the browser address bar.
  • Press Enter to load the interactive documentation.
  • Expand POST /triage.
  • Click Try it out.
  • Replace string with All customers receive HTTP 500 errors and cannot open the dashboard..

Before you send the report, do you think the response will contain predictable severity and category fields?

  • Click Execute.

You have your first working customer loop: the report now reaches the model through your containerized API.

The response body contains one raw_output field. The severity, category, summary, and action remain embedded inside a model-written string.

Why Does This Prototype Fall Short?

The raw_output response proves that the request can travel from the browser to the model. Its wording and structure can vary across requests.

Support automation needs predictable fields before it can route incidents safely. This successful demo exposes the exact contract gap that the next step closes.

Your containerized prototype accepts real incident prose and returns a model answer. Next up, you'll turn that free-form result into fields that customer systems can validate and use.

Replace Free-Form Output with a Decision Contract

Your FastAPI prototype now proves that incident text can reach the model. Its single raw_output string gives routing automation no dependable fields to inspect.

This step uses OpenAI Structured Outputs to constrain the response to a decision contract. Pydantic validates that contract before FastAPI returns it.

In this step, get ready to:
  • Define the typed decision schema.
  • Parse model responses through OpenAI Structured Outputs.
  • Verify the human-review rule in the interactive documentation.
Define the decision contract

A machine-safe contract limits each decision to known severity levels. It also gives every successful response the same shape.

  • In app/main.py, replace the import block through app = FastAPI(title="Incident Triage API") with this code:
from enum import Enum
from uuid import uuid4

from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel

MODEL = "gpt-6-astra"

client = OpenAI()
app = FastAPI(title="Incident Triage API", version="1.0.0")

What does this code do?

  • Enum supports fixed severity choices inside the decision contract.
  • uuid4() creates the identifier returned with each triage result.
  • version="1.0.0" records the first stable version of the API contract.
  • Insert these severity types above the existing IncidentRequest class:
class Severity(str, Enum):
    p1 = "P1"
    p2 = "P2"
    p3 = "P3"
    p4 = "P4"


class Category(str, Enum):
    platform = "platform"
    identity = "identity"
    billing = "billing"
    product = "product"

How do the enums protect the contract?

  • Severity limits the model to four escalation levels.
  • Category limits routing to four documented support areas.
  • String-backed enums keep the final response readable as JSON text values.
  • Save app/main.py.
  • Switch back to the terminal running the service.
  • Press Ctrl+C to stop the current container.
  • Rebuild the api service by running this command:
docker compose up --build

What does this command do?

Docker Compose rebuilds the image with your updated Python file. It starts the service again using the existing runtime configuration.

You should see the service start without a Python traceback. The interactive documentation now identifies the API as version 1.0.0.

Service not starting?

Check that both enum classes sit above IncidentRequest. Confirm that every enum value stays inside its class indentation.

Compare the import block with the first code sample. A missing Enum import prevents the severity classes from loading.

Help me fix the contract type setup

Good progress. Your service now recognises only the severity levels and routing categories that the customer approved.

  • Add these response models below the existing IncidentRequest class:
class IncidentDecision(BaseModel):
    severity: Severity
    category: Category
    summary: str
    recommended_action: str


class IncidentTriage(IncidentDecision):
    needs_human_review: bool


class TriageResponse(BaseModel):
    request_id: str
    model: str
    triage: IncidentTriage

How is the response organised?

  • IncidentDecision defines the fields the model must produce.
  • IncidentTriage extends that model with the application-controlled review decision.
  • TriageResponse wraps the decision with its model name and request identifier.
  • Save app/main.py.
  • Switch back to the terminal running the service.
  • Press Ctrl+C to stop the current container.
  • Rebuild the api service by running this command:
docker compose up --build

What does this rebuild prove?

The rebuild imports every new response model inside the container. A successful startup confirms that the inheritance and type references are valid.

You should see the service start normally. This confirms that the typed response models load successfully.

Seeing a model definition problem?

Confirm that IncidentDecision appears below IncidentRequest. Check that IncidentTriage inherits from IncidentDecision.

Help me fix the Pydantic response models

Add the triage policy

The schema controls response shape. The triage policy defines how the model chooses values inside that shape.

  • Insert this policy below the TriageResponse class in app/main.py:
TRIAGE_INSTRUCTIONS = """
You triage incident reports for a SaaS support team.
Return one severity: P1 for a widespread outage, security incident, or data-loss risk;
P2 for major degradation affecting many users; P3 for a limited user-impacting issue;
or P4 for a question, feature request, or cosmetic issue.
Return one category: platform, identity, billing, or product.
Write a concise summary and one concrete recommended action.
Do not include secrets or unsupported facts.
""".strip()

What does the policy control?

  • TRIAGE_INSTRUCTIONS gives each severity a documented operational meaning.
  • The category policy keeps routing inside the customer team's four support areas.
  • The final lines constrain the summary and recommended action to useful customer-facing content.
  • Save app/main.py.
  • Switch back to the terminal running the service.
  • Press Ctrl+C to stop the current container.
  • Rebuild the api service by running this command:
docker compose up --build

What does this rebuild check?

The container loads the multiline policy during application startup. A normal startup confirms that the policy is valid Python.

You should see the service start without a Python traceback. The customer policy is now ready for the structured model call.

Policy stopping the service?

Check that the policy starts and ends with three double quotes. Confirm that .strip() appears after the closing quotes.

Help me fix the triage policy string

Return the typed response

The endpoint can now ask the model for an IncidentDecision directly. Application code then derives the human-review flag from the validated severity.

  • In app/main.py, replace the existing POST /triage route with this typed route:
@app.post("/triage", response_model=TriageResponse)
def triage_incident(incident: IncidentRequest) -> TriageResponse:
    response = client.responses.parse(
        model=MODEL,
        input=[
            {"role": "system", "content": TRIAGE_INSTRUCTIONS},
            {"role": "user", "content": incident.description},
        ],
        text_format=IncidentDecision,
    )
    decision = response.output_parsed
    triage = IncidentTriage(
        **decision.model_dump(),
        needs_human_review=decision.severity == Severity.p1,
    )
    return TriageResponse(request_id=str(uuid4()), model=MODEL, triage=triage)

How does the typed route work?

  • client.responses.parse() asks the model to produce an IncidentDecision.
  • response.output_parsed provides the validated Pydantic object.
  • needs_human_review becomes true only when the validated severity equals Severity.p1.
  • TriageResponse adds a new request identifier to the final response.
  • Save app/main.py.

✔️ Awesome, I've got everything!

Your policy and typed route are in place.

  • Confirm that app/main.py is saved.

ⓧ I'd like to double check the full code

  • Compare your complete app/main.py file with this reference:
from enum import Enum
from uuid import uuid4

from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel

MODEL = "gpt-6-astra"

client = OpenAI()
app = FastAPI(title="Incident Triage API", version="1.0.0")


class Severity(str, Enum):
    p1 = "P1"
    p2 = "P2"
    p3 = "P3"
    p4 = "P4"


class Category(str, Enum):
    platform = "platform"
    identity = "identity"
    billing = "billing"
    product = "product"


class IncidentRequest(BaseModel):
    description: str


class IncidentDecision(BaseModel):
    severity: Severity
    category: Category
    summary: str
    recommended_action: str


class IncidentTriage(IncidentDecision):
    needs_human_review: bool


class TriageResponse(BaseModel):
    request_id: str
    model: str
    triage: IncidentTriage


TRIAGE_INSTRUCTIONS = """
You triage incident reports for a SaaS support team.
Return one severity: P1 for a widespread outage, security incident, or data-loss risk;
P2 for major degradation affecting many users; P3 for a limited user-impacting issue;
or P4 for a question, feature request, or cosmetic issue.
Return one category: platform, identity, billing, or product.
Write a concise summary and one concrete recommended action.
Do not include secrets or unsupported facts.
""".strip()


@app.post("/triage", response_model=TriageResponse)
def triage_incident(incident: IncidentRequest) -> TriageResponse:
    response = client.responses.parse(
        model=MODEL,
        input=[
            {"role": "system", "content": TRIAGE_INSTRUCTIONS},
            {"role": "user", "content": incident.description},
        ],
        text_format=IncidentDecision,
    )
    decision = response.output_parsed
    triage = IncidentTriage(
        **decision.model_dump(),
        needs_human_review=decision.severity == Severity.p1,
    )
    return TriageResponse(request_id=str(uuid4()), model=MODEL, triage=triage)

What should match?

This reference shows the final file order. Matching it prevents the prototype route from surviving below the typed route.

  • Switch back to the terminal running the service.
  • Press Ctrl+C to stop the current container.
  • Rebuild the typed service by running this command:
docker compose up --build

What does the final rebuild apply?

This rebuild packages the response schema and structured route into the image. The restarted container serves the new contract through the existing local port.

You should see the service start normally. The typed endpoint is ready for its customer acceptance check.

Before you send the report, do you expect one free-form string or separate contract fields?

  • Return to the FastAPI interactive documentation from earlier at http://127.0.0.1:8000/docs.
  • Refresh the documentation page.
  • Expand POST /triage.
  • Enable the request editor.
  • Enter All customers receive HTTP 500 errors and cannot open the dashboard. in the description field.
  • Execute the triage request.

What should I see?

  • The top-level response contains request_id.
  • The top-level response contains model.
  • The triage object contains severity.
  • The triage object contains category.
  • The triage object contains summary.
  • The triage object contains recommended_action.
  • A P1 result sets needs_human_review to true.
  • The old raw_output field is gone.

Still seeing raw_output?

Confirm that the route decorator includes response_model=TriageResponse. Check that the function calls client.responses.parse().

Stop the running service before rebuilding. Refresh the documentation after the rebuilt container starts.

Help me troubleshoot the structured response

That closes the integration gap. Your API now turns an unstructured incident report into a validated operational decision.

Your API now produces a stable decision contract. Next, you'll correlate each request ID with service logs. You'll also add liveness reporting.

Add Operational Signals and Safe Failures

Your structured FastAPI service now gives the support team consistent triage fields. Customer engineers also need evidence that the service is alive.

This step adds traceable request IDs, operational logs, and safe HTTP responses for OpenAI failures. These signals turn the working integration into a service that engineers can operate.

In this step, get ready to:
  • Configure application logging with client timeout and retry settings.
  • Add request correlation with safe upstream-error responses.
  • Verify service health and correlate an API response with its logs.
Configure logs and client reliability

A production service needs consistent logging before individual requests can be traced. Timeout and retry settings also limit how the SDK handles a slow or interrupted upstream request.

  • In app/main.py, select the code from from enum import Enum through app = FastAPI(title="Incident Triage API", version="1.0.0").
  • Replace the selected code with the block below:
import logging
from enum import Enum
from uuid import uuid4

import openai
from fastapi import FastAPI, HTTPException
from openai import OpenAI
from pydantic import BaseModel

MODEL = "gpt-6-astra"

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("incident-triage")

client = OpenAI(timeout=30.0, max_retries=2)
app = FastAPI(title="Incident Triage API", version="1.0.0")

What does this setup do?

  • The logging.basicConfig() call gives every application log a timestamp and severity level.
  • The incident-triage logger keeps this service's events identifiable in the container output.
  • The timeout=30.0 setting limits how long the client waits for an upstream operation.
  • The max_retries=2 setting allows the client to retry eligible failures twice.
  • Save app/main.py.
  • Switch back to the PowerShell terminal running your Docker Compose service.
  • Press Ctrl+C to stop the current service.
  • Rebuild the image with the new client configuration by running:
docker compose up --build

What should happen?

Docker Compose rebuilds the image from your updated app/main.py. The service remains attached to this PowerShell terminal after it starts.

Service not starting after the rebuild?

Check the first Python-related line in the build output. Confirm that the import section in app/main.py matches the block above.

Confirm that Docker Desktop is still running before rebuilding again.

Help me diagnose the failed rebuild.

Add request correlation and safe failures

A UUID gives each triage request a unique correlation value. Logging that value at the start and end lets a customer engineer follow one request through the service without recording the incident description.

  • In app/main.py, select the start of triage_incident() from @app.post("/triage", response_model=TriageResponse) through the closing parenthesis of client.responses.parse().
  • Replace the selected code with the block below:
@app.post("/triage", response_model=TriageResponse)
def triage_incident(incident: IncidentRequest) -> TriageResponse:
    request_id = str(uuid4())
    logger.info("triage_started request_id=%s", request_id)

    try:
        response = client.responses.parse(
            model=MODEL,
            input=[
                {"role": "system", "content": TRIAGE_INSTRUCTIONS},
                {"role": "user", "content": incident.description},
            ],
            text_format=IncidentDecision,
        )
    except openai.APIError as error:
        logger.exception("triage_failed request_id=%s", request_id)
        raise HTTPException(
            status_code=503,
            detail="Triage service is temporarily unavailable.",
        ) from error

How does this protect the service?

  • The request_id value is created before the model request so every outcome has a correlation value.
  • The triage_started event proves that the service accepted the request without recording its body.
  • The except openai.APIError path records the internal failure with the same request ID.
  • The HTTPException returns status 503 with a safe message that does not expose credentials or upstream details.
  • Save app/main.py.
  • Switch back to the attached PowerShell terminal.
  • Press Ctrl+C to stop the service.
  • Load the request-start logging code by rebuilding the service:
docker compose up --build

What does this rebuild prove?

The rebuilt container starts with the request-correlation code installed. It is ready to log the next triage request.

Rebuild failing after the try block?

Check that except openai.APIError as error: aligns with try:. Python uses indentation to determine which statements belong to each branch.

Help me correct the request-handling block.

  • Return to http://127.0.0.1:8000/docs in the browser from earlier.
  • Submit the same outage report through POST /triage.

You'll receive the typed triage response from the previous step.

In the attached PowerShell output, you'll see a triage_started event containing a request ID.

The start event confirms that a request entered the service. The completion path now needs to return that same ID and record the outcome.

  • In app/main.py, select the code from decision = response.output_parsed through the current return TriageResponse(...) line.
  • Replace the selected code with the block below:
    decision = response.output_parsed
    if decision is None:
        logger.error("triage_unparseable request_id=%s", request_id)
        raise HTTPException(
            status_code=502,
            detail="The model did not return a usable triage decision.",
        )

    triage = IncidentTriage(
        **decision.model_dump(),
        needs_human_review=decision.severity == Severity.p1,
    )
    logger.info(
        "triage_completed request_id=%s severity=%s category=%s",
        request_id,
        triage.severity.value,
        triage.category.value,
    )
    return TriageResponse(request_id=request_id, model=MODEL, triage=triage)

How does the completion path work?

  • The decision is None check catches a response that cannot produce a usable typed decision.
  • Status 502 tells the caller that the service received an unusable upstream result.
  • The triage_completed event records the final severity and category without storing the incident body.
  • The response reuses request_id so the caller can locate the matching log events.
  • Save app/main.py.
  • Switch back to the attached PowerShell terminal.
  • Press Ctrl+C to stop the service.

Before you rebuild, do you expect the next successful request to use one request ID or several different IDs?

  • Load the completed correlation path by rebuilding the service:
docker compose up --build

What should happen now?

The rebuilt service creates one request ID for each triage call. That value now follows the request from its start log through its response and completion log.

  • Return to the interactive documentation in your browser.
  • Submit the outage report through POST /triage again.

You'll see a request_id in the API response. The same value appears beside triage_started and triage_completed in the attached logs.

Request IDs do not match?

Confirm that the return statement uses the existing request_id variable. Remove any remaining str(uuid4()) call from that return statement.

Help me trace the mismatched request IDs.

Add and verify the health endpoint

A liveness endpoint gives operators a fast way to confirm that the API process is responding. This check reports the configured model without making a billed model request.

  • In app/main.py, find the @app.post("/triage", response_model=TriageResponse) decorator.
  • Add the health path operation directly above that decorator:
@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok", "model": MODEL}

What does this endpoint report?

The GET /health path returns a small liveness response from the application process. The model field shows which model the service is configured to use.

  • Save app/main.py.

✔️ Awesome, I've got everything!

Your saved app/main.py now includes logging, request correlation, safe failure handling, and the health endpoint.

ⓧ I'd like to double check the full code

Compare your complete app/main.py with this reference:

import logging
from enum import Enum
from uuid import uuid4

import openai
from fastapi import FastAPI, HTTPException
from openai import OpenAI
from pydantic import BaseModel

MODEL = "gpt-6-astra"

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("incident-triage")

client = OpenAI(timeout=30.0, max_retries=2)
app = FastAPI(title="Incident Triage API", version="1.0.0")


class Severity(str, Enum):
    p1 = "P1"
    p2 = "P2"
    p3 = "P3"
    p4 = "P4"


class Category(str, Enum):
    platform = "platform"
    identity = "identity"
    billing = "billing"
    product = "product"


class IncidentRequest(BaseModel):
    description: str


class IncidentDecision(BaseModel):
    severity: Severity
    category: Category
    summary: str
    recommended_action: str


class IncidentTriage(IncidentDecision):
    needs_human_review: bool


class TriageResponse(BaseModel):
    request_id: str
    model: str
    triage: IncidentTriage


TRIAGE_INSTRUCTIONS = """
You triage incident reports for a SaaS support team.
Return one severity: P1 for a widespread outage, security incident, or data-loss risk;
P2 for major degradation affecting many users; P3 for a limited user-impacting issue;
or P4 for a question, feature request, or cosmetic issue.
Return one category: platform, identity, billing, or product.
Write a concise summary and one concrete recommended action.
Do not include secrets or unsupported facts.
""".strip()


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok", "model": MODEL}


@app.post("/triage", response_model=TriageResponse)
def triage_incident(incident: IncidentRequest) -> TriageResponse:
    request_id = str(uuid4())
    logger.info("triage_started request_id=%s", request_id)

    try:
        response = client.responses.parse(
            model=MODEL,
            input=[
                {"role": "system", "content": TRIAGE_INSTRUCTIONS},
                {"role": "user", "content": incident.description},
            ],
            text_format=IncidentDecision,
        )
    except openai.APIError as error:
        logger.exception("triage_failed request_id=%s", request_id)
        raise HTTPException(
            status_code=503,
            detail="Triage service is temporarily unavailable.",
        ) from error

    decision = response.output_parsed
    if decision is None:
        logger.error("triage_unparseable request_id=%s", request_id)
        raise HTTPException(
            status_code=502,
            detail="The model did not return a usable triage decision.",
        )

    triage = IncidentTriage(
        **decision.model_dump(),
        needs_human_review=decision.severity == Severity.p1,
    )
    logger.info(
        "triage_completed request_id=%s severity=%s category=%s",
        request_id,
        triage.severity.value,
        triage.category.value,
    )
    return TriageResponse(request_id=request_id, model=MODEL, triage=triage)

What should match?

Use this full-file reference to check import order, indentation, and route placement. Your health route should appear immediately before the triage route.

  • Switch back to the attached PowerShell terminal.
  • Press Ctrl+C to stop the service.
  • Rebuild the service with the health endpoint by running:
docker compose up --build

What does the final rebuild do?

Docker Compose packages the completed app/main.py into a fresh image. The running container now exposes both API paths.

Health route missing after the rebuild?

Confirm that you saved app/main.py before rebuilding. Check that @app.get("/health") starts at the left edge of the file.

Help me restore the health endpoint.

Before you check the health URL, which two fields do you expect the liveness response to contain?

  • Open http://127.0.0.1:8000/health in a new browser tab.

You'll see status set to ok. You'll also see model set to gpt-6-astra.

  • Return to http://127.0.0.1:8000/docs.
  • Submit the outage report through POST /triage.
  • Keep the API response visible in your browser.
  • Switch back to the attached PowerShell terminal.
  • Press Ctrl+C to stop the service.

Before you read the saved logs, do you expect the response ID to match both lifecycle events?

  • Display the service logs by running:
docker compose logs

What do the logs prove?

You'll find the API response's request ID in both triage_started and triage_completed entries. This correlation gives a customer engineer one value to follow across the request lifecycle.

Cannot find the matching request ID?

Confirm that you submitted a triage request after the final rebuild. Search the output for triage_started to locate the request's first event.

Help me correlate the API response with the logs.

You have given the customer a service they can check, trace, and troubleshoot without exposing incident text or credentials. That operational path now works from the API response to the container logs.

Your deployment now has the operational signals needed for customer use. Next, you'll turn those capabilities into measurable acceptance evidence.

Prove Readiness with Customer Acceptance Tests

Your FastAPI service now returns validated decisions with traceable request IDs. Customer engineers still need evidence that representative incidents satisfy the response contract.

Acceptance tests turn readiness into repeatable evidence. A handoff gives the customer a practical operating guide.

In this step, get ready to:
  • Add three representative incidents to an acceptance harness.
  • Measure contract compliance separately from severity agreement.
  • Document how the customer runs and evaluates the deployment.
Add representative incident cases

A useful acceptance suite reflects the customer workflow. These cases cover an urgent outage, a limited sign-in problem, and a feature request.

  • Return to Visual Studio Code from earlier.
  • Create eval_cases.py inside the incident-triage-api folder with the Explorer sidebar.
  • Add the imports and representative cases by pasting this code:
from app.main import IncidentRequest, Severity, triage_incident

CASES = [
    {
        "name": "widespread outage",
        "description": "All customers receive HTTP 500 errors and cannot open the dashboard.",
        "expected_severity": Severity.p1,
    },
    {
        "name": "single-user sign-in issue",
        "description": "One user cannot sign in after forgetting their password.",
        "expected_severity": Severity.p3,
    },
    {
        "name": "feature request",
        "description": "A customer asks for a dark mode option in account settings.",
        "expected_severity": Severity.p4,
    },
]

What Do These Cases Cover?

  • The widespread outage case expects Severity.p1 because every customer loses access.
  • The sign-in case expects Severity.p3 because the report affects one user.
  • The feature request expects Severity.p4 because it describes a product suggestion.
  • Save eval_cases.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm eval_cases.py appears beside Dockerfile in the Explorer sidebar.

Seeing Problems in the New File?

Check that eval_cases.py sits directly inside incident-triage-api. Confirm each incident dictionary ends with a comma.

Help me check the acceptance cases.

Measure customer acceptance

The suite measures two forms of readiness. Contract compliance proves that each response contains the required operational fields.

Semantic agreement compares the model's severity with the expected customer label. Keeping these scores separate makes the evidence honest.

  • Place your cursor below the closing ] in eval_cases.py.
  • Add the scoring loop by pasting this code:
contract_passes = 0
semantic_matches = 0

for case in CASES:
    result = triage_incident(IncidentRequest(description=case["description"]))
    contract_ok = all(
        [
            bool(result.request_id),
            bool(result.triage.summary),
            bool(result.triage.recommended_action),
            result.triage.needs_human_review
            == (result.triage.severity == Severity.p1),
        ]
    )
    severity_match = result.triage.severity == case["expected_severity"]
    contract_passes += int(contract_ok)
    semantic_matches += int(severity_match)
    print(
        f"{case['name']}: expected={case['expected_severity'].value} "
        f"actual={result.triage.severity.value} contract_ok={contract_ok}"
    )

print(f"Contract score: {contract_passes}/{len(CASES)}")
print(f"Semantic match score: {semantic_matches}/{len(CASES)}")

How Does the Scoring Work?

  • Each case calls triage_incident() through the same application path used by the API.
  • The contract_ok check confirms that the request ID and operational fields contain usable values.
  • The human-review check confirms that every P1 decision triggers review.
  • The final counters report structural reliability separately from severity agreement.
  • Save eval_cases.py.

Seeing a Python Warning?

Compare the indentation inside the for loop with the code above. Check that both final score lines sit outside the loop.

Help me fix the acceptance script.

✔️ Awesome, I've got everything!

Your eval_cases.py file now contains three cases and two separate acceptance scores. Make sure the file is saved.

ⓧ I'd like to double check the full code

from app.main import IncidentRequest, Severity, triage_incident

CASES = [
    {
        "name": "widespread outage",
        "description": "All customers receive HTTP 500 errors and cannot open the dashboard.",
        "expected_severity": Severity.p1,
    },
    {
        "name": "single-user sign-in issue",
        "description": "One user cannot sign in after forgetting their password.",
        "expected_severity": Severity.p3,
    },
    {
        "name": "feature request",
        "description": "A customer asks for a dark mode option in account settings.",
        "expected_severity": Severity.p4,
    },
]

contract_passes = 0
semantic_matches = 0

for case in CASES:
    result = triage_incident(IncidentRequest(description=case["description"]))
    contract_ok = all(
        [
            bool(result.request_id),
            bool(result.triage.summary),
            bool(result.triage.recommended_action),
            result.triage.needs_human_review
            == (result.triage.severity == Severity.p1),
        ]
    )
    severity_match = result.triage.severity == case["expected_severity"]
    contract_passes += int(contract_ok)
    semantic_matches += int(severity_match)
    print(
        f"{case['name']}: expected={case['expected_severity'].value} "
        f"actual={result.triage.severity.value} contract_ok={contract_ok}"
    )

print(f"Contract score: {contract_passes}/{len(CASES)}")
print(f"Semantic match score: {semantic_matches}/{len(CASES)}")

What Should You Compare?

Confirm the three cases appear before the scoring counters. Check that the two score lines appear after the loop.

The container image needs the saved acceptance script before a customer can run it. After the rebuild finishes, the service starts in the foreground.

  • Switch back to PowerShell in the incident-triage-api folder.
  • Rebuild and start the service by running this command:
docker compose up --build

What Does This Command Do?

Docker Compose rebuilds the image so the new eval_cases.py file is copied into it. The command then starts the API with the existing service configuration.

You'll see the image build complete. The terminal then shows service startup logs from the running API.

Did the Rebuild Stop?

Confirm eval_cases.py is saved beside Dockerfile. A missing file prevents the Dockerfile from copying the acceptance script.

Help me troubleshoot the container rebuild.

  • Stop the foreground service by pressing Ctrl+C after the startup logs appear.
Write the deployment handoff

Acceptance evidence becomes useful when another engineer can reproduce it. The handoff captures the customer problem, operating commands, acceptance criteria, and known limits.

  • Create HANDOFF.md inside the incident-triage-api folder with Visual Studio Code's Explorer sidebar.
  • Add the customer handoff by pasting this content:
# Incident Triage API Handoff

## Customer problem

Support agents receive incident reports as inconsistent prose. The API converts each report into a stable triage contract for routing and escalation.

## Runbook

- Start or rebuild: `docker compose up --build`
- Interactive API documentation: `http://127.0.0.1:8000/docs`
- Liveness check: `http://127.0.0.1:8000/health`
- Acceptance run: `docker compose run --rm api python eval_cases.py`
- Service logs: `docker compose logs`
- Stop and remove local runtime resources: `docker compose down`

## Acceptance criteria

- Every successful response contains a request ID and the documented triage fields.
- Every P1 result sets `needs_human_review` to `true`.
- The acceptance script reports contract compliance separately from semantic agreement.

## Current limits

- This demo has no authentication or persistent storage.
- Semantic classifications can still be wrong even when the response matches the schema.
- The liveness endpoint confirms that the API process is running, not that the upstream model is reachable.
- Production rollout would require authentication, authorization, rate limits, monitoring, privacy review, and a larger domain-reviewed evaluation set.

What Does the Handoff Provide?

  • The customer problem keeps the deployment tied to routing and escalation impact.
  • The runbook gives the customer repeatable commands for operation and verification.
  • The acceptance criteria define the evidence required for a successful deployment.
  • The limits identify the work required before a production rollout.
  • Save HANDOFF.md.
  • Confirm the rendered Markdown shows four sections with readable headings and bullet lists.

Does the Handoff Look Misaligned?

Check that each heading starts at the beginning of its line. Keep a blank line between every heading and its body content.

Help me check the handoff formatting.

✔️ Awesome, I've got everything!

Your HANDOFF.md file now gives a customer engineer the complete operating and acceptance guide. Make sure the file is saved.

ⓧ I'd like to double check the full code

# Incident Triage API Handoff

## Customer problem

Support agents receive incident reports as inconsistent prose. The API converts each report into a stable triage contract for routing and escalation.

## Runbook

- Start or rebuild: `docker compose up --build`
- Interactive API documentation: `http://127.0.0.1:8000/docs`
- Liveness check: `http://127.0.0.1:8000/health`
- Acceptance run: `docker compose run --rm api python eval_cases.py`
- Service logs: `docker compose logs`
- Stop and remove local runtime resources: `docker compose down`

## Acceptance criteria

- Every successful response contains a request ID and the documented triage fields.
- Every P1 result sets `needs_human_review` to `true`.
- The acceptance script reports contract compliance separately from semantic agreement.

## Current limits

- This demo has no authentication or persistent storage.
- Semantic classifications can still be wrong even when the response matches the schema.
- The liveness endpoint confirms that the API process is running, not that the upstream model is reachable.
- Production rollout would require authentication, authorization, rate limits, monitoring, privacy review, and a larger domain-reviewed evaluation set.

What Should You Compare?

Confirm the runbook contains all six commands. Check that the acceptance criteria and current limits match the deployment you built.

This final check makes three billed requests to the OpenAI API. The fixed three-case suite keeps the acceptance run bounded.

Before you run the suite, which score do you expect to stay deterministic? Which score can vary with the model's classification?

  • Run the customer acceptance suite from PowerShell in the incident-triage-api folder with this command:
docker compose run --rm api python eval_cases.py

What Does This Acceptance Run Prove?

The command starts a one-off container with the API service configuration. The --rm option removes that container after the script finishes.

Each case passes through triage_incident(). This tests the same validation and human-review logic used by the API.

You'll see one line for each case with its expected severity, actual severity, and contract result. The final two lines report the contract score and semantic match score.

A healthy contract run shows Contract score: 3/3. The semantic score may differ because model classifications can vary.

You now have acceptance evidence that a customer engineer can rerun directly from the handoff.

Did the Acceptance Run Stop Early?

  • Confirm your local .env file still contains the API key without displaying or sharing it.
  • Repeat the container rebuild from earlier before rerunning the acceptance command.

Help me diagnose the acceptance run.

Secret mission

Add an Offline Fallback Mode

Your triage contract works while the model API is available. Can you preserve basic incident routing during an upstream outage while making the degraded decision source visible?

Clean Up Your Resources

Clean Up Your Resources

Your Docker resources stay on your Windows machine. OpenAI API requests are usage-based, so choose the cleanup option that matches what you plan to do next.

Cost warning

GPT-6 Astra input is listed at $10 / Input MTok. Output is listed at $50 / Output MTok as of 2026-09-14.

  • Set a project spend limit before expanding the acceptance suite or sharing the API.
  • Monitor your OpenAI Platform usage while the deployment remains available.

Resources you used:

  • The api container and network managed by Docker Compose.
  • The local Docker image built from Dockerfile.
  • The incident-triage-api folder with the application files and customer handoff.
  • The local .env file containing your OpenAI API key.
  • The OpenAI Platform API key used by the deployment.

Keep everything running

No resources need to be removed. Choose this if you are still testing the incident triage API or demonstrating it to a customer engineer.

  • Return to PowerShell in your existing incident-triage-api folder.
  • Keep the API running in the background by running:
docker compose up --detach

This starts the api service in the background. PowerShell remains available for other commands.

  • Open http://127.0.0.1:8000/health in your browser.

You'll see the service status and model name. Model charges occur only when a request reaches OpenAI.

Pause - I'll come back to this later

Shut down the local runtime to free system resources. Your image and project files remain available for another session.

  • Return to PowerShell in your existing incident-triage-api folder.
  • Stop the local deployment by running:
docker compose down

This stops and removes the Compose container and network. It keeps your Docker image and the files inside incident-triage-api.

  • Use the command in the Keep tab when you are ready to restart the deployment.

Delete - I don't want to use this again

Deletion is permanent. Your source files and customer handoff disappear when you remove the project folder.

  • Copy HANDOFF.md elsewhere if you want to keep the operating notes.
  • Return to PowerShell in your existing incident-triage-api folder.
  • Remove the Compose container and network by running:
docker compose down

This removes the runtime resources created by Compose. The locally built image remains until you delete it separately.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop to find the application.
  • Press Enter to open Docker Desktop.
  • Select Images in the left sidebar.
  • Locate the image associated with the api service.
  • Use the image's delete control to remove it.
  • Confirm that the image no longer appears in Docker Desktop.
  • Open the official OpenAI API-key page.
  • Locate the project API key referenced by .env.
  • Use the key's revocation control to prevent future requests with that credential.
  • Confirm that the key is no longer active.
  • Return to PowerShell in the existing incident-triage-api folder.
  • Remove the local project folder by running these commands:
cd ..
Remove-Item -Recurse -Force incident-triage-api

The first command moves PowerShell to the folder containing incident-triage-api. The second command permanently removes the project files and local .env file.

  • Confirm that incident-triage-api no longer appears in File Explorer.

Nice Work!

Nice Work!

You made it! Your customer-ready incident triage deployment now turns unstructured reports into validated routing decisions.

You've learned how to:

  • Build a customer incident triage API with FastAPI that turns support reports into operational decisions.
  • Replace free-form model text with a Pydantic response contract powered by OpenAI Structured Outputs.
  • Package the service as a reproducible Docker Compose deployment. Prove customer readiness with acceptance scores.
  • Secret Mission: Keep triage available during upstream failures with a deterministic offline fallback. Preserve the response contract while labeling every degraded result as fallback.

Ready to quiz yourself?