-
Notifications
You must be signed in to change notification settings - Fork 0
Getting Started
This page walks you from a fresh clone to a running Gradient server: prerequisites, the full environment-variable reference, install, migrations, seeding, tests, and how to regenerate the API contract. For the shape of the API you'll be calling, see API Reference; for what the system does, see Features and the Home overview.
Gradient is backend-only — there is no in-repo UI. Once the server is up you
talk to it over HTTP at /api/v1/* (every domain route is token-gated; see
Auth), fetch assets from /media/*, and read the interactive
docs at http://localhost:8000/docs.
Note: The FastAPI app still self-identifies as
title="MCAT Coach"(app/main.py), a holdover from the pre-fork PoC. The OpenAPI title and any generated client will carry that name even though the project is Gradient.
| Tool | Version | Why |
|---|---|---|
| Python | 3.12+ |
requires-python = ">=3.12" (pyproject.toml) |
| Postgres | 16 |
docker-compose.yml pins postgres:16; pgvector planned (see note) |
| uv | recent | Dependency + venv manager; uv.lock is committed |
| Docker / Docker Compose | any | Runs the bundled Postgres (docker compose up -d) |
| OpenAI API key | — | Optional for boot; required for every LLM-touching job |
| AnkiConnect | — | Optional; only for the Anki sync/review jobs |
| Notion integration token | — | Optional; only for the Notion write-out mirror |
Note:
pgvectoris declared as a Python dependency (pgvector>=0.3) and the KB substrate reserves anembeddingcolumn, but migration0003_kb_substrateintentionally lands it asJSONBand defers theCREATE EXTENSION vector+vector(N)ALTER (see its module docstring). The committeddocker-compose.ymltherefore runs stockpostgres:16, not a pgvector image. When the embedding path is wired, swap to a pgvector-enabled image and run the ALTER; until then plain Postgres 16 is sufficient.
The external clients — the native macOS app, the Chrome capture extension, and the MCP tutor host — live in separate repos and are not needed to run the server.
Copy the template and edit it:
cp .env.example .envSettings (app/config.py) loads .env via pydantic-settings. With env_ignore_empty=True,
a shell var exported as empty (e.g. a sandbox that sets OPENAI_API_KEY="") is ignored in
favor of the .env value. The test suite sets GRADIENT_DISABLE_DOTENV=1 so your real
.env never bleeds into tests (V-TC1).
The authoritative field list below is pulled from Settings. Only DATABASE_URL has
no default and is strictly required; everything else has a default but several jobs no-op
until their credential is supplied.
| Var | Purpose | Default | Required? |
|---|---|---|---|
DATABASE_URL |
Async Postgres URL (postgresql+asyncpg://…) |
— | Yes |
Note:
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DBappear in.env.examplebut are notSettingsfields — they are read only bydocker-compose.yml(to provision the container) and by themise run db:drop-envtask. Keep them in sync with the credentials embedded inDATABASE_URL.
| Var | Purpose | Default | Required? |
|---|---|---|---|
OPENAI_API_KEY |
Key for every LLM call (tag / extract / calibrate / vision / embed). Empty disables LLM jobs (they no-op). | "" |
For LLM jobs |
OPENAI_BASE_URL |
OpenAI-compatible base URL — point at vLLM / lm-studio without code change | None |
No |
OPENAI_MODEL |
Default chat model (tag / extract / calibrate text) | gpt-5.4-nano |
No |
OPENAI_VISION_MODEL |
Vision model for PDF page transcription (V-KB3/V-L5); None falls back to OPENAI_MODEL
|
gpt-5.4-mini |
No |
OPENAI_CALIBRATOR_MODEL |
Logprobs-capable model for confidence calibration; runs reasoning OFF. MUST NOT be an o-series reasoning model | gpt-5.4-nano |
No |
EMBEDDING_MODEL |
Embedding model (provider/dim change ⇒ bump embedding_version + full re-embed, V-E1) |
text-embedding-3-small |
No |
OPENAI_SERVICE_TIER |
Processing tier threaded into chat calls (flex/auto/default/priority); None omits the param. Embeddings stay on the synchronous path. |
flex |
No |
Note:
.env.exampledocuments older model knobs (gpt-4.1-mini,CATEGORIZER_MODEL,FEATURE_EXTRACTOR_MODEL,ANKI_TOPIC_RESOLVER_MODEL) that are notSettingsfields in the current code (extra="ignore"silently drops them). The live, code-backed model knobs are exactly the rows above. Where the comments in.env.exampleandSettingsdefaults disagree,Settingswins.
| Var | Purpose | Default | Required? |
|---|---|---|---|
COACH_TOKEN |
Shared secret clients send in the X-Coach-Token header (constant-time check, V-auth) |
change_me_before_use |
Yes (change before use) |
BACKEND_BASE_URL |
Base URL for MCP transport + absolute deep-links rendered into responses | http://localhost:8000 |
No |
| Var | Purpose | Default | Required? |
|---|---|---|---|
MEDIA_ROOT |
Directory the /media/{file_path} server reads from (path-traversal guarded); created on demand by ensure_media_root()
|
<repo>/data/media |
No |
| Var | Purpose | Default | Required? |
|---|---|---|---|
ANKICONNECT_URL |
AnkiConnect endpoint. Use 127.0.0.1 not localhost (AnkiConnect binds IPv4 only) |
http://127.0.0.1:8765 |
No |
ANKI_DECK_NAME |
Source deck name, matched exactly as it appears in Anki | MileDown |
No |
ANKI_DECK_PREFIX |
Namespace for server-created filtered review decks (<prefix>::review::*); the write allowlist (V50) is scoped here |
mcat-coach |
No |
ANKI_SYNC_INTERVAL_MINUTES |
run_anki_sync cadence |
15 |
No |
ANKI_ASSIGNMENT_UNLOCK_INTERVAL_MINUTES |
run_anki_assignment_unlock cadence (V51/V55) |
60 |
No |
ANKI_ASSIGNMENT_COMPLETE_CRON_HOUR |
Hour for daily run_anki_assignment_complete cron (V51) |
5 |
No |
ANKI_ASSIGNMENT_COMPLETE_CRON_MINUTE |
Minute for that cron | 15 |
No |
ANKI_REVIEW_PUSH_INTERVAL_MINUTES |
run_anki_review push cadence (V53/V55) |
60 |
No |
| Var | Purpose | Default | Required? |
|---|---|---|---|
PDF_INBOX_DIR |
Local directory the PDF poller watches for <slug>/*.pdf
|
<repo>/data/pdf_inbox |
No |
NOTION_API_TOKEN |
Notion integration token for the one-way write-out mirror (V-N1) | None |
For Notion sync |
NOTION_WIKI_DB_ID |
Target Notion database id for the mirror | None |
For Notion sync |
PDF_INGEST_INTERVAL_MINUTES |
run_pdf_ingest cadence |
30 |
No |
NOTION_SYNC_INTERVAL_MINUTES |
run_notion_sync cadence |
60 |
No |
validate_kb_config() (app/kb_config.py) inspects Settings at startup and emits a
WARN for each missing optional (e.g. absent Notion token) rather than failing boot — jobs
whose credentials are absent simply no-op.
| Var | Purpose | Default | Required? |
|---|---|---|---|
SCHEDULER_ENABLED |
Start the APScheduler jobs in the FastAPI lifespan | true |
No |
EMBED_INTERVAL_MINUTES |
run_embed cadence (runs ahead of tagging — recall needs node vectors) |
15 |
No |
GROUNDED_TAG_INTERVAL_MINUTES |
run_grounded_tag cadence — the categorizer (recall → grounded pick → calibrate → persist) |
20 |
No |
See Intelligence Layer for the embed → recall → grounded-tag → calibrate chain those last two jobs drive, and Features for the full scheduler job roster.
Sync the locked dependency set into a managed virtualenv:
uv syncThis reads pyproject.toml + uv.lock and creates .venv/. Prefix commands with
uv run to execute inside it (e.g. uv run pytest), or activate .venv directly.
Note:
README.mddocuments apython3 -m venv .venv && pip install -e ".[dev]"path. That still works, butuvis the canonical toolchain here (the repo commitsuv.lockand the docs/contract commands useuv run). Preferuv sync.
Bring up the bundled Postgres (matches the .env.example credentials):
docker compose up -dThen apply all migrations. Alembic resolves the DB URL from ALEMBIC_DATABASE_URL
if set, else settings.DATABASE_URL (so .env alone is enough), else the blank
sqlalchemy.url in alembic.ini (which errors clearly if nothing is set):
uv run alembic upgrade headThe current head is 0007_question_course_id. The chain is
0001_initial → 0002_source_discriminator → 0003_kb_substrate → 0004_atomic_fact_tags → 0005_atomic_fact_extractor → 0006_correct_choice_nullable → 0007_question_course_id.
To add a migration later:
uv run alembic revision --autogenerate -m "describe the change"
uv run alembic upgrade headAutogenerate diffs your SQLAlchemy models against the live schema — always review the generated file (async engines + custom types aren't perfectly handled).
To drop the env's database (uses the running compose container, refuses protected DB names):
mise run db:drop-env
ENV_FILE=path/to/.env mise run db:drop-envuv run uvicorn app.main:app --reload- API base:
http://localhost:8000 - Interactive docs (Swagger UI):
http://localhost:8000/docs - Health check (open, no token):
curl localhost:8000/healthz
# {"status":"ok"}CORS is open to chrome-extension://* and http(s)://localhost origins via
allow_origin_regex (app/main.py).
Create a course, then materialize its outline by uploading a {course, nodes} schema.
The bundled AAMC example has no privileged position — uploading any other schema file
materializes that course instead (§V-O3):
# 1. Create the course (returns the new course id).
curl -X POST localhost:8000/api/v1/courses \
-H 'content-type: application/json' \
-H 'X-Coach-Token: <your COACH_TOKEN>' \
-d '{"slug":"aamc","name":"MCAT — AAMC Content Outline"}'
# 2. Import the outline tree (use the .schema.json, not the raw .json).
curl -X POST localhost:8000/api/v1/courses/1/outline:import \
-H 'X-Coach-Token: <your COACH_TOKEN>' \
--data-binary @app/seeds/aamc_outline.schema.jsonNote: Two AAMC files live in
app/seeds/. The import endpoint consumesaamc_outline.schema.json(shape{course, nodes:[{path, kind, name, position}…]}). The siblingaamc_outline.jsonis the raw upstream source data (shape{version, sections:[…]}) and is not the import payload. Theoutlinerouter is token-gated for its whole surface, so these calls needX-Coach-Token.
Once the outline exists, scripts/seed_dev.py populates questions, attempts, tags,
notes, and the KB substrate (PDF sources, atomic facts, concept edges, Notion pages)
directly in the DB — no OpenAI / Anki / Notion calls. Every synthetic qid is namespaced
dev-…, so it is idempotent:
uv run python -m scripts.seed_dev # seed (idempotent)
uv run python -m scripts.seed_dev --course aamc # pick course by slug
uv run python -m scripts.seed_dev --wipe # remove only dev- rowsEvery domain route is gated by verify_coach_token (app/api/deps.py), which compares
the X-Coach-Token request header against settings.COACH_TOKEN in constant time and
returns 401 "invalid coach token" on a miss. Only GET /healthz and the capture/PDF
ingest entry points differ in gating — confirm per route in API Reference.
curl localhost:8000/api/v1/courses \
-H 'X-Coach-Token: <your COACH_TOKEN>'uv run pytestpyproject.toml configures asyncio_mode = "auto", testpaths = ["tests"], and
--import-mode=importlib. Tests run with GRADIENT_DISABLE_DOTENV=1 (set in
tests/conftest.py) so config comes from process env + field defaults, never your
real .env (V-TC1).
docs/openapi.json is generated from the live app, not hand-maintained. Regenerate it
after adding or changing routes:
uv run python -c "import json; from app.main import app; \
open('docs/openapi.json','w').write(json.dumps(app.openapi(), indent=2)+'\n')"Note: The committed
docs/openapi.jsonis mostly current but has been observed to miss at least the admin recategorize route — regenerate before trusting it as a client source, and cross-check against API Reference.
- Home — project overview and the wiki map.
- Architecture — request lifecycle, app factory, lifespan, scheduler.
- Data Models — the SQLAlchemy tables behind the migrations above.
- API Reference — every live route, its auth, and its response shape.
- Function Index / Glossary — symbol-level and term-level lookup.
- Features — capability catalog with backing code and SPEC invariants.
- Intelligence Layer — the embed → tag → calibrate AI chain.
-
Core Library — the
gradient-coreboundary and migration plan.
Reference
Features
Design Notes