Train, Deploy, and Ship an AI Classifier

Fine-tune DistilBERT, serve it via Node.js, and ship a mobile feedback app.

Introduction

30 Second Summary

Every app with users generates feedback. The challenge is sorting through hundreds of messages to find the critical bugs hiding among feature requests and praise.

In this project, you will build an AI-powered feedback triage system that automatically classifies customer submissions by category and priority. You'll train a machine learning model, deploy it through a containerized API, and connect it to a mobile app.

What You'll Build

A full-stack ML system that fine-tunes DistilBERT for text classification, serves predictions via a Node.js API running ONNX Runtime, and displays results in a React Native mobile app.

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

  • A fine-tuned DistilBERT model exported to ONNX format and tracked with MLflow experiment history and model registry.
  • A Node.js inference API serving real-time predictions inside a multi-container Docker environment.
  • A React Native mobile app where users submit feedback and see AI-powered classification results with color-coded priority badges.
  • A GitHub Actions CI/CD pipeline that automates testing and Docker image builds.
  • Secret Mission: Add a human-in-the-loop correction system where users can fix misclassifications for future retraining.

Are there any prerequisites?

This project assumes experience with Node.js, React Native, and Docker Compose. Familiarity with Python basics is also helpful.

Before We Start

This project spans the entire ML lifecycle. You will train a text classifier, serve it through a containerized API, and consume predictions from a mobile app.

Before writing any code, you need a few tools installed and verified. This step ensures your environment is ready for Docker containers, Node.js services, Python training scripts, and React Native mobile development.

In this step, get ready to:
  • Verify Node.js 22 LTS and Python 3.11 are installed.
  • Confirm Expo CLI works via npx.
  • Start Docker Desktop and open your IDEs.
Verify Node.js 22 LTS

The inference API and Expo mobile app both rely on Node.js. This project requires version 22 or higher (the current LTS release).

  • Open your terminal by pressing Cmd+Space (macOS) and typing Terminal, then press Enter.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 22 or higher

Your Node.js version starts with v22 or higher. You are ready to move on.

ⓧ I see an older version

If your terminal shows a version lower than v22 (for example v18 or v20), you need to upgrade. macOS may ship with an older Node.js version that shadows newer installs.

The simplest upgrade path on macOS is using nvm (Node Version Manager). If you already have nvm, run:

nvm install 22
nvm use 22

If you do not have nvm installed, install it first:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash
  • Close and reopen your terminal after installing nvm.
  • Then run nvm install 22 and nvm use 22.
  • Verify with node --version to confirm it now shows v22.

ⓧ Command not found

Node.js is not installed on your machine. Install it using nvm:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash
  • Close and reopen your terminal after the install script finishes.
  • Install Node.js 22 by running:
nvm install 22
  • Verify with node --version. You should see v22 followed by a minor and patch number.
Verify Python 3.11

The training pipeline uses PyTorch and Hugging Face Transformers, both of which require Python 3.10 or higher. This project pins to Python 3.11.

  • Check your Python version by running this command:
python3 --version

✔️ I see version 3.11 or higher

Your terminal shows Python 3.11 or higher. You are good to go.

ⓧ I see an older version

macOS ships with an older Python by default. Even if you installed Python 3.11, the python3 command might still resolve to an older version (like 3.9 or 3.10).

Use pyenv to install and activate Python 3.11:

brew install pyenv
pyenv install 3.11
pyenv global 3.11
  • Add pyenv to your shell by adding this line to your ~/.zshrc:
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
source ~/.zshrc
  • Verify with python3 --version. It should now show Python 3.11 followed by a patch number.

ⓧ Command not found

Python 3 is not installed. Install it using pyenv:

brew install pyenv
pyenv install 3.11
pyenv global 3.11
  • Add pyenv to your shell by adding this line to your ~/.zshrc:
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
source ~/.zshrc
  • Verify with python3 --version. You should see Python 3.11 followed by a patch number.
Verify Expo CLI and start Docker Desktop

Modern Expo does not require a global CLI install. The CLI is bundled with the expo package and runs via npx. As long as Node.js is installed, you can use npx expo commands.

  • Confirm that npx is available by running this command:
npx --version
  • You should see a version number printed. This means npx (and by extension, Expo CLI via npx expo) is ready to use.

Why not install expo-cli globally?

The global expo-cli package was deprecated in SDK 46 (2022). The modern approach uses the local Expo CLI bundled inside the expo package. You invoke it with npx expo start, npx expo install, and npx create-expo-app@latest.

Now start Docker Desktop so it is ready for the containerized training and API services.

  • Press Cmd+Space to open Spotlight Search.
  • Type Docker and press Enter to open Docker Desktop.
  • Wait for the Docker icon in your menu bar to show a steady state (not animating).
  • Verify Docker is running by running this command:
docker --version
  • You should see a version number printed, confirming the Docker daemon is active.

Docker command not responding?

If you see an error about connecting to the Docker daemon, Docker Desktop has not finished starting. Wait for the menu bar icon to stop animating, then try the command again.

help me troubleshoot Docker Desktop not starting on macOS

Finally, open your two IDEs so they are ready for the next steps.

  • Press Cmd+Space, type WebStorm, and press Enter to open WebStorm.
  • Press Cmd+Space again, type PyCharm, and press Enter to open PyCharm.

Why two IDEs?

This project spans two languages. You will use PyCharm for the Python training code and WebStorm for the Node.js API and React Native mobile app. Keeping them separate makes it easier to manage language-specific tooling and linting.

  • Confirm you can sign in to your GitHub account by visiting github.com in your browser. You will need this for the CI/CD step later.

Your environment is locked and loaded. Next up, you will create the project structure and spin up the Docker infrastructure for MLflow experiment tracking.

Set Up Project Structure and Docker Infrastructure

Production ML systems need a clear boundary between training code, serving code, and frontend code. Each component has different dependencies and a different deployment lifecycle.

In this step, you'll create a monorepo that keeps these concerns in separate directories. You'll also spin up Docker containers for MLflow and PostgreSQL so you can track experiments from the very start.

In this step, get ready to:
  • Create the monorepo directory structure with dedicated folders for each component.
  • Set up Docker Compose with MLflow and PostgreSQL containers.
  • Verify the MLflow tracking server is running and accessible.
Create the monorepo directory structure

A monorepo keeps all related code in one repository while separating concerns into folders. You'll create four top-level directories for training, serving, mobile, and CI/CD.

  • Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir feedback-triage
cd feedback-triage
  • Create the subdirectories for each project component by running:
mkdir -p train api mobile .github/workflows

What did you just create?

  • train/ holds the Python training pipeline (dataset, scripts, Dockerfile).
  • api/ holds the Node.js inference server that serves predictions.
  • mobile/ holds the React Native (Expo) app for submitting feedback.
  • .github/workflows/ holds the GitHub Actions CI/CD pipeline definitions.
  • Confirm the directories exist by running:
ls -a

You should see train, api, mobile, and .github listed in the output.

Don't see all four directories?

Make sure you ran the mkdir -p command from inside the feedback-triage folder. The -p flag creates nested directories (like .github/workflows) in one go.

help me troubleshoot why my directories didn't get created

Create the Docker Compose file

Docker Compose lets you define multi-container applications in a single YAML file. You'll configure two services: PostgreSQL as the database backend for MLflow, and the MLflow tracking server itself.

  • Switch back to WebStorm.
  • Click File in the top menu bar.
  • Click Open.
  • Select the feedback-triage folder on your Desktop, then click Open.
  • Right-click the feedback-triage root in the Project panel on the left.
  • Select New, then File.
  • Name the file docker-compose.yml and press Enter.
  • Paste the following configuration into docker-compose.yml:
version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: mlflow
      POSTGRES_PASSWORD: mlflow
      POSTGRES_DB: mlflow
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mlflow"]
      interval: 5s
      timeout: 5s
      retries: 5

  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.11.1
    ports:
      - "5000:5000"
    environment:
      MLFLOW_BACKEND_STORE_URI: postgresql://mlflow:mlflow@postgres:5432/mlflow
      MLFLOW_DEFAULT_ARTIFACT_ROOT: /mlflow/artifacts
    volumes:
      - mlflow_artifacts:/mlflow/artifacts
    command: mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri postgresql://mlflow:mlflow@postgres:5432/mlflow --default-artifact-root /mlflow/artifacts
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
  mlflow_artifacts:

What does this configuration do?

