Skip to content

Getting Started

Ramiro Cantu edited this page May 31, 2026 · 2 revisions

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.


Prerequisites

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: pgvector is declared as a Python dependency (pgvector>=0.3) and the KB substrate reserves an embedding column, but migration 0003_kb_substrate intentionally lands it as JSONB and defers the CREATE EXTENSION vector + vector(N) ALTER (see its module docstring). The committed docker-compose.yml therefore runs stock postgres: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.


Environment variables

Copy the template and edit it:

cp .env.example .env

Settings (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.

Database

Var Purpose Default Required?
DATABASE_URL Async Postgres URL (postgresql+asyncpg://…) Yes

Note: POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB appear in .env.example but are not Settings fields — they are read only by docker-compose.yml (to provision the container) and by the mise run db:drop-env task. Keep them in sync with the credentials embedded in DATABASE_URL.

OpenAI (the LLM provider, behind app/services/llm/)

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.example documents older model knobs (gpt-4.1-mini, CATEGORIZER_MODEL, FEATURE_EXTRACTOR_MODEL, ANKI_TOPIC_RESOLVER_MODEL) that are not Settings fields 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.example and Settings defaults disagree, Settings wins.

Auth & origin

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

Media

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

Anki integration

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

KB substrate (PDF ingest + Notion mirror)

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.

Scheduler

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.


Install

Sync the locked dependency set into a managed virtualenv:

uv sync

This 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.md documents a python3 -m venv .venv && pip install -e ".[dev]" path. That still works, but uv is the canonical toolchain here (the repo commits uv.lock and the docs/contract commands use uv run). Prefer uv sync.


Database: start Postgres and migrate

Bring up the bundled Postgres (matches the .env.example credentials):

docker compose up -d

Then 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 head

The 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 head

Autogenerate 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-env

Run the server

uv 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).


Seeding

Outline (required before app data is meaningful)

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.json

Note: Two AAMC files live in app/seeds/. The import endpoint consumes aamc_outline.schema.json (shape {course, nodes:[{path, kind, name, position}…]}). The sibling aamc_outline.json is the raw upstream source data (shape {version, sections:[…]}) and is not the import payload. The outline router is token-gated for its whole surface, so these calls need X-Coach-Token.

Synthetic dev/test data (offline)

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- rows

Calling the API

Every 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>'

Running tests

uv run pytest

pyproject.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).


Regenerating the OpenAPI contract

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.json is 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.


Where to go next

  • 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-core boundary and migration plan.

Clone this wiki locally