The VirtuJudge backend is the product control plane for pitch-practice sessions. It owns the data and rules that turn a team's uploaded material into a practice session: authorization, immutable input manifests, AI-job dispatch, grounded Q&A, reports, exports, and erasure requests.
It is a FastAPI application built around four layers. PostgreSQL is the source of truth for product state. Redis is used for Celery job delivery, rate limiting, and live session notifications; it is never the source of business truth. AI processing runs in the separate AI-ML repository, which can write only its own derived data and must report results through the backend's internal contract.
Pitch practice involves more than uploading a recording and asking a model for feedback. Teams need to control who can access shared material, keep a stable record of the exact presentation and documents an analysis used, process media asynchronously, ask grounded follow-up questions, and produce feedback that does not overstate what the evidence supports.
Those steps span the browser, object storage, PostgreSQL, Redis, and a separate AI worker. The backend keeps them coherent when requests are retried, a worker updates a job more than once, an upload expires, or a user cancels a session. It also has to keep private material out of logs, public events, and queue payloads.
- users, teams, memberships, invitations, and project access;
- private, versioned assets and direct signed uploads to S3-compatible storage;
- practice-session state, consent, frozen manifests, retries, cancellation, and speaker mappings;
- durable AI jobs, callback sequence checks, result validation, and browser notifications;
- the Q&A round, including exactly three primary questions and at most two follow-ups;
- evaluations, feedback for the team and every mapped speaker, PDF exports, and data-erasure state.
The backend is deliberately not an AI inference service. It authorizes requests, stores the workflow state, gives workers scoped input references, and accepts only contract-valid results.
flowchart LR
Browser[Frontend] -->|REST and SSE| API[FastAPI API]
API --> Workflow[Application use cases]
Workflow --> Domain[Domain rules]
Workflow --> DB[(PostgreSQL)]
Workflow --> Store[(S3-compatible storage)]
Workflow --> Queue[Celery / Redis]
Queue --> Worker[AI-ML worker]
Worker -->|authenticated updates| API
API --> Events[Redis session events]
Events --> Browser
Dependencies point inward:
| Layer | Location | Responsibility |
|---|---|---|
| Domain | app/domain/ |
Framework-free entities, state transitions, scoring rules, and domain errors. |
| Application | app/application/ |
Use cases and ports. SessionWorkflow, AIJobs, and AssetStore keep workflow, queue, and object-key rules out of routes. |
| API | app/api/ |
FastAPI routers, request validation, authentication context, Problem Details responses, correlation IDs, and SSE. |
| Infrastructure | app/infrastructure/ |
SQLAlchemy repositories, Redis/Celery, S3, OIDC, mail, media/document validation, and PDF adapters. |
app/main.py is the composition root. Domain and application code must not import FastAPI, SQLAlchemy, Redis, Celery, or provider SDKs.
| Choice | Why it is here |
|---|---|
| FastAPI and Pydantic v2 | Typed HTTP boundaries, request validation, and generated OpenAPI from the running service. |
| Python 3.11, SQLAlchemy async, Alembic, and PostgreSQL | Transactional product state, explicit migrations, and database concurrency controls without leaking ORM models into the domain. |
| Celery with Redis | Durable job records can be dispatched to the separate AI worker without making the API wait for media processing. |
| S3-compatible storage and MinIO locally | Browsers upload large private files directly with narrowly scoped, short-lived URLs. |
| OIDC JWT validation | Authentication remains standards-based and separate from the identity provider UI. |
| Redis-backed SSE | A connected browser receives safe workflow progress and can resync from REST state after a missed event. |
| Adapter ports | Storage, mail, queue, auth, media, document, and PDF providers can be tested with fakes and replaced without changing the workflow. |
- A team member uploads a presentation and optional supporting documents directly to private object storage with short-lived signed URLs. The backend verifies size, checksum, and file structure before a version can be used.
- The member creates a Practice Session. Starting analysis records consent, freezes the exact asset-version manifest, creates an Analysis Attempt and an AI Job in one transaction, then enqueues the job after commit.
- The AI worker claims the job and sends ordered, authenticated updates to
/internal/v1/ai-jobs/{job_id}/updates. The backend ignores duplicate or stale sequences, validates completed results, and publishes safe progress events to the session's SSE stream. - The Q&A round exposes the three Primary Questions produced from the analysis. Submitted answers can produce no more than two grounded Follow-up Questions.
- Once Q&A is complete, the backend serializes the validated Q&A artifact, dispatches report generation, validates the returned evaluation/report, and makes a PDF export available through another signed URL.
If queue delivery fails, the persisted job remains eligible for redispatch with backoff. A worker callback can therefore be retried without creating a second Analysis Attempt.
- Direct object storage instead of API uploads. This keeps large files out of FastAPI workers and job messages. The trade-off is a more involved client flow: the client must use a signed URL, provide a checksum, and complete the upload before the asset becomes usable.
- Durable jobs with at-least-once delivery instead of a distributed transaction. PostgreSQL records the job before enqueueing it, so a broker outage cannot lose the request. A worker may see the same message again, which is why job IDs, callback sequences, and state transitions are idempotent.
- SSE with bounded replay instead of a general event platform. Redis keeps the live-update path small. It is not permanent event storage; a missing, expired, or trimmed cursor produces a resync signal and the frontend reloads canonical REST resources.
- Asynchronous analysis and report generation. The API returns after state is committed rather than holding a request open while media is processed. Clients need to represent queued and running states, but retries and cancellation remain explicit and observable.
- Private uploads with automatic cleanup. Expired, unfinished uploads are reclaimed with a lease and a delayed tombstone re-sweep. This avoids accumulating orphaned objects while preserving a previously verified version when a replacement fails.
Expected failure paths are handled at the boundary where they occur. Storage or verification failures leave an asset unverified rather than exposing it to a session. Duplicate or out-of-order worker updates are ignored. A failed enqueue remains redispatchable. If a session event cannot be replayed, the client is told to resync rather than receiving partial state. Mail has a fake adapter for ordinary tests, and live Gmail delivery requires an explicit allow-listed smoke command.
- Files move between the browser, S3-compatible storage, and the AI worker without passing through the API process.
- SQLAlchemy runs asynchronously for database I/O; workflows use transactions and optimistic
ETag/If-Matchchecks where concurrent session changes would conflict. - Redis is limited to ephemeral responsibilities: queue transport, rate limiting, and retained session notifications. PostgreSQL remains the canonical recovery point.
- AI processing and report generation run outside the normal request-response path. Upload verification and PDF rendering sit behind dedicated adapters, so their validation and implementation details stay out of routes and workflows.
- Queue dispatch uses bounded scans and backoff, while asset cleanup works in leased batches so background recovery does not contend with every active request.
app/
├── api/ HTTP routes, schemas, dependencies, errors, SSE
├── application/ use cases, workflows, ports, and application services
├── domain/ product entities, rules, statuses, and scoring
├── infrastructure/ database, storage, auth, queues, mail, PDF, media
├── main.py application wiring and background schedulers
└── settings.py typed environment configuration
contracts/ generated OpenAPI and versioned AI JSON schemas
migrations/ Alembic migrations for backend-owned product tables
tests/ architecture, unit, integration, acceptance, system tests
scripts/ quality checks, contract checks, and local-stack commands
local_stack/ Compose health and smoke checks
- Python 3.11 or newer
- uv
- Docker Engine with Compose v2.17 or newer for the shared local stack
- FFmpeg for media-validation tests and the production image
The shared stack also expects sibling checkouts:
VirtuJudge/
├── Backend/
├── Frontend/
└── AI-ML/
Backend/scripts/local-stack.sh checks that the frontend has package.json and package-lock.json, and that AI-ML has its Python project and worker entry point, before it starts anything.
Install the locked dependencies, create your ignored host configuration, apply migrations, and start FastAPI:
uv sync --locked
cp .env.example .env
uv run alembic upgrade head
uv run uvicorn app.main:app --reloadThe API is then available at:
- API documentation: http://localhost:8000/docs
- OpenAPI document: http://localhost:8000/openapi.json
- Liveness endpoint: http://localhost:8000/health
.env is for host processes. If PostgreSQL, Redis, and MinIO are running through Compose, copy their generated credentials privately from .env.local into .env before running host tools. Do not run a host API server on port 8000 while the Compose backend is using that port.
From Backend/:
bash scripts/local-stack.sh up
bash scripts/local-stack.sh smoke
bash scripts/local-stack.sh status
bash scripts/local-stack.sh downThe first up generates an ignored .env.local with random credentials and mode 0600, builds the Backend, Frontend, and AI worker, waits for health checks, then runs a storage smoke check. It does not clone, alter, or pin the sibling repositories.
| Service | Local address | Notes |
|---|---|---|
| Frontend | http://localhost:3000 | Starts with its existing mock API setting unless NEXT_PUBLIC_MOCK_API is changed. |
| Backend | http://localhost:8000 | Health at /health; interactive API docs at /docs. |
| PostgreSQL + pgvector | localhost:5432 |
Database virtujudge; Backend and AI use separate roles. |
| Redis | localhost:6379 |
DB 0 is the diagnostic/job broker namespace; DB 1 holds cache keys. |
| MinIO API | http://localhost:9000 | Private S3-compatible object store. |
| MinIO console | http://localhost:9001 | Local storage administration. |
| AI worker | internal Compose service | Processes the local FakePipeline and reports through the internal API. |
down keeps the volumes and .env.local; up reuses them. If you intentionally replace .env.local, recreate the volumes too, because PostgreSQL roles retain their original passwords.
Start with .env.example. Keep .env, .env.local, production secrets, JWTs, signed URLs, private media, and provider responses out of Git and logs.
| Area | Important variables |
|---|---|
| Application | APP_ENV, APP_HOST, APP_PORT, CORS_ALLOWED_ORIGINS |
| Persistence and queue | DATABASE_URL, REDIS_URL, REDIS_CACHE_URL, CELERY_BROKER_URL |
| Object storage | OBJECT_STORAGE_ENDPOINT, OBJECT_STORAGE_PUBLIC_ENDPOINT, OBJECT_STORAGE_BUCKET, credentials, upload/download URL TTLs |
| Identity and workers | OIDC_ISSUER, OIDC_AUDIENCE, OIDC_JWKS_URL, AI_WORKER_SHARED_SECRET |
| Job recovery | AI_JOB_DISPATCHER_*, AI_WORKER_TASK_NAME, AI_WORKER_QUEUE_NAME |
| Asset retention | ASSET_CLEANUP_* |
MAIL_BACKEND, Resend settings, or Gmail SMTP settings and GMAIL_SMOKE_ALLOWLIST |
The asset-cleanup and job-redispatch schedulers run by default outside tests. Tests disable them unless explicitly enabled. See app/settings.py for defaults and validation, and the deployment guide for Render with Cloudflare R2.
MAIL_BACKEND=fake is the default and is always used by Compose. It retains messages in memory and never sends mail. Production can use resend (the supported option for Render Free, which blocks outbound SMTP ports) or gmail. Gmail sending is only exercised through an explicit, allow-listed smoke command:
uv run python -m local_stack.gmail_smoke --recipient allowed@example.com --sendThat command refuses to connect unless the recipient is in GMAIL_SMOKE_ALLOWLIST and --send is present.
The interactive API reference at /docs and contracts/openapi.json are the public API source of truth. Main route groups cover:
| Area | Routes |
|---|---|
| Identity, teams, and projects | /api/v1/me, /api/v1/teams, /api/v1/projects |
| Assets | upload intents, verification completion, immutable versions, download intents, and erasure |
| Practice Sessions | draft creation, manifest editing, analysis attempts, cancellation, retry, speaker mapping, and session events |
| Q&A | Q&A state, answer-upload intents, answer submission, and skips |
| Reports | evaluation, report payload, PDF export, export status, and download intent |
| Workers | /internal/v1/ai-jobs/{job_id} and ordered status updates |
Public mutations use Idempotency-Key where repeated delivery must be safe. Versioned session updates use ETag and If-Match for optimistic concurrency. Errors use application/problem+json and never expose private inputs, provider bodies, secrets, or stack traces.
GET /api/v1/practice-sessions/{session_id}/events is an authenticated Server-Sent Events stream. Notifications expose only safe status, progress, IDs, versions, and correlation data. Clients reconnect with Last-Event-ID; a resync event tells them to refetch canonical REST state when the retained event history cannot satisfy that cursor.
The worker boundary uses versioned JSON schemas in contracts/schemas/. Queue messages and callbacks are intentionally small. Workers receive scoped artifact references, while the backend validates sequence numbers, ownership, attempt currency, question limits, evidence references, report feedback coverage, and scores before changing product state.
For endpoint details and the cross-repository contract, see:
Browser clients upload directly to private object storage; large files never travel through API or queue payloads. An upload intent has an absolute expiry, and replaying it does not extend that deadline. Completion re-checks content length, checksum, media/document structure, and ownership before a version becomes verified.
Abandoned uploads are claimed with a lease, deleted from storage, and marked deleted transactionally. A delayed tombstone re-sweep catches objects that arrive after cleanup. Verified versions remain protected when a replacement is rejected or abandoned.
Every team-owned resource is authorized through Team Membership ancestry. OIDC verification checks issuer, audience, signature, expiry, and subject. Worker callbacks use a credential separate from user JWTs. Starting erasure revokes access immediately, while physical deletion follows the documented retention window.
Project creation requires Idempotency-Key (1 to 255 characters), scoped to the actor, Team, and create operation. Matching retries return 201 with the same Project ID and current fields. The hash covers the validated name and description; omitted and null descriptions are equivalent. Changed payloads return 409 idempotency_key_reused. Membership is rechecked on replay, and revoked or purged projects return 409 project_creation_unavailable without being recreated.
Migration 0a1b2c3d4e5f adds project creation keys and must be applied before deploying this version. Keys and projects commit atomically. Content-free key records survive project erasure. Downgrading preserves projects but drops retry history; preserve the table and prefer a forward fix while creation retries are outstanding.
Project deletion requires a confirmation body and Idempotency-Key. Keys are scoped to actor, target, and delete operation, with both project delete paths sharing the same scope. Every accepted key is remembered, including additional keys for an existing request. Repeating a key with a changed confirmation returns 409 idempotency_conflict; a valid replay returns 202 with the same Erasure Request ID and Location, including after physical deletion. The returned status reflects current erasure progress. Replay rechecks current Team Owner permission and parent path ancestry.
Migration f7a8b9c0d1e2 adds scoped command records and backfills the original keys from existing Erasure Requests. Apply it before deploying this version. The records contain hashes rather than confirmation text and survive target deletion. A downgrade drops only command records; it preserves erasure progress, but loses conflict detection for additional keys, so retain the table and prefer a forward fix while deletion retries are outstanding.
Invitation delivery now reports accepted_by_provider for Gmail and Resend. This means provider acceptance, not confirmed recipient delivery. Deploy clients that handle both this value and legacy accepted_by_gmail first. Stop existing backend processes and background sends, apply migration 3d4e5f6a7b8c, then start this backend version. The PostgreSQL enum rename migrates all existing accepted records; old and new backend versions must not run together across this migration. To roll back, stop this version, downgrade to 2c3d4e5f6a7b, and restart the old backend. The downgrade preserves invitations and restores the legacy status.
Migration 4e5f6a7b8c9d enables a Q&A Round per Analysis Attempt and repairs active sessions whose latest attempt is already failed. Apply it before deploying worker-failure recovery. Failed session, answer, and report jobs now fail the current session and attempt atomically. A creator or Team Owner can retry with a new idempotency key, starting analysis and Q&A again from the frozen manifest while keeping earlier history. Roll back application code only before multiple rounds exist for a session; afterward, preserve the schema and prefer a forward fix. The migration refuses a downgrade that would require deleting Q&A history. Repaired failure states are retained on downgrade.
Backend-owned product tables use one Alembic history. Generate migrations from model changes, inspect the result, and test both directions before merging:
uv run alembic revision --autogenerate -m "describe change"
uv run alembic upgrade head
uv run alembic downgrade -1The container startup command applies alembic upgrade head before Uvicorn starts. AI-ML owns only its ai_* tables and derived-object prefix; it must not modify Backend migrations or product tables.
Run the full repository gate before opening a pull request:
uv sync --locked
docker run --detach --rm --name virtujudge-test-postgres \
-e POSTGRES_USER=virtujudge_test -e POSTGRES_PASSWORD=virtujudge_test \
-e POSTGRES_DB=virtujudge_test -p 127.0.0.1:55432:5432 postgres:16-alpine
until docker exec virtujudge-test-postgres pg_isready -U virtujudge_test; do sleep 1; done
export ASSET_TEST_DATABASE_URL=postgresql+asyncpg://virtujudge_test:virtujudge_test@localhost:55432/virtujudge_test
bash scripts/check.sh
docker stop virtujudge-test-postgresUse a disposable test database: persistence and migration tests create and drop tables. The gate requires ASSET_TEST_DATABASE_URL with the postgresql+asyncpg driver and defaults PROJECT_TEST_DATABASE_URL to the same URL. CI provisions PostgreSQL 16 for both groups. Missing PostgreSQL configuration or any skipped test under tests/integration/persistence/ fails the gate, including migration and concurrency checks. Focused uv run pytest commands can still use SQLite without PostgreSQL; add --require-postgres to enforce the full persistence gate.
It checks required repository files, Ruff linting and formatting, strict mypy, OpenAPI and AI-contract snapshots, and the full pytest suite. The optional cross-repository erasure check requires the sibling AI-ML test environment and may skip in Backend-only CI. The test layout is intentional:
| Test type | Focus |
|---|---|
tests/architecture/ |
Inward dependency rules and boundary enforcement. |
tests/unit/ |
Domain rules, workflows, queues, contracts, storage boundaries, notifications, and PDF generation without external services. |
tests/integration/ |
API behavior, adapter behavior, repositories, migrations, concurrency, and callback authentication. |
tests/acceptance/ |
Product workflows such as uploads, cleanup, team/project access, durable enqueue recovery, and dispatch scheduling. |
tests/system/ |
Compose services, stack smoke checks, worker flow, and explicit Gmail verification. |
Useful focused commands:
uv run pytest tests/architecture
uv run pytest tests/unit
uv run pytest tests/integration
uv run pytest tests/acceptance
uv run pytest tests/system
uv run python scripts/openapi_contract.py --check
uv run python scripts/ai_contract.py --checkCI runs concurrent matching and conflicting project creation tests against PostgreSQL. To run them locally against a disposable test database, set PROJECT_TEST_DATABASE_URL to a postgresql+asyncpg URL and run uv run pytest tests/integration/persistence/test_project_creation_idempotency.py. Without that variable, the PostgreSQL cases are skipped; SQLite API, persistence, and migration cases still run.
The shared-stack smoke check verifies PostgreSQL/pgvector access boundaries, Redis, a MinIO round trip, and a correlated fake analysis. It uses synthetic data and removes its temporary records. For an isolated PostgreSQL/MinIO asset lifecycle check, run:
uv run python scripts/smoke-assets.py --env-file .env.localrender.yaml describes the prototype Render deployment: a Docker web service, Redis, startup migrations, CORS configuration, and Cloudflare R2-compatible object storage. Store all runtime secrets in Render's environment settings or another secret manager; they are intentionally absent from the Blueprint.
Read Deploy on Render with Cloudflare R2 before configuring a hosted environment. The deployment guide includes the credential-safe R2 smoke check and the production mail setup.
Use the shared domain language and update the owning contract or decision before implementing a cross-boundary behavior change. Keep changes small, include the relevant test depth, inspect generated OpenAPI/schema changes, and review the complete diff before opening a PR.
See CONTRIBUTING.md, SECURITY.md, and the review workflow. Security reports should go directly to a maintainer; never open a public issue containing an exploit, credential, or user data.
The documentation repository holds the product requirements, architectural decisions, contracts, retention policy, and testing plan. The most useful entry points are: