Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b02952f
Add spaced repetition backend: SM-2 scheduling, review + study_queue …
tpaulshippy Aug 25, 2026
6156ebc
feat: complete implementation per roadmap doc 07
tpaulshippy Aug 25, 2026
0566ef8
Spaced repetition study UX: flip animation, Again/Hard/Good/Easy rati…
tpaulshippy Aug 25, 2026
48fc48b
Spaced repetition study UX: flip animation, Again/Hard/Good/Easy rati…
tpaulshippy Aug 25, 2026
cfa0e22
Add Detox e2e spec for spaced repetition study flow
tpaulshippy Aug 25, 2026
fc0da62
fix: lint and ruff errors
Aug 26, 2026
90e722d
docs: add Playwright screenshot and recording evidence
Aug 26, 2026
1bd6f4d
docs: add mp4 recordings for GitHub rendering
Aug 26, 2026
ff901be
docs: redo Playwright screenshot evidence for PR #49
Aug 26, 2026
ea23c75
fix(study): correct study_queue/review endpoint URLs
Sep 3, 2026
7580dc2
fix(study): format-suffix actions must accept format kwarg + fresh ev…
Sep 3, 2026
47faf0e
chore(49): renumber migration 0039->0047 onto 48
Sep 3, 2026
73da217
Merge origin/main into feature/roadmap-07-spaced-repetition
tpaulshippy Sep 9, 2026
2a9d8ed
fix(49): address review feedback
tpaulshippy Sep 9, 2026
7b85249
fix(49): update deck/study screen tests for due-queue study flow
tpaulshippy Sep 9, 2026
072da08
fix(49): address copilot review comments
tpaulshippy Sep 12, 2026
99f1793
chore(49): remove evidence files, now attached to PR description
tpaulshippy Sep 12, 2026
f86dba7
fix(49): address copilot review batch 2
tpaulshippy Sep 12, 2026
b5b3b1c
fix(49): drive study reload via key so effect sets no state synchrono…
tpaulshippy Sep 12, 2026
30a8618
fix(49): address copilot review batch 3 (e2e + all-mode error)
tpaulshippy Sep 12, 2026
c77e5c4
fix(49): address suppressed copilot comments
tpaulshippy Sep 12, 2026
224a2ae
fix(49): auth errors take logout flow; stop flip animation on reset
tpaulshippy Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions back/bots/management/commands/seed_e2e_spaced_repetition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Seed deterministic data for the spaced repetition Detox e2e flow.

Creates (idempotently):
- parent user e2e-test-user / testpassword123
- one profile and one bot for that user
- deck "Cell Bio" with 8 cards: 6 due (3 overdue + 3 brand new) and
2 not-yet-due

Re-running resets the deck's scheduling state to this exact layout, so the
e2e suite always starts from the same queue.

