Skip to content

Core Library

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

gradient-core — library boundary

Design doc. Defines the domain core that can be lifted out of the FastAPI app into an importable gradient-core library, the transport/runtime that stays behind as a consumer of it, and the runtime contract a consumer must satisfy.

Status: design only — no code moved yet. Stance: pragmatic — keep the existing settings singleton and the module-level DB engine; document the seams to revisit later (see Phase 2) rather than refactor now.


Context

gradient-server is single-user and local-first (SPEC §C: backend + Postgres local, multi-user; OpenAI + Notion are the only cloud egress). Nothing requires a hosted server — every external call is outbound (OpenAI, Notion) or to a local process (AnkiConnect on 127.0.0.1:8765, Postgres). The only piece that needs a local HTTP listener is the Chrome-extension capture ingress.

The decided direction (see Deployment & storage) is a self-hosted home-server API with thin Apple clients over Tailscale — not a native app bundling the backend, and the React SPA experiment was reverted. So the API stays the transport; the value of gradient-core is a clean domain boundary FastAPI sits on, reusable by other transports (MCP, a future client, tests) without dragging HTTP along. The domain code is already library-shaped (see Boundary Findings); FastAPI is a thin transport over it. This doc draws the line so the extraction is mechanical and the boundary stops drifting.


Boundary findings (current state)