This file defines two services that work together:

  • postgres runs a PostgreSQL 15 database that MLflow uses to store experiment metadata (runs, metrics, parameters). The healthcheck block ensures the database is accepting connections before MLflow tries to connect.
  • mlflow runs the MLflow tracking server on port 5000. It connects to the PostgreSQL database for persistent storage and uses a Docker volume for artifact files (saved models, plots).
  • The depends_on with condition: service_healthy means the MLflow container waits until PostgreSQL passes its health check before starting.

Named volumes (postgres_data and mlflow_artifacts) persist data between container restarts so you never lose experiment history.

Why PostgreSQL instead of SQLite?

MLflow's Model Registry requires a database-backed store. SQLite works for basic experiment tracking, but you need PostgreSQL (or MySQL) to register, version, and alias models. Since you'll use the Model Registry in Step 3, you're setting up the right backend from the start.

  • Save docker-compose.yml by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red underlines in the YAML?

Check that your indentation uses spaces, not tabs. YAML is whitespace-sensitive and requires consistent 2-space indentation for nested keys.

help me fix YAML indentation errors in my docker-compose file

Start the stack and verify MLflow is running

With the Compose file saved, you can bring up both containers in one command. Docker will pull the images, create the network, and start the services in the correct order.

  • Return to your terminal (still in the feedback-triage folder).
  • Start the Docker Compose stack in detached mode by running:
docker compose up -d

What does this command do?

The -d flag runs containers in the background (detached mode) so your terminal stays free. Docker Compose creates a default network, pulls the postgres:15 and ghcr.io/mlflow/mlflow:v2.11.1 images, provisions the named volumes, and starts both services.

The first run takes a minute or two while Docker downloads the images. Subsequent starts are near-instant.

  • Verify both containers are running and healthy by running:
docker compose ps

You should see two services listed: postgres with status healthy and mlflow with status running.

  • Open your browser and navigate to http://localhost:5000.

You should see the MLflow Tracking UI with an empty experiments page. This confirms the server is operational and connected to PostgreSQL.

Can't reach localhost:5000?

Run docker compose logs mlflow to check for errors. The most common issue is port 5000 already being used by another process.

On macOS, AirPlay Receiver uses port 5000 by default. Disable it in System Settings > General > AirDrop & Handoff > AirPlay Receiver.

help me debug why MLflow isn't accessible at localhost:5000

✔️ Awesome, I've got everything!

Great. Double check you've saved docker-compose.yml and both containers are running.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file:

version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: mlflow
      POSTGRES_PASSWORD: mlflow
      POSTGRES_DB: mlflow
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mlflow"]
      interval: 5s
      timeout: 5s
      retries: 5

  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.11.1
    ports:
      - "5000:5000"
    environment:
      MLFLOW_BACKEND_STORE_URI: postgresql://mlflow:mlflow@postgres:5432/mlflow
      MLFLOW_DEFAULT_ARTIFACT_ROOT: /mlflow/artifacts
    volumes:
      - mlflow_artifacts:/mlflow/artifacts
    command: mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri postgresql://mlflow:mlflow@postgres:5432/mlflow --default-artifact-root /mlflow/artifacts
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
  mlflow_artifacts:

Your infrastructure is live. Next up, you'll build the training pipeline that fine-tunes a DistilBERT model and logs every metric to this MLflow server.

Build the Training Pipeline

Your Docker infrastructure is running and MLflow is tracking at http://localhost:5000. Now you need something to track.

In this step, you will build the ML training pipeline that fine-tunes a DistilBERT model on customer feedback data. The script loads labeled examples, trains a text classifier, and logs every metric to MLflow so you can compare runs and pick the best model later.

In this step, get ready to:
  • Create a labeled training dataset with feedback examples across 5 categories and 4 priority levels.
  • Write a training script that fine-tunes DistilBERT using Hugging Face Trainer with MLflow experiment tracking.
  • Create the training Dockerfile and dependency file for containerized execution.
Create the training dataset

Every ML model needs labeled data to learn from. You will create a CSV file containing customer feedback examples, each tagged with a category and priority level.

  • Open PyCharm and open the feedback-triage folder (on your Desktop) by clicking File then Open.
  • In the Project tool window on the left, right-click the train folder.
  • Select New then Directory and name it data.
  • Right-click the new data folder, select New then File, and name it feedback.csv.
  • Paste the following labeled data into feedback.csv:
text,category,priority
"App crashes on login",Bug,Critical
"Please add dark mode",Feature Request,Medium
"This is terrible service",Complaint,High
"Love the new update!",Praise,Low
"How do I reset my password?",Question,Medium
"Payment button not working",Bug,Critical
"Would love calendar integration",Feature Request,Low
"Waited 2 hours for support",Complaint,High
"Best app I've ever used",Praise,Low
"Where are my saved items?",Question,Medium

What does this data represent?

Each row is one piece of customer feedback with two labels. The category column has 5 classes: Bug, Feature Request, Complaint, Praise, and Question. The priority column has 4 levels: Critical, High, Medium, and Low.

This is a small dataset for demonstration purposes. In production, you would have thousands of labeled examples. The model will still learn meaningful patterns from these 10 examples during fine-tuning because it starts from a pre-trained distilbert-base-uncased checkpoint that already understands English.

  • Save feedback.csv with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file exists by opening a terminal in PyCharm (View then Tool Windows then Terminal) and running:
ls train/data/

You should see feedback.csv in the output.

Don't see feedback.csv?

  • Make sure you created the data directory inside train/, not at the project root.
  • Check that the terminal is in the feedback-triage folder. Run pwd to confirm.

Help me create a CSV file inside train/data/ in my PyCharm project.

Write the training script

The training script is the core of your ML pipeline. It loads the CSV, tokenizes the text for DistilBERT, fine-tunes the model using Hugging Face Transformers, and logs all metrics to MLflow.

  • Right-click the train folder in the Project tool window, select New then Python File, and name it train.
  • Start by pasting the imports, label configuration, and MLflow setup into train.py:
import os
import pandas as pd
import numpy as np
import mlflow
import mlflow.pytorch
from datasets import Dataset
from transformers import (
    DistilBertTokenizerFast,
    DistilBertForSequenceClassification,
    TrainingArguments,
    Trainer,
)
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score
import torch

# Define the classification labels
CATEGORY_LABELS = ["Bug", "Feature Request", "Complaint", "Praise", "Question"]
PRIORITY_LABELS = ["Critical", "High", "Medium", "Low"]

# Connect to the MLflow tracking server running in Docker
MLFLOW_TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI", "http://mlflow:5000")
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
mlflow.set_experiment("feedback-triage")

What does this setup do?

  • CATEGORY_LABELS and PRIORITY_LABELS define the classes the model will predict. The model learns to map feedback text to one of these labels.
  • mlflow.set_tracking_uri points to the MLflow server running in your Docker Compose stack. Every metric and artifact from training will appear in the UI at http://localhost:5000.
  • mlflow.set_experiment("feedback-triage") creates a named experiment so all runs are grouped together.
  • Below the setup code, paste the data loading and tokenization pipeline:
# Load and prepare the training data
df = pd.read_csv("/app/data/feedback.csv")
df["category_id"] = df["category"].apply(lambda x: CATEGORY_LABELS.index(x))
df["priority_id"] = df["priority"].apply(lambda x: PRIORITY_LABELS.index(x))

# Split into training and validation sets
train_df, val_df = train_test_split(df, test_size=0.2, random_state=42)

# Load the DistilBERT tokenizer
tokenizer = DistilBertTokenizerFast.from_pretrained("distilbert-base-uncased")

def tokenize(batch):
    return tokenizer(batch["text"], truncation=True, padding="max_length", max_length=128)

# Create Hugging Face datasets and tokenize them
train_dataset = Dataset.from_pandas(train_df[["text", "category_id"]].rename(columns={"category_id": "labels"}))
val_dataset = Dataset.from_pandas(val_df[["text", "category_id"]].rename(columns={"category_id": "labels"}))

train_dataset = train_dataset.map(tokenize, batched=True)
val_dataset = val_dataset.map(tokenize, batched=True)
train_dataset.set_format("torch", columns=["input_ids", "attention_mask", "labels"])
val_dataset.set_format("torch", columns=["input_ids", "attention_mask", "labels"])

What does this code do?

This section converts raw CSV text into the numeric format DistilBERT expects. The tokenizer splits each feedback string into tokens and maps them to integer IDs. Padding ensures every input is exactly 128 tokens long.

The category_id column maps text labels like "Bug" to numeric indices (0-4) that the model's classification head outputs. The 80/20 train/validation split lets you measure how well the model generalizes.

  • Below the data pipeline, paste the model configuration and metrics function:
# Load pre-trained DistilBERT with a classification head
model = DistilBertForSequenceClassification.from_pretrained(
    "distilbert-base-uncased", num_labels=len(CATEGORY_LABELS)
)