Usage: python manage.py seed_e2e_spaced_repetition
"""

from datetime import timedelta

from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from django.utils import timezone

from bots.models import AiModel, Bot, Deck, Flashcard, Profile

USERNAME = 'e2e-test-user'
PASSWORD = 'testpassword123'
DECK_NAME = 'Cell Bio'

# front -> scheduling state at seed time
CARDS = [
# Due now (overdue): the e2e run rates these.
('What is anaphase?', 'Sister chromatids separate and move to opposite poles', 'due'),
('Define osmosis', 'Diffusion of water across a semipermeable membrane', 'due'),
('What organelle makes ATP?', 'The mitochondrion', 'due'),
# Not due yet: must be skipped by the due queue.
('What is mitosis?', 'Cell division producing two identical daughter cells', 'future'),
('Function of ribosomes', 'Protein synthesis', 'future'),
# Never reviewed, due immediately by default.
('What is a cell membrane?', 'The phospholipid bilayer enclosing the cell', 'new'),
('Define photosynthesis', 'Conversion of light energy into chemical energy (glucose)', 'new'),
('What does DNA stand for?', 'Deoxyribonucleic acid', 'new'),
]


class Command(BaseCommand):
help = 'Seed idempotent e2e data for spaced repetition study flows'

def handle(self, *args, **options):
user, user_created = User.objects.get_or_create(
username=USERNAME,
defaults={'email': f'{USERNAME}@example.com'},
)
if user_created or not user.check_password(PASSWORD):
user.set_password(PASSWORD)
user.save()

profile, _ = Profile.objects.update_or_create(
user=user,
name='E2E Test Profile',
# A previous run may have soft-deleted this profile; the
# profiles API hides deleted rows, so reactivate it.
defaults={'deleted_at': None},
)

ai_model = AiModel.objects.order_by('pk').first()
bot_defaults = {'deleted_at': None}
if ai_model is not None:
bot_defaults['ai_model'] = ai_model
bot, _ = Bot.objects.update_or_create(
user=user,
name='E2E Test Bot',
# Same soft-delete reactivation as the profile above.
defaults=bot_defaults,
)

deck, _ = Deck.objects.get_or_create(
profile=profile,
name=DECK_NAME,
defaults={'description': 'Seeded deck for spaced repetition e2e tests'},
)

now = timezone.now()
# Recreate the deck's cards from scratch every run: this removes
# stale/duplicate cards (front is not unique, so upserting cannot
# converge) and cascades to the cards' FlashcardReview rows, so
# repeated seeds converge to exactly these eight cards with no
# leftover activity.
Flashcard.objects.filter(deck=deck).delete()
for order, (front, back, state) in enumerate(CARDS):
card_kwargs = {
'deck': deck,
'front': front,
'back': back,
'order': order,
'ease': 2.5,
'interval_days': 0,
'reps': 0,
'lapses': 0,
'last_reviewed_at': None,
}
if state == 'due':
card_kwargs['due_at'] = now - timedelta(days=1)
elif state == 'future':
card_kwargs['due_at'] = now + timedelta(days=7)
else: # new
card_kwargs['due_at'] = now
Flashcard.objects.create(**card_kwargs)

due_count = Flashcard.objects.filter(deck=deck, due_at__lte=now).count()
self.stdout.write(self.style.SUCCESS(
f"E2E seed complete: user={user.username} (created={user_created}) "
f"profile_id={profile.profile_id} bot_id={bot.bot_id} "
f"deck_id={deck.deck_id} cards={Flashcard.objects.filter(deck=deck).count()} "
f"due_now={due_count}"
))
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 6.0.4 on 2026-08-25 21:12

import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('bots', '0049_profile_photo_filename'),
]

operations = [
migrations.AddField(
model_name='flashcard',
name='due_at',
field=models.DateTimeField(blank=True, db_index=True, default=django.utils.timezone.now, null=True),
),
migrations.AddField(
model_name='flashcard',
name='ease',
field=models.FloatField(default=2.5),
),
migrations.AddField(
model_name='flashcard',
name='interval_days',
field=models.FloatField(default=0),
),
migrations.AddField(
model_name='flashcard',
name='lapses',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='flashcard',
name='last_reviewed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='flashcard',
name='reps',
field=models.PositiveIntegerField(default=0),
),
migrations.CreateModel(
name='FlashcardReview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.CharField(choices=[('again', 'Again'), ('hard', 'Hard'), ('good', 'Good'), ('easy', 'Easy')], max_length=8)),
('reviewed_at', models.DateTimeField(auto_now_add=True)),
('flashcard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='bots.flashcard')),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flashcard_reviews', to='bots.profile')),
],
options={
'ordering': ['reviewed_at'],
'indexes': [models.Index(fields=['profile', 'reviewed_at'], name='bots_flashc_profile_c2f806_idx'), models.Index(fields=['flashcard', '-reviewed_at'], name='bots_flashc_flashca_f96eaf_idx')],
},
),
]
2 changes: 2 additions & 0 deletions back/bots/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .deck import Deck
from .device import Device
from .flashcard import Flashcard
from .flashcard_review import FlashcardReview
from .message import Message
from .profile import Profile
from .safety_event import SafetyEvent
Expand All @@ -17,6 +18,7 @@
'Deck',
'Device',
'Flashcard',
'FlashcardReview',
'Message',
'Profile',
'RevenueCatWebhookEvent',
Expand Down
8 changes: 8 additions & 0 deletions back/bots/models/flashcard.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid

from django.db import models
from django.utils import timezone


class Flashcard(models.Model):
Expand All @@ -9,6 +10,13 @@ class Flashcard(models.Model):
front = models.TextField()
back = models.TextField()
order = models.PositiveIntegerField(default=0)
# Spaced repetition scheduling (SM-2 style). New cards are due immediately.
due_at = models.DateTimeField(null=True, blank=True, db_index=True, default=timezone.now)
interval_days = models.FloatField(default=0)
ease = models.FloatField(default=2.5)
reps = models.PositiveIntegerField(default=0)
lapses = models.PositiveIntegerField(default=0)
last_reviewed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand Down
31 changes: 31 additions & 0 deletions back/bots/models/flashcard_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.db import models


class FlashcardReview(models.Model):
"""One row per rating a learner gives a card during study.

