From 92eeee11867ca0a58ca6cf4bc20f5d718cce17dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:40:58 +0000 Subject: [PATCH 1/3] Initial plan From 3902deb06b11c33319d230cae0273698ef857f20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:17:54 +0000 Subject: [PATCH 2/3] feat: add SM-2 spaced repetition with user-adjustable settings and Never Seen card action Co-authored-by: coreysreid <111491611+coreysreid@users.noreply.github.com> --- study/admin.py | 10 +- study/migrations/0041_spaced_repetition.py | 48 +++ study/models.py | 77 +++++ study/templates/study/base.html | 1 + .../study/spaced_repetition_settings.html | 242 ++++++++++++++ study/templates/study/study_session.html | 198 ++++++++++-- study/templates/study/topic_detail.html | 12 + study/tests.py | 301 ++++++++++++++++++ study/urls.py | 5 + study/views.py | 271 ++++++++++++++-- 10 files changed, 1122 insertions(+), 43 deletions(-) create mode 100644 study/migrations/0041_spaced_repetition.py create mode 100644 study/templates/study/spaced_repetition_settings.html diff --git a/study/admin.py b/study/admin.py index c0c3c9b..dc7aced 100644 --- a/study/admin.py +++ b/study/admin.py @@ -2,7 +2,7 @@ from .models import ( Course, Topic, Flashcard, StudySession, FlashcardProgress, Skill, MultipleChoiceOption, CardTemplate, CourseEnrollment, - StudyPreference, TopicScore, CardSuggestion + StudyPreference, TopicScore, CardSuggestion, SpacedRepetitionSettings ) # Register your models here. @@ -160,3 +160,11 @@ class CardSuggestionAdmin(admin.ModelAdmin): def question_preview(self, obj): return obj.question[:60] + '…' if len(obj.question) > 60 else obj.question question_preview.short_description = 'Question' + + +@admin.register(SpacedRepetitionSettings) +class SpacedRepetitionSettingsAdmin(admin.ModelAdmin): + list_display = ['user', 'ease_modifier', 'max_interval_days', 'daily_new_cards', 'updated_at'] + list_filter = ['ease_modifier'] + search_fields = ['user__username'] + ordering = ['-updated_at'] diff --git a/study/migrations/0041_spaced_repetition.py b/study/migrations/0041_spaced_repetition.py new file mode 100644 index 0000000..6ade77e --- /dev/null +++ b/study/migrations/0041_spaced_repetition.py @@ -0,0 +1,48 @@ +# Generated by Django 6.0.3 on 2026-03-12 21:51 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('study', '0040_merge_20260309_1337'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='flashcardprogress', + name='easiness_factor', + field=models.FloatField(default=2.5, help_text='SM-2 easiness factor (≥1.3). Higher = longer intervals between reviews.', validators=[django.core.validators.MinValueValidator(1.3)]), + ), + migrations.AddField( + model_name='flashcardprogress', + name='interval_days', + field=models.IntegerField(default=0, help_text='Current interval in days (0 = new card, never reviewed via SM-2).', validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AddField( + model_name='flashcardprogress', + name='next_review_date', + field=models.DateField(blank=True, help_text='Date this card is next due for review. Null means the card is new.', null=True), + ), + migrations.AddField( + model_name='flashcardprogress', + name='sm2_repetitions', + field=models.IntegerField(default=0, help_text='Consecutive successful SM-2 recalls (resets to 0 on failure).', validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.CreateModel( + name='SpacedRepetitionSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ease_modifier', models.IntegerField(choices=[(-2, 'Much slower — review cards very frequently'), (-1, 'Slower — more frequent reviews'), (0, 'Normal — standard SM-2 schedule'), (1, 'Faster — longer gaps between reviews'), (2, 'Much faster — aggressive spacing')], default=0, help_text='Shifts how quickly review intervals grow. Increase if you remember things faster than average; decrease if slower.', validators=[django.core.validators.MinValueValidator(-2), django.core.validators.MaxValueValidator(2)])), + ('max_interval_days', models.IntegerField(default=365, help_text='Maximum days between reviews (30–730). Cap prevents intervals from growing too large.', validators=[django.core.validators.MinValueValidator(30), django.core.validators.MaxValueValidator(730)])), + ('daily_new_cards', models.IntegerField(default=20, help_text='Maximum new (never reviewed) cards to introduce per review session (1–100).', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sr_settings', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/study/models.py b/study/models.py index 970b789..97b7f5c 100644 --- a/study/models.py +++ b/study/models.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User import secrets import string +import datetime BADGE_DEFINITIONS = [ {'slug': 'first_session', 'name': 'First Steps', 'icon': '🎯', 'description': 'Complete your first study session', 'type': 'sessions', 'threshold': 1}, @@ -391,6 +392,28 @@ class FlashcardProgress(models.Model): help_text="0-5 confidence level for spaced repetition" ) + # SM-2 spaced repetition fields + easiness_factor = models.FloatField( + default=2.5, + help_text="SM-2 easiness factor (≥1.3). Higher = longer intervals between reviews.", + validators=[MinValueValidator(1.3)], + ) + interval_days = models.IntegerField( + default=0, + help_text="Current interval in days (0 = new card, never reviewed via SM-2).", + validators=[MinValueValidator(0)], + ) + sm2_repetitions = models.IntegerField( + default=0, + help_text="Consecutive successful SM-2 recalls (resets to 0 on failure).", + validators=[MinValueValidator(0)], + ) + next_review_date = models.DateField( + null=True, + blank=True, + help_text="Date this card is next due for review. Null means the card is new.", + ) + class Meta: unique_together = ['user', 'flashcard', 'step_index'] ordering = ['-last_reviewed'] @@ -404,8 +427,62 @@ def success_rate(self): return 0 return (self.times_correct / self.times_reviewed) * 100 + @property + def is_due(self): + """Return True if the card is due for review today or overdue.""" + if self.next_review_date is None: + return False + return self.next_review_date <= datetime.date.today() + + +class SpacedRepetitionSettings(models.Model): + """Per-user settings for the SM-2 spaced repetition algorithm.""" + + SPEED_CHOICES = [ + (-2, 'Much slower — review cards very frequently'), + (-1, 'Slower — more frequent reviews'), + (0, 'Normal — standard SM-2 schedule'), + (1, 'Faster — longer gaps between reviews'), + (2, 'Much faster — aggressive spacing'), + ] + + DEFAULT_EASE_MODIFIER = 0 + DEFAULT_MAX_INTERVAL_DAYS = 365 + DEFAULT_DAILY_NEW_CARDS = 20 + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='sr_settings', + ) + ease_modifier = models.IntegerField( + default=DEFAULT_EASE_MODIFIER, + choices=SPEED_CHOICES, + help_text=( + "Shifts how quickly review intervals grow. " + "Increase if you remember things faster than average; decrease if slower." + ), + validators=[MinValueValidator(-2), MaxValueValidator(2)], + ) + max_interval_days = models.IntegerField( + default=DEFAULT_MAX_INTERVAL_DAYS, + help_text="Maximum days between reviews (30–730). Cap prevents intervals from growing too large.", + validators=[MinValueValidator(30), MaxValueValidator(730)], + ) + daily_new_cards = models.IntegerField( + default=DEFAULT_DAILY_NEW_CARDS, + help_text="Maximum new (never reviewed) cards to introduce per review session (1–100).", + validators=[MinValueValidator(1), MaxValueValidator(100)], + ) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.user.username} SR settings (ease_modifier={self.ease_modifier:+d})" + def get_min_easiness(self): + """Return the minimum easiness factor after applying user's speed modifier.""" + # Each step shifts min EF by 0.1: -2→1.1, -1→1.2, 0→1.3, +1→1.4, +2→1.5 + return round(1.3 + self.ease_modifier * 0.1, 1) class StudyGoal(models.Model): """User's daily card study target""" user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='study_goal') diff --git a/study/templates/study/base.html b/study/templates/study/base.html index 585e8a9..e2ca9d2 100644 --- a/study/templates/study/base.html +++ b/study/templates/study/base.html @@ -94,6 +94,7 @@