def compute_metrics(eval_pred):
    """Calculate accuracy and F1 score during evaluation."""
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)
    acc = accuracy_score(labels, predictions)
    f1 = f1_score(labels, predictions, average="weighted")
    return {"accuracy": acc, "f1": f1}

# Configure training hyperparameters
training_args = TrainingArguments(
    output_dir="/app/model_output",
    num_train_epochs=3,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=8,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="f1",
    logging_dir="/app/logs",
    logging_steps=10,
    report_to="none",
)

What does this configure?

  • DistilBertForSequenceClassification.from_pretrained loads the pre-trained 66M-parameter model and adds a fresh classification head with 5 output neurons (one per category).
  • compute_metrics calculates accuracy and weighted F1 score after each epoch. MLflow will log these so you can track improvement over time.
  • TrainingArguments sets 3 epochs, batch size 8, and uses F1 score to pick the best checkpoint. The report_to="none" flag prevents the Trainer from logging to external services (MLflow logging is handled manually).
  • Finally, paste the training execution block at the bottom of the file:
# Run training with MLflow experiment tracking
with mlflow.start_run(run_name="distilbert-finetune") as run:
    mlflow.log_params({
        "model": "distilbert-base-uncased",
        "epochs": training_args.num_train_epochs,
        "batch_size": training_args.per_device_train_batch_size,
        "num_labels": len(CATEGORY_LABELS),
    })

    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=train_dataset,
        eval_dataset=val_dataset,
        compute_metrics=compute_metrics,
    )
    trainer.train()
    metrics = trainer.evaluate()
    mlflow.log_metrics({"eval_accuracy": metrics["eval_accuracy"], "eval_f1": metrics["eval_f1"]})

    # Save the best model and tokenizer for ONNX export later
    model.save_pretrained("/app/model_output/best")
    tokenizer.save_pretrained("/app/model_output/best")
    mlflow.pytorch.log_model(model, artifact_path="model")
    print(f"Run ID: {run.info.run_id}")
    print(f"Accuracy: {metrics['eval_accuracy']:.4f}, F1: {metrics['eval_f1']:.4f}")

What happens during training?

The mlflow.start_run context manager creates a tracked experiment run. Inside it, the script logs hyperparameters, trains for 3 epochs, evaluates on the validation set, and logs final metrics.

After training, the best model and tokenizer are saved to /app/model_output/best for ONNX export in the next step. The model is also logged to MLflow as an artifact, creating a versioned record you can browse in the UI.

  • Save train.py with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file exists by running:
ls train/train.py

You should see train/train.py printed in the terminal.

Seeing a red underline in PyCharm?

PyCharm may highlight imports like mlflow or transformers as unresolved. This is expected because these packages are installed inside the Docker container, not on your local machine. The code will run correctly when you build the container in the next step.

Help me understand why PyCharm shows import errors for packages that run inside Docker.

✔️ Awesome, I've got everything!

Great. Double check you have saved train.py and that it contains all four sections: imports, data pipeline, model config, and training execution.

ⓧ I'd like to double check the full code

Here is the complete train.py file. Make sure yours matches exactly:

import os
import pandas as pd
import numpy as np
import mlflow
import mlflow.pytorch
from datasets import Dataset
from transformers import (
    DistilBertTokenizerFast,
    DistilBertForSequenceClassification,
    TrainingArguments,
    Trainer,
)
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score
import torch

CATEGORY_LABELS = ["Bug", "Feature Request", "Complaint", "Praise", "Question"]
PRIORITY_LABELS = ["Critical", "High", "Medium", "Low"]

MLFLOW_TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI", "http://mlflow:5000")
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
mlflow.set_experiment("feedback-triage")

df = pd.read_csv("/app/data/feedback.csv")
df["category_id"] = df["category"].apply(lambda x: CATEGORY_LABELS.index(x))
df["priority_id"] = df["priority"].apply(lambda x: PRIORITY_LABELS.index(x))

train_df, val_df = train_test_split(df, test_size=0.2, random_state=42)

tokenizer = DistilBertTokenizerFast.from_pretrained("distilbert-base-uncased")

def tokenize(batch):
    return tokenizer(batch["text"], truncation=True, padding="max_length", max_length=128)

train_dataset = Dataset.from_pandas(train_df[["text", "category_id"]].rename(columns={"category_id": "labels"}))
val_dataset = Dataset.from_pandas(val_df[["text", "category_id"]].rename(columns={"category_id": "labels"}))

train_dataset = train_dataset.map(tokenize, batched=True)
val_dataset = val_dataset.map(tokenize, batched=True)
train_dataset.set_format("torch", columns=["input_ids", "attention_mask", "labels"])
val_dataset.set_format("torch", columns=["input_ids", "attention_mask", "labels"])

model = DistilBertForSequenceClassification.from_pretrained(
    "distilbert-base-uncased", num_labels=len(CATEGORY_LABELS)
)

def compute_metrics(eval_pred):
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)
    acc = accuracy_score(labels, predictions)
    f1 = f1_score(labels, predictions, average="weighted")
    return {"accuracy": acc, "f1": f1}

training_args = TrainingArguments(
    output_dir="/app/model_output",
    num_train_epochs=3,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=8,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="f1",
    logging_dir="/app/logs",
    logging_steps=10,
    report_to="none",
)

with mlflow.start_run(run_name="distilbert-finetune") as run:
    mlflow.log_params({
        "model": "distilbert-base-uncased",
        "epochs": training_args.num_train_epochs,
        "batch_size": training_args.per_device_train_batch_size,
        "num_labels": len(CATEGORY_LABELS),
    })

    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=train_dataset,
        eval_dataset=val_dataset,
        compute_metrics=compute_metrics,
    )
    trainer.train()
    metrics = trainer.evaluate()
    mlflow.log_metrics({"eval_accuracy": metrics["eval_accuracy"], "eval_f1": metrics["eval_f1"]})

    model.save_pretrained("/app/model_output/best")
    tokenizer.save_pretrained("/app/model_output/best")
    mlflow.pytorch.log_model(model, artifact_path="model")
    print(f"Run ID: {run.info.run_id}")
    print(f"Accuracy: {metrics['eval_accuracy']:.4f}, F1: {metrics['eval_f1']:.4f}")
Create the training Dockerfile and dependencies

The training script runs inside a Docker container so you get a reproducible environment. You need a requirements.txt listing the Python packages and a Dockerfile that builds the image.

  • Right-click the train folder, select New then File, and name it requirements.txt.
  • Paste the following pinned dependencies:
torch==2.2.0
transformers==4.38.2
datasets==2.18.0
mlflow==2.11.1
scikit-learn==1.4.1
pandas==2.2.1
numpy==1.26.4
optimum[onnxruntime]==1.17.1
psycopg2-binary==2.9.9

Why pin exact versions?

Pinning versions ensures every team member and CI run trains with identical dependencies. Without pins, a new release of transformers or torch could change model behaviour or break the build.

The optimum[onnxruntime] package is included now so the same image can later export the model to ONNX format. The psycopg2-binary package lets the training script communicate with the PostgreSQL-backed MLflow server.

  • Save requirements.txt.
  • Create another file in the train folder named Dockerfile (no extension) by right-clicking train, selecting New then File, and typing Dockerfile.
  • Paste the following container definition:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "train.py"]

What does this Dockerfile do?

  • FROM python:3.11-slim starts from a minimal Python 3.11 image to keep the build small.
  • COPY requirements.txt and RUN pip install install dependencies first. Docker caches this layer, so rebuilds are fast when only your code changes.
  • COPY . . copies your training script and data into the container.
  • CMD ["python", "train.py"] runs the training script when the container starts.
  • Save Dockerfile.
  • Verify all training files are in place by running:
ls train/

You should see Dockerfile, data, requirements.txt, and train.py listed.

Missing a file in the output?

Double check that you created all files inside the train/ directory, not at the project root. The Dockerfile must have no file extension. If you accidentally named it Dockerfile.txt, right-click it in the Project tool window and select Refactor then Rename to fix it.

Help me fix my training directory structure in PyCharm.

Your training pipeline is ready to run. In the next step, you will build the Docker image, kick off training, and watch MLflow log your model's accuracy and F1 score in real time.

Train the Model and Export to ONNX

In the previous step, you built the entire training pipeline: the dataset, the training script, and the Docker infrastructure to run it all.

Now it's time to actually execute that pipeline. You'll run the training container, watch MLflow track every metric in real-time, register your best model in the Model Registry, and then export it to ONNX format so the Node.js API can serve predictions.