The split is mostly latent already:

  • No transport leaks. Nothing under app/services/ or app/models/ imports fastapi / starlette, and nothing imports app/api/* or app/web/*. The dependency arrows already point the right way.
  • Services are session-injected. No service imports app/database.py; every service takes an AsyncSession as a parameter. The DB engine is a pure runtime concern.
  • No Pydantic in services. Services return frozen dataclasses (e.g. AnkiLoadAdherence, CalibrationResult, CategorizeResult) or plain dict[str, Any]. Pydantic lives only at the HTTP boundary (app/schemas/*), with one exception below.
  • One DTO crosses the line. app/schemas/captures.py (CapturePayload, IngestResponse, ParsedCapture, MediaCapture, ChoiceItem, PassageCapture) is imported by the capture adapters (app/services/ingest.py, app/services/adapters/*) — so these specific schema classes are core, not transport.
  • settings is the shared core dependency. app/config.py exports a module-level settings = Settings() singleton imported by ~14 service modules (LLM, anki, categorizer, kb, media, tutor). Core depends on config; config does not depend on core.

Layer map

Package / module Role Disposition
app/models/* SQLAlchemy ORM (the schema) core
app/config.py (Settings, settings) env-backed config core (singleton; see seam C1)
app/services/ (live subset — see below) domain logic over an injected session/clients core
app/schemas/captures.py capture wire DTOs unpacked by adapters core (only this schema module)
app/database.py async engine + AsyncSessionLocal (eager at import) transport/runtime (seam C2)
app/api/deps.py get_session, verify_coach_token, get_settings transport
app/api/*, app/main.py FastAPI routers + app assembly transport
app/web/* (dashboard dormant, viewer, media, spa) HTTP/UI surfaces transport
app/scheduler.py APScheduler + job wrappers (build clients, open sessions) transport/runtime
app/kb_config.py startup env validation (WARN-only) transport/runtime
app/schemas/* (all except captures.py) HTTP request/response DTOs transport

Rule of thumb: core = "given an AsyncSession, injected clients, and settings, do the domain work and return dataclasses/dicts." Everything that opens a session, builds a client, parses a request, renders a response, or schedules a job is transport/runtime.


What's in core

models/ — the schema

All of app/models/* (Course/OutlineNode, Question/Attempt/RawCapture, the *_tags tables, AtomicFact/PdfSource/ContentEmbedding/ConceptEdge/ NotionPage, DiscriminatorFactor, anki_* tables, AttemptNote, Media, TaskRun). app/database.py:Base is the declarative base — core needs Base + the model classes, not the engine.

services/ — live domain modules

  • Capture ingestingest.py (dispatcher) + adapters/ (registry + extension_capture.normalize_capture, uworld, web_qbank, manual). Depends on schemas/captures.py. Public seam: get_adapter, register_adapter, ingest_capture, normalize_capture.
  • Outlineoutline_subtree.py (V-O1 subtree rollup), tutor/outline.py (search / tree / subtree, node-keyed, domain-blind), categorizer/outline_lookup.py + outline_render.py + _text.py (path → node_id index, candidate rendering).
  • Categorizercategorizer/{__init__,llm,worker,cache}.py (OpenAI tagging → <target>_tags, EXTRACTOR_VERSION-stamped).
  • LLM seamllm/{client,calibrator,grounded,cache}.py (build_openai_client() factory, logprob calibration V69, grounded generation). OpenAI is injected/factory-built and mocked at the SDK boundary in tests (V16).
  • KB substratekb/{embeddings,recall,persist_tags,pdf_ingest, notion}.py (pgvector embeddings + versioning, candidate recall V-L3, calibrated-tag persistence, PDF ingest, Notion write-out). Notion client is injected (not constructed in core).
  • Anki (live parts)anki/{client,sync,assignment,review, load_adherence,tag_parser,topic_resolver,topic_resolver_cache}.py and the outline-free helpers in anki/queries.py. Read calls + write allowlist only (V13).
  • Tutor / PKM reads + writestutor/{sessions,questions,flags, captures,health}.py, discriminator persistence (tutor/discriminators.py), outline_subtree.
  • Miscadmin_tags.py (tag CRUD), attempt_notes.py, media_store.py (filesystem media, seam C3).

config.py

Settings (pydantic-settings) + the settings singleton. Carried into core as-is under the pragmatic stance.


What stays transport / runtime

  • app/api/* + app/main.py — routers translate HTTP ⇄ service calls; deps.py provides get_session (session lifecycle: commit on clean exit, rollback on error) and verify_coach_token.
  • app/web/* — viewer, media, SPA static serving (web/spa.py), and the dormant Jinja dashboard (unmounted at T16 cutover; see SPEC §O).
  • app/database.py — the engine + AsyncSessionLocal. A core consumer brings its own engine/session; core never imports this.
  • app/scheduler.py — APScheduler + the job wrappers. Each job builds its own clients (build_openai_client, AnkiConnectClient) and opens a session, then calls core services. The work is core; the scheduling + wiring is runtime.
  • app/kb_config.py — startup validation (WARN-only).
  • app/schemas/* except captures.py.

Excluded from core (dead / fenced / legacy)

A clean extraction must not carry these. They are unmounted, stubbed, or reference the pre-rescope topic_id/cc_code schema (SPEC §C residual debt, V-RB1..V-RB5, V-O5). Delete or quarantine on extraction; do not move into core.

Module Why excluded
services/analytics.py FENCED (T17/V-RB1); route commented out in main.py
services/recommender.py FENCED (T17/V-RB1); route commented out
services/topic_subtree.py LEGACY — recursive CTE over dropped topics/cc_code (use outline_subtree.py)
services/analyzer/* FENCED (T17); scheduler job disabled; feature_extractor/synthesizer still Anthropic-era
services/anki/state.py, anki/retention.py FENCED (T18/V-RB2); /anki/performance route-disabled; return empty
services/anki/queries.py (subtree helpers only) FENCED; the outline-free helpers in the same file are LIVE — split, don't drop the file
services/anki/topic_resolver_worker.py, topic_resolver_batch.py STUBs (T13) — return empty, awaiting rebuild
services/llm/batch.py RETIRED with the OpenAI pivot (T4) — entry points raise NotImplementedError

(Resolving these — port or delete — is tracked separately under the rebaseline invariants; the extraction just refuses to inherit them.)


Runtime / config contract

What a gradient-core consumer (FastAPI today; MCP / tests / another transport later) must provide vs. what core builds itself.

Consumer must supply

  • A session. Core services take AsyncSession. The consumer owns the engine + session lifecycle (commit/rollback). Reference impl: app/api/deps.py:get_session.
  • settings / env. All env vars on Settings (app/config.py), grouped:
    • DB: DATABASE_URL
    • OpenAI/LLM: OPENAI_API_KEY, OPENAI_BASE_URL?, OPENAI_MODEL, OPENAI_CALIBRATOR_MODEL, CATEGORIZER_MODEL, FEATURE_EXTRACTOR_MODEL
    • Embeddings: EMBEDDING_MODEL
    • Anki: ANKICONNECT_URL, ANKI_DECK_NAME, ANKI_DECK_PREFIX, ANKI_*_INTERVAL_MINUTES, ANKI_TOPIC_RESOLVER_*
    • Notion (optional): NOTION_API_TOKEN, NOTION_WIKI_DB_ID
    • Filesystem: MEDIA_ROOT, PDF_INBOX_DIR, *_CACHE_PATH (SQLite)
    • Auth/misc: COACH_TOKEN, BACKEND_BASE_URL, SCHEDULER_ENABLED
  • Injected Notion client. kb/notion.py:sync_node_to_notion(..., notion_client=...) — core never constructs it (V16: mock at SDK boundary).
  • Writable filesystem for MEDIA_ROOT and the SQLite cache paths.

Core constructs itself (from settings)

  • OpenAI client — llm/client.py:build_openai_client(max_retries≥5) factory.
  • AnkiConnect client — anki/client.py:AnkiConnectClient(base_url).
  • SQLite caches — CategorizerCache, AnkiTopicResolverCache, etc., per run.

Documented seams to revisit (Phase 2 — not now)

  • C1 — global settings singleton. Services from app.config import settings at import; config reads env on import. Fine for one process; for embedding (multiple configs, tests, another transport) prefer an injected config object. ~14 modules touch it.
  • C2 — eager engine. app/database.py builds the engine at import from settings.DATABASE_URL. A library should not create an engine on import. Core is already session-injected, so this only affects the consumer's wiring — keep the engine in the consumer, never re-add it to core.
  • C3 — media_store reads settings.MEDIA_ROOT directly. Candidate for a passed-in path under C1.

Invariants the boundary must preserve

  1. Direction. core ⊥ import transport (app/api, app/web, fastapi, starlette). Transport imports core, never the reverse.
  2. Session-injected. Core services accept AsyncSession; core never opens a session or owns the engine.
  3. Clients injected or factory-built from settings — never hard-wired; OpenAI + Notion mock at the SDK boundary in tests (V16).
  4. Return domain types (dataclasses/dicts), not Pydantic/HTTP types — the capture DTOs (schemas/captures.py) are the one allowed Pydantic dependency.
  5. No dead code — fenced/stub/legacy modules above are excluded.

Phased migration plan

  • Phase 0 — this doc. Boundary defined; no code moved.
  • Phase 1 — mechanical carve (pragmatic). Create a gradient_core package (e.g. src/gradient_core/ or a top-level package) holding models/, the live services/ subset, config.py, and schemas/captures.py. Leave app/ (api, web, scheduler, database, deps, other schemas) importing from gradient_core. Keep the settings singleton + engine in place (C1/C2 unchanged). Exclude the dead modules. Add a boundary guard test (gradient_core ⊥ import fastapi/app.api/app.web). Suite stays green.
  • Phase 2 — de-globalize (optional). Address C1/C2: inject a config object instead of the import-time singleton; ensure no engine is created on gradient_core import. Enables reuse across transports (MCP, tests) + cleaner embedding.
  • Phase 3 — deploy on the home server. Run the existing FastAPI/gradient-core API + Postgres (+pgvector) + MEDIA_ROOT on one self-hosted box; Apple clients reach it API-only over Tailscale (Postgres never public). Media stays on the filesystem, served by the API (no BYTEA/S3/CloudKit); backup = pg_dump + a MEDIA_ROOT copy. MCP runs wherever it can reach the API/DB; Anki sync stays on the machine running AnkiConnect. Full rationale + rejected alternatives: Deployment & storage.

Verification (boundary holds)

  • Static guard (Phase 1+): a test asserts no module under gradient_core imports fastapi, starlette, app.api, or app.web — e.g. walk the package and check ast/import graph, or use import-linter contracts. (Today the equivalent grep is clean: grep -rl "from fastapi\|from starlette" app/services app/models → empty.)
  • Session-injection guard: no gradient_core module imports the engine / AsyncSessionLocal.
  • Suite green: the existing Python suite passes unchanged after Phase 1 (no behavior change — only module locations + imports).
  • Smoke: import gradient_core in a fresh process with DATABASE_URL set, open a session, run one read (e.g. tutor.sessions.get_recent_sessions) and one write (tutor.discriminators.write_discriminator_factor) against a test DB with OpenAI/Notion clients mocked.

Clone this wiki locally