Enables streaks and parent activity summaries ("studied 20 cards")
without scanning scheduling fields on the cards themselves.
"""

RATING_CHOICES = [
('again', 'Again'),
('hard', 'Hard'),
('good', 'Good'),
('easy', 'Easy'),
]

flashcard = models.ForeignKey('Flashcard', on_delete=models.CASCADE, related_name='reviews')
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='flashcard_reviews')
rating = models.CharField(max_length=8, choices=RATING_CHOICES)
reviewed_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ['reviewed_at']
indexes = [
models.Index(fields=['profile', 'reviewed_at']),
models.Index(fields=['flashcard', '-reviewed_at']),
]

def __str__(self):
return f"{self.flashcard_id}: {self.rating} at {self.reviewed_at}"
Comment thread
tpaulshippy marked this conversation as resolved.
32 changes: 27 additions & 5 deletions back/bots/serializers/flashcard_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,25 @@ class FlashcardSerializer(serializers.ModelSerializer):

class Meta:
model = Flashcard
fields = ['id', 'flashcard_id', 'deck', 'front', 'back', 'order', 'created_at', 'updated_at']
fields = [
'id', 'flashcard_id', 'deck', 'front', 'back', 'order',
'due_at', 'interval_days', 'ease', 'reps', 'lapses',
'last_reviewed_at', 'created_at', 'updated_at',
]
# Scheduling is owned by the review endpoint: POST/PATCH payloads
# cannot set these directly, so every schedule change has a
# FlashcardReview log row. Still serialized on output.
read_only_fields = [
'due_at', 'interval_days', 'ease', 'reps', 'lapses',
'last_reviewed_at',
]


class DeckSerializer(serializers.ModelSerializer):
flashcards = FlashcardSerializer(many=True, read_only=True)
card_count = serializers.IntegerField(read_only=True, source='flashcard_count')
card_count = serializers.IntegerField(read_only=True, source='flashcard_count', default=0)
due_count = serializers.IntegerField(read_only=True, default=0)
last_studied_at = serializers.DateTimeField(read_only=True, allow_null=True, default=None)
profile = serializers.SlugRelatedField(
queryset=Profile.objects.all(),
slug_field='profile_id',
Expand All @@ -33,12 +46,21 @@ class DeckSerializer(serializers.ModelSerializer):

class Meta:
model = Deck
fields = ['id', 'deck_id', 'profile', 'chat', 'name', 'description', 'flashcards', 'card_count', 'created_at', 'updated_at']
fields = [
'id', 'deck_id', 'profile', 'chat', 'name', 'description',
'flashcards', 'card_count', 'due_count', 'last_studied_at',
'created_at', 'updated_at',
]


class DeckListSerializer(serializers.ModelSerializer):
card_count = serializers.IntegerField(read_only=True, source='flashcard_count')
card_count = serializers.IntegerField(read_only=True, source='flashcard_count', default=0)
due_count = serializers.IntegerField(read_only=True, default=0)
last_studied_at = serializers.DateTimeField(read_only=True, allow_null=True, default=None)

class Meta:
model = Deck
fields = ['id', 'deck_id', 'name', 'description', 'card_count', 'created_at', 'updated_at']
fields = [
'id', 'deck_id', 'name', 'description', 'card_count',
'due_count', 'last_studied_at', 'created_at', 'updated_at',
]
97 changes: 97 additions & 0 deletions back/bots/services/srs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Spaced repetition scheduling (SM-2 style) for flashcard reviews.

The math lives here as a pure function so it is unit-testable and
swappable — viewsets must not inline scheduling logic.
"""
from datetime import timedelta