In this step, get ready to:
  • Build and run the training container, watching MLflow log metrics in real-time.
  • Register the best model in the MLflow Model Registry with a champion alias.
  • Export the trained model to ONNX format for Node.js serving.
Build and run the training container

The training script needs to communicate with the MLflow server running in your Docker Compose stack. Since MLflow's port 5000 is mapped to your host machine, the training container can reach it via host.docker.internal. This is a special DNS name that Docker Desktop resolves to your host machine's IP address.

  • Build the training Docker image by running this command from your project root directory:
docker build -t feedback-trainer ./train

What does this command do?

This builds a Docker image tagged feedback-trainer from the train/ directory. It installs PyTorch, Transformers, MLflow, and all other dependencies defined in requirements.txt inside the image.

  • Run the training container by executing this command:
docker run --rm \
  -v $(pwd)/train/model_output:/app/model_output \
  -e MLFLOW_TRACKING_URI=http://host.docker.internal:5000 \
  feedback-trainer

What does this command do?

The -v flag mounts a local folder so the trained model files persist on your machine after the container exits. The -e flag overrides the default MLflow URI so the container talks to your MLflow server via the host machine's network.

The --rm flag automatically removes the container once training completes. This keeps your system clean.

Training will take a few minutes on CPU. You'll see output showing each epoch's progress, including loss, accuracy, and F1 metrics being computed.

What should I see?

The terminal will display training progress with three epochs. At the end, you'll see a summary like:

Run ID: <a long alphanumeric string> Accuracy: 0.XXXX, F1: 0.XXXX

The exact metric values depend on your hardware and random seed. With only 10 training examples, the metrics won't be production-quality, but that's fine for demonstrating the workflow.

Training container failing to connect to MLflow?

Make sure your Docker Compose stack is still running. Run docker compose ps and confirm both postgres and mlflow show a healthy state.

If you see a connection refused error, verify that MLflow is accessible at http://localhost:5000 in your browser. If it's not, restart the stack with docker compose up -d.

help me debug my training container's connection to MLflow

View training results and register the model

With training complete, MLflow has captured every metric from your run. Now you'll inspect the results in the MLflow UI and register the model so it can be versioned and referenced by alias.

  • Open http://localhost:5000 in your browser.
  • Click the feedback-triage experiment in the left sidebar.
  • Click on the run named distilbert-finetune to open its details page.

You should see the logged parameters (model name, epochs, batch size, number of labels) and metrics (eval_accuracy, eval_f1) in the run details.

  • Scroll down to the Artifacts section and click the model folder.
  • Click the Register Model button.
  • In the form that appears, select Create New Model from the Model dropdown.
  • Enter feedback-triage-classifier as the model name and confirm.

What is the MLflow Model Registry?

The Model Registry is a central store for managing model versions. Each time you register a model, MLflow creates a new version (starting at version 1). You can assign aliases like champion to specific versions, making it easy to reference the production model without hardcoding a version number.

In production teams, the champion alias points to whichever version is currently serving traffic. You can promote a new version by simply reassigning the alias.

  • Navigate to the registered model's overview page by clicking Models in the top navigation bar, then clicking feedback-triage-classifier.
  • On the model version row, click to add an alias.
  • Enter champion as the alias name and save.

Your model is now registered as feedback-triage-classifier version 1 with the champion alias. The Node.js API will later reference this model by alias.

Don't see the Register Model button?

Make sure you clicked into the model folder inside the Artifacts section, not just the run summary. The button only appears when you have a model artifact selected.

If the Model Registry features aren't available at all, confirm your MLflow backend is PostgreSQL (not the default SQLite). The Model Registry requires a database-backed backend store.

help me register my model in the MLflow Model Registry

Export the model to ONNX format

The trained model is currently in PyTorch format. To serve it from a Node.js API, you need to convert it to ONNX. The optimum-cli tool from Hugging Face handles this conversion, preserving the model architecture while making it compatible with ONNX Runtime.

  • Create a new file called export_onnx.py inside the train/ directory with the following content:
import subprocess
import sys

subprocess.run([
    "optimum-cli", "export", "onnx",
    "--model", "/app/model_output/best",
    "--task", "text-classification",
    "/app/model_output/onnx"
], check=True)
print("ONNX export complete: /app/model_output/onnx/model.onnx")

What does this script do?

This script calls optimum-cli export onnx which converts the PyTorch model saved at /app/model_output/best into ONNX format. The --task text-classification flag tells the exporter how to structure the ONNX graph for this specific model type.

The output lands in /app/model_output/onnx/ which maps to train/model_output/onnx/ on your machine thanks to the volume mount.

  • Save export_onnx.py.
  • Rebuild the training image to include the new script by running:
docker build -t feedback-trainer ./train
  • Run the ONNX export by executing:
docker run --rm \
  -v $(pwd)/train/model_output:/app/model_output \
  feedback-trainer python export_onnx.py

Why rebuild before exporting?

The Dockerfile uses COPY . . to include all files from the train/ directory into the image. Since you just created export_onnx.py, you need to rebuild so the new script is available inside the container.

The model weights don't need to be in the image. They're accessed via the volume mount at /app/model_output, which persists between container runs.

  • Verify the ONNX file was created by running:
ls train/model_output/onnx/

You should see model.onnx in the output. This is the file your Node.js API will load for inference.

ONNX export failing?

Make sure the training step completed successfully first. Check that train/model_output/best/ contains model files by running ls train/model_output/best/. You should see files like config.json and model.safetensors.

If the directory is empty, re-run the training container from the first substep.

help me debug my ONNX model export

✔️ Awesome, I've got everything!

Great. Double check you've saved export_onnx.py and that your train/model_output/onnx/model.onnx file exists.

ⓧ I'd like to double check the full code

Here is the complete train/export_onnx.py file:

import subprocess
import sys

subprocess.run([
    "optimum-cli", "export", "onnx",
    "--model", "/app/model_output/best",
    "--task", "text-classification",
    "/app/model_output/onnx"
], check=True)
print("ONNX export complete: /app/model_output/onnx/model.onnx")

You now have a trained, registered, and ONNX-exported model ready for production serving. Next up, you'll build the Node.js API that loads this ONNX model and serves real-time predictions.

Build the Node.js Inference API

Your model is trained, registered in MLflow, and exported to ONNX format. Now you need a way to serve predictions to the outside world so applications can classify feedback in real time.

You'll build a Node.js API using Express and ONNX Runtime. This separates training (Python) from serving (Node.js), which is how production ML teams operate.

In this step, get ready to:
  • Create an Express server with ONNX Runtime inference and a health check endpoint.
  • Add rate limiting middleware and environment-based configuration.
  • Containerize the API and verify end-to-end predictions via Docker Compose.
Create the Express server with ONNX Runtime inference

The inference API loads your ONNX model into memory once at startup, then runs predictions on each incoming request. This avoids the overhead of reloading the model for every call.

Why serve the model with Node.js instead of Python?

In production ML systems, data scientists train models in Python while engineers serve them in the language their application stack uses. ONNX is the bridge: it's a portable format that any runtime can load.

This forces a clean separation between training and serving. It also means your API team doesn't need Python infrastructure in production.

  • Create the API directory structure by running these commands:
mkdir -p api/src/inference api/src/middleware
  • Create api/package.json with your project dependencies by pasting this content:
{
  "name": "feedback-triage-api",
  "version": "1.0.0",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.18.3",
    "onnxruntime-node": "^1.17.1",
    "express-rate-limit": "^7.2.0",
    "tokenizers": "^0.13.4"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "nodemon": "^3.1.0"
  }
}

What does this file do?

This defines your Node.js project and its dependencies. The key packages are:

  • express handles HTTP routing and middleware.
  • onnxruntime-node loads and runs your ONNX model with native binaries for near-native performance.
  • express-rate-limit protects your API from being overwhelmed with too many requests.
  • Create api/src/inference/classifier.js with the ONNX inference logic:
const ort = require('onnxruntime-node');
const path = require('path');

const CATEGORY_LABELS = ['Bug', 'Feature Request', 'Complaint', 'Praise', 'Question'];
const PRIORITY_MAP = {
  Bug: { Critical: 0.8, High: 0.15, Medium: 0.04, Low: 0.01 },
  Complaint: { Critical: 0.1, High: 0.6, Medium: 0.25, Low: 0.05 },
  'Feature Request': { Critical: 0.02, High: 0.1, Medium: 0.5, Low: 0.38 },
  Praise: { Critical: 0.01, High: 0.04, Medium: 0.15, Low: 0.8 },
  Question: { Critical: 0.05, High: 0.2, Medium: 0.5, Low: 0.25 },
};

let session = null;

