Deploy an AI Incident Analyzer

Deploy a structured AI API for industrial incident analysis on Azure.

Introduction

30 Second Summary

Industrial incident reports often arrive as dense notes written during a stressful shift. The next decision depends on turning those notes into clear facts quickly.

In this project, you will build QualityLens AI as a FastAPI REST API for converting written incident reports into validated records. The finished service runs from a Docker Image at a public Azure Container Apps URL.

What You'll Build

Picture pasting a conveyor incident report into public interactive documentation and watching a predictable JSON record appear with the details needed to route the incident.

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

  • A working industrial analysis API whose health response and interactive documentation you can open from your browser.
  • A clear free-form versus structured output demo where the first request produces one prose string. The upgraded request uses Structured Outputs with Pydantic to return five validated fields for downstream software.
  • A containerized cloud service that runs the same application at a public URL. Your OpenAI API key stays outside the source code throughout the deployment.
  • Secret Mission: Add deterministic human-review routing that flags high-severity or low-confidence analyses with a clear reason.

Are there any prerequisites?

You need an Azure subscription with permission to create resources. You also need an OpenAI API account with billing or credits enabled.

This walkthrough uses Windows with Python, Visual Studio Code, Git, Docker Desktop, and PowerShell.

Before We Start

Before any hands-on work begins, this is your moment to define QualityLens AI as a focused industrial incident-analysis MVP. A clear user and decision goal give every technical choice a purpose.

Set Up QualityLens on Windows

QualityLens will eventually send incident reports to the OpenAI API. It will later run in Azure Container Apps.

That path needs an isolated Python environment. The environment prevents this project's package versions from affecting other projects.

You also need authenticated access through Azure CLI. Your OpenAI key stays outside source control in one PowerShell session.

In this step, get ready to:
  • Authenticate Azure CLI against your active Azure subscription.
  • Prepare the isolated qualitylens-ai workspace using the pinned packages.
  • Load the OpenAI key into the current PowerShell session.
Install and authenticate the Azure CLI

Azure CLI gives PowerShell a command-line connection to your Azure subscription. This project uses version 2.91.0 or later.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check the installed Azure CLI version by running this command:
az version

What does this command check?

The command asks Azure CLI to report its installed components. The result tells you whether the required command-line tool is available.

✔️ I see version 2.91.0 or higher

Your Azure CLI version meets the project requirement. Continue with authentication below.

ⓧ I see an older version

The installed Azure CLI needs an update before deployment commands can run reliably.

  • Install the latest Azure CLI release by running this command:
winget install --exact --id Microsoft.AzureCLI

What does this command do?

WinGet finds the official Microsoft Azure CLI package. It installs the latest available release on Windows.

  • Close PowerShell after the installation finishes.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell session.
  • Confirm the updated Azure CLI version by running:
az version

Why open a fresh session?

A fresh PowerShell session reloads the commands available on your system. The version report should now show Azure CLI 2.91.0 or later.

ⓧ Command not found

Azure CLI is missing from Windows. WinGet can install the official Microsoft package.

  • Install Azure CLI by running this command:
winget install --exact --id Microsoft.AzureCLI

What does this command do?

WinGet downloads the official Azure CLI package. It then registers the az command with Windows.

  • Close PowerShell after the installation finishes.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell session.
  • Confirm Azure CLI is available by running:
az version

Why repeat the check?

The repeated check proves Windows can now find Azure CLI. The report should include version 2.91.0 or later.

Still unable to run Azure CLI?

Confirm that you closed every PowerShell window after installation. A terminal that stayed open cannot see the newly installed command.

If a fresh window still cannot find Azure CLI, help me troubleshoot the Windows installation.

Authentication connects Azure CLI to the subscription that receives your later deployment. The sign-in command opens a browser so you can complete authentication without entering credentials in PowerShell.

  • Start the Azure sign-in flow by running this command:
az login

What does this command do?

Azure CLI starts an interactive browser sign-in. The authenticated session returns to PowerShell after the browser flow finishes.

  • Complete the authentication flow in the browser window.
  • Return to PowerShell after the sign-in completes.
  • Confirm the active subscription by running this command:
az account show --output table

What should the table confirm?

The table identifies the subscription that Azure CLI currently uses. Later deployment commands create resources under this active subscription.

You should see one active subscription in the table. Your Azure CLI session is now authenticated.

Unable to confirm a subscription?

Complete the browser flow with the Azure account that owns your subscription. Return to PowerShell only after the browser confirms authentication.

If the account table remains unavailable, help me troubleshoot Azure CLI authentication.

Build the isolated project workspace

A virtual environment gives QualityLens its own dependency set. Python 3.10 or later is required by every pinned package in this project.

  • Check the Python version in your current PowerShell session by running:
python --version

Why check Python first?

The virtual environment inherits the Python runtime that creates it. Checking first prevents an unsupported runtime from being locked into .venv.

✔️ I see version 3.10 or higher

Your Python runtime supports all four pinned dependencies. Continue with the project folder below.

ⓧ I see an older version

