-
Notifications
You must be signed in to change notification settings - Fork 0
Core Library
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.
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.
The split is mostly latent already:
-
No transport leaks. Nothing under
app/services/orapp/models/importsfastapi/starlette, and nothing importsapp/api/*orapp/web/*. The dependency arrows already point the right way. -
Services are session-injected. No service imports
app/database.py; every service takes anAsyncSessionas a parameter. The DB engine is a pure runtime concern. -
No Pydantic in services. Services return frozen dataclasses (e.g.
AnkiLoadAdherence,CalibrationResult,CategorizeResult) or plaindict[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. -
settingsis the shared core dependency.app/config.pyexports a module-levelsettings = Settings()singleton imported by ~14 service modules (LLM, anki, categorizer, kb, media, tutor). Core depends on config; config does not depend on core.
| 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.
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.
-
Capture ingest —
ingest.py(dispatcher) +adapters/(registry +extension_capture.normalize_capture,uworld,web_qbank,manual). Depends onschemas/captures.py. Public seam:get_adapter,register_adapter,ingest_capture,normalize_capture. -
Outline —
outline_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). -
Categorizer —
categorizer/{__init__,llm,worker,cache}.py(OpenAI tagging →<target>_tags, EXTRACTOR_VERSION-stamped). -
LLM seam —
llm/{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 substrate —
kb/{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}.pyand the outline-free helpers inanki/queries.py. Read calls + write allowlist only (V13). -
Tutor / PKM reads + writes —
tutor/{sessions,questions,flags, captures,health}.py, discriminator persistence (tutor/discriminators.py),outline_subtree. -
Misc —
admin_tags.py(tag CRUD),attempt_notes.py,media_store.py(filesystem media, seam C3).
Settings (pydantic-settings) + the settings singleton. Carried into core
as-is under the pragmatic stance.
-
app/api/*+app/main.py— routers translate HTTP ⇄ service calls;deps.pyprovidesget_session(session lifecycle: commit on clean exit, rollback on error) andverify_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/*exceptcaptures.py.
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.)
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 onSettings(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
- DB:
-
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_ROOTand 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
settingssingleton. Servicesfrom app.config import settingsat 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.pybuilds the engine at import fromsettings.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_storereadssettings.MEDIA_ROOTdirectly. Candidate for a passed-in path under C1.
-
Direction.
core ⊥ import transport(app/api,app/web,fastapi,starlette). Transport imports core, never the reverse. -
Session-injected. Core services accept
AsyncSession; core never opens a session or owns the engine. -
Clients injected or factory-built from
settings— never hard-wired; OpenAI + Notion mock at the SDK boundary in tests (V16). -
Return domain types (dataclasses/dicts), not Pydantic/HTTP types — the
capture DTOs (
schemas/captures.py) are the one allowed Pydantic dependency. - No dead code — fenced/stub/legacy modules above are excluded.
- Phase 0 — this doc. Boundary defined; no code moved.
-
Phase 1 — mechanical carve (pragmatic). Create a
gradient_corepackage (e.g.src/gradient_core/or a top-level package) holdingmodels/, the liveservices/subset,config.py, andschemas/captures.py. Leaveapp/(api, web, scheduler, database, deps, other schemas) importing fromgradient_core. Keep thesettingssingleton + 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_coreimport. 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_ROOTon 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+ aMEDIA_ROOTcopy. MCP runs wherever it can reach the API/DB; Anki sync stays on the machine running AnkiConnect. Full rationale + rejected alternatives: Deployment & storage.
-
Static guard (Phase 1+): a test asserts no module under
gradient_coreimportsfastapi,starlette,app.api, orapp.web— e.g. walk the package and checkast/importgraph, or useimport-lintercontracts. (Today the equivalent grep is clean:grep -rl "from fastapi\|from starlette" app/services app/models→ empty.) -
Session-injection guard: no
gradient_coremodule 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_corein a fresh process withDATABASE_URLset, 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.
Reference
Features
Design Notes