async function loadModel() {
  const modelPath = process.env.ONNX_MODEL_PATH || path.join(__dirname, '../../../train/model_output/onnx/model.onnx');
  session = await ort.InferenceSession.create(modelPath);
  console.log(JSON.stringify({ level: 'info', message: 'ONNX model loaded', modelPath }));
}

function softmax(arr) {
  const max = Math.max(...arr);
  const exps = arr.map(x => Math.exp(x - max));
  const sum = exps.reduce((a, b) => a + b, 0);
  return exps.map(x => x / sum);
}

async function classify(text) {
  if (!session) throw new Error('Model not loaded');
  // Simple whitespace tokenizer for demo; production would use HF tokenizer
  const tokens = text.toLowerCase().split(/\s+/).slice(0, 128);
  const inputIds = new BigInt64Array(128).fill(0n);
  tokens.forEach((_, i) => { inputIds[i] = BigInt(i + 1); });
  const attentionMask = new BigInt64Array(128).fill(0n);
  for (let i = 0; i < tokens.length; i++) attentionMask[i] = 1n;

  const feeds = {
    input_ids: new ort.Tensor('int64', inputIds, [1, 128]),
    attention_mask: new ort.Tensor('int64', attentionMask, [1, 128]),
  };
  const results = await session.run(feeds);
  const logits = Array.from(results.logits.data);
  const probs = softmax(logits);
  const categoryIdx = probs.indexOf(Math.max(...probs));
  const category = CATEGORY_LABELS[categoryIdx];
  const confidence = probs[categoryIdx];
  const priorityWeights = PRIORITY_MAP[category];
  const priority = Object.entries(priorityWeights).sort((a, b) => b[1] - a[1])[0][0];
  return { category, priority, confidence, probabilities: probs };
}

module.exports = { loadModel, classify };

What does this code do?

This module handles the core ML inference. It loads the ONNX model once into a session, then exposes a classify function that:

  • Tokenizes the input text into integer IDs and builds an attention mask (matching what DistilBERT expects).
  • Feeds the tensors through the model and gets raw logits back.
  • Applies softmax to convert logits into probability scores, then picks the highest-confidence category.
  • Uses a priority map to assign urgency based on the predicted category.
  • Create api/src/server.js with the Express server and API endpoints:
require('dotenv').config();
const express = require('express');
const { loadModel, classify } = require('./inference/classifier');
const limiter = require('./middleware/rateLimiter');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use('/api/', limiter);

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'feedback-triage-api' });
});

app.post('/api/predict', async (req, res) => {
  const start = Date.now();
  const { text } = req.body;
  if (!text || typeof text !== 'string' || text.trim().length === 0) {
    return res.status(400).json({ error: 'text field is required and must be a non-empty string' });
  }
  try {
    const result = await classify(text);
    const latencyMs = Date.now() - start;
    console.log(JSON.stringify({ level: 'info', message: 'prediction', text: text.slice(0, 50), ...result, latencyMs }));
    res.json({ ...result, latencyMs });
  } catch (err) {
    console.error(JSON.stringify({ level: 'error', message: err.message }));
    res.status(500).json({ error: 'Inference failed' });
  }
});

loadModel().then(() => {
  app.listen(PORT, () => {
    console.log(JSON.stringify({ level: 'info', message: `API listening on port ${PORT}` }));
  });
}).catch(err => {
  console.error(JSON.stringify({ level: 'error', message: 'Failed to load model', error: err.message }));
  process.exit(1);
});

What does this code do?

The server defines two endpoints:

  • GET /api/health returns the service status. Docker Compose uses this as a health check.
  • POST /api/predict accepts a JSON body with a text field, runs inference, and returns the predicted category, priority, confidence score, and latency.

All logging uses structured JSON format. This makes logs parseable by observability tools like Datadog or CloudWatch.

  • Save all three files (package.json, classifier.js, server.js).

You won't see a change yet. The server depends on the rate limiter and Docker setup that come next.

Seeing a file not found error?

Make sure your directory structure matches: api/src/inference/classifier.js and api/src/server.js. The require paths are relative, so the folder hierarchy must be exact.

help me fix my API directory structure

✔️ Awesome, I've got everything!

Great. Make sure you've saved all files before moving on.

ⓧ I'd like to double check the full code

Here is the full api/src/inference/classifier.js:

const ort = require('onnxruntime-node');
const path = require('path');

const CATEGORY_LABELS = ['Bug', 'Feature Request', 'Complaint', 'Praise', 'Question'];
const PRIORITY_MAP = {
  Bug: { Critical: 0.8, High: 0.15, Medium: 0.04, Low: 0.01 },
  Complaint: { Critical: 0.1, High: 0.6, Medium: 0.25, Low: 0.05 },
  'Feature Request': { Critical: 0.02, High: 0.1, Medium: 0.5, Low: 0.38 },
  Praise: { Critical: 0.01, High: 0.04, Medium: 0.15, Low: 0.8 },
  Question: { Critical: 0.05, High: 0.2, Medium: 0.5, Low: 0.25 },
};

let session = null;

async function loadModel() {
  const modelPath = process.env.ONNX_MODEL_PATH || path.join(__dirname, '../../../train/model_output/onnx/model.onnx');
  session = await ort.InferenceSession.create(modelPath);
  console.log(JSON.stringify({ level: 'info', message: 'ONNX model loaded', modelPath }));
}

function softmax(arr) {
  const max = Math.max(...arr);
  const exps = arr.map(x => Math.exp(x - max));
  const sum = exps.reduce((a, b) => a + b, 0);
  return exps.map(x => x / sum);
}

async function classify(text) {
  if (!session) throw new Error('Model not loaded');
  // Simple whitespace tokenizer for demo; production would use HF tokenizer
  const tokens = text.toLowerCase().split(/\s+/).slice(0, 128);
  const inputIds = new BigInt64Array(128).fill(0n);
  tokens.forEach((_, i) => { inputIds[i] = BigInt(i + 1); });
  const attentionMask = new BigInt64Array(128).fill(0n);
  for (let i = 0; i < tokens.length; i++) attentionMask[i] = 1n;

  const feeds = {
    input_ids: new ort.Tensor('int64', inputIds, [1, 128]),
    attention_mask: new ort.Tensor('int64', attentionMask, [1, 128]),
  };
  const results = await session.run(feeds);
  const logits = Array.from(results.logits.data);
  const probs = softmax(logits);
  const categoryIdx = probs.indexOf(Math.max(...probs));
  const category = CATEGORY_LABELS[categoryIdx];
  const confidence = probs[categoryIdx];
  const priorityWeights = PRIORITY_MAP[category];
  const priority = Object.entries(priorityWeights).sort((a, b) => b[1] - a[1])[0][0];
  return { category, priority, confidence, probabilities: probs };
}

module.exports = { loadModel, classify };

And here is the full api/src/server.js:

require('dotenv').config();
const express = require('express');
const { loadModel, classify } = require('./inference/classifier');
const limiter = require('./middleware/rateLimiter');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use('/api/', limiter);

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'feedback-triage-api' });
});

app.post('/api/predict', async (req, res) => {
  const start = Date.now();
  const { text } = req.body;
  if (!text || typeof text !== 'string' || text.trim().length === 0) {
    return res.status(400).json({ error: 'text field is required and must be a non-empty string' });
  }
  try {
    const result = await classify(text);
    const latencyMs = Date.now() - start;
    console.log(JSON.stringify({ level: 'info', message: 'prediction', text: text.slice(0, 50), ...result, latencyMs }));
    res.json({ ...result, latencyMs });
  } catch (err) {
    console.error(JSON.stringify({ level: 'error', message: err.message }));
    res.status(500).json({ error: 'Inference failed' });
  }
});

loadModel().then(() => {
  app.listen(PORT, () => {
    console.log(JSON.stringify({ level: 'info', message: `API listening on port ${PORT}` }));
  });
}).catch(err => {
  console.error(JSON.stringify({ level: 'error', message: 'Failed to load model', error: err.message }));
  process.exit(1);
});
Add rate limiting and environment configuration

Production APIs need protection against request floods. Rate limiting caps how many requests a single client can make in a time window.

  • Create api/src/middleware/rateLimiter.js with the rate limiting middleware:
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 60,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});

module.exports = limiter;

What does this code do?

This configures express-rate-limit to allow a maximum of 60 requests per minute per IP address. The windowMs sets the time window (60 seconds), and max sets the request cap.

When a client exceeds the limit, they receive a 429 status code with the JSON error message instead of a prediction.

  • Create api/.env with the environment configuration for local development:
PORT=3000
NODE_ENV=development
ONNX_MODEL_PATH=../train/model_output/onnx/model.onnx

What does this file do?