The current Python runtime is too old for this dependency set. Install a supported release before creating .venv.

  • Open the official Python downloads page for Windows.
  • Install a current Python release that is 3.10 or later.
  • Close PowerShell after the guided installation finishes.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell session.
  • Confirm the new Python version by running:
python --version

What should the new session show?

The fresh session should report Python 3.10 or later. That runtime is ready to create the project environment.

ⓧ Command not found

Windows cannot find a Python runtime. Install a supported release before creating the project environment.

  • Open the official Python downloads page for Windows.
  • Install a current Python release that is 3.10 or later.
  • Close PowerShell after the guided installation finishes.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell session.
  • Confirm Python is available by running:
python --version

What should the check prove?

The version check proves Windows can find Python. The reported version should be 3.10 or later.

Still seeing the wrong Python version?

Close every PowerShell window after installing Python. Run the check from a fresh window so Windows reloads the available commands.

If the older runtime still appears, help me select the supported Python installation.

  • Move to your Desktop by running this command:
sl desktop

What does this command do?

The command changes PowerShell's current location to your Desktop. Creating the project there makes the folder easy to find.

  • Create the project folder by running these commands:
New-Item -Path . -Name qualitylens-ai -ItemType Directory
sl .\qualitylens-ai

What do these commands create?

The first command creates qualitylens-ai on your Desktop. The second command moves PowerShell into that folder.

  • Initialize the local Git repository by running:
git init

What does Git initialize?

Git creates repository metadata inside qualitylens-ai. This lets the project track source files while ignoring local environments.

The virtual environment now needs to use the supported Python runtime you just checked. Activation makes its interpreter the default for commands in this PowerShell session.

  • Create the virtual environment by running these commands:
python -m venv .venv
.\.venv\Scripts\Activate.ps1

What do these commands do?

Python creates an isolated environment inside .venv. The activation script updates this PowerShell session to use that environment.

You should see (.venv) at the start of the PowerShell prompt. That prefix confirms the isolated environment is active.

Virtual environment not active?

Confirm that the environment creation command finished before running the activation script. Also confirm PowerShell is still inside qualitylens-ai.

If PowerShell blocks the activation script, help me activate this Windows virtual environment safely.

The dependency file locks the exact library versions used throughout QualityLens. A shared file makes local runs plus later container builds use the same package set.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the qualitylens-ai folder on your Desktop.
  • Click Select Folder.
  • Create requirements.txt from the Explorer sidebar.
  • Paste this dependency list into requirements.txt.
fastapi==0.159.1
openai==3.36.1
pydantic==2.14.1
uvicorn==0.54.0

What are these packages?

  • FastAPI defines the web API plus its interactive documentation.
  • The OpenAI SDK sends incident reports to the model.
  • Pydantic validates the request plus response data contracts.
  • Uvicorn runs the FastAPI application locally.
  • Save requirements.txt.
  • Switch back to the activated PowerShell session from earlier.
  • Install the pinned dependencies into .venv by running:
python -m pip install -r requirements.txt

What does this installation change?

Python reads each version pin from requirements.txt. It installs those packages inside the active .venv environment.

Dependency installation stopped?

Confirm that the PowerShell prompt still starts with (.venv). Also confirm the session is inside qualitylens-ai.

If installation still fails, help me troubleshoot the pinned Python dependencies.

The ignore file protects local-only artifacts from accidental Git staging. It excludes the virtual environment plus common Python cache files.

  • Return to Visual Studio Code.
  • Create .gitignore from the Explorer sidebar.
  • Paste these ignore rules into .gitignore.
.venv/
__pycache__/
*.pyc
.env

What do these rules protect?

The rules keep the local environment plus generated Python caches out of Git. They also prevent a future .env file from being staged.

  • Save .gitignore.
  • Switch back to PowerShell.
  • Check the working tree in short format by running:
git status -s

What should Git report?

Git should list .gitignore plus requirements.txt as untracked files. It should omit .venv because the ignore rule is active.

✔️ I have both project files

Both files are saved with the required contents. Your dependency contract plus ignore rules are ready.

ⓧ I'd like to double check the full code

Compare each saved file with these complete versions.

requirements.txt

fastapi==0.159.1
openai==3.36.1
pydantic==2.14.1
uvicorn==0.54.0

.gitignore

.venv/
__pycache__/
*.pyc
.env
Load the OpenAI key and verify the setup

An API key authorizes requests against your OpenAI account. The SDK reads OPENAI_API_KEY from the process environment at runtime.

This action handles a live credential, so keep the full value off-screen whenever you share screenshots. The key stays only in the current PowerShell session.

  • Open the OpenAI API keys page.
  • Click Create new secret key.
  • Create the key using the dashboard form.
  • Copy the full key when the dashboard reveals it.
  • Return to the activated PowerShell session from earlier.
  • Replace your-api-key-here with the copied key in this command:
$Env:OPENAI_API_KEY = "your-api-key-here"

Why store the key this way?

PowerShell stores the key in the current process environment. Closing this PowerShell window removes the session value without writing it into a project file.

  • Keep this PowerShell session open for the next steps.

Before you run the final check, how many tool versions plus package versions do you expect PowerShell to report?

  • Verify the complete setup by running these commands:
python --version; git --version; docker --version; az version
python -c "import fastapi, openai, pydantic, uvicorn; print(fastapi.__version__, openai.__version__, pydantic.__version__, uvicorn.__version__)"

What should the checks prove?

  • Python should report version 3.10 or later.
  • Git should print an installed version.
  • Docker should print an installed version.
  • Azure CLI should include version 2.91.0 or later.
  • The package check should print 0.159.1 3.36.1 2.14.1 0.54.0.

That is the foundation locked in. Your tools can now run the same pinned QualityLens environment.

One of the final checks failed?

Confirm that the PowerShell prompt still starts with (.venv). A missing prefix means the package check is using a different Python environment.

For a missing tool or mismatched package version, help me diagnose the final setup check.

Your toolchain is ready for real work. Next, you will ship a visible FastAPI health endpoint.

Ship the First Visible API

Your Windows environment is ready. Your OpenAI secret remains confined to the current PowerShell session.

Before model calls add complexity, a minimal FastAPI service proves the request path from Uvicorn to your browser. You will expose a health response plus interactive API documentation.

In this step, get ready to:
  • Create the FastAPI application with a health route in main.py.
  • Run the application through Uvicorn's development server.
  • Verify the health response plus the interactive API documentation.
Create the health endpoint

A health endpoint gives clients a quick way to confirm that the service can receive requests. This first route isolates the web layer before AI behavior enters the picture.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Choose the folder-opening option from the Visual Studio Code welcome screen.
  • Select the existing qualitylens-ai folder.

Your existing .gitignore plus requirements.txt files should appear in the file sidebar. This confirms that Visual Studio Code has the correct folder.

  • Use the new-file control in the file sidebar to create main.py directly inside qualitylens-ai.
  • In the empty main.py file, paste this code:
from fastapi import FastAPI

app = FastAPI(title="QualityLens AI", version="1.0.0")


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

What does this code do?

  • The FastAPI import gives the file access to the application class.
  • The app variable stores the application object that Uvicorn imports.
  • The @app.get("/health") decorator maps browser requests for /health to the handler below it.
  • The health() handler returns a predictable status response.

Why FastAPI Instead of Flask?

FastAPI turns Python type hints into API contracts. It also generates interactive documentation from application routes.

Those built-in capabilities fit QualityLens's typed boundary with less framework wiring than Flask.

  • Save main.py.
  • Confirm that main.py appears next to .gitignore plus requirements.txt in the file sidebar.

Does main.py Look Wrong?

Check that the file is named main.py without an extra text extension. Make sure the decorator starts at the left edge of the file.

Help me check my QualityLens main.py file for naming or indentation problems.

Start the development server

Your activated virtual environment supplies the pinned server package. The PowerShell session from earlier also holds your OpenAI secret for later steps.

  • Switch back to the PowerShell window from earlier.

Before you run the server, do you expect Uvicorn to import app cleanly from main.py? Keep your prediction in mind.

  • Start the development server by running this command:
uvicorn main:app --reload

What does this command do?

  • The uvicorn command launches the local server process.
  • The main:app value tells Uvicorn to import app from main.py.
  • The --reload option restarts the development server after source-file changes.
  • Leave the PowerShell window running while you test the service.
  • Confirm that the terminal displays startup messages for the local server.

You should see Uvicorn remain active with the service available at http://127.0.0.1:8000.

Did the Server Fail to Start?

Check that main.py is directly inside qualitylens-ai. Confirm that your PowerShell prompt still shows the activated virtual environment.

Help me troubleshoot why uvicorn main:app --reload cannot start my QualityLens API.

Verify the request path

The /health route is a lightweight health check. The documentation page shows how FastAPI turns your application metadata plus routes into a browser interface.

Before you check the endpoint, what response do you expect from health()? Keep that prediction in mind.

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

You should see {"status":"ok"} in the browser.

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

You should see interactive API documentation titled QualityLens AI with GET /health listed.

You have your first visible win. QualityLens now answers browser requests through its API boundary.

What Did You Just Prove?

  • Your browser can send a request to the local Uvicorn server.
  • Uvicorn can import the app object from main.py.
  • FastAPI can match /health to the correct handler.
  • The handler can return a response that another client can consume.

✔️ Awesome, I've got everything!

Great work. Save main.py while the server continues running.

ⓧ I'd like to double check the full code

Compare each current project file with these exact contents.

.gitignore.

.venv/
__pycache__/
*.pyc
.env

main.py.

from fastapi import FastAPI

app = FastAPI(title="QualityLens AI", version="1.0.0")


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

requirements.txt.

fastapi==0.159.1
openai==3.36.1
pydantic==2.14.1
uvicorn==0.54.0
  • Keep the PowerShell window open so Uvicorn continues serving QualityLens.

Your local API boundary is working from browser to handler. Next, you will connect QualityLens to its first model call plus observe the integration cost of receiving one free-form paragraph.

Expose Free-Form AI Output

Your health route already proves that requests can reach the QualityLens FastAPI service from the browser.

An industrial workflow needs separate values for severity, equipment, causes, action, and confidence. You will first see what happens when an OpenAI model returns one prose response.

