Technical reference for the personalised learning engine that drives task recommendations, practice sessions, and spaced-repetition flashcard scheduling.
- Overview
- Content Structure and Tagging
- Tag System Architecture
- Tag-Based Skill Analytics
- Adaptive Task Recommendations
- Practice Session Composition
- Spaced Repetition — Flashcard System
- Combined Learning Flow
- Configuration and Tuning
The adaptive learning system personalises the study experience for each student by tracking their performance history on a per-tag basis and using that data to surface the most valuable practice material. It is built from three cooperating subsystems:
| Subsystem | Location | Responsibility |
|---|---|---|
| Skill analytics | lib/analytics.ts |
Aggregates task_progress rows into per-tag statistics. Outputs tag weakness scores used by other subsystems. |
| Task recommendation engine | app/api/recommend/tasks/route.ts |
Selects practice tasks from the CMS based on weakness scores, past errors, or random variety. |
| Spaced repetition (SRS) | lib/srs.ts + app/api/flashcards/ |
Schedules flashcard reviews per user using the SM-2 algorithm. Study session ordering is also influenced by weakness scores. |
The subsystems are orthogonal: the SRS algorithm and the task recommendation engine each call getUserWeakTags() independently. There is no shared state between the two beyond what lib/analytics.ts derives from current task_progress rows.
Organization of learning material:
Subject
└── Course (one subject, many courses)
└── Module (ordered sections)
└── Lesson (rich-text content blocks)
└── Task (question + correct answer)
│
└── tags (N-to-many Tag records)
Tasks are the atomic unit of knowledge assessment. Each task can be assigned one or more Tag records that describe which knowledge areas the task covers. Examples: algebra, sorting-algorithms, cell-biology.
Flashcards are a parallel study mechanism: they are not lesson blocks. Each card lives in the public schema on a FlashcardDeck (Flashcard.deckId). Like tasks, each flashcard can carry multiple Tag records. Question and answer fields are GFM Markdown with optional KaTeX ($ / $$) on the study UI; optional question/answer images reference Payload media (see PLATFORM_FEATURES.md § Flashcards and deck hierarchy → Card content).
Deck shapes:
- Course-linked — A course can have one main deck (
FlashcardDeck.courseId, noparentDeckId). Subdecks are children of that main (parentDeckId), typically one per module (moduleId). Cards for curriculum content are assigned to subdecks; the course main deck is structural (card placement rules are enforced inlib/validate-flashcard-refs.tsand the flashcard APIs). - Standalone — Decks with no
courseIdare collections outside the course tree. The standalone main may hold direct cards; optional subdecks nest under that main for topical splits. Study access for standalone trees is gated: the learner must have enrolled the root main deck (UserStandaloneFlashcardDeckrow, created fromPOST /api/flashcards/standalone-decks/enroll). Course-linked decks do not use that join. Enforcement lives inlib/flashcards-study-access.ts(assertUserCanStudyDeckScope), called fromGET /api/flashcards/studywhenmainDeckSlugorsubdeckSlugis present.
Admins manage hierarchy and cards at /admin/flashcards; see PLATFORM_FEATURES.md § Flashcards and deck hierarchy.
Flashcard tags feed into the SRS session ordering (see Section 7.3) but do not currently feed into the task recommendation analytics, since task performance and flashcard performance are tracked separately.
Tag records are the canonical source of truth and live in the public schema:
public.tags
id String — cuid, primary key
name String — unique display name
slug String — unique URL-safe kebab-case identifier
main Boolean — whether the tag appears in the default UI filter bar
Tags reach tasks and flashcards through different paths:
| Path | Table | Type |
|---|---|---|
| Flashcard ↔ Tag | public._FlashcardTags (implicit) |
Prisma many-to-many |
| TaskProgressTag ↔ Tag | public.task_progress_tags |
Normalised join, with cascade |
| Payload task ↔ Tag name/slug | payload.tasks_tags |
Denormalised copy, no FK |
The payload.tasks_tags table stores denormalised name and slug columns to allow Payload CMS to display tag names without querying the public schema. Changes to a canonical Tag are propagated by updating matching tasks documents through Payload APIs:
// PUT /api/tags/[id]
const { docs } = await payload.find({
collection: 'tasks',
where: { 'tags.tagId': { equals: id } },
})
for (const task of docs) {
const next = task.tags.map((t) =>
t?.tagId === id ? { ...t, name: newName, slug: newSlug } : t
)
await payload.update({
collection: 'tasks',
id: String(task.id),
data: { tags: next },
})
}// DELETE /api/tags/[id]
await removeTagFromTasks(id)
await prisma.tag.delete({ where: { id } })Both operations are wrapped in try/catch so task-tag sync failures do not abort the primary mutation.
When a student submits a task answer, the tags declared in the Payload task's metadata are resolved against canonical Tag records in public.tags and written to public.task_progress_tags as a normalised many-to-many relationship:
// app/actions/submit-task.ts (simplified)
const canonicalTags = await prisma.tag.findMany({
where: {
OR: [
{ slug: { in: normalisedTaskTagSlugs } },
{ name: { in: taskTagNames } },
],
},
})
await prisma.$transaction(
canonicalTags.map((t) =>
prisma.taskProgressTag.upsert({
where: { taskProgressId_tagId: { taskProgressId: taskProgress.id, tagId: t.id } },
create: { taskProgressId: taskProgress.id, tagId: t.id },
update: {},
})
)
)This sync is best-effort, non-blocking: failures are logged with console.warn but do not roll back the task submission.
All adaptive content selection is derived from the statistics computed in lib/analytics.ts. No ML models or external services are used — the analytics are computed on-demand from the append-only task_attempts event log.
TaskProgress still keeps latest per-task state via (userId, taskId) + upsert, while TaskAttempt now stores one row per submission for longitudinal analytics. Adaptive scoring and review-history logic read from task_attempts.
Returns a TagStat[] array with one entry per tag the user has encountered.
Query:
const records = await prisma.taskAttempt.findMany({
where: { userId },
select: {
taskAttemptTags: { select: { tag: { select: { name: true } } } },
isCorrect: true,
attemptedAt: true,
},
})Aggregation algorithm:
For each TaskAttempt row:
For each tag linked via taskAttemptTags:
acc[tag].attempts += 1
acc[tag].correct += isCorrect ? 1 : 0
acc[tag].lastAttemptAt = max(lastAttemptAt, row.attemptedAt)
For each tag, derive:
successRate = correct / attempts
score = (correct + 1) / (attempts + 2) ← Laplace (add-one) smoothing
The Laplace-smoothed score prevents tags with very few attempts from dominating the weakness ranking. A tag seen zero times receives a neutral prior of 0.5; a tag with one wrong answer receives 0.33 rather than 0.0.
TagStat shape:
| Field | Type | Description |
|---|---|---|
tag |
string |
Tag name from public.tags |
attempts |
number |
Total task submissions including this tag |
correct |
number |
Submissions where isCorrect = true |
successRate |
number |
correct / attempts |
score |
number |
Bayesian-smoothed score in (0, 1) |
lastAttemptAt |
Date | null |
Timestamp of the most recent attempt |
Wraps getUserTagStats, computes weakness = 1 - successRate, and sorts descending:
return stats
.map((s): WeakTag => ({ tag: s.tag, weakness: 1 - s.successRate }))
.sort((a, b) => b.weakness - a.weakness)A tag with weakness = 1.0 means every attempt was wrong. A tag with weakness = 0.0 means every attempt was correct.
- Query cost: One single
findManywith nested includes per call. With typical student histories (hundreds to low thousands of task_progress rows) this runs well under 50 ms. - Caching: Results are cached per user in-memory for 60 seconds (
CACHE_TTL_MS = 60_000) inlib/analytics.ts. Cache entries are invalidated after new task submissions. - Minimum data required: The user needs at least one completed task for any tags to appear. Routes that call
getUserWeakTagsgracefully handle the empty-array case.
Endpoint: GET /api/recommend/tasks?limit=<n>&mode=<mode>
The recommendation engine fetches up to CANDIDATE_LIMIT = 300 published tasks from Payload CMS, then scores and filters them using the analytics layer.
| Parameter | Default | Options | Description |
|---|---|---|---|
limit |
5 | 1–20 | Number of tasks to return |
mode |
"weak" |
"weak" | "review" | "mixed" |
Selection strategy |
- Fetch top-3 weakest tags from
getUserWeakTags(). - For each published task, compute a weakness score based on how many of the task's tags overlap with the weak-tag set, weighted by tag weakness.
- Apply bonuses:
- Novelty bonus (
+0.3): task has never been attempted by this user. - Staleness bonus (
+0.2): task was last attempted more than 7 days ago.
- Novelty bonus (
- Sort by score descending, return top
limitresults.
Score formula for a single task:
score = Σ (weakness of matching tags)
+ 0.3 if never attempted
+ 0.2 if last attempted ≥ 7 days ago
- Load all
TaskProgressrows for the user, ordered byattemptedAtdescending. - Build a set of task IDs ever answered correctly.
- Of tasks answered incorrectly, exclude any that have since been answered correctly.
- Match remaining task IDs against the Payload task documents.
- Return the
limitmost recently attempted wrong answers.
This mode effectively shows the student a list of "things you got wrong and haven't fixed yet."
Blends all three pools proportionally:
| Allocation | Source |
|---|---|
| ~40% | weakMode results |
| ~30% | reviewMode results |
| ~30% | Random published tasks |
Tasks already present in the weak or review pools are deduplicated before random tasks are selected. The three slices are then interleaved to produce a varied list.
{
"tasks": [
{
"id": "abc123",
"question": "What is the time complexity of merge sort?",
"tags": ["sorting-algorithms", "complexity"],
"score": 1.52
}
],
"explanation": "Tasks selected because the user struggles with tags: sorting-algorithms, recursion, trees",
"mode": "weak"
}Endpoint: GET /api/practice/session?limit=<n>
Generates a single practice queue of up to limit tasks (default 10, max 50) balanced across three skill-level bands.
| Band | Target share | Source |
|---|---|---|
| Weak | ~40% | Tasks whose tags overlap with the user's top-3 weakest tags |
| Medium | ~30% | Tasks whose tags fall in the 0.40–0.70 success-rate band |
| Random | ~30% | Any remaining published tasks |
Tasks already solved correctly are removed from all three pools first. If a pool runs dry before its quota is filled, already-solved tasks are added back. In normal datasets this fills the requested limit; if the total unique candidate pool is smaller than limit, the response can still be shorter.
Step 1: getUserTagStats + getUserWeakTags (parallel fetch)
Step 2: Build solved-correctly exclusion set from task_progress
Step 3: Fetch up to 300 published tasks from Payload CMS
Step 4: Partition tasks into three bands: weak / medium / random
Step 5: Shuffle each band (Fisher-Yates)
Step 6: Pick proportional quotas with deduplication
Step 7: Return sessionId + ordered task queue
The sessionId is a random UUID generated per request. It is informational — the client may use it to correlate session analytics, but there is no corresponding database record.
A tag is in the "medium" band if its success rate is between 0.40 and 0.70 (inclusive of 0.40, exclusive of 0.70). Tags below 0.40 are in the "weak" band; tags at 0.70 and above are considered solid knowledge not prioritised for practice.
The SRS algorithm is implemented as a pure function with no database dependencies:
calculateNextReview(card: SRSCardData, answer: ReviewAnswer, settings: SRSSettings): SRSUpdateResultIt implements a standard SM-2 state machine with Anki-style extensions:
State transitions:
NEW ──[any answer]──► LEARNING
LEARNING ──[GOOD/EASY]──► REVIEW (graduated)
LEARNING ──[AGAIN]──────► LEARNING (reset steps)
REVIEW ──[GOOD/EASY]──► REVIEW (interval grows)
REVIEW ──[AGAIN]──────► RELEARNING (ease drops by 0.2)
RELEARNING ──[GOOD/EASY]──► REVIEW (re-enters at short interval)
REVIEW ──[interval ≥ masteredThreshold]──► MASTERED
MASTERED ──[any review]──────────────────► MASTERED (if still above threshold)
| Answer | Effect |
|---|---|
AGAIN |
Reset to step 0 of current learning/relearning steps; reduce ease (if in REVIEW) |
HARD |
Stay at current step or increase interval by 1.2× in REVIEW |
GOOD |
Advance to next step; graduate to REVIEW when steps exhausted |
EASY |
Skip remaining steps; graduate immediately with easyInterval |
Every user has an independent SRS schedule for each flashcard. The UserFlashcardProgress table stores the full SM-2 state:
| Field | Type | Description |
|---|---|---|
state |
FlashcardState |
Current state in the SM-2 state machine |
interval |
Int |
Current review interval in days |
easeFactor |
Float |
Ease multiplier (clamped ≥ 1.3, default 2.5) |
repetition |
Int |
Count of successful consecutive reviews |
stepIndex |
Int |
Position within learning/relearning steps array |
nextReviewAt |
DateTime? |
Timestamp when card is next due |
lastReviewedAt |
DateTime? |
Timestamp of the most recent review |
lastResult |
LastResult? |
Last answer: AGAIN | HARD | GOOD | EASY |
When a user first reviews a card, a new UserFlashcardProgress row is created using canonical defaults from DEFAULT_SETTINGS in lib/srs.ts (state NEW, interval 0, easeFactor 2.5).
GET /api/flashcards/study uses weak-tag scores to reorder the study deck within the set of due cards:
Eligibility (filters): SRS determines WHICH cards are due
Ordering (weak bonus): analytics determines the ORDER within due cards
score = getCardUrgency(card, now) + (card_has_weak_tag ? WEAK_TAG_BONUS : 0)
WEAK_TAG_BONUS = 0.5. Cards tagged with one of the user's weakest topics receive a route-level ordering bonus.
GET /api/flashcards/study?mode=<mode>&tagSlug=<slug>&subject=<slug>&deckSlug=<slug>&subdeckSlug=<slug>&mainDeckSlug=<slug>
| Mode | Behaviour |
|---|---|
srs (default) |
Shows only cards that are due now; respects newCardsPerDay and maxReviews daily budgets |
free |
Shows all cards in the set regardless of schedule; no daily limits |
Optional filters scope candidate cards before SRS/free rules:
tagSlug,subject— topic / catalog-style narrowing.deckSlug(legacy alias) orsubdeckSlug— limit to a single deck by slug (use one of these; do not combine withmainDeckSlug).mainDeckSlug— include cards on that main deck plus all of its subdecks (resolved by slug). Mutually exclusive withdeckSlug/subdeckSlugin the handler.
Access: for standalone roots (no courseId), the route rejects the request unless the current user is enrolled on that root (UserStandaloneFlashcardDeck). Course mains remain available to any signed-in user.
In srs mode, the daily new-card budget is calculated via a count query rather than by loading all progress rows:
const newReviewedToday = await prisma.userFlashcardProgress.count({
where: {
userId: user.id,
state: { not: 'NEW' },
lastReviewedAt: { gte: startOfToday },
},
})
const newBudget = Math.max(0, settings.newCardsPerDay - newReviewedToday)Every user can customise their SRS parameters. If no settings row exists, one is created with these defaults on first access:
| Setting | Default | Description |
|---|---|---|
newCardsPerDay |
20 | Daily budget for introducing new cards |
maxReviews |
200 | Daily cap on review-state card sessions |
learningSteps |
"1 10" |
Minutes between prompts during learning |
relearningSteps |
"10" |
Minutes for cards that failed a review |
graduatingInterval |
1 | Days to first REVIEW after completing learning (Good) |
easyInterval |
4 | Days to first REVIEW if Easy during learning |
startingEase |
2.5 | Initial ease factor for all new cards |
masteredThreshold |
21 | Interval (days) at which a card is promoted to MASTERED |
Settings are serialised as space-delimited strings in the database (learningSteps = "1 10") and parsed back to number[] by parseSettings() in lib/srs.ts.
The following describes a complete learning arc from first login through adaptive study.
- Student authenticates and opens a course.
- They submit their first task answers. The
submitTaskAnsweraction createsTaskProgressrows and syncsTaskProgressTagrecords. - Before any tagged progress exists,
getUserWeakTags()returns an empty list.GET /api/recommend/tasks?mode=weaktherefore returns an emptytasksarray with an explanation prompting the user to submit tasks first.
- Tag statistics accumulate across multiple lesson completions.
GET /api/recommend/tasks?mode=weakbegins surfacing tasks that cover underperforming tags.GET /api/practice/sessiongenerates sessions where ~40% of tasks are in the student's weakest knowledge areas.
- Student opens the flashcard study interface.
GET /api/flashcards/study?mode=srsreturns only due cards, ordered by urgency plus weak-tag bonus.- The student provides answers (
AGAIN / HARD / GOOD / EASY). Each answer triggersPOST /api/flashcards/[id]/review, which runs the SM-2 algorithm and writes the updated state toUserFlashcardProgress. - Cards reviewed as
GOODorEASYare rescheduled to a future date. Cards reviewed asAGAINre-enter the learning steps immediately.
Task submission
└── TaskProgressTag written
└── Aggregated by getUserTagStats()
└── getUserWeakTags() ranks knowledge gaps
│ │
▼ ▼
Task recommendations Flashcard study
(recommend/tasks) (flashcards/study)
focus on weak tags boost weak-tag cards
Both surfaces consume the same analytics functions. The feedback loop tightens as the student accumulates more history — the recommendations become more personalised and the flashcard ordering becomes more targeted.
Defined at the top of app/api/recommend/tasks/route.ts:
| Constant | Value | Effect |
|---|---|---|
TOP_WEAK_TAGS |
3 | How many of the user's weakest tags to consider |
CANDIDATE_LIMIT |
300 | Maximum tasks fetched from Payload CMS |
STALE_DAYS |
7 | Days after which a stale task gets a staleness bonus |
BONUS_NEW |
0.3 | Score bonus for never-attempted tasks |
BONUS_STALE |
0.2 | Score bonus for tasks not attempted in ≥ 7 days |
Defined at the top of app/api/practice/session/route.ts:
| Constant | Value | Effect |
|---|---|---|
TOP_WEAK_TAGS |
3 | Weak-band tag window |
CANDIDATE_LIMIT |
300 | Maximum tasks fetched from Payload CMS |
DEFAULT_LIMIT |
10 | Default session length |
MAX_LIMIT |
50 | Maximum session length (capped server-side) |
The 0.40–0.70 success-rate window for the medium band is hardcoded in app/api/practice/session/route.ts. Narrowing the window (e.g. 0.45–0.65) makes the medium band more selective. Widening it overlaps more with the weak band.
DEFAULT_SETTINGS in lib/srs.ts is the runtime default object used by SRS logic and per-user progress bootstrap. Database defaults for new FlashcardSettings rows are defined in prisma/schema.prisma (@default(...) values). Keep both aligned when tuning.
- Task analytics and review-history logic use append-only
TaskAttemptevents;TaskProgressremains the latest-state snapshot used by other progress flows. mode=weakrecommendations are empty for cold-start users until at least one tagged task result is recorded.- Study ordering in
GET /api/flashcards/studyuses route-level numeric sorting (getCardUrgency + weak-tag bonus) rather than a strict two-pass comparator.