The .env file stores environment-specific settings. When running locally, the server reads these values. When running in Docker, the docker-compose.yml injects its own environment variables that override these defaults.

  • Save both rateLimiter.js and .env.

You won't see a change yet. The next substep containerizes the API and runs it.

Containerize the API and verify predictions

With the code written, you'll package the API into a Docker image and wire it into the existing Compose stack alongside MLflow and PostgreSQL.

  • Create api/Dockerfile to containerize the Node.js API:
FROM node:22-slim
WORKDIR /app
COPY package.json .
RUN npm install --production
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/server.js"]

What does this Dockerfile do?

It starts from the official Node.js 22 slim image. It copies package.json first and installs dependencies before copying source code. This ordering lets Docker cache the dependency layer, so rebuilds are fast when only your code changes.

  • Open your docker-compose.yml in the project root.
  • Find the mlflow: service block. After it (and before the volumes: section at the bottom), add the api service:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - ONNX_MODEL_PATH=/app/model/model.onnx
      - PORT=3000
    volumes:
      - ./train/model_output/onnx:/app/model:ro
    depends_on:
      - mlflow
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3

What does this service definition do?

The volumes line mounts your local ONNX model file into the container at /app/model/model.onnx as read-only. This means you don't need to bake the model into the Docker image. When you retrain and re-export, the container picks up the new model on restart.

The healthcheck calls /api/health every 10 seconds. Docker marks the container as unhealthy if it fails 3 times in a row.

  • Save docker-compose.yml.
  • Rebuild and start the full stack by running:
docker compose up --build -d
  • Wait about 30 seconds for the API container to start and load the ONNX model.
  • Verify the health endpoint by running:
curl http://localhost:3000/api/health

You should see a JSON response with "status": "ok" and the service name feedback-triage-api.

  • Test a prediction by running:
curl -X POST http://localhost:3000/api/predict \
  -H "Content-Type: application/json" \
  -d '{"text": "The app crashes every time I open settings"}'

You should see a JSON response containing category, priority, confidence, and latencyMs fields.

API container not starting?

Check the container logs for errors by running docker compose logs api. Common issues include:

  • The ONNX model file doesn't exist at train/model_output/onnx/model.onnx. Make sure you completed the export step from Step 3.
  • Port 3000 is already in use by another process. Stop the conflicting process or change the port mapping in docker-compose.yml.

help me debug why my API container won't start

✔️ Awesome, I've got everything!

Great. Double check you've saved docker-compose.yml and that docker compose up --build -d completed without errors.

ⓧ I'd like to double check the full code

Here is the full docker-compose.yml:

version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: mlflow
      POSTGRES_PASSWORD: mlflow
      POSTGRES_DB: mlflow
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mlflow"]
      interval: 5s
      timeout: 5s
      retries: 5

  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.11.1
    ports:
      - "5000:5000"
    environment:
      MLFLOW_BACKEND_STORE_URI: postgresql://mlflow:mlflow@postgres:5432/mlflow
      MLFLOW_DEFAULT_ARTIFACT_ROOT: /mlflow/artifacts
    volumes:
      - mlflow_artifacts:/mlflow/artifacts
    command: mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri postgresql://mlflow:mlflow@postgres:5432/mlflow --default-artifact-root /mlflow/artifacts
    depends_on:
      postgres:
        condition: service_healthy

  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - ONNX_MODEL_PATH=/app/model/model.onnx
      - PORT=3000
    volumes:
      - ./train/model_output/onnx:/app/model:ro
    depends_on:
      - mlflow
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  postgres_data:
  mlflow_artifacts:

Your inference API is live. You now have three services running in Docker Compose: PostgreSQL, MLflow, and the Node.js API serving real-time predictions. Next up, you'll build a mobile app that sends feedback to this API and displays the results.

Build the React Native Mobile App

Your inference API is running and returning predictions. Now you need a way for real users to submit feedback and see the AI's classification results.

In this step, you'll build a React Native mobile app using Expo. The app sends feedback text to your API and displays the predicted category, priority, and confidence score with color-coded visual indicators.

In this step, get ready to:
  • Create the Expo project and build the feedback submission form UI.
  • Connect the app to the inference API and display classification results.
  • Add color-coded priority badges and category icons for visual feedback.
Create the Expo project and build the feedback submission form UI

Expo is a framework that simplifies React Native development. It handles the build tooling so you can focus on writing JavaScript components.

  • Switch back to your terminal and make sure you're in the project root (the folder containing train/, api/, and docker-compose.yml).
  • Create the Expo project in a mobile/ directory by running this command:
npx create-expo-app@latest mobile --template blank

What does this command do?

The --template blank flag creates a minimal Expo project without navigation or routing. This gives you a single App.js entry point, which is all you need for this feedback form.

The command prompts for the app name. Enter FeedbackTriage when asked.

  • Move into the new project directory by running:
cd mobile
  • Confirm the project was created by running:
ls

You should see App.js, package.json, app.json, and a node_modules/ folder in the output.

Command not found?

Make sure Node.js is installed (you installed it in Step 1). If npx isn't recognized, close and reopen your terminal so the PATH updates take effect.

help me debug why npx create-expo-app isn't working

Now you'll replace the generated app.json with a minimal configuration for this project.

  • Open mobile/app.json in your editor and replace its contents with the following:
{
  "expo": {
    "name": "FeedbackTriage",
    "slug": "feedback-triage",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"]
  }
}

What does this configure?

The name field is the display name for the app. The slug is the URL-friendly identifier used by Expo's hosting services. Restricting platforms to iOS and Android keeps the config focused on mobile.

  • Save app.json.

Next, you'll build the main app component with the feedback submission form. This is the UI the user interacts with to type and submit their feedback.

  • Open mobile/App.js and replace all of its contents with the following imports, constants, and component setup:
import React, { useState } from 'react';
import {
  View, Text, TextInput, TouchableOpacity,
  StyleSheet, ActivityIndicator, ScrollView, SafeAreaView
} from 'react-native';

const API_URL = 'http://localhost:3000';

What does this code do?

The imports pull in React Native's core UI components. SafeAreaView keeps content away from the device notch. TouchableOpacity creates a tappable button with a press animation.

The API_URL constant points to your locally running inference API at port 3000. On a physical device, you would replace this with your machine's local network IP address.

Connect to the inference API and display classification results

The app needs to send the user's feedback text to the prediction endpoint and handle the response. You'll add the state management, the API call, and the full JSX layout.

  • In App.js, add the following code directly below the API_URL line. This defines the visual indicator constants and the main component with API integration:
const PRIORITY_COLORS = {
  Critical: '#ef4444',
  High: '#f97316',
  Medium: '#eab308',
  Low: '#22c55e',
};

const CATEGORY_ICONS = {
  Bug: '\uD83D\uDC1B',
  'Feature Request': '\u2728',
  Complaint: '\uD83D\uDE24',
  Praise: '\uD83C\uDF89',
  Question: '\u2753',
};

