Estimated time: 6–10 hours · Portfolio-grade · Deployable
This is the Chapter 3 capstone. It integrates every pattern from the chapter into a production-shaped service that you can deploy, demonstrate, and discuss in interviews. The system accepts document uploads, processes them asynchronously, persists results, and exposes a clean API — the architecture that backs real document intelligence products.
The system implements:
The system supports two distinct usage patterns:
Synchronous chat — the user sends a message, the LLM replies in real time. Backed by conversation history in PostgreSQL.
Asynchronous document processing — the user submits a document, receives a job ID immediately, and polls for results. The Celery worker processes the document (extract, classify, summarize) and stores results.
Client
│
▼
FastAPI (port 8000) ─── PostgreSQL (port 5432)
│ ├── conversations table
├── /conversations/* ├── messages table
├── /jobs/* ├── jobs table
├── /chat └── documents table
└── /health
│
▼
Redis (port 6379) ◄──── Celery Worker
├── process_document task
└── batch_classify task
MCP Server (stdio / port 8001)
├── tool: summarize_text
├── tool: classify_text
├── tool: get_job_status
└── resource: api://jobs/recent
| Service | Image | Exposed port |
|---|---|---|
api |
Your Dockerfile | 8000 |
db |
postgres:16-alpine | 5432 |
redis |
redis:7-alpine | 6379 |
worker |
Your Dockerfile (different command) | — |
flower |
mher/flower | 5555 |
| Method | Path | Description |
|---|---|---|
| POST | /conversations |
Create a conversation |
| GET | /conversations |
List conversations (recent first) |
| GET | /conversations/{id} |
Get conversation with messages |
| POST | /conversations/{id}/messages |
Send message, receive LLM reply |
| DELETE | /conversations/{id} |
Delete conversation + messages |
| Method | Path | Description |
|---|---|---|
| POST | /jobs |
Submit a document for processing (returns 202 + job ID) |
| GET | /jobs/{id} |
Get job status and result |
| GET | /jobs |
List recent jobs (last 20) |
| DELETE | /jobs/{id} |
Cancel a queued job (or delete a completed one) |
| Method | Path | Description |
|---|---|---|
| GET | /health |
Returns service status, DB connectivity, worker count |
| POST | /chat |
Stateless single-turn chat (from P007) |
| POST | /chat/batch |
Concurrent batch chat (from P007) |
process_documentTriggered by POST /jobs. Runs three sequential LLM calls:
Stores all three results in the jobs table as JSON. Updates status from queued → processing → complete (or failed). Retries on OpenAI errors up to 3 times with exponential backoff.
batch_classifyAccepts a list of texts and labels. Classifies all in parallel using Python ThreadPoolExecutor (sync OpenAI client, multi-threaded). Returns a list of {text_index, label, confidence} objects.
asyncio.gather. For parallelism inside a task, use concurrent.futures.ThreadPoolExecutor — each thread makes its own synchronous OpenAI call.
All tables must be managed via Alembic. Your migrations directory should have:
conversations and messages tablesjobs table with columns: id (UUID), status, document_text, result_json (TEXT, nullable), error_message (TEXT, nullable), created_at, completed_at (nullable)cost_usd (FLOAT, nullable) to messagesBuild an MCP server (mcp_server.py) that exposes the system to AI clients:
| Name | Type | Description |
|---|---|---|
summarize_text |
Tool | Summarizes text using the AI backend |
classify_document |
Tool | Classifies text into legal/financial/technical/medical/general |
submit_document_job |
Tool | Submits a document to the processing queue and returns a job ID |
get_job_status |
Tool | Returns current status and result (if complete) for a job ID |
api://jobs/recent |
Resource | JSON list of the 10 most recent jobs with their statuses |
The MCP tools that call the API should use httpx to call your FastAPI server (which is running on localhost:8000 or the container’s hostname).
docker compose up -d starts all 5 services with no errorsGET /health returns 200 with DB connectivity confirmedalembic history shows 3 migrations; alembic current shows headdocker compose down → docker compose up -d → all conversations and jobs are still in the databasesubmit_document_job tool submits a job; get_job_status returns the result/docs shows them.env is in both .dockerignore and .gitignoreREADME.md explains how to run the project (1 page max — just the commands needed)ai-backend/
├── Dockerfile
├── docker-compose.yml
├── entrypoint.sh
├── .env # not committed
├── .dockerignore
├── .gitignore
├── alembic.ini
├── alembic/
│ ├── env.py
│ └── versions/
│ ├── 001_create_conversations_messages.py
│ ├── 002_create_jobs.py
│ └── 003_add_cost_usd_to_messages.py
├── main.py # FastAPI app, includes routers
├── database.py # SQLAlchemy models + engine + get_db
├── tasks.py # Celery app + task definitions
├── mcp_server.py # MCP server
├── dependencies.py # get_client(), require_api_key()
├── routers/
│ ├── conversations.py
│ ├── jobs.py
│ ├── chat.py
│ └── health.py
└── models/
├── requests.py
└── responses.py
Streaming chat endpoint: Add POST /chat/stream that uses FastAPI’s StreamingResponse and the OpenAI streaming API to send tokens as they are generated. Use Server-Sent Events format.
API key authentication: Add bearer token authentication to all endpoints except /health. Store valid API keys in Postgres and verify via a dependency.
Job priority: Add a priority field to job submission (low/normal/high). Use Celery’s task priority feature to process high-priority jobs before low-priority ones.
Webhook notifications: Add an optional callback_url to job submission. When a job completes, the Celery task makes an HTTP POST to the callback URL with the result. This removes the need for the client to poll.
Deploy it: Push to a GitHub repo, set up a Hetzner CX21 VM (€3.29/month), install Docker, and run docker compose up -d on the VM. This is the Chapter 6 deployment you will do properly — doing it now for practice is valuable.
Finished this project? Share your GitHub repo link in the discussion below. Browse what others built to get inspiration and give feedback.