Architecture¶
Adaptive Learner is a 4-layer plugin-driven application.
┌─────────────────────────────────────────────────────────────┐
│ Frontend React 19 + TypeScript 6 + Vite 8 + │
│ Vitest 4 + Dexie 4 (IndexedDB) + TipTap │
└─────────────────────────────────────────────────────────────┘
↑↓ /api/*
┌─────────────────────────────────────────────────────────────┐
│ Backend FastAPI ^0.136 + SQLAlchemy ^2.0 + │
│ Pydantic v2 + Alembic + Fernet │
└─────────────────────────────────────────────────────────────┘
↑↓ hookspecs
┌─────────────────────────────────────────────────────────────┐
│ PluginForge ^0.10.0 (external PyPI; identity-gated │
│ via target_application) │
└─────────────────────────────────────────────────────────────┘
↑↓ entry_points
┌─────────────────────────────────────────────────────────────┐
│ Plugins 10 packages under plugins/ │
│ (ai-{anthropic,openai,gemini}, assessment,│
│ session, tracking, tools, gamification, │
│ anki, notebooklm) │
└─────────────────────────────────────────────────────────────┘
New features ALWAYS belong in a plugin, unless they touch the core (users / projects / settings / curriculum / topics / lessons / backup / sync / system / import).
Dual storage (v0.7.0)¶
The frontend has a single seam where the backing store is
chosen: getStorage(): IStorageService. Two implementations
satisfy one contract:
apiStorage(default): thin wrapper aroundapi/client.tsthat talks to the FastAPI backend.dexieStorage(local-first): full IndexedDB stack mirroring all 25 SQLAlchemy models. AI calls fire direct from the browser viastorage/ai-providers.ts.
IStorageService exposes 22 namespaces (users, projects,
settings, assessment, session with streaming, tracking,
tools, curricula, topics, lessons, plugins, system, backup,
export, subjects, tags, projectTaxonomy, imports,
gamification, anki, pronunciation, notebooklm). Both
backings implement every method.
The factory reads
localStorage["adaptive-learner.storage_mode"] then
VITE_STORAGE_MODE (set by the GH Pages build) then
defaults to api. Switching modes is not a live-swap: the
Settings page persists the choice and toasts a reload-
required notice.
Three-layer secrets (v1.20.0 / Phase 34)¶
Every AI call walks the chain via
services/settings.resolve_api_key:
ADAPTIVE_LEARNER_<PROVIDER>_API_KEYenv var.ai.<provider>.api_keyin~/.config/adaptive_learner/secrets.yaml.- Fernet-decrypted DB column.
None— the AI call surfaces an error to the UI.
Source attribution lives on UserSettingsOut.key_source_*
(enum: env / secrets_yaml / settings / none).
Settings UI disables Save / Remove when source is env or
secrets_yaml.
Same chain applies to default_model overrides per
provider; secrets.yaml beats the UI override per the
Phase 34 design (file-config wins over UI for power users).
Plugin structure¶
plugins/adaptive-learner-plugin-<name>/
adaptive_learner_<name>/
plugin.py # <Name>Plugin(BasePlugin), hook implementations
routes.py # FastAPI router (delegates to service functions)
<module>.py # business logic
tests/
test_*.py # pytest tests
pyproject.toml # entry point: [project.entry-points."adaptive_learner.plugins"]
- Plugin class inherits from
BasePlugin(pluginforge). - Business logic lives in its own modules, NOT in routes.py.
- routes.py contains only FastAPI endpoints that delegate.
- Hook specs live in
backend/app/hookspecs.py. - Plugin dependencies as a class attribute:
depends_on = ["session"]. - All plugins are free (MIT). The licensing infrastructure
exists but is dormant (
LICENSING_ENABLED = False).
Hooks (8 specs in backend/app/hookspecs.py)¶
| Hook | When | First-result? |
|---|---|---|
get_assessment_questions(lang) |
Assessment page load | yes |
calculate_profile(answers) |
Assessment submit | yes |
create_session_prompt(...) |
Each chat turn | yes |
ai_complete(messages, model, api_key, max_tokens) |
Standard AI call | yes (provider routes by model prefix) |
ai_complete_async(...) |
Parallel cycle-boundary eval (v1.5.0) | yes |
ai_complete_stream(...) |
Streaming session reply (v1.6.0) | yes |
recommend_method_switch(...) |
Dashboard + Session | yes |
on_session_complete(session, rating) |
Session end | broadcast |
get_progress_summary(project_id) |
Dashboard widgets | broadcast |
get_tool_recommendations(profile, lang) |
Dashboard tools | broadcast |
Data flow¶
UI (React) → IStorageService
→ (API mode) FastAPI router → service → SQLAlchemy → SQLite
→ (Dexie mode) Dexie table → IndexedDB
↓
AI orchestrator → resolve_api_key (env > yaml > DB)
→ pluginforge → provider plugin's ai_complete*
→ Anthropic / OpenAI / Gemini SDK
Unidirectional. No direct DB access from routers (services own the SQLAlchemy work). No frontend code in the backend.
Error handling¶
Frontend ApiError (status + detail) → toast for the user
API client HTTP error → converted to ApiError
Router Thin, catches nothing. Global exception handler maps.
Service Throws AdaptiveLearnerError subclasses
Plugin Throws PluginError(plugin_name, message)
External ExternalServiceError(service, message) for provider SDKs
Services NEVER throw HTTPException; routers catch
NOTHING. The global exception handler in main.py maps
domain errors to HTTP status codes. See
.claude/rules/code-hygiene.md for the full pattern.
Persistence¶
- Backend: SQLAlchemy + SQLite. Alembic migrations in
backend/migrations/versions/. - Sync surface: 28 tables (v1.19.0 baseline). Append-only history rows (sessions, messages, ratings, progress commits, step evaluations, method switches, imported conversations, imported messages, anki cards, study questions) plus mutable settings + curriculum rows.
- Backup format: JSON; API keys stripped on export; restore is a merge.
- Test isolation: production data dirs carry a
.adaptive-learner-productionmarker; if a test ever sees it, the run aborts withpytest.exit(returncode=2).
Theming¶
5 themes (Classic, Cool Modern, Nord, Notebook, Studio) ×
light/dark = 10 variants. CSS variables throughout; no
Tailwind. Custom properties in
frontend/src/styles/global.css. New UI elements MUST use
the variable set.
Mobile / PWA¶
@media (max-width: 768px) is the canonical mobile cut-over
(hamburger drawer, 44×44 touch targets, stacked layouts).
@media (max-width: 360px) is the extreme-narrow safety
net. Desktop styles ≥769px unchanged.
Service worker (Workbox via vite-plugin-pwa): NetworkFirst
on GET /api/ with 4s timeout, 24h LRU, 60-entry cap.
Mutating /api/ is NetworkOnly.