FastAPI + HTMX + LangGraph for conversational language learning
- System Overview
- Technology Stack
- Project Structure
- LangGraph Conversation Engine
- Lesson System
- Spaced Repetition
- SSE Streaming
- Voice Architecture
- Conversation Threads
- Progress and Learning Paths
- Hermano Personality System
- API Layer
- Database Schema
- Middleware and Layer Architecture
- Security and Privacy
- Frontend Architecture
- Testing
Habla Hermano is a conversational language tutor that teaches Spanish, German, and French from complete beginner (A0) to intermediate (B1). Users chat with "Hermano," an AI tutor built on a stateful LangGraph pipeline backed by Claude Haiku. The system provides grammar feedback, scaffolding for beginners, 60 structured lessons, spaced repetition, voice input/output, and encrypted persistence.
graph LR
B["Browser<br/>(HTMX + ES Modules)"]
F["FastAPI"]
LG["LangGraph Pipeline"]
C["Claude Haiku 4.5"]
DG_STT["Deepgram Nova-3 STT"]
DG_TTS["Deepgram Aura-2 TTS"]
DB["Supabase PostgreSQL"]
B -- "SSE POST /chat/stream" --> F --> LG --> C
B -- "WebSocket /ws/transcribe" --> F -- "WS Proxy" --> DG_STT
B -- "WebSocket /ws/speak" --> F -- "WS Proxy" --> DG_TTS
B -- "HTMX requests" --> F -- "Jinja2 SSR" --> DB
Key design decisions:
- Server-rendered HTML with HTMX for UI updates — no SPA framework, no client-side routing
- LangGraph for stateful conversation management with conditional routing and checkpointing
- SSE streaming for real-time token-by-token response delivery
- WebSocket proxies for voice (Deepgram) — API keys never reach the browser
- Fernet encryption for all user data at rest, including LangGraph checkpoint blobs
- Row-level security on all Supabase tables
Test coverage: 2,529 tests (2,291 Python + 238 JavaScript), 97% coverage, strict mypy, ruff linting.
| Component | Technology | Why |
|---|---|---|
| Backend | FastAPI | Async SSE streaming, Pydantic validation, WebSocket support |
| Frontend | HTMX + Jinja2 + Tailwind | Server-driven UI, minimal JS, 11 ES modules |
| Agent | LangGraph | Stateful conversation graphs with conditional routing and checkpointing |
| LLM | Claude Haiku 4.5 | Multilingual understanding, structured output for exercises |
| Database | PostgreSQL (Supabase) | Row-level security, auth, production persistence |
| Auth | Supabase Auth | JWT with httponly cookies, guest sessions via signed UUIDs |
| Voice | Deepgram (Nova-3 STT, Aura-2 TTS) | Real-time WebSocket streaming with linear16 PCM |
| Encryption | cryptography (Fernet) | Field-level + checkpoint blob encryption, PBKDF2 key derivation |
| Monitoring | Sentry | Error tracking for backend (FastAPI) and frontend (JS) |
| Testing | pytest + Vitest | 2,529 tests, parallel execution via pytest-xdist |
src/
├── config.py Settings + get_settings (canonical)
├── validation.py VALID_LANGUAGES, VALID_LEVELS, validators (canonical)
├── api/
│ ├── main.py FastAPI app factory, middleware registration, Sentry init
│ ├── auth.py JWT validation, CurrentUserDep, OptionalUserDep, EffectiveUser
│ ├── middleware.py SecurityHeadersMiddleware (CSP, HSTS) + CSRFMiddleware
│ ├── streaming.py SSE streaming: StreamResult, stream_chat_events()
│ ├── cookies.py Cookie signing with itsdangerous
│ ├── rate_limit.py REST decorator + WebSocket sliding window limiter
│ └── routes/
│ ├── chat.py GET / (freeform + lesson mode), POST /chat/stream
│ ├── auth.py Login, signup, logout, password reset
│ ├── lessons.py Lesson catalog endpoints
│ ├── progress.py Dashboard stats, vocabulary, chart data
│ ├── review.py Spaced repetition review sessions
│ ├── learn.py Learning paths and adaptive recommendations
│ ├── threads.py Thread CRUD (create, list, rename, delete)
│ ├── voice.py WebSocket STT/TTS proxy + REST TTS fallback
│ └── privacy.py Privacy info page, delete history, delete account
├── agent/
│ ├── graph.py Freeform chat graph: respond → scaffold → analyze
│ ├── lesson_chat_graph.py Lesson chat graph: lesson_respond (phase machine)
│ ├── state.py ConversationState TypedDict
│ ├── prompts.py Level prompts + LANGUAGE_ADAPTER
│ ├── prompts_lesson_chat.py Phase-specific lesson prompts + TEACHING_ADJUSTMENTS
│ ├── checkpointer.py AsyncPostgresSaver + MemorySaver fallback
│ ├── llm.py Claude client with zero-retention headers
│ └── nodes/
│ ├── respond.py Generate AI response
│ ├── scaffold.py Word bank, hints, sentence starters (A0/A1)
│ ├── analyze.py Grammar feedback, vocabulary extraction, pronunciation tips
│ ├── lesson_chat.py Lesson respond node with 5-phase state machine
│ ├── lesson.py AI-enhanced lesson nodes (load, enhance, validate)
│ └── review.py Review question generation, answer evaluation, SM-2 update
├── db/
│ ├── client.py Supabase client factory (canonical)
│ ├── encryption.py FieldEncryptor + FernetCipher + EncryptedSerializer
│ ├── repository.py Repository classes for Supabase data access
│ └── models.py Pydantic models (Vocabulary, Session, Thread, etc.)
├── services/
│ ├── review.py ReviewService with SM-2 algorithm
│ ├── progress.py ProgressService for dashboard aggregation
│ ├── paths.py PathService for structured learning paths
│ ├── adaptive.py AdaptiveService for daily recommendations
│ ├── lesson_completion.py Exercise validation, vocab upsert, persistence
│ ├── threads.py ThreadService: CRUD for conversation_threads
│ ├── thread_titling.py Auto-title generation via Claude Haiku
│ ├── thread_messages.py Checkpoint message extraction for history
│ └── data_retention.py Configurable data cleanup policies
├── lessons/
│ ├── models.py Lesson, Step, Exercise, Progress models
│ └── service.py YAML loader, filtering, vocabulary extraction
├── templates/ Jinja2 with HTMX (42 templates)
└── static/ CSS + 11 ES modules + AudioWorklet processor
data/lessons/ 60 YAML lessons (es/, de/, fr/ × A0-B1)
tests/ 2,291 pytest + 238 Vitest tests
docs/ Architecture, API, design docs, ADRs
The core is a stateful LangGraph pipeline with conditional routing. Each user message traverses a graph that decides what feedback to generate based on the learner's level and message content.
graph TD
A["User Message"] --> R["respond<br/>Generate AI response via Claude Haiku"]
R --> S{"should_scaffold?<br/>Level == A0 or A1?"}
S -- yes --> SC["scaffold<br/>Word bank, hints, sentence starters"]
S -- no --> AN
SC --> AN{"should_analyze?<br/>Did the user write in target language?"}
AN -- yes --> AZ["analyze<br/>Grammar corrections, vocabulary extraction, pronunciation tips"]
AN -- no --> W
AZ --> W{"should_weave_review?<br/>SM-2 words due for review?"}
W -- yes --> WV["weave<br/>Insert due vocabulary naturally"]
W -- no --> E["END<br/>Stream outputs via SSE"]
WV --> E
Flow paths:
- A0/A1 learners: respond → scaffold → analyze → END
- A2/B1 learners: respond → analyze → END
class ConversationState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
language: str # "es" | "de" | "fr"
level: str # "A0" | "A1" | "A2" | "B1"
scaffolding: ScaffoldingConfig
grammar_feedback: list[GrammarFeedback]
new_vocabulary: list[VocabWord]
pronunciation_tips: list[PronunciationTip]
review_words_offered: NotRequired[list[ReviewWordOffered]]
review_words_used: NotRequired[list[ReviewWordUsed]]
user_id: NotRequired[str]
session_id: strrespond — Calls Claude with a level-specific system prompt (via get_prompt_for_level()). The prompt defines Hermano's personality, language mix ratio, and behavior at each CEFR level.
scaffold — Uses with_structured_output(ScaffoldingConfig) to generate contextual word banks, hints, and sentence starters based on the AI's response. Auto-expands for A0 learners.
analyze — Extracts grammar errors, new vocabulary, and pronunciation tips from the user's message. Only flags errors appropriate for the learner's level.
- Production:
AsyncPostgresSaverbacked by Supabase PostgreSQL with encrypted serialization (EncryptedSerializerwraps state blobs with Fernet) - Local dev:
MemorySaver(in-memory, no database required) - Thread isolation: Each conversation uses a unique
thread_id(user:{user_id}:{uuid4})
Beyond freeform chat, Hermano teaches 60 structured lessons (3 languages × 4 CEFR levels × 5 lessons) through the chat interface. Lessons open at /?lesson={id} and use a dedicated LangGraph graph.
GET /?lesson={id} → chat.html renders in lesson mode
POST /chat/stream with lesson_id → lesson_chat_graph (single node: lesson_respond)
Phase Machine (inside lesson_respond):
intro → teaching → exercise_ask → exercise_eval → complete
Unlike the freeform graph's multi-node pipeline, the lesson graph uses a single node with an internal 5-phase state machine. This reuses the existing SSE infrastructure and chat UI while maintaining lesson state via LangGraph checkpointing.
| Phase | What Happens |
|---|---|
| intro | Welcome, preview lesson content |
| teaching | Present steps in batches of 3, advance each turn |
| exercise_ask | Present the current exercise (MC, fill-blank, translate) |
| exercise_eval | Evaluate answer with LLM, record result, advance |
| complete | Calculate score, emit completion events |
Each lesson prompt includes {teaching_adjustments} injected per level:
| Level | Behavior |
|---|---|
| A0 | One concept at a time, ~80% English, yes/no questions |
| A1 | 2-3 concepts grouped, 50/50 mix, pattern-based grammar |
| A2 | Mini-dialogues, 80% target language, insider expressions |
| B1 | Nuanced discussion, 95%+ target language, peer corrections |
| Type | Validation |
|---|---|
multiple_choice |
selected_index == correct_index |
fill_blank |
Case-insensitive match with alternatives |
translate |
LLM-based evaluation with accent-preserving normalization |
Lessons are defined in data/lessons/{lang}/{level}/{category}-{num}.yaml:
id: greetings-001
title: Basic Greetings
language: es
level: A0
category: greetings
steps:
- type: vocabulary
vocabulary:
- word: hola
translation: hello
exercises:
- id: ex-mc-greet-001
type: multiple_choice
question: "How do you say 'hello' in Spanish?"
options: [Hola, Adios, Gracias]
correct_index: 0Conversation-first spaced repetition using the SM-2 algorithm. No flashcards — words come back through Hermano naturally.
Chat weaving (passive): During normal conversation, the respond node queries due-for-review words, weaves them into Hermano's response, and the analyze node silently detects correct usage and updates SM-2 scores. The user never sees review UI.
Dedicated review mode (active): Conversational micro-quizzes accessed via ?mode=review, the progress page, or a chat warmup prompt. Three question types: translate, fill-blank, recognize.
quality >= 3 (correct):
repetitions += 1
interval = 1 day (rep 1), 6 days (rep 2), previous × easiness_factor (rep 3+)
quality < 3 (incorrect):
repetitions = 0, interval = 1 day
easiness_factor = max(1.3, EF + 0.1 - (5 - quality) × (0.08 + (5 - quality) × 0.02))
Two compiled LangGraph subgraphs handle review sessions:
- Question generation:
generate_question_node→ picks type, generates with Hermano's voice → END - Answer evaluation:
evaluate_answer_node→ AI evaluates, infers quality 0-5 →update_sm2_node→ updates scheduling → END
Responses stream token-by-token via Server-Sent Events (POST /chat/stream). The user sees Hermano's response appear in real time rather than waiting 5-15 seconds for the full pipeline.
event: token → Append to bubble (throttled scroll every 3 tokens)
event: response_complete → Finalize bubble with server-rendered markdown
event: scaffolding → Insert collapsible help section (A0/A1 only)
event: grammar → Insert grammar correction panel
event: pronunciation → Insert pronunciation tips
event: lesson_progress → Update segmented progress indicator
event: done → Re-enable input
The backend uses graph.astream(stream_mode=["messages", "updates"]) to get both per-token message chunks and per-node state updates in a single pass. Only tokens from the respond node are streamed — scaffold and analyze tokens are filtered silently.
The frontend (stream.js) intercepts the chat form submit, POSTs via fetch(), and reads the SSE response via ReadableStream. HTMX is not used for chat submission. A 60-second AbortController timeout ensures safety.
After injecting feedback HTML (scaffolding, grammar, pronunciation), Alpine.initTree() activates the newly inserted interactive components.
Voice is a progressive enhancement. The app degrades gracefully without Deepgram keys.
All Deepgram API calls are proxied through FastAPI. The DEEPGRAM_API_KEY stays server-side, matching the same pattern used for LLM calls.
sequenceDiagram
participant B as Browser
participant F as FastAPI
participant D as Deepgram Nova-3
B->>B: getUserMedia() + AudioWorklet (Float32 → Int16 @ 16kHz)
B->>F: WebSocket /ws/transcribe (binary PCM)
F->>F: _authenticate_websocket()
F->>D: WebSocket (audio bytes)
D-->>F: transcript events
F-->>B: {"transcript": "Hola, como estas?", "is_final": true}
B->>B: Populate chat input
B->>F: POST /chat/stream (normal SSE flow)
Deepgram STT config: Nova-3, multilingual code-switching, linear16 @ 16kHz, interim results, 300ms endpointing, smart formatting.
Two paths: WebSocket streaming (primary, ~300ms time-to-first-audio) and REST fallback.
sequenceDiagram
participant B as Browser
participant F as FastAPI
participant D as Deepgram Aura-2
B->>B: User taps speaker icon → AudioContext.resume()
B->>F: WebSocket /ws/speak?voice=aura-2-nestor-es
F->>F: _authenticate_websocket()
F->>D: wss://api.deepgram.com/v1/speak (linear16, 24kHz)
B->>F: {"text": "Hola amigo"}
D-->>F: binary audio chunks (linear16 PCM)
F-->>B: binary PCM chunks
B->>B: Int16 → Float32 → AudioBufferSource.start() (gapless playback)
REST fallback: POST /api/speak returns MP3 stream, played via Audio() element.
Default voices: Spanish (aura-2-nestor-es), German (aura-2-julius-de), French (aura-2-hector-fr) — masculine voices matching the Hermano persona.
| Module | Responsibility |
|---|---|
voice.js |
Orchestrator: wires FSM services, owns state, public API |
voice-stt.js |
STT state machine, mic capture via AudioWorklet |
voice-tts.js |
TTS state machine, WebSocket PCM streaming, REST fallback |
voice-ui.js |
Stateless UI helpers: indicators, timers, tooltips |
voice-constants.js |
Sample rates, voice IDs, SVG icons, audio utilities |
fsm.js |
Generic finite state machine: createMachine + interpret |
Each session gets its own AbortController. All async callbacks check signal.aborted to prevent stale handlers from corrupting active sessions.
| Endpoint | Limit |
|---|---|
POST /api/speak |
10 requests / 60s (decorator) |
/ws/speak |
30 messages / 60s per connection (sliding window) |
/ws/transcribe |
Not message-limited (AudioWorklet fires ~375 frames/sec) |
Authenticated users maintain multiple independent conversations. Each thread has its own language, level, and message history.
| Component | Purpose |
|---|---|
ThreadService |
CRUD on conversation_threads table, scoped per user |
generate_thread_title() |
Claude Haiku (30-token budget) creates 3-5 word title after first exchange |
get_thread_messages() |
Extracts HumanMessage/AIMessage pairs from LangGraph checkpoint state |
Thread ID format: user:{user_id}:{uuid4} — embeds owner UUID so the checkpoint RLS function checkpoint_owner() can extract it without a lookup.
sequenceDiagram
participant B as Browser
participant F as FastAPI
participant S as ThreadService
participant G as LangGraph Checkpoint
B->>F: GET /chat/thread-content?thread_id={id}
F->>S: get_thread(thread_id) — validate ownership
F->>G: graph.aget_state({"thread_id": id})
G-->>F: checkpoint state with message list
F->>F: Set active_thread httponly cookie
F-->>B: HTML partial (rendered messages)
B->>B: Swap #chat-messages, dispatch 'thread-switched'
The sidebar is a fixed overlay drawer toggled by the hamburger button. It works on all screen sizes without reflowing the chat layout. The active thread is stored in an active_thread httponly cookie, not in the URL.
After each streaming response, the sidebar thread item's title and timestamp update in-place (no page reload).
The ProgressService aggregates data from three repositories:
| Repository | Data |
|---|---|
VocabularyRepository |
Words learned, accuracy rates |
LearningSessionRepository |
Session counts, streak calculation |
LessonProgressRepository |
Lessons completed, scores |
All data operations use get_supabase_for_user(sb_access_token) so RLS works naturally. Guests see empty stats with a sign-up prompt.
Structured progression through 20 lessons per language (4 CEFR levels × 5 lessons). The AdaptiveService recommends what to do next based on:
| Signal | What It Checks |
|---|---|
| Path progress | Next uncompleted lesson |
| Vocabulary accuracy | Categories below 70% |
| Review schedule | Words due for spaced repetition |
| Level readiness | All lessons at current level complete |
Hermano is a friendly, laid-back big brother figure defined in src/agent/prompts.py. His behavior adapts to the learner's level:
| Level | Language Mix | Personality |
|---|---|---|
| A0 | 80% English, 20% target | Supportive big brother, celebrates tiny wins |
| A1 | 50/50 | Chill friend who spent a year abroad |
| A2 | 80% target, 20% English | Challenges you while keeping it fun |
| B1 | 95%+ target | Natural conversation partner, peer-to-peer |
A LANGUAGE_ADAPTER dictionary maps language codes to language-specific data (greetings, pronunciation guidance, stress rules). Prompt templates use {language_name}, {hello}, {tricky_sounds}, etc. as placeholders, resolved at runtime by get_prompt_for_level().
This pattern means adding a new language requires only a dictionary entry and lesson YAML files — no prompt logic changes.
| Category | Routes | Auth | Guest Behavior |
|---|---|---|---|
| Chat | GET /, POST /chat/stream |
Optional | Full chat, scaffolding, grammar feedback |
| Lessons | GET /lessons/ |
Optional | Full catalog access |
| Progress | GET /progress/* |
Required for data | Empty stats with sign-up prompt |
| Review | POST /review/* |
Required | 401 Unauthorized |
| Threads | GET/POST/DELETE /chat/threads/* |
Required | 401 Unauthorized |
| Voice WS | /ws/transcribe, /ws/speak |
Required (JWT or session cookie) | 1008 rejection |
| Voice REST | POST /api/speak |
Optional + CSRF | Full access |
| Privacy | GET/POST /privacy/* |
Optional | Info page only, no data actions |
| Auth | GET/POST /auth/* |
None | Public |
All state-changing requests require HX-Request: true (sent automatically by HTMX) or X-Requested-With: XMLHttpRequest (sent by fetch/JS). This uses the OWASP custom-header pattern — browsers enforce that cross-origin forms cannot set custom headers.
Supabase PostgreSQL with row-level security on all tables:
-- Vocabulary (with SM-2 fields)
CREATE TABLE vocabulary (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
word TEXT NOT NULL,
translation TEXT NOT NULL, -- Fernet encrypted
language TEXT NOT NULL,
easiness_factor FLOAT DEFAULT 2.5,
interval_days INTEGER DEFAULT 0,
repetition_count INTEGER DEFAULT 0,
next_review_at TIMESTAMPTZ,
times_seen INTEGER DEFAULT 1,
times_correct INTEGER DEFAULT 0,
UNIQUE(user_id, word, language)
);
-- Learning sessions
CREATE TABLE learning_sessions (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
language TEXT NOT NULL,
level TEXT NOT NULL,
messages_count INTEGER DEFAULT 0,
started_at TIMESTAMPTZ DEFAULT NOW()
);
-- Lesson completion
CREATE TABLE lesson_progress (
user_id UUID NOT NULL,
lesson_id TEXT NOT NULL,
completed_at TIMESTAMPTZ,
score INTEGER,
PRIMARY KEY (user_id, lesson_id)
);
-- Conversation threads (Phase 26)
CREATE TABLE conversation_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
thread_id TEXT NOT NULL UNIQUE, -- user:{user_id}:{uuid4}
title TEXT,
language TEXT,
level TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);RLS: Every table has USING (auth.uid() = user_id) policies. Checkpoint tables use a checkpoint_owner() function that extracts the user UUID from the thread_id column.
Encryption: vocabulary.translation and user_profiles.display_name are Fernet-encrypted at the repository layer. All LangGraph checkpoint blobs are encrypted via EncryptedSerializer.
Request processing order (outermost first):
Request → SecurityHeadersMiddleware → CSRFMiddleware → CORSMiddleware → Route Handler
| Middleware | Purpose |
|---|---|
| SecurityHeaders | CSP (nonce-based), HSTS, X-Frame-Options, X-Content-Type-Options, Cache-Control |
| CSRF | Custom-header pattern for POST/PUT/DELETE/PATCH |
| CORS | Explicit allow_headers allowlist (no wildcard) |
Inner layers do not import from outer layers:
src/api/ → imports: config, validation, db, services, agent, lessons
src/services/ → imports: config, validation, db, lessons
src/agent/ → imports: config, validation, db
src/db/ → imports: config
src/lessons/ → imports: config
Canonical modules (src/config.py, src/validation.py, src/db/client.py) live at the src/ level. The old src/api/ locations remain as thin re-export shims.
| Layer | Mechanism | Module |
|---|---|---|
| Transport | HSTS, X-Frame-Options, X-Content-Type-Options | middleware.py |
| CSP | Nonce-based script-src | middleware.py |
| CSRF | Custom-header pattern (HX-Request / X-Requested-With) | middleware.py |
| WebSocket Auth | JWT or session cookie validated before accept, reject 1008 | voice.py |
| Rate Limiting | REST decorator + WebSocket sliding window | rate_limit.py |
| RLS | All app tables + all 4 checkpoint tables | Supabase |
| Encryption at Rest | Fernet field-level + checkpoint blob encryption | encryption.py |
| LLM Privacy | Anthropic zero-retention headers (x-no-store: true) |
llm.py |
| XSS | nh3 sanitization + markupsafe.escape() | Templates, routes |
| Cookies | Signed with itsdangerous, environment-aware Secure flag | cookies.py |
| Error Monitoring | Sentry SDK (backend + frontend) | main.py, base.html |
Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256) protects all sensitive data:
Key derivation: SECRET_KEY + ENCRYPTION_SALT → PBKDF2-HMAC-SHA256 (480,000 iterations) → Fernet key.
Field-level: vocabulary.translation and user_profiles.display_name are encrypted/decrypted transparently by the FieldEncryptor class.
Checkpoint-level: All LangGraph state blobs (conversation history, lesson progress, exercise state) are encrypted via EncryptedSerializer, injected at graph construction time.
| Data | Setting | Default |
|---|---|---|
| Checkpoint blobs | CHECKPOINT_RETENTION_DAYS |
30 days |
| Sessions/Vocabulary | Configurable | No auto-purge |
Forgot password flow via Supabase Auth email recovery. The recovery link contains a token that is extracted client-side and sent to POST /auth/reset-password, which establishes a Supabase auth session and updates the password.
POST /privacy/delete-account removes all user data: vocabulary, learning sessions, lesson progress, conversation threads, and LangGraph checkpoints.
Server-rendered HTML (Jinja2 + HTMX) with 11 ES modules:
| Module | Responsibility |
|---|---|
stream.js |
SSE client, streaming bubble management, lesson progress events |
voice.js |
Voice orchestrator (imports 4 sub-modules) |
voice-stt.js |
STT state machine, mic capture via AudioWorklet |
voice-tts.js |
TTS state machine, WebSocket PCM streaming, REST fallback |
voice-ui.js |
Stateless voice UI helpers |
voice-constants.js |
Audio config, sample rates, SVG icons |
fsm.js |
Generic finite state machine |
dom.js |
Scroll management, focus, message rendering, HTML escaping |
scaffold.js |
Click-to-insert word bank |
shortcuts.js |
Keyboard shortcuts (/ to focus, Shift+Enter for newline) |
htmx-handlers.js |
HTMX lifecycle event handlers |
Five culture-inspired themes built on CSS custom properties:
| Theme | Palette |
|---|---|
| Azulejo | Cool Mediterranean blue |
| Terracotta | Warm earth tones (dark mode default) |
| Flamenco | Sunset reds and warm amber |
| Sangria | Deep berry reds |
| Jardín | Mint green light theme |
Typography: Plus Jakarta Sans. All themes comply with WCAG AA contrast requirements.
2,529 tests (2,291 Python + 238 JavaScript) with 97% code coverage.
Tests run in parallel via pytest-xdist. All database calls are mocked via Supabase client fixtures. LLM calls are mocked via get_llm patches.
| Domain | What's Tested |
|---|---|
| Agent | Node behavior, conditional routing, state mutations, prompt injection |
| API | Every route, CSRF, rate limiting, auth flows, password reset |
| Services | SM-2 algorithm, lesson completion, adaptive paths, thread management |
| Database | Repository pattern, encryption boundary (encrypt-on-write, decrypt-on-read) |
| JavaScript | All 11 ES modules: DOM, streaming, scaffolding, voice FSM |
| Security | CSP nonce, WebSocket auth rejection, headers, Fernet round-trip |
See Testing Documentation for the full test inventory.
| Doc | Content |
|---|---|
| Product Vision | Pedagogical approach, CEFR progression, personality system |
| API Reference | All endpoints, WebSocket protocols, SSE event spec |
| Design System | Token architecture, typography, spacing, themes |
| Testing | Test strategy, mock patterns, coverage targets |
| Codebase Summary | Full onboarding guide |
| Setup Guide | Local development and deployment |
| E2E Tests | Playwright test scenarios |
Each phase has a historical design document in docs/design/:
| Phase | Design |
|---|---|
| 6 | Micro-Lessons |
| 12 | Spaced Repetition |
| 13 | Mobile Responsive |
| 14 | Learning Paths |
| 15 | SSE Streaming |
| 16 | ES Module Refactor |
| 17 | Voice Conversation |
| 19 | Conversational Lessons |
| 24 | Message Encryption |
| 25 | Design System Revamp |
| 26 | Conversation Threads |
| 27 | Privacy & Security |