export default function App() {
  const [text, setText] = useState('');
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async () => {
    if (!text.trim()) return;
    setLoading(true);
    setError(null);
    setResult(null);
    try {
      const response = await fetch(`${API_URL}/api/predict`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      if (!response.ok) throw new Error(`API error: ${response.status}`);
      const data = await response.json();
      setResult(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

What does this code do?

The PRIORITY_COLORS object maps each priority level to a hex color. Critical is red, High is orange, Medium is yellow, Low is green. These drive the badge background colors in the results display.

The CATEGORY_ICONS object maps each category to an emoji for quick visual recognition.

The handleSubmit function sends a POST request to /api/predict with the feedback text. It manages loading and error states so the UI can show a spinner while waiting and an error message if the request fails.

Add visual feedback indicators

Now you'll add the JSX that renders the form and classification results, followed by the stylesheet that controls the layout and colors.

  • Still in App.js, add the following return statement and closing brace directly below the handleSubmit function:
  return (
    <SafeAreaView style={styles.container}>
      <ScrollView contentContainerStyle={styles.scroll}>
        <Text style={styles.title}>Feedback Triage</Text>
        <Text style={styles.subtitle}>AI-powered classification</Text>
        <TextInput
          style={styles.input}
          multiline
          numberOfLines={4}
          placeholder="Describe your feedback..."
          value={text}
          onChangeText={setText}
        />
        <TouchableOpacity style={styles.button} onPress={handleSubmit} disabled={loading}>
          {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Classify Feedback</Text>}
        </TouchableOpacity>
        {error && <Text style={styles.error}>{error}</Text>}
        {result && (
          <View style={styles.resultCard}>
            <Text style={styles.resultTitle}>Classification Result</Text>
            <View style={styles.row}>
              <Text style={styles.icon}>{CATEGORY_ICONS[result.category]}</Text>
              <Text style={styles.category}>{result.category}</Text>
            </View>
            <View style={[styles.badge, { backgroundColor: PRIORITY_COLORS[result.priority] }]}>
              <Text style={styles.badgeText}>{result.priority} Priority</Text>
            </View>
            <Text style={styles.confidence}>Confidence: {(result.confidence * 100).toFixed(1)}%</Text>
            <Text style={styles.latency}>Latency: {result.latencyMs}ms</Text>
          </View>
        )}
      </ScrollView>
    </SafeAreaView>
  );
}

What does this JSX do?

The form section renders a multiline text input and a submit button. While the API call is in progress, the button shows a spinning indicator instead of text.

The results card only renders when result is not null. It displays the category with its emoji icon, a color-coded priority badge, the confidence percentage, and the inference latency in milliseconds.

  • Finally, add the stylesheet at the very bottom of App.js, after the closing brace of the App function:
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f8fafc' },
  scroll: { padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', color: '#1e293b', marginBottom: 4 },
  subtitle: { fontSize: 14, color: '#64748b', marginBottom: 24 },
  input: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 12, padding: 16, fontSize: 16, minHeight: 100, textAlignVertical: 'top', marginBottom: 16 },
  button: { backgroundColor: '#6366f1', borderRadius: 12, padding: 16, alignItems: 'center', marginBottom: 16 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: '#ef4444', marginBottom: 16 },
  resultCard: { backgroundColor: '#fff', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#e2e8f0' },
  resultTitle: { fontSize: 18, fontWeight: '600', color: '#1e293b', marginBottom: 12 },
  row: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
  icon: { fontSize: 24, marginRight: 8 },
  category: { fontSize: 20, fontWeight: '600', color: '#334155' },
  badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6, marginBottom: 12 },
  badgeText: { color: '#fff', fontWeight: '600', fontSize: 14 },
  confidence: { fontSize: 14, color: '#64748b', marginBottom: 4 },
  latency: { fontSize: 12, color: '#94a3b8' },
});

What do these styles control?

The badge style creates a pill-shaped element. Its backgroundColor is set dynamically in the JSX using the PRIORITY_COLORS map. Critical feedback gets a red badge, High gets orange, Medium gets yellow, and Low gets green.

The input style uses textAlignVertical: 'top' so text starts at the top of the multiline box rather than vertically centered.

  • Save App.js.
  • Start the Expo development server by running:
npx expo start

You should see a QR code in your terminal. Scan it with the Expo Go app on your phone, or press i to open the iOS Simulator.

What should I see?

The app displays a title ("Feedback Triage"), a subtitle ("AI-powered classification"), a multiline text input, and a purple "Classify Feedback" button. When you type feedback and tap the button, it sends the text to your API and shows a result card with the category emoji, category name, a color-coded priority badge, the confidence percentage, and latency.

Seeing a network error?

If you're testing on a physical device, localhost won't resolve to your computer. Replace the API_URL value in App.js with your machine's local IP address (e.g. http://192.168.1.100:3000). Find your IP by running ifconfig on macOS or ipconfig on Windows.

Also confirm your Docker Compose stack is still running. The API container must be up for the app to receive predictions.

help me debug network errors between my Expo app and Docker API

✔️ Awesome, I've got everything!

Great. Double check you've saved App.js and app.json in the mobile/ directory.

ⓧ I'd like to double check the full code

import React, { useState } from 'react';
import {
  View, Text, TextInput, TouchableOpacity,
  StyleSheet, ActivityIndicator, ScrollView, SafeAreaView
} from 'react-native';

const API_URL = 'http://localhost:3000';

const PRIORITY_COLORS = {
  Critical: '#ef4444',
  High: '#f97316',
  Medium: '#eab308',
  Low: '#22c55e',
};

const CATEGORY_ICONS = {
  Bug: '🐛',
  'Feature Request': '✨',
  Complaint: '😤',
  Praise: '🎉',
  Question: '❓',
};

export default function App() {
  const [text, setText] = useState('');
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async () => {
    if (!text.trim()) return;
    setLoading(true);
    setError(null);
    setResult(null);
    try {
      const response = await fetch(`${API_URL}/api/predict`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      if (!response.ok) throw new Error(`API error: ${response.status}`);
      const data = await response.json();
      setResult(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <SafeAreaView style={styles.container}>
      <ScrollView contentContainerStyle={styles.scroll}>
        <Text style={styles.title}>Feedback Triage</Text>
        <Text style={styles.subtitle}>AI-powered classification</Text>
        <TextInput
          style={styles.input}
          multiline
          numberOfLines={4}
          placeholder="Describe your feedback..."
          value={text}
          onChangeText={setText}
        />
        <TouchableOpacity style={styles.button} onPress={handleSubmit} disabled={loading}>
          {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Classify Feedback</Text>}
        </TouchableOpacity>
        {error && <Text style={styles.error}>{error}</Text>}
        {result && (
          <View style={styles.resultCard}>
            <Text style={styles.resultTitle}>Classification Result</Text>
            <View style={styles.row}>
              <Text style={styles.icon}>{CATEGORY_ICONS[result.category]}</Text>
              <Text style={styles.category}>{result.category}</Text>
            </View>
            <View style={[styles.badge, { backgroundColor: PRIORITY_COLORS[result.priority] }]}>
              <Text style={styles.badgeText}>{result.priority} Priority</Text>
            </View>
            <Text style={styles.confidence}>Confidence: {(result.confidence * 100).toFixed(1)}%</Text>
            <Text style={styles.latency}>Latency: {result.latencyMs}ms</Text>
          </View>
        )}
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f8fafc' },
  scroll: { padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', color: '#1e293b', marginBottom: 4 },
  subtitle: { fontSize: 14, color: '#64748b', marginBottom: 24 },
  input: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 12, padding: 16, fontSize: 16, minHeight: 100, textAlignVertical: 'top', marginBottom: 16 },
  button: { backgroundColor: '#6366f1', borderRadius: 12, padding: 16, alignItems: 'center', marginBottom: 16 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: '#ef4444', marginBottom: 16 },
  resultCard: { backgroundColor: '#fff', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#e2e8f0' },
  resultTitle: { fontSize: 18, fontWeight: '600', color: '#1e293b', marginBottom: 12 },
  row: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
  icon: { fontSize: 24, marginRight: 8 },
  category: { fontSize: 20, fontWeight: '600', color: '#334155' },
  badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6, marginBottom: 12 },
  badgeText: { color: '#fff', fontWeight: '600', fontSize: 14 },
  confidence: { fontSize: 14, color: '#64748b', marginBottom: 4 },
  latency: { fontSize: 12, color: '#94a3b8' },
});
{
  "expo": {
    "name": "FeedbackTriage",
    "slug": "feedback-triage",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"]
  }
}

Your mobile app is live and classifying feedback in real time. Next up, you'll automate testing and image builds with a GitHub Actions CI/CD pipeline.

Set Up CI/CD with GitHub Actions

Your mobile app is connected and classifying feedback in real-time. The entire system works end-to-end on your machine.

Now you need to ensure that every code change is tested automatically before it can break production. GitHub Actions lets you define workflows that run on every push, catching bugs before they reach your users.

In this step, get ready to:
  • Create a GitHub Actions workflow that tests the Node.js API on every push.
  • Add a Docker image build job that pushes to GitHub Container Registry with layer caching.
  • Add a model validation gate that blocks deployment if model accuracy falls below 0.7.
Create the GitHub Actions workflow with the test job

A CI/CD pipeline automates testing and deployment. You will define a workflow file that GitHub discovers and runs every time you push code to the main branch.

  • In WebStorm, create a new file called ci.yml inside the .github/workflows/ directory of your feedback-triage project.
  • Paste the following workflow configuration into ci.yml:
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test-api:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: api
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: api/package-lock.json
      - run: npm ci
      - run: npm test

What does this workflow do?

The on block triggers the workflow on every push or pull request to the main branch.

The test-api job checks out the code, installs Node.js 22 with npm caching enabled, then runs npm ci (a clean install from the lockfile) followed by npm test. The working-directory: api setting scopes all commands to the api/ folder inside the monorepo.

Why cache npm dependencies?

The cache: 'npm' option on actions/setup-node caches the global npm cache between workflow runs. This means subsequent runs skip downloading packages that haven't changed, cutting CI time significantly.

  • Save ci.yml.

Jest needs at least one test file

By default, jest exits with an error if no test files are found. To make the workflow pass, create a placeholder test file at api/src/__tests__/health.test.js with a simple assertion, or add --passWithNoTests to the test script in api/package.json.

Workflow not showing up in GitHub?

The file must be at the exact path .github/workflows/ci.yml from the repository root. Double-check the folder names are lowercase and there are no typos.

Help me debug why my workflow file isn't detected

Add the Docker build and push job

Once tests pass, you want to automatically build a Docker image of your API and push it to a container registry. GitHub Container Registry (GHCR) is free for public repositories and integrates directly with your GitHub account.

  • In ci.yml, add the following build-and-push job below the test-api job (still inside the jobs: block):
  build-and-push:
    needs: test-api
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v5
        with:
          context: ./api
          push: true
          tags: ghcr.io/${{ github.repository }}/feedback-triage-api:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

What does this job do?

The needs: test-api line ensures this job only runs after tests pass. It logs into GHCR using the built-in GITHUB_TOKEN secret (no manual setup required).

The docker/build-push-action builds the image from ./api and pushes it to GHCR. The cache-from: type=gha and cache-to: type=gha,mode=max lines enable GitHub Actions layer caching so subsequent builds reuse unchanged layers.

  • Save ci.yml.

Permission denied pushing to GHCR?

The permissions block grants packages: write to the job. If you see a 403 error, check that your repository's Settings > Actions > General > Workflow permissions is set to allow read and write.

Help me fix GHCR permission errors

Add the model validation gate

You do not want to deploy a model that performs below a minimum quality bar. The model validation gate queries the MLflow Model Registry and fails the pipeline if the registered model's accuracy is below 0.7.

  • In ci.yml, add the following model-validation job below the build-and-push job:
  model-validation:
    needs: test-api
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install mlflow psycopg2-binary
      - name: Validate model accuracy
        env:
          MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
        run: |
          python - <<'EOF'
          import mlflow
          import sys
          client = mlflow.MlflowClient()
          versions = client.get_latest_versions('feedback-triage-classifier', stages=['Production', 'champion'])
          if not versions:
              print('No registered model found, skipping validation')
              sys.exit(0)
          run_id = versions[0].run_id
          run = client.get_run(run_id)
          accuracy = run.data.metrics.get('eval_accuracy', 0)
          print(f'Model accuracy: {accuracy}')
          if accuracy < 0.7:
              print(f'FAIL: accuracy {accuracy} below threshold 0.7')
              sys.exit(1)
          print('PASS: model meets accuracy threshold')
          EOF

What does the validation gate do?

This job installs mlflow and connects to your MLflow tracking server using the MLFLOW_TRACKING_URI secret. It queries the Model Registry for a model named feedback-triage-classifier in the Production or champion stage.

If no model is registered yet, the gate passes gracefully with sys.exit(0). If a model exists but its eval_accuracy metric is below 0.7, the job fails with sys.exit(1) and blocks the pipeline.

Set the MLFLOW_TRACKING_URI secret later

For now, the model validation job will pass because no model is registered on the remote MLflow instance. When you deploy MLflow to a public URL, add MLFLOW_TRACKING_URI as a repository secret under Settings > Secrets and variables > Actions.

  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/ci.yml with all three jobs.

ⓧ I'd like to double check the full code

Here is the complete ci.yml file with all three jobs:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test-api:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: api
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
          cache-dependency-path: api/package-lock.json
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test-api
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v5
        with:
          context: ./api
          push: true
          tags: ghcr.io/${{ github.repository }}/feedback-triage-api:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  model-validation:
    needs: test-api
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install mlflow psycopg2-binary
      - name: Validate model accuracy
        env:
          MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
        run: |
          python - <<'EOF'
          import mlflow
          import sys
          client = mlflow.MlflowClient()
          versions = client.get_latest_versions('feedback-triage-classifier', stages=['Production', 'champion'])
          if not versions:
              print('No registered model found, skipping validation')
              sys.exit(0)
          run_id = versions[0].run_id
          run = client.get_run(run_id)
          accuracy = run.data.metrics.get('eval_accuracy', 0)
          print(f'Model accuracy: {accuracy}')
          if accuracy < 0.7:
              print(f'FAIL: accuracy {accuracy} below threshold 0.7')
              sys.exit(1)
          print('PASS: model meets accuracy threshold')
          EOF
Push to GitHub and verify the workflow runs

The workflow file exists locally but GitHub Actions only runs after the code is pushed to a remote repository. You need to create a GitHub repository and push your monorepo.

  • Open your browser and go to github.com.
  • Click the + button in the top right corner, then select New repository.
  • Name the repository feedback-triage.
  • Leave it as Public (required for free GHCR access).
  • Click Create repository.
  • Back in your terminal, navigate to the feedback-triage project root and initialize git by running these commands:
cd ~/Desktop/feedback-triage
git init
git add .
git commit -m "Initial commit: full ML pipeline"
  • Connect to your remote repository and push by running:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/feedback-triage.git
git branch -M main
git push -u origin main

What should I see?

After pushing, GitHub detects the .github/workflows/ci.yml file and automatically starts a workflow run. You can watch it live in the Actions tab.

  • In your browser, go to your feedback-triage repository on GitHub.
  • Click the Actions tab at the top of the repository page.
  • You should see a workflow run named CI triggered by your push. Click it to see the job details.

You should see three jobs listed: test-api, build-and-push, and model-validation. The build-and-push and model-validation jobs wait for test-api to finish before starting.

Workflow run not appearing?

Make sure you pushed to the main branch. The workflow only triggers on pushes to main or pull requests targeting main.

If the Actions tab is missing entirely, go to Settings > Actions > General and confirm that Actions is enabled for the repository.

Help me debug why my workflow isn't running

Your CI/CD pipeline is live. Every push to main now triggers automated testing, container builds, and model quality checks. Next up, the Secret Mission challenges you to add a human-in-the-loop correction system.

Secret mission

Add Human-in-the-Loop Feedback Corrections

Your AI model is live, but no model is perfect. What happens when the classifier gets a prediction wrong? In production, the best teams close the loop by letting users correct mistakes and feeding those corrections back into the next training run. Build a human-in-the-loop correction system that makes your AI smarter over time.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker containers. There are no ongoing cloud costs or subscriptions to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker containers (PostgreSQL, MLflow, API) defined in docker-compose.yml.
  • Docker volumes (postgres_data, mlflow_artifacts) storing database and artifact data.
  • Docker images built during the project (training image, API image).
  • Local project files (train/, api/, mobile/, .github/workflows/, docker-compose.yml).

Keep everything running

No action needed. Choose this if you're still actively experimenting with different model versions or tuning the training pipeline.

  • Your Docker Compose stack continues running in the background.
  • MLflow UI stays accessible at http://localhost:5000.
  • The API remains available at http://localhost:3000.
  • Docker Desktop will use memory while containers are running.

Pause - I'll come back to this later

Stop all containers to free up memory without losing any data. Your volumes and project files remain intact so you can resume later.

  • Open your terminal and navigate to the folder containing your docker-compose.yml file.
  • Stop all containers by running:
docker compose stop
  • When you're ready to resume, start everything back up by running:
docker compose start

What does docker compose stop do?

This command sends a stop signal to each running container without removing them. Your data in postgres_data and mlflow_artifacts volumes is preserved.

Delete - I don't want to use this again

Remove all containers, volumes, images, and project files to completely clean up.

  • Open your terminal and navigate to the folder containing your docker-compose.yml file.
  • Remove all containers, networks, and volumes by running:
docker compose down -v

What does docker compose down -v do?

This stops and removes all containers and networks created by Docker Compose. The -v flag also deletes named volumes, including your postgres_data (MLflow database) and mlflow_artifacts (stored models).

  • Remove the Docker images built during the project by running:
docker image prune -a --filter "label=com.docker.compose.project"
  • Delete the entire project directory by running:

macOS

cd ~/Desktop
rm -rf feedback-triage

Windows

cd ~\Desktop
rmdir /s /q feedback-triage

After these commands, all project resources are fully removed from your machine.

Nice Work!

Nice Work!

You just built a production-grade AI system from scratch. From training a custom text classifier to serving real-time predictions through a containerized API and mobile app, you've completed the full MLOps lifecycle.

You learned how to:

  • Fine-tuned a DistilBERT text classifier on custom data and tracked every experiment with MLflow Model Registry.
  • Exported the model to ONNX format and served sub-50ms predictions from a Node.js inference API with structured logging and rate limiting.
  • Orchestrated the entire stack with Docker Compose and automated testing and image builds with GitHub Actions CI/CD.
  • Added a human-in-the-loop correction system that lets users fix misclassifications and feeds corrections back into the next training run for continuous model improvement.

Ready to quiz yourself?