In this step, get ready to:
  • Define the incident request contract plus the runtime OpenAI client.
  • Expose POST /analyze-naive with provider error handling.
  • Submit a Line 3 incident report to reveal the free-form integration problem.
Prepare the OpenAI request layer

The API needs a request shape before it can accept incident text. It also needs a client that reads OPENAI_API_KEY from the current PowerShell environment.

  • Return to main.py in Visual Studio Code.
  • Select everything above @app.get("/health").
  • Replace the selected code by pasting this block:
import openai
from fastapi import FastAPI, HTTPException
from openai import OpenAI
from pydantic import BaseModel

app = FastAPI(title="QualityLens AI", version="1.0.0")
MODEL = "gpt-6-luna"


class IncidentInput(BaseModel):
    report: str


def openai_client() -> OpenAI:
    return OpenAI()

What Does This Code Do?

  • IncidentInput uses a Pydantic model to require one string named report.
  • MODEL keeps the gpt-6-luna identifier in one place.
  • openai_client() creates the SDK client when the route needs it. The client reads the API key from the environment.
  • HTTPException gives the route a controlled way to return an API error.
  • Save main.py.
  • Refresh http://127.0.0.1:8000/health in your browser.

You should still see {"status":"ok"}. This confirms Uvicorn reloaded the new imports plus request setup without breaking the existing route.

Good progress. QualityLens can now describe an incident request and create its model client at runtime.

Does the Health Route Stop Loading?

  • Check that each import in main.py matches the code block exactly.
  • Confirm that .venv remains activated in the PowerShell session running Uvicorn.
  • Review the PowerShell output for the line number that failed during reload.

Ask for help with the reload failure.

Add the free-form analysis route

The first analysis route sends the report through the Responses API. Its prompt asks for one concise paragraph.

  • Place your cursor below the existing health() function in main.py.
  • Add the free-form route by pasting this code:
@app.post("/analyze-naive")
def analyze_naive(incident: IncidentInput) -> dict[str, str]:
    try:
        response = openai_client().responses.create(
            model=MODEL,
            instructions=(
                "Analyze the industrial incident report and return one concise "
                "paragraph covering severity, equipment, likely causes, and next action."
            ),
            input=incident.report,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

    return {"analysis": response.output_text}

How Does the Route Work?

  • @app.post("/analyze-naive") exposes a POST route for incident reports.
  • responses.create(...) sends the report plus the paragraph instruction to the selected model.
  • store=False disables storage for later API retrieval.
  • openai.APIError catches provider failures. HTTPException converts them into an HTTP 502 response.
  • response.output_text becomes the single analysis value returned to the caller.
  • Save main.py.
  • Refresh http://127.0.0.1:8000/docs in your browser.

You should see POST /analyze-naive listed beside the existing health route. The visible route confirms that Uvicorn loaded the new handler.

Is the New Route Missing?

  • Confirm that @app.post("/analyze-naive") starts at the left edge of main.py.
  • Check the PowerShell output for a reload failure after your last save.
  • Refresh the documentation page after Uvicorn finishes reloading.

Ask for help finding why the endpoint is missing.

✔️ Awesome, I've got everything!

Your new endpoint is visible. Make sure main.py is saved before testing it.

ⓧ I'd like to double check the full code

Compare each project file with these exact contents.

main.py

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

app = FastAPI(title="QualityLens AI", version="1.0.0")
MODEL = "gpt-6-luna"


class IncidentInput(BaseModel):
    report: str


def openai_client() -> OpenAI:
    return OpenAI()


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


@app.post("/analyze-naive")
def analyze_naive(incident: IncidentInput) -> dict[str, str]:
    try:
        response = openai_client().responses.create(
            model=MODEL,
            instructions=(
                "Analyze the industrial incident report and return one concise "
                "paragraph covering severity, equipment, likely causes, and next action."
            ),
            input=incident.report,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

    return {"analysis": response.output_text}

.gitignore

.venv/
__pycache__/
*.pyc
.env

requirements.txt

fastapi==0.159.1
openai==3.36.1
pydantic==2.14.1
uvicorn==0.54.0
Submit the incident report

The endpoint is ready for its first realistic report. This request tests whether a concise paragraph gives another application a dependable result shape.

Before you send the report, do you think the response will expose individual fields or one combined result?

  • Switch back to http://127.0.0.1:8000/docs in your browser.
  • Expand POST /analyze-naive.
  • Click Try it out.
  • Replace the request body by pasting this incident report:
{
  "report": "During final inspection of Line 3, the conveyor motor temperature reached 92 C, vibration increased, and two units showed misalignment. The operator stopped the line."
}

What Does This Report Test?

This JSON request includes a temperature reading, increased vibration, unit misalignment, and an operator shutdown. The model has enough evidence to discuss equipment risk plus a next action.

  • Click Execute to send the report.

You should receive one analysis property containing a prose paragraph. You will not receive separate severity, equipment, cause, action, or confidence fields.

Why Is One String a Problem?

A person can interpret the paragraph. A dashboard or workflow has to guess where each fact begins.

Small wording changes can break that guess. The response needs stable fields before another system can depend on it.

Does the Request Fail?

  • Confirm that OPENAI_API_KEY remains available in the PowerShell session running Uvicorn.
  • Check that the current environment has network access to the provider.
  • Review the PowerShell output if the endpoint returns HTTP 502.

Ask for help diagnosing the provider request.

You have made the integration gap visible. Next, you will replace the loose paragraph with a validated incident contract that downstream systems can consume.

Enforce Structured AI Output

Your naive endpoint exposed the integration problem. A useful paragraph still forces every downstream client to interpret prose.

In this step, you will use OpenAI Structured Outputs with a Pydantic schema. The route returns validated JSON through a stable five-field object.

In this step, get ready to:
  • Define the incident-analysis response contract.
  • Add a route that parses model output into the contract.
  • Compare the structured response with the earlier prose response.
Define the incident contract

A schema defines the names and types every successful analysis must contain. Literal narrows severity to four approved values.

  • In main.py, find the first import:
import openai

Why start from this line?

This existing import gives you an exact anchor at the top of main.py. The new type import belongs directly above it.

  • Replace that import anchor with these lines:
from typing import Literal

import openai

What does Literal add?

The Literal type lets the schema accept a fixed set of severity labels. An unexpected label cannot satisfy this contract.

  • In main.py, place the following model below IncidentInput:
class IncidentAnalysis(BaseModel):
    severity: Literal["low", "medium", "high", "critical"]
    affected_equipment: list[str]
    suspected_causes: list[str]
    recommended_action: str
    confidence: float

What does this contract define?

  • The severity field accepts low, medium, high, or critical.
  • The affected_equipment field stores equipment names as a list of strings.
  • The suspected_causes field stores possible causes as a list of strings.
  • The recommended_action field stores the proposed next action as text.
  • The confidence field stores the model confidence as a number.
  • Save main.py.
  • Visit http://127.0.0.1:8000/health in your browser.

You should still see {"status":"ok"}. Uvicorn has reloaded the file without a Python syntax failure.

Health route stopped responding?

Switch back to the PowerShell window from earlier. Check that from typing import Literal sits above import openai.

Compare the indentation of every field inside IncidentAnalysis. Help me fix the schema in main.py.

Add the structured route

The Responses API can parse model output against your schema. The responses.parse call returns the validated object through output_parsed.

The route is easiest to assemble in two valid Python sections. The first section sends the provider request through the existing error boundary.

  • In main.py, find return {"analysis": response.output_text} at the end of analyze_naive().
  • Add the following route directly below that line:
@app.post("/analyze")
def analyze(incident: IncidentInput) -> IncidentAnalysis:
    try:
        response = openai_client().responses.parse(
            model=MODEL,
            input=[
                {
                    "role": "system",
                    "content": (
                        "You analyze industrial quality and maintenance incident reports. "
                        "Use only the supplied report. Use empty lists when equipment or "
                        "causes are not stated. Set confidence from 0.0 to 1.0."
                    ),
                },
                {"role": "user", "content": incident.report},
            ],
            text_format=IncidentAnalysis,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

What does this request do?

  • The @app.post("/analyze") decorator creates the production analysis endpoint.
  • The system message limits the analysis to the supplied report.
  • The text_format=IncidentAnalysis argument applies your response schema.
  • The store=False argument disables storage for later API retrieval.
  • The exception handler converts provider failures into an HTTP 502 response.
  • Add the parsed-response guard directly below the exception block:
    if response.output_parsed is None:
        raise HTTPException(
            status_code=502,
            detail="AI response could not be parsed",
        )

    return response.output_parsed

Why check output_parsed?

A completed provider request can still lack a parsed object. The guard converts that state into a safe HTTP 502 response.

A successful request returns response.output_parsed. Its shape already matches IncidentAnalysis.

  • Save main.py.
  • Return to http://127.0.0.1:8000/docs in your browser.
  • Refresh the interactive documentation.

You should now see POST /analyze listed beside the existing health route and naive route.

Structured route missing from the docs?

Confirm the new decorator starts at the left edge of main.py. A route nested inside analyze_naive() cannot register with the application.

Check the PowerShell window for a syntax problem near the new route. Help me find why POST /analyze is missing.

✔️ Awesome, I've got everything!

Great. Save main.py before testing the structured response.

ⓧ I'd like to double check the full code

Compare your complete main.py file with this version.

from typing import Literal

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

app = FastAPI(title="QualityLens AI", version="1.0.0")
MODEL = "gpt-6-luna"


class IncidentInput(BaseModel):
    report: str


class IncidentAnalysis(BaseModel):
    severity: Literal["low", "medium", "high", "critical"]
    affected_equipment: list[str]
    suspected_causes: list[str]
    recommended_action: str
    confidence: float


def openai_client() -> OpenAI:
    return OpenAI()


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


@app.post("/analyze-naive")
def analyze_naive(incident: IncidentInput) -> dict[str, str]:
    try:
        response = openai_client().responses.create(
            model=MODEL,
            instructions=(
                "Analyze the industrial incident report and return one concise "
                "paragraph covering severity, equipment, likely causes, and next action."
            ),
            input=incident.report,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

    return {"analysis": response.output_text}


@app.post("/analyze")
def analyze(incident: IncidentInput) -> IncidentAnalysis:
    try:
        response = openai_client().responses.parse(
            model=MODEL,
            input=[
                {
                    "role": "system",
                    "content": (
                        "You analyze industrial quality and maintenance incident reports. "
                        "Use only the supplied report. Use empty lists when equipment or "
                        "causes are not stated. Set confidence from 0.0 to 1.0."
                    ),
                },
                {"role": "user", "content": incident.report},
            ],
            text_format=IncidentAnalysis,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

    if response.output_parsed is None:
        raise HTTPException(
            status_code=502,
            detail="AI response could not be parsed",
        )

    return response.output_parsed

What should this file contain?

This version preserves the naive route for comparison. It adds the schema and production route below the existing code.

Compare the two response shapes

Using the same incident report isolates the effect of the contract. Any change in response shape comes from the structured route.

  • In the interactive documentation, expand POST /analyze.
  • Enable the request editor.
  • Reuse the Line 3 conveyor-motor request body from the earlier test.

Before you submit, predict whether the route returns one prose string or separate fields.

  • Submit the request.

What should I see?

You should receive a successful JSON response with exactly five top-level fields.

  • The severity field contains one approved severity label.
  • The affected_equipment field contains a list of equipment names.
  • The suspected_causes field contains a list of possible causes.
  • The recommended_action field contains the proposed next action.
  • The confidence field contains a number.
  • Compare this response with the earlier POST /analyze-naive result.

The naive route gives you one analysis string. The production route gives downstream code five predictable fields.

That is the key integration gap closed. QualityLens now produces a response that application code can consume directly.

Your local API now enforces a validated response contract. Next, you will package the service with Docker for deployment to Azure Container Apps.

Containerize and Deploy QualityLens

Your validated FastAPI service works on your laptop. Local success still leaves the API tied to your development environment.

This step packages the service as a Docker image. You will run that same artifact locally before deploying it to Azure Container Apps with runtime secret management.

In this step, get ready to:
  • Package QualityLens as the qualitylens-api image.
  • Test the image as the qualitylens-local container.
  • Deploy the API publicly with its OpenAI key supplied through an Azure application secret.
Package and test the app locally

A container image bundles your application with its pinned runtime. The image must exclude local environments plus secret files so every deployment starts from a clean boundary.

  • In the Explorer sidebar in Visual Studio Code, use the file creation control inside the qualitylens-ai folder.
  • Name the new file .dockerignore.
  • Paste the following ignore rules into .dockerignore:
.git
.venv
__pycache__
*.pyc
.env

What does this file do?

  • .dockerignore keeps Git metadata plus the local virtual environment outside the image build context.
  • The cache patterns exclude generated Python files that the container can recreate.
  • The .env rule prevents a local secret file from entering the image.
  • Save .dockerignore.
  • Confirm .dockerignore appears beside main.py in the Explorer sidebar.

Missing the ignore file?

Check that the filename starts with a dot. Confirm Visual Studio Code created it inside qualitylens-ai.

Ask for help with finding a missing .dockerignore file.

  • Use the file creation control in the Explorer sidebar again.
  • Name the new file Dockerfile.
  • Paste the following container definition into Dockerfile:
FROM python:3.11.17-slim

WORKDIR /code

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

COPY . .

EXPOSE 3100

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3100"]

What does this container definition do?

  • FROM starts from the official python:3.11.17-slim image.
  • WORKDIR gives the application a predictable directory inside the container.
  • COPY plus RUN installs the pinned packages before adding the application files.
  • CMD starts Uvicorn on port 3100 so the service accepts traffic from outside the container.
  • Save Dockerfile.

The first image build can take a few minutes while Docker downloads the Python base image. The progress output confirms that each layer is moving forward.

  • Build the qualitylens-api image from the current folder by running:
docker build --tag qualitylens-api .

What does this command do?

Docker reads Dockerfile from the current folder. It assigns the completed image the qualitylens-api tag so the next command can run it by name.

You should see the build complete after Docker installs the packages from requirements.txt.

Image build failing?

Confirm PowerShell is still inside the qualitylens-ai folder. Docker needs Dockerfile plus requirements.txt in the current build context.

Ask for help with debugging the QualityLens image build.

The image contains the application plus its dependencies. Your OpenAI key stays in the current PowerShell session until Docker injects it when the container starts.

  • Start the local container with the current session's key by running:
docker run --detach --name qualitylens-local --publish 3100:3100 --env OPENAI_API_KEY qualitylens-api

How is the secret handled?

  • --detach keeps the container running in the background.
  • --publish 3100:3100 maps your computer's port to the port exposed by Uvicorn.
  • --env OPENAI_API_KEY forwards the existing PowerShell value at runtime without copying it into the image.

Before you check the container, do you expect the health response to match the service you ran directly with Uvicorn?

  • Open http://localhost:3100/health in your browser.

You should see {"status":"ok"}. That matching response proves the same application now runs from the image.

Local container not responding?

Confirm Docker Desktop is running. Check that another local process is not already using port 3100.

Ask for help with diagnosing the local QualityLens container.

✔️ Awesome, I've got everything!

Your Docker files are saved. The qualitylens-local container now serves the expected health response.

ⓧ I'd like to double check the full code

Compare each project file with the complete versions below.

.dockerignore.

.git
.venv
__pycache__
*.pyc
.env

.gitignore.

.venv/
__pycache__/
*.pyc
.env

Dockerfile.

FROM python:3.11.17-slim

WORKDIR /code

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

COPY . .

EXPOSE 3100

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3100"]

requirements.txt.

fastapi==0.159.1
openai==3.36.1
pydantic==2.14.1
uvicorn==0.54.0

main.py.

from typing import Literal

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

app = FastAPI(title="QualityLens AI", version="1.0.0")
MODEL = "gpt-6-luna"


class IncidentInput(BaseModel):
    report: str


class IncidentAnalysis(BaseModel):
    severity: Literal["low", "medium", "high", "critical"]
    affected_equipment: list[str]
    suspected_causes: list[str]
    recommended_action: str
    confidence: float


def openai_client() -> OpenAI:
    return OpenAI()


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


@app.post("/analyze-naive")
def analyze_naive(incident: IncidentInput) -> dict[str, str]:
    try:
        response = openai_client().responses.create(
            model=MODEL,
            instructions=(
                "Analyze the industrial incident report and return one concise "
                "paragraph covering severity, equipment, likely causes, and next action."
            ),
            input=incident.report,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

    return {"analysis": response.output_text}


@app.post("/analyze")
def analyze(incident: IncidentInput) -> IncidentAnalysis:
    try:
        response = openai_client().responses.parse(
            model=MODEL,
            input=[
                {
                    "role": "system",
                    "content": (
                        "You analyze industrial quality and maintenance incident reports. "
                        "Use only the supplied report. Use empty lists when equipment or "
                        "causes are not stated. Set confidence from 0.0 to 1.0."
                    ),
                },
                {"role": "user", "content": incident.report},
            ],
            text_format=IncidentAnalysis,
            store=False,
        )
    except openai.APIError as exc:
        raise HTTPException(
            status_code=502,
            detail="AI provider request failed",
        ) from exc

    if response.output_parsed is None:
        raise HTTPException(
            status_code=502,
            detail="AI response could not be parsed",
        )

    return response.output_parsed
Deploy the source-built container

The local container proves the artifact works beyond your Python virtual environment. Azure now builds that same source into a public Container App.

This deployment creates persistent resources that can incur charges. The qualitylens-rg resource group keeps the Container App plus its supporting registry environment and logging resources together.

Source deployment can take several minutes while Azure builds the image and provisions the hosting resources. Ongoing status messages mean the deployment is still progressing.

  • Deploy QualityLens with external ingress by running:
az containerapp up --resource-group qualitylens-rg --name qualitylens-api --ingress external --target-port 3100 --source .

What does this deployment create?

  • --resource-group qualitylens-rg places the deployment resources in one Azure resource group.
  • --name qualitylens-api names the public Container App.
  • --ingress external makes the app reachable from an Azure-generated public URL.
  • --target-port 3100 sends incoming traffic to Uvicorn inside the container.
  • --source . sends the current project source to Azure for the container build.
  • Record the URL printed by the deployment as your deployed Azure URL.
  • Open your deployed Azure URL with /health appended.

You should see {"status":"ok"} from the public service. This proves Azure can reach the container through external ingress.

Public health route unavailable?

Check the deployment output for an incomplete build or provisioning failure. Confirm the recorded URL belongs to qualitylens-api.

Ask for help with troubleshooting the Azure Container Apps deployment.

Inject the secret and test the cloud API

The public health route does not need an API key. The analysis route needs the key at runtime without storing it in source code or the Docker image.

This action handles a live credential. PowerShell passes the existing session value directly to Azure so you do not paste the key into a project file.

  • Create the openai-api-key application secret by running:
az containerapp secret set --name qualitylens-api --resource-group qualitylens-rg --secrets "openai-api-key=$Env:OPENAI_API_KEY"

What does this command protect?

The command reads OPENAI_API_KEY from the current PowerShell session. Azure stores the value under the application secret name openai-api-key.

Your source files plus the built image remain free of the credential.

  • Reference the application secret from the container environment by running:
az containerapp update --name qualitylens-api --resource-group qualitylens-rg --set-env-vars "OPENAI_API_KEY=secretref:openai-api-key"

How does the secret reference work?

The update creates an environment variable named OPENAI_API_KEY inside the running app. Its value comes from the openai-api-key secret reference.

The OpenAI client reads that environment variable when the public POST /analyze route handles a request.

Before you send the public request, do you expect the deployed API to preserve the same five-field contract as the local service?

  • Open your deployed Azure URL with /docs appended.
  • Expand POST /analyze in the interactive API documentation.
  • Enable request editing with the interactive request control.
  • Replace the example request with the same incident report body you tested locally.
  • Submit the public analysis request.

You should see a successful JSON response containing exactly severity, affected_equipment, suspected_causes, recommended_action, and confidence.

Public analysis request failing?

Confirm both Azure commands completed for qualitylens-api in qualitylens-rg. A missing secret reference prevents the OpenAI client from authenticating.

Ask for help with debugging the deployed analysis route.

That is the deployment boundary working: one dependency-pinned artifact now serves the same validated API locally and from a public Azure URL. Your OpenAI key reaches the service through runtime configuration instead of source code.

Secret mission

Add Human Review Routing

Your API can analyze an industrial incident, but risky or uncertain results still need a clear path to a person. Add deterministic routing that flags high-severity or low-confidence analyses for human review.

Clean Up Your Resources

Clean Up Your Resources

Your local files have no ongoing cost. Decide whether to keep your Azure Container Apps deployment running, pause active use, or delete every project resource.

Cost warning

The Azure resource group can continue generating charges through persistent supporting resources. Scale-to-zero removes Container Apps resource-consumption charges only.

New OpenAI API requests consume billable tokens. Choose Delete if you do not want project-related cloud resources left running.

Resources you used:

  • Azure resource group qualitylens-rg. It contains the public qualitylens-api app. It also contains openai-api-key. Supporting deployment resources live in the same group.
  • Local Docker container qualitylens-local.
  • Local Docker image qualitylens-api.
  • Local qualitylens-ai folder. The Git repository is inside it. The .venv environment is also inside it.

Keep everything running

No action is needed. Choose this if you are still demonstrating QualityLens or extending its review policy.

  • Your public qualitylens-api endpoint stays available for demonstrations.
  • Your qualitylens-rg resources remain deployed. Supporting services can continue generating charges.
  • Your local container remains available while Docker is running.
  • Your local image stays ready for another container run.
  • Your qualitylens-ai folder remains available for future edits.
  • Monitor your Azure costs while the resource group exists.
  • Expect each new OpenAI request to use billable tokens.

Pause - I'll come back to this later

Pausing active use frees local resources while preserving your files. The Azure deployment remains available for later.

  • Stop the local container by running this command:
docker stop qualitylens-local

What gets paused?

This stops qualitylens-local. Your image stays available. Your source files also stay available.

  • Close the current PowerShell window.
  • Avoid sending requests to the public QualityLens endpoint.
  • Leave qualitylens-rg deployed for your return.

Closing PowerShell clears OPENAI_API_KEY from that session. Persistent Azure resources can still incur charges while the resource group exists.

Delete - I don't want to use this again

Removing everything is permanent. This option deletes the local runtime artifacts plus the public Azure deployment.

The local container must stop before its container record can be removed.

  • Stop the local container by running this command:
docker stop qualitylens-local

What gets stopped?

This stops qualitylens-local so it no longer consumes local runtime resources. The container record still exists.

  • Remove the stopped container by running this command:
docker rm qualitylens-local

What gets removed?

This removes the qualitylens-local container record. The qualitylens-api image remains on your machine.

The Docker image occupies local disk space until you remove it.

  • Press the Windows key to open search.
  • Enter Docker Desktop in search.
  • Start Docker Desktop from the result.
  • Open the image list in Docker Desktop.
  • Select qualitylens-api.
  • Use the delete control for the selected image.
  • Confirm that qualitylens-api no longer appears in the image list.

The qualitylens-ai folder contains the local source files. Removing that folder also removes its Git repository plus its virtual environment.

  • Close the current PowerShell window.

This clears the session-only OPENAI_API_KEY value. It also releases the activated .venv environment.

  • Press the Windows key to open search.
  • Search for File Explorer.
  • Start File Explorer from the result.
  • Navigate to the parent folder that contains qualitylens-ai.
  • Select the qualitylens-ai folder.
  • Use File Explorer's delete control for the selected folder.
  • Confirm that qualitylens-ai no longer appears in its previous location.

Deletion is permanent

Deleting qualitylens-rg removes the public API. It also removes the secret plus every supporting Azure resource inside the group.

The deletion command pauses for confirmation before it begins.

  • Delete the Azure resource group by running this command:
az group delete --name qualitylens-rg

What gets deleted?

Deleting the resource group removes qualitylens-api from Azure. It also removes openai-api-key plus the deployment's supporting resources.

  • Approve the confirmation prompt.
  • Wait for the deletion command to finish.
  • Refresh the public /docs URL from earlier.

You should no longer reach the QualityLens API documentation. This confirms that the public service has been removed.

That closes the cloud billing loop. Your public API plus its supporting Azure resources are gone.

Nice Work!

Nice Work!

You did it! QualityLens AI is now a live industrial incident-analysis API running on Azure Container Apps.

You've learned how to:

  • Built a FastAPI incident-analysis API that accepts reports through interactive API documentation.
  • Converted free-form OpenAI model prose into a Pydantic-validated contract with predictable JSON fields for severity, equipment, causes, action, and confidence.
  • Ran the same dependency-pinned service inside Docker and on Azure Container Apps with OPENAI_API_KEY supplied securely at runtime.
  • Completed the Secret Mission by adding deterministic human-review routing for high or critical incidents and analyses with confidence below 0.75.

Ready to quiz yourself?