Lessons + SRS internals¶
This page documents how the v1.27.0–v1.31.0 content-lesson +
SRS feature stack is wired across the backend, frontend, and
two plugins. For the user-facing overview see
user-guide/lessons.md.
Architecture overview¶
┌──────────────────────────────────────────────────────────┐
│ Frontend: lesson viewer + review session │
│ pages/Lesson.tsx ──→ ExerciseDispatcher ──→ one of │
│ pages/Review.tsx ↓ 4 exercise │
│ ↓ components │
│ ↓ │
│ recordStepResult │
│ ↓ │
│ elementErrors.recordBulk │
│ ↓ │
└──────────────────────────────────────────────────────────┘
↓
IStorageService boundary
↓
┌─────────────────────┐ ┌─────────────────────────┐
│ ApiStorage │ │ DexieStorage │
│ │ │ │
│ POST /api/users/ │ │ element-errors-dexie.ts │
│ {id}/element- │ │ mirrors the backend │
│ errors │ │ service 1:1 against │
│ │ │ IndexedDB │
└─────────────────────┘ └─────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ Backend │
│ │
│ app/services/element_errors.py │
│ - upsert transition matrix │
│ - MASTERY_THRESHOLD = 3 │
│ │
│ app/services/element_srs.py │
│ - 1d/3d/7d band scheduler │
│ - overdue → error_count → last_error_at sort │
│ │
│ app/services/lesson_progress.py │
│ - upsert_progress with mark_completed flip │
│ triggers lesson_session_unification │
│ │
│ app/services/lesson_session_unification.py │
│ - find_or_create_content_pseudo_project (lazy) │
│ - record_lesson_completion_session │
│ - fires on_session_complete via manager._pm.hook │
└──────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ Gamification plugin │
│ on_session_complete dispatch │
│ method == "content" → │
│ award_xp_for_lesson_ │
│ session (lesson formula) │
│ else → │
│ award_xp_for_session │
│ (chat formula, unchanged) │
│ badge_service.evaluate_user │
│ → 4 new lesson predicates │
└─────────────────────────────────┘
Element-level error tracking (v1.30.0 / Phase 46B)¶
Model¶
app/models/__init__.py:ElementError with a composite
UNIQUE constraint on
(user_id, set_id, lesson_id, exercise_id, element_key).
Lesson-scoped element keys per decision D2 — the same
word in two different lessons is two rows.
class ElementError(Base):
user_id: str # FK → users.id (CASCADE)
set_id: str # content set id (string, not FK)
lesson_id: str # content lesson id (string, not FK)
exercise_id: str # exercise id within the lesson
element_key: str # the specific word/pair/phrase
element_type: str # "vocabulary" | "grammar_rule"
user_answer: str
correct_answer: str
error_count: int # incremented on each wrong attempt
correct_streak: int # incremented on correct, reset on wrong
last_error_at: datetime | None
last_attempt_at: datetime
mastered: bool # True iff correct_streak ≥ 3
mastered_at: datetime | None
Decoupled from learning_sessions (no FK) by design —
content lessons reference content set / lesson IDs by string,
not via a relational join. This means the table survives
cache evictions independent of any session row.
Upsert transition matrix¶
app/services/element_errors.py:upsert_element_error is
the only mutator. Behaviour:
| Trigger | Action |
|---|---|
| First sighting (correct) | INSERT row, correct_streak=1 |
| First sighting (wrong) | INSERT row, error_count=1 |
| Existing row, correct attempt | correct_streak += 1; flip mastered=True iff streak reaches MASTERY_THRESHOLD (3) |
| Existing row, wrong attempt on a non-mastered row | error_count += 1, correct_streak = 0 |
| Existing row, wrong attempt on a mastered row | demote: mastered=False, mastered_at=None, correct_streak=0, error_count += 1 |
The mastery threshold is a code-level constant
(MASTERY_THRESHOLD = 3); per decision D4 it is
intrinsic to the SRS semantics, not a config knob.
Dexie mirror¶
frontend/src/storage/element-errors-dexie.ts mirrors the
backend service 1:1 against IndexedDB. The contract on
IElementErrorsNamespace is identical across both storage
implementations, so the dexie-mode release gate
(make test-dexie-smoke) catches any drift.
SRS scheduling (v1.30.0 / Phase 46C)¶
Interval policy¶
app/services/element_srs.py:next_review_due_at projects a
non-mastered row's next review:
correct_streak |
Interval |
|---|---|
| 0 | 1 day after last_attempt_at |
| 1 | 3 days |
| 2 | 7 days |
| ≥ 3 | mastered — excluded from the queue |
Priority sort¶
The review-queue endpoint sorts by:
- Overdue first (
next_review_due_at < nowranks abovenext_review_due_at > now) - Error count descending (more errors = higher priority)
- Last error first (most recent failure ranks above
older failures, microsecond resolution via
-last_error_at.timestamp_us)
The tuple is keyed by _sort_key returning
tuple[int, int, int] (the v1.30.0 CI hotfix
9275841 pinned the mypy annotation after a refactor
from datetime to integer microseconds).
Endpoint¶
GET /api/users/{user_id}/element-errors/review-queue
returns the prioritised queue. Dexie-side equivalent:
computeReviewQueueDexie in
element-errors-dexie.ts. The Dashboard
<ReviewQueueCard> widget calls one or the other
depending on the configured storage mode and renders the
count + overdue badge + a CTA to the review session.
LessonProgress ↔ LearningSession unification (v1.31.0 / Phase 46F)¶
The decision¶
The v1.30.0 ElementError work intentionally decoupled
itself from LearningSession. v1.31.0's Phase 46F adds
the unification layer: every content-lesson completion
now writes a LearningSession row so the existing
gamification + tracking + streak machinery picks it up
without any new hooks.
Three decisions drive the shape:
- D1 (lazy pseudo-project): a "Content Lessons"
LearningProjectwithkind="content"is auto- created on first lesson completion (never seeded during onboarding). One per user. - D2 (
method="content"7th value): added to the set of validLearningSession.methodvalues specifically for the unification path. The other six methods (deductive / inductive / error_based / dialogic / contextual / ai_adaptive) cover chat sessions unchanged. - D5 (reuse, don't extend): no new hookspec.
record_lesson_completion_sessionfires the existingon_session_completehook viamanager._pm.hook. The gamification + tracking plugins' existing handlers run as if the lesson were a chat session, with dispatch onmethod.
Schema change¶
# app/models/__init__.py — Phase 46F.1
LEARNING_PROJECT_KIND_STANDARD = "standard"
LEARNING_PROJECT_KIND_CONTENT = "content"
class LearningProject(Base):
...
kind: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=LEARNING_PROJECT_KIND_STANDARD,
server_default=LEARNING_PROJECT_KIND_STANDARD,
)
Alembic 0020_learning_project_kind adds the column via
batch_alter_table + add_column with
server_default="standard" so existing rows back-fill
cleanly on SQLite. Sync surface picks up the column so the
round-trip through ApiStorage ↔ DexieStorage works
in both directions.
Unification helper¶
app/services/lesson_session_unification.py has two
public functions:
find_or_create_content_pseudo_project(db, user_id)— idempotent lookup; creates only on miss.record_lesson_completion_session(db, *, user_id, lesson_progress_id, score_correct, score_total)— writes theLearningSessionrow, commits, then fireson_session_complete.
Both are invoked from
app/services/lesson_progress.py:upsert_progress when
the row flips from in_progress to completed. The
helper's own DB writes propagate exceptions (real DB
problem), but the hook-fire path wraps subscriber
exceptions per the
_fire_on_session_complete pattern from the session
plugin's routes.py — a gamification crash cannot
roll back the lesson the user already saw on the summary
screen.
Frontend filter¶
frontend/src/lib/learning-project.ts exposes
isStandardProject + filterStandardProjects. Three
consumers apply the filter:
DashboardFilterBar.tsx(the dashboard project picker)ExportSection.tsx(export picker)Anki.tsx(Anki project dropdown)
The backend endpoint intentionally still exposes the pseudo-project so a future "all activity" admin view can opt in. The filter is a UI-policy decision, not data hiding.
Lesson-formula XP rule (v1.31.0 / Phase 46E.1)¶
adaptive_learner_gamification.xp_service gains:
compute_stars(correct, total)— 0-3 from a score, with bands at 50 % / 75 % / 90 %. Mirrors the frontend'scomputeStarsinlib/lesson-summary.tsso both sides project the same star rating.calculate_lesson_session_xp(*, stars, first_attempt, streak_days)— pure calculator. 30 base + 10/star + 20 first-attempt-3-star + same +25 %/day streak multiplier (capped at 7) as the chat formula._is_first_attempt(db, lesson_progress_id)— readsLessonProgress.step_resultsJSON and returns True iff every step row hasattempts == 1.award_xp_for_lesson_session(db, *, session)— persistence wrapper that resolves user_id from the project FK and applies the formula.
Dispatch happens in GamificationPlugin.on_session_complete
based on session["method"]. Chat methods stay on
award_xp_for_session. Content method routes to the
lesson formula. The session payload from the unification
helper carries the lesson-specific keys
(lesson_progress_id, score_correct,
score_total); chat-session payloads do not, so the
lesson XP wrapper would degrade gracefully if the dispatch
ever leaked — but the regression-pin test in
backend/tests/test_lesson_session_unification.py
asserts the exact lesson award (100 XP for a 4/4
first-attempt completion + first-day streak) so a leak
would surface immediately.
Lesson badges (v1.31.0 / Phase 46E.2)¶
Four new predicates added to
adaptive_learner_gamification.badge_service._EVALUATORS:
| Key | Predicate | Helper |
|---|---|---|
first_lesson |
_completed_lesson_count >= 1 |
counts LessonProgress.status="completed" (not via LearningSession — the lesson row is authoritative) |
lessons_10 |
_completed_lesson_count >= 10 |
same |
three_star_streak |
_last_n_lessons_all_three_star(n=3) |
reads the user's last 3 completed LessonProgress by completed_at desc; projects each via xp_service.compute_stars |
review_master |
_mastered_elements_count >= 50 |
counts ElementError.mastered=True |
Catalog count: 24 → 28 (+1 getting_started + 1
consistency + 2 depth). The existing every yaml badge has
an evaluator symmetry test (in
backend/tests/test_gamification_badges_integration.py)
catches drift between the two lists.
Storage-mode caveats¶
The element-tracking + SRS chain works identically in
both storage modes — the IElementErrorsNamespace
contract is mode-agnostic and the dexie-mode release gate
(18 specs incl. the /review route) blocks any regression.
The lesson-session unification + gamification side effects
are API-mode only. In Dexie mode the lesson completion
still writes LessonProgress, still records
ElementError rows, and still drives the review queue —
but the LearningSession write + on_session_complete
hook never fire (no backend, no hookable). Dexie-mode users
get the full review loop; the XP / badge awards from the
chat-session path still work, but lesson completions don't
yet contribute to that total.
A future unification of the gamification side effects into
DexieStorage (so a Dexie-mode user's lesson completion
also awards XP locally) is a deliberate non-goal for
v1.31.0 — it would either duplicate the formula
implementation in TypeScript or require a service-worker
shim of the on_session_complete hook. Both are larger
refactors than the v1.31.0 scope allows.
Where to look next¶
backend/app/services/element_errors.py— the upsert transition matrix.backend/app/services/element_srs.py— the scheduler.backend/app/services/lesson_session_unification.py— the pseudo-project + hook fire.plugins/adaptive-learner-plugin-gamification/ adaptive_learner_gamification/xp_service.py—calculate_lesson_session_xp+ dispatch.plugins/adaptive-learner-plugin-gamification/ adaptive_learner_gamification/badge_service.py— the four new predicates.frontend/src/lib/learning-project.ts— the pseudo-project filter helper.e2e/dexie/dexie-mode.spec.ts— the release gate that prevents Dexie-mode regressions (lessons spec at/lesson/..., review spec at/review/...).
Token-diff + cloze + correction round (v1.35.0 / Phase 52)¶
Three layered additions that turn passive replay into active learning:
Token-diff + DiffHighlight — Free-text and word-tiles
wrong answers now render <DiffHighlight tokens={tokenDiff(
input, canonical)} /> inline below the result paragraph.
The lesson summary's per-exercise breakdown shows the same
diff for free-text + word-tiles when the v1.35.0+ stored
user_answer is available (older rows fall back to the
canonical-only line). Algorithm in
frontend/src/lib/exercises/token-diff.ts — pure word-
level LCS, NFC normalized, case + accent sensitive.
Cloze exercise type (schema 1.1) — fifth ExerciseType:
fill-in-the-blank with visible ___ markers. Two render
modes: type (default, <input>) and select
(<select> with options from distractors). Per-blank SRS
fan-out via deriveClozeAttempts — one ElementAttempt per
blank, so per-blank mastery tracking lights up cleanly.
Renderer at
frontend/src/components/exercises/ClozeExercise.tsx;
schema in
plugins/adaptive-learner-plugin-content-loader/
adaptive_learner_content_loader/schema.py.
Cloze generator — generateClozeFromError(error,
sourceExercise, sourceCard) synthesises a cloze step from
an ElementError. Algorithm:
- If
sourceCard.token_roleshas an entry whosetoken === error.correct_answer, blank that token insourceCard.front. - Otherwise, if
sourceCard.frontliterally containserror.correct_answerexactly once, blank it. - Otherwise, if the source is free_text and its prompt contains the answer exactly once, blank it.
- Otherwise return null — caller falls back to replay.
Deterministic: same inputs → byte-identical output. No AI,
no randomness, no async. Distractors carry
error.user_answer first (when different from correct),
then sourceExercise.distractors filtered + deduped. Code
at frontend/src/lib/exercises/cloze-generator.ts.
Lesson-end correction round —
<CorrectionBlock /> mounts inside LessonSummary between
the score / breakdown and the action buttons. On mount, it
reads ElementError rows for the just-finished lesson,
generates a cloze for each non-mastered failure (cap 5),
and walks the user through them. Each completed cloze
writes fresh ElementAttempt rows against the same
element_key the original failure was recorded against, so
SRS streak + mastery advances. Self-hides on perfect score
/ no errors / no cloze constructable. Code at
frontend/src/components/exercises/CorrectionBlock.tsx.
Cloze in review sessions (Phase 52G) —
synthesizeReviewLesson's per-item branch
(_buildReviewStep) now picks:
- free_text or word_tiles source → try cloze, fall back to replay
- matching, picture_choice, cloze → always replay
Decision criteria documented in
frontend/src/lib/review-lesson.ts. Replay step ids start
with review-; generated cloze step ids start with
review-cloze- for traceability.
Token-roles on cards (Phase 52I) — optional
token_roles: list[{token, role}] annotation on Card with
a closed enum of grammatical roles (article / verb / noun
/ adjective / preposition / gender_marker /
tense_marker). The generator uses these to pick a
semantically-meaningful blank instead of relying on
substring matching. Adding a role is a minor
schema_version bump — keep the enum closed.