A backend service for managing customer support tickets with asynchronous processing. Built with FastAPI, PostgreSQL, and SQLAlchemy (async).
Status: complete. Ticket CRUD API, async background worker (summary, priority, spam detection, department routing), structured JSON logging, Prometheus metrics, liveness/readiness probes — all running in a single
make run. Worker results are visible inGET /tickets/{id}underevents.
- Overview
- Architecture Decisions
- Tech Stack
- Setup
- Environment Variables
- API Documentation
- API Usage
- Observability
- Testing
- Connection pool
- Common Commands
- Tradeoffs and Assumptions
- Roadmap
- License
The system lets customers create support tickets, agents update ticket status, and background workers process tickets asynchronously (summary generation, priority assignment, routing). It is structured in clean layers:
API (routes) -> Service (business logic) -> Repository (data access) -> ORM
- FastAPI — native async, automatic OpenAPI docs, Pydantic validation.
- asyncpg + SQLAlchemy 2.0 (async) — true async database access with connection pooling.
- Alembic — version-controlled schema migrations.
- Layered design — routes, services, and repositories are separated so each layer is testable in isolation.
- Enums for domain constants — ticket status, priority, and category are enforced at both the schema and database level.
- Python 3.12+
- FastAPI
- PostgreSQL 16
- SQLAlchemy 2.0 (async) + asyncpg
- Alembic
- Docker / Podman + Compose
Requires Docker (or Podman) and Compose. No local Python install needed to run the containerized stack.
cp .env.example .env # adjust if needed
make run # build and start all services (migrations run automatically)
make health # verify the API is up
make demo-api # API + worker walkthrough (recommended for review)
make seed # optional: sample agents and tickets for manual explorationThe API is served at http://localhost:8000. The root path redirects to the
interactive docs.
make demo-api exercises the worker with the default keyword/rules backends. To
see the optional ML backends (transformer summarizer + zero-shot
priority/spam/routing) produce output, see
Seeing the ML backends in action.
make install-dev # create .venv and install deps
make test-db # start PostgreSQL in a container
make local-run # run uvicorn with autoreloadTo run the worker locally with DistilBART summarization:
make install-ml # adds torch + transformers (see requirements-ml.txt)
SUMMARIZER_BACKEND=transformer python -m arq app.worker.main.WorkerSettings| Variable | Description | Default (Compose) |
|---|---|---|
DATABASE_URL |
Async SQLAlchemy connection URL (+asyncpg) |
postgresql+asyncpg://ticketsupport:ticketsupport@db:5432/ticketsupport |
REDIS_URL |
Redis URL for the worker queue | redis://redis:6379 |
SUMMARIZER_BACKEND |
Worker summarizer: noop, transformer, or anthropic |
noop (worker service only) |
ANTHROPIC_API_KEY |
API key required by SUMMARIZER_BACKEND=anthropic |
(unset) |
CLASSIFIER_BACKEND |
Worker priority/spam/routing: rules or zeroshot |
rules (worker service only) |
CLASSIFIER_MODEL |
Zero-shot MNLI model (when CLASSIFIER_BACKEND=zeroshot) |
valhalla/distilbart-mnli-12-3 |
LOG_LEVEL |
Log verbosity: DEBUG INFO WARNING ERROR CRITICAL |
INFO |
DEBUG |
Enable debug behavior | false |
DB_POOL_SIZE |
SQLAlchemy connection pool size per process | 20 |
DB_MAX_OVERFLOW |
Max connections above pool size per process | 10 |
RATE_LIMIT_CREATE_TICKET |
Per-IP rate limit for POST /tickets (slowapi format) |
20/minute |
WORKER_METRICS_PORT |
Port for the worker's Prometheus /metrics endpoint |
9091 |
Once running, interactive docs are available at:
- Swagger UI:
http://localhost:8000/docs - ReDoc:
http://localhost:8000/redoc - OpenAPI schema:
http://localhost:8000/openapi.json
Enum values (status, priority, category) are accepted case-insensitively
and echoed back in canonical upper case.
scripts/demo-api.sh is the source of truth for an
end-to-end API walkthrough: ticket CRUD and status lifecycle, error envelopes,
OpenAPI surface, and live async worker results. It creates tickets via POST
(no hardcoded ids), seeds agents only when a compose api container is
available, and exits non-zero on the first failure.
Diagrams below summarize architecture; behavioral proof is still
make demo-api.
make run # API + Postgres + Redis + worker (required for full demo)
make demo-api # agents-only seed + demo phases A–Cmake seed— exploratory sample tickets with static audit events written at seed time (no queue). Useful for browsing the DB, not for proving workers.make demo-api— proves the live API + queue: worker events appear only on tickets created during the script’s Phase CPOST.
Set DEMO_WORKER=0 to run API-only checks (skips Phase C). With the default
DEMO_WORKER=auto, Phase C fails if Redis is not healthy — run the full
stack with make run. Use VERBOSE=1 to print JSON bodies. See
./scripts/demo-api.sh --help.
If compose seed still prints skip ticket ..., the API container may be on an
old image — rebuild with podman compose up -d --build --force-recreate api
(or docker compose equivalent) so --agents-only is honored.
| Requirement | Demo phase | What you should see |
|---|---|---|
| Create ticket (all fields) | A — [demo: create] |
201, status: OPEN |
| Retrieve by id | A — [demo: retrieve] |
events with CREATED only (no worker types yet) |
| List + filters + pagination | A — [demo: list] |
{ items, total, skip, limit } |
| Update status (agent) | A — [demo: update status] |
OPEN → IN_PROGRESS → RESOLVED → CLOSED, 409 from CLOSED |
| OpenAPI surface | B — OpenAPI smoke | openapi.json lists POST/GET/PATCH ticket routes |
| REST validation + errors | B — error envelopes | 404 / 409 / 422 envelopes |
| Async queue + stored results | C | Fresh ticket id with SUMMARIZED, PRIORITY_CHANGED, SPAM_FLAGGED, ROUTED; priority: CRITICAL |
| Assign agent (optional) | A — [extra: assign] |
ASSIGNED event (demo extra; needs a seeded agent) |
Allowed moves (simplified; source of truth:
app/services/ticket.py ALLOWED_TRANSITIONS).
Same-status PATCH is an idempotent no-op (no event). CLOSED is terminal.
stateDiagram-v2
[*] --> OPEN
OPEN --> IN_PROGRESS
OPEN --> RESOLVED
OPEN --> CLOSED
IN_PROGRESS --> RESOLVED
IN_PROGRESS --> OPEN
IN_PROGRESS --> CLOSED
RESOLVED --> CLOSED
RESOLVED --> IN_PROGRESS
CLOSED --> [*]
The curls below mirror what the script exercises (replace {id} with a real
ticket id from POST /tickets):
# Create a ticket (201). Priority defaults to MEDIUM; category is case-insensitive.
curl -s -X POST http://localhost:8000/tickets \
-H 'Content-Type: application/json' \
-d '{
"customer_name": "Ada Lovelace",
"customer_email": "ada@example.com",
"subject": "Cannot reset my password",
"description": "The reset link 404s after I click it.",
"category": "technical"
}'
# Retrieve one ticket with event history (200, or 404 if missing)
curl -s http://localhost:8000/tickets/{id}
# List with filters + pagination (200). Returns {items, total, skip, limit}.
curl -s 'http://localhost:8000/tickets?status=OPEN&priority=MEDIUM&category=TECHNICAL&skip=0&limit=20'
# Full-text search across subject + description (200). Combines with filters.
curl -s 'http://localhost:8000/tickets?q=password+reset'
curl -s 'http://localhost:8000/tickets?q="two factor"&status=OPEN'
# Transition status (200). Illegal moves return 409; CLOSED is terminal.
curl -s -X PATCH http://localhost:8000/tickets/{id}/status \
-H 'Content-Type: application/json' \
-d '{"status": "in_progress"}'
# Assign to an agent (200). `make demo-api` seeds agents; or `make seed`.
curl -s -X PATCH http://localhost:8000/tickets/{id}/assign \
-H 'Content-Type: application/json' \
-d '{"agent_id": 1}'Every error returns a uniform envelope, {"detail": ..., "error_type": ...}
(request-validation failures add an errors list with per-field detail):
error_type |
HTTP | When |
|---|---|---|
validation_error |
422 | Request body/params failed validation |
ticket_not_found |
404 | No ticket with that id |
agent_not_found |
404 | No support agent with that id |
invalid_status_transition |
409 | Status move not allowed (e.g. out of CLOSED) |
concurrent_update |
409 | Another request modified the ticket first (retry) |
internal_server_error |
500 | Unexpected server failure (details not exposed) |
When Redis and the worker are running, creating a ticket enqueues four background jobs that run asynchronously:
- One job per ticket per task, deduped by ticket id at enqueue time
- Ticket creation succeeds even if Redis is down or enqueue fails
- Each job runs at most once (
max_tries=1); failures are logged, not retried
sequenceDiagram
participant Client
participant API
participant Redis as Redis_arq
participant Worker
participant DB as Postgres
Client->>API: POST /tickets
API->>DB: insert ticket + CREATED event
API->>Redis: enqueue 4 jobs
API-->>Client: 201 OPEN
Worker->>Redis: dequeue jobs
Worker->>DB: read ticket / write events
Note over Worker,DB: SUMMARIZED PRIORITY_CHANGED SPAM_FLAGGED ROUTED
Client->>API: GET /tickets/id
API->>DB: load ticket + events
API-->>Client: 200 with events array
Simplified flow; enqueue details live in
app/services/ticket.py and
app/worker/tasks.py.
Worker results are written to ticket_events and returned in
GET /tickets/{id} under the events array (at most 100 events; when
truncated, events_truncated is true and events_total is the full count).
Worker-emitted event types:
event_type |
field_changed |
new_value |
|---|---|---|
SUMMARIZED |
summary |
Generated summary text (or original description with noop backend) |
PRIORITY_CHANGED |
priority |
Upgraded priority (e.g. "CRITICAL") — only if keywords detected; never downgrades |
SPAM_FLAGGED |
spam |
"true" — ticket flagged for human review |
ROUTED |
department |
Routing department (e.g. "technical-support", "billing", "general") |
Demo — async loop end-to-end: make demo-api (Phase C) polls until the
worker ticket shows CREATED, SUMMARIZED (non-empty summary), PRIORITY_CHANGED,
SPAM_FLAGGED (new_value: true), ROUTED (technical-support for TECHNICAL),
and priority: CRITICAL.
Note:
POST /ticketsandGET /tickets(list) return a flat ticket object (noevents).GET /tickets/{id}andPATCH .../statusor.../assignreturn the same detail shape with boundedevents(seeevents_truncatedwhen older rows are omitted).
Summarizer backend:
- With
SUMMARIZER_BACKEND=noop(compose default): stored summary equals the ticket description unchanged - With
SUMMARIZER_BACKEND=transformer: usessshleifer/distilbart-cnn-6-6 - Duplicate
SUMMARIZEDevents are allowed (e.g. manual re-enqueue)
There is no re-summarize endpoint; a failed or skipped job is not backfilled unless you add that explicitly later.
The default stack uses noop so containers start without ML dependencies.
To enable DistilBART (sshleifer/distilbart-cnn-6-6):
- Install optional deps:
make install-mlorpip install -r requirements-ml.txt - Set
SUMMARIZER_BACKEND=transformerfor the worker process - On first run, HuggingFace downloads ~300 MB of weights to the local cache
The worker defaults to max_jobs=10, but the HuggingFace pipeline object is
not thread-safe. TransformerSummarizer serializes inference with a
process-wide lock so concurrent arq jobs do not corrupt shared model state.
If you need higher throughput, run multiple worker replicas (each with its own
process and model copy) rather than raising max_jobs alone.
The lean default Containerfile runtime image does not include
requirements-ml.txt. To run transformer mode in Compose without a manual
Dockerfile edit, use the runtime-ml image stage and the compose.ml.yaml
overlay — see Running ML backends in Compose.
A third backend summarizes via the Anthropic Messages API (Claude Haiku) instead of a local model — useful when you want higher-quality summaries without shipping torch/transformers. It needs no GPU and only a small dependency:
-
Install the optional dep:
pip install -r requirements-optional.txt -
Set
SUMMARIZER_BACKEND=anthropicandANTHROPIC_API_KEY=sk-...for the worker process:SUMMARIZER_BACKEND=anthropic ANTHROPIC_API_KEY=sk-... \ python -m arq app.worker.main.WorkerSettings
The synchronous SDK call runs in run_in_executor so it never blocks the arq
event loop (same pattern as the transformer backend). If ANTHROPIC_API_KEY is
unset or the anthropic package is missing, the registry logs a warning and
falls back to NoopSummarizer — the worker keeps running. anthropic is not in
the default requirements.txt or the Containerfile, so the default stack
installs nothing new.
The priority, spam, and routing tasks ship with deterministic keyword/rule
classifiers (CLASSIFIER_BACKEND=rules, the default — no ML dependencies). An
optional zero-shot backend swaps in a transformer behind the same task
interface, the same way SUMMARIZER_BACKEND swaps DistilBART for noop:
- Install optional deps:
make install-ml - Set
CLASSIFIER_BACKEND=zeroshotfor the worker process - On first run, HuggingFace downloads the MNLI model (~700 MB,
valhalla/distilbart-mnli-12-3; override withCLASSIFIER_MODEL, e.g.facebook/bart-large-mnlifor higher accuracy at ~1.6 GB)
Concrete local worker command (combine with the transformer summarizer if you want both ML backends in one process):
CLASSIFIER_BACKEND=zeroshot SUMMARIZER_BACKEND=transformer \
python -m arq app.worker.main.WorkerSettingsZero-shot classification needs no labeled training data: it scores the ticket
text against candidate labels via natural-language inference, then maps the top
label back to a Priority / spam flag / department. One shared pipeline serves
all three tasks (loaded once), with inference serialized under a process-wide
lock like the summarizer. If the ML dependencies are missing or the model fails
to load, the worker logs a warning and falls through to the rules backend —
the queue keeps working.
Two guards keep the ML output honest: spam uses single-label (softmax) scoring so a long but legitimate ticket is not flagged in isolation, and routing defers to the deterministic category→department map whenever the top zero-shot score is below a confidence floor — ML only overrides the category rule when it is sure.
Honest tradeoff: for these tasks the keyword rules are deterministic,
instant, and dependency-free, and priority's upgrade-only semantics make a
confident keyword match safer than a fuzzy score. The zero-shot path mainly
demonstrates that the pluggable-backend pattern extends cleanly to ML; without a
labeled evaluation set it is not a proven accuracy win, which is why rules
stays the default.
The default stack (make run) stays lean: the worker runs the noop summarizer
and rules classifiers, with no ML dependencies in the image. To run the worker
with ML backends instead, use the dedicated image stage and Compose overlay:
make run-ml
# equivalent to:
podman compose -f compose.yaml -f compose.ml.yaml up --build -dWhat this changes (only the worker; the API stays on the lean image):
- Builds the
runtime-mlimage stage — the multi-stageContainerfilelayersrequirements-ml.txt(torch + transformers) into a separatebuilder-ml/runtime-mlpath, so the defaultruntimeimage is untouched. - Sets
SUMMARIZER_BACKEND=transformerandCLASSIFIER_BACKEND=zeroshotfor the worker viacompose.ml.yaml. - Mounts a named
hf_cachevolume atHF_HOME(/opt/hf-cache) so the ~1 GB of HuggingFace weights download once on first worker startup, not on every restart.
First run is slow: the image build pulls ~2.5 GB of torch/transformers and the
worker then downloads ~1 GB of model weights into hf_cache — budget several
minutes, and watch make worker-logs for model loaded / worker: ready
before testing (it isn't hung). If the build or model load fails, the worker
logs a warning and falls back to the rules/noop backends — the queue keeps
working.
Tear down with make container-down (add clear-db semantics, or
podman compose -f compose.yaml -f compose.ml.yaml down -v, to also drop the
hf_cache volume).
Two ways to actually watch the ML produce output:
1. Fastest — printouts, no stack. make test-ml installs the ML deps, then
runs two demo scripts before the test suite:
scripts/demo_ml_summary.py— prints a real DistilBART summary of a sample ticket (and how much shorter it is).scripts/demo_ml_classify.py— prints the zero-shot priority / spam / department decision (with the winning label and confidence) for several sample tickets, including where the routing confidence floor defers to the category rule.
Either script also runs standalone once the ML deps are installed (make install-ml), e.g. python -m scripts.demo_ml_classify. Example classifier
output:
=== Technical outage (shared sample) ===
Priority : CRITICAL (label 'critical incident or service outage', score 0.34)
Spam : NO (spam score 0.43, threshold 0.50)
Department : technical-support (zero-shot 'general inquiry or something else' 0.42, below 0.50 floor → category rule)
=== Promotional spam ===
Priority : HIGH (label 'high priority or urgent issue', score 0.55)
Spam : YES (spam score 1.00, threshold 0.50)
Department : general (zero-shot 'general inquiry or something else' 0.40, below 0.50 floor → category rule)
=== Billing refund ===
Priority : LOW (label 'routine or low priority request', score 0.67)
Spam : NO (spam score 0.17, threshold 0.50)
Department : billing (zero-shot 'billing, payment, refund, or invoice' 0.82)
Note the two guards at work: the long legitimate technical ticket is not
spam-flagged, and its low-confidence routing falls back to the category rule
(technical-support). Scores shift slightly by model version; the decisions are
stable.
2. Live — ML decisions on a real ticket. Bring up the ML worker, wait for the
models to load, then create a ticket and read its events (the snippet uses jq):
make run-ml
make worker-logs # wait for "ZeroShotPipeline: model loaded" / "worker: ready"
# Create a ticket (capture its id), then read it back to see the ML-derived events
tid=$(curl -s -X POST http://localhost:8000/tickets -H 'Content-Type: application/json' \
-d '{"customer_name":"Ada","customer_email":"ada@example.com",
"subject":"Production database is completely down",
"description":"Our entire production system is offline. Customers get 500 errors on every request.",
"category":"technical"}' | jq -r .id)
curl -s "http://localhost:8000/tickets/$tid" | jq '.priority, .events'You should see a SUMMARIZED event with a generated (not verbatim) summary, a
PRIORITY_CHANGED upgrade, and a ROUTED department — all produced by the
zero-shot/transformer backends rather than the keyword rules. Example (trimmed)
response:
{
"priority": "CRITICAL",
"events": [
{"event_type": "CREATED", "new_value": "OPEN"},
{"event_type": "PRIORITY_CHANGED", "field_changed": "priority", "new_value": "CRITICAL"},
{"event_type": "ROUTED", "field_changed": "department", "new_value": "technical-support"},
{"event_type": "SUMMARIZED", "field_changed": "summary", "new_value": "Customers get 500 errors on every request. Our entire produ..."}
]
}Note: use plain
curlhere, notmake demo-api. The demo script asserts the rules backend's deterministic outcomes (e.g.priority: CRITICAL,SPAM_FLAGGED), which the ML backends may classify differently — sodemo-apican fail against the ML stack even though everything is working.
All log output is emitted as newline-delimited JSON. Every line includes
timestamp, level, logger, message, and any structured fields from the
call site (e.g. ticket_id, elapsed_seconds). API request lines also carry
request_id, which matches the X-Request-ID response header — use it to
correlate all lines for a single request, including worker jobs enqueued by that
request.
# Tail JSON logs from the running stack
docker compose logs -f api
docker compose logs -f worker
# Set DEBUG for verbose output
LOG_LEVEL=DEBUG docker compose upEvery API request gets an X-Request-ID header in the response. Pass the same
header on the request to inject your own trace ID (must be a valid UUID4):
curl -H "X-Request-ID: 550e8400-e29b-41d4-a716-446655440000" \
http://localhost:8000/health# Scrape all metrics
curl http://localhost:8000/metricsHTTP metrics (http_requests_total, http_request_duration_seconds,
http_requests_inprogress) are collected automatically per route.
The default prometheus_client process/platform/GC collectors (process_*,
python_info, python_gc_*) are unregistered at startup (app/main.py), so
GET /metrics exposes only the HTTP and business metrics below — no CPU, memory,
open-FD, start-time, or interpreter internals. This keeps the endpoint safe to
serve on the public API port without leaking operational details.
Custom business counters:
| Metric | Labels | Description |
|---|---|---|
tickets_created_total |
— | Tickets created via POST /tickets |
ticket_status_transitions_total |
from_status, to_status |
Successful status transitions |
ticket_summarization_outcomes_total |
outcome |
Enqueue outcomes at create time |
outcome label values: enqueued, deduped, skipped_no_pool, enqueue_failed.
The arq worker runs in its own process, so it exposes its own Prometheus endpoint
on a dedicated port (default 9091, set via WORKER_METRICS_PORT) rather than
the API's /metrics. With the stack up, scrape it directly:
curl http://localhost:9091/metrics| Metric | Labels | Description |
|---|---|---|
worker_jobs_total |
task, outcome |
Task executions by terminal outcome |
worker_job_duration_seconds |
task |
Histogram of task wall-clock duration |
worker_summarizer_backend_info |
backend |
Active summarizer backend in the worker process |
task values: summarize_ticket, assign_priority, detect_spam,
route_ticket. outcome values: completed, skipped, not_found, failed.
These cover task execution in the worker, complementing the API-side
ticket_summarization_outcomes_total, which only tracks enqueue outcomes at
create time. A scraper points at both :8000/metrics (API) and :9091/metrics
(worker). The metrics server is best-effort — a bind failure is logged and the
worker keeps processing jobs.
docker compose logs -f worker | grep '"ticket_id": 42'— find all worker log lines for ticket 42.- From the complete/error line, copy
"request_id": "abc-123". docker compose logs -f api | grep '"request_id": "abc-123"'— find thePOST /ticketsthat created it and its latency.curl http://localhost:8000/metrics | grep ticket_summarization_outcomes— check outcome counters to see how many jobs are failing vs. succeeding.curl http://localhost:8000/ready— if"redis": "unavailable", the worker never received the job.
The API exposes two separate probes — a standard pattern for containerised services:
GET /health — liveness probe
Returns 200 unconditionally. No network calls, no dependency checks. The Docker
HEALTHCHECK in Containerfile targets this endpoint so a Redis or Postgres
blip never restarts the container.
curl http://localhost:8000/health
# {"status": "ok"}
make health # same — targets liveness onlyGET /ready — readiness probe
Probes both Postgres and Redis (each with a 2-second timeout). Returns 200 when both are healthy; 503 when either is degraded. Use this for load-balancer health checks so traffic is held back when a dependency is temporarily unreachable.
curl http://localhost:8000/ready
# {"status": "ok", "database": "ok", "redis": "ok"}
# {"status": "degraded","database": "unavailable", "redis": "ok"}
# {"status": "degraded","database": "ok", "redis": "unavailable"}make test # run the test suite in a container (falls back to local)
make coverage # run tests with a coverage report
make test-ml # print DistilBART summary + priority/spam/routing decisions, then run ML testsTests run against an isolated ticketsupport_test database.
Each Python process (API and worker) creates its own SQLAlchemy pool
(DB_POOL_SIZE=20, DB_MAX_OVERFLOW=10 by default → up to 30
connections per process; tune via environment variables).
The default Compose stack runs two processes, so plan for roughly
60 concurrent Postgres connections under burst load.
PostgreSQL's default max_connections is 100, which leaves modest headroom
for admin sessions and migration tooling. Before scaling out — multiple uvicorn
workers, several worker replicas, or other services on the same database —
either lower per-process pool settings or raise max_connections in Postgres.
The worker holds at most max_jobs=10 concurrent arq tasks; each
summarize_ticket job uses two short DB sessions (read ticket, then write
event), so jobs do not hold a connection open during CPU-bound summarization.
make help # list all available targets
make run # build and start the stack
make demo-api # runnable walkthrough (phases A–C; agents-only seed in compose)
make health # curl the /health liveness probe
make migrate # apply database migrations in the running container
make install-ml # optional: torch + transformers for local worker ML runs
make seed # sample agents + tickets for local testing (idempotent)
make clean # remove caches and build artifactsAfter the stack is up, run make seed to insert three support agents and five
sample tickets covering every status (OPEN, IN_PROGRESS, RESOLVED, CLOSED),
multiple categories, and representative audit events (including worker-style
SUMMARIZED / ROUTED / SPAM_FLAGGED rows on selected tickets). Re-running
seed skips rows that already exist (matched by agent email or ticket email+subject).
make seed
curl -s 'http://localhost:8000/tickets?limit=10' | jq '.total, .items[].subject'
curl -s http://localhost:8000/tickets/2 | jq '.status, .events'This is a take-home project scoped for a single technical grader running the stack once. The decisions below trade production completeness for clarity and a clean, runnable demonstration of the core patterns.
Assumptions:
- Single instance, trusted network. One API process and one worker in the
default Compose stack. Callers are trusted; the only abuse control is per-IP
rate limiting on
POST /tickets(RATE_LIMIT_CREATE_TICKET). - Grader runs the code first.
make run+make demo-apiis the source of truth; the README documents behaviour, the demo proves it. - Postgres
max_connections=100is enough. Two processes × 30 connections (pool + overflow) ≈ 60, leaving headroom (see Connection pool).
Tradeoffs:
- No authentication. All endpoints are unauthenticated. In production, JWT
bearer tokens (or API keys for service callers) verified via a FastAPI
Dependsguard, with the resolved identity recorded asactor_idon audit events. Deferred — see Roadmap. - Worker classification defaults to keyword rules. Priority upgrade, spam
detection, and department routing run deterministic keyword/rule heuristics by
default — fast and dependency-free. An optional zero-shot ML backend
(
CLASSIFIER_BACKEND=zeroshot) swaps in a transformer behind the same task interface (see ML classifiers). Rules stay the default because, without a labeled eval set, the ML path is not a proven accuracy win over the rules here. noopsummarizer by default. Containers start without ML dependencies; the stored summary equals the description. The real DistilBART backend is opt-in (SUMMARIZER_BACKEND=transformer,make install-ml) to keep the image small and startup fast.- Best-effort jobs, no retries. Jobs run at most once (
max_tries=1); failures are logged, not retried, and there is no re-summarize/backfill path. Ticket creation succeeds even if Redis is down or enqueue fails. - Response-shape asymmetry.
GET /tickets/{id}(andPATCH .../status,.../assign) return the full detail shape with aneventsarray;POSTand the list endpoint return a flat ticket (noevents) to keep the list lean and avoid an N+1 load. CallGET /tickets/{id}to see worker results. - Bounded, unpaginated events. Detail responses cap at 100 events
(
events_truncated/events_totalsignal omissions). There is no dedicated events pagination endpoint — acceptable for the demo, a clear next step at scale. - Optimistic locking, no row locks. Concurrent updates are detected via a
version check (
409 concurrent_update, retry) rather than pessimistic locking, favouring throughput for a low-contention workload.
Explicitly out of scope: authentication/authorization, multi-tenancy, SLA enforcement, and an Anthropic summarizer backend.
- Project foundation: models, migrations, config,
/health - Ticket CRUD API (create, retrieve, list with pagination/filtering)
- Status transition validation (state machine + optimistic locking)
- Background worker — summarize-at-create, best-effort once (arq + pluggable backends)
- Structured JSON logging, Prometheus metrics, correlation IDs
- Observability hardening: liveness/readiness split, UUID4 correlation IDs, probe timeouts
- Additional worker tasks — priority upgrade, spam detection, department routing (keyword rules by default, optional zero-shot ML backend; all write audit events)
- Worker results visible in
GET /tickets/{id}underevents - Assign-agent REST endpoint —
PATCH /tickets/{id}/assignsetsassigned_agent_id, validates agent exists (404agent_not_found), writesASSIGNEDaudit event; idempotent when already assigned to the same agent - Authentication — deferred. All endpoints are unauthenticated in this
take-home scope. In production: JWT bearer tokens issued at login,
verified via a FastAPI
Dependsguard, with the resolved agent/user identity passed asactor_idon audit events. API key middleware is a simpler alternative for server-to-server callers.
Released under the MIT License.