from django.utils import timezone

RATINGS = ('again', 'hard', 'good', 'easy')

# Minimum ease factor (classic SM-2 floor).
MIN_EASE = 1.3
# Default ease for new cards.
DEFAULT_EASE = 2.5
# First interval (days) after learning a new card with Good/Good.
FIRST_INTERVAL_DAYS = 1
# Second interval (days).
SECOND_INTERVAL_DAYS = 6
# Interval (days) after an "Again" lapse; ~4 hours so the card comes
# back the same day without re-entering the current session queue.
LAPSE_INTERVAL_DAYS = 1 / 6
# Ease adjustments per rating.
EASE_DELTA = {
'again': -0.2,
'hard': -0.15,
'good': 0.0,
'easy': +0.15,
}
# Multiplier applied to the "good" interval for an "easy" rating.
EASY_BONUS = 1.3
# Multiplier for a "hard" review of a card already in review.
HARD_MULTIPLIER = 1.2


def apply_sm2(card, rating, now=None):
"""Return updated scheduling fields for `card` after a review.

Pure: does not mutate or save `card`. Accepts any object exposing
``interval_days``, ``ease``, ``reps`` and ``lapses`` attributes
(a Flashcard works directly). Returns a dict with:

due_at, interval_days, ease, reps, lapses, last_reviewed_at

Sketch:
again: reset reps, bump lapses, short same-day interval,
ease down (floored at MIN_EASE)
hard: interval = max(1, interval * 1.2), ease down, reps += 1
good: 1 day -> 6 days -> interval * ease; reps += 1
easy: like good * 1.3, ease up; reps += 1
"""
if rating not in RATINGS:
raise ValueError(f"Invalid rating: {rating!r}. Expected one of {RATINGS}")

now = now or timezone.now()

interval_days = float(getattr(card, 'interval_days', 0) or 0)
ease = float(getattr(card, 'ease', DEFAULT_EASE))
reps = int(getattr(card, 'reps', 0) or 0)
lapses = int(getattr(card, 'lapses', 0) or 0)

if rating == 'again':
# Lapse: relearn from scratch.
reps = 0
lapses += 1
interval_days = LAPSE_INTERVAL_DAYS
elif rating == 'hard':
interval_days = max(FIRST_INTERVAL_DAYS, interval_days * HARD_MULTIPLIER)
reps += 1
elif rating == 'good':
if reps == 0:
interval_days = FIRST_INTERVAL_DAYS
elif reps == 1:
interval_days = SECOND_INTERVAL_DAYS
else:
interval_days = interval_days * ease
reps += 1
else: # easy
if reps == 0:
interval_days = FIRST_INTERVAL_DAYS
elif reps == 1:
interval_days = SECOND_INTERVAL_DAYS
else:
interval_days = interval_days * ease
interval_days *= EASY_BONUS
reps += 1

ease = max(MIN_EASE, ease + EASE_DELTA[rating])

return {
'due_at': now + timedelta(days=interval_days),
'interval_days': interval_days,
'ease': ease,
'reps': reps,
'lapses': lapses,
'last_reviewed_at': now,
}
Loading
Loading