Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Empty file.
Empty file.
103 changes: 103 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,103 @@
"""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.get_or_create(user=user, name='E2E Test Profile')

ai_model = AiModel.objects.order_by('pk').first()
bot, _ = Bot.objects.get_or_create(
user=user,
name='E2E Test Bot',
defaults={'ai_model': ai_model},
)

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

now = timezone.now()
created_cards = 0
for order, (front, back, state) in enumerate(CARDS):
card, card_created = Flashcard.objects.get_or_create(
deck=deck,
front=front,
defaults={'back': back, 'order': order},
)
if card_created:
created_cards += 1

# Reset scheduling every run so repeated seeds converge.
card.back = back
card.order = order
card.ease = 2.5
card.interval_days = 0
card.reps = 0
card.lapses = 0
card.last_reviewed_at = None
if state == 'due':
card.due_at = now - timedelta(days=1)
elif state == 'future':
card.due_at = now + timedelta(days=7)
else: # new
card.due_at = now
card.save()

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"(new={created_cards}) 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', '0038_alter_aimodel_options_alter_device_options'),
]

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 .usage_limit_hit import UsageLimitHit
Expand All @@ -16,6 +17,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}"
21 changes: 18 additions & 3 deletions back/bots/serializers/flashcard_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ 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',
]


class DeckSerializer(serializers.ModelSerializer):
flashcards = FlashcardSerializer(many=True, read_only=True)
card_count = serializers.IntegerField(read_only=True, source='flashcard_count')
due_count = serializers.IntegerField(read_only=True)
last_studied_at = serializers.DateTimeField(read_only=True)
profile = serializers.SlugRelatedField(
queryset=Profile.objects.all(),
slug_field='profile_id',
Expand All @@ -33,12 +39,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')
due_count = serializers.IntegerField(read_only=True)
last_studied_at = serializers.DateTimeField(read_only=True)

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',
]
96 changes: 96 additions & 0 deletions back/bots/services/srs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""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
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)
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