add spaced repetition system (SRS) models, engine, and word selector logic

This commit is contained in:
ruslangilfanov 2025-08-16 19:31:06 +03:00
parent 2a1f15fdf2
commit abe16c20ef
No known key found for this signature in database
5 changed files with 119 additions and 1 deletions

View File

View File

@ -0,0 +1,24 @@
from datetime import datetime, timedelta
from .models import UserWordProgress, ReviewState
def sm2_update(p: UserWordProgress, grade: int, now: datetime) -> None:
# grade: 0..5
e = p.ease + (0.1 - (5 - grade) * (0.08 + (5 - grade) * 0.02))
p.ease = max(1.3, e)
if grade < 3:
p.interval_days = 1
p.reps = 0
p.lrn_step = 0
p.state = ReviewState.lapsed
else:
if p.reps == 0:
p.interval_days = 1
elif p.reps == 1:
p.interval_days = 6
else:
p.interval_days = round(p.interval_days * p.ease)
p.reps += 1
p.state = ReviewState.review
p.due_at = now + timedelta(days=p.interval_days)

View File

@ -0,0 +1,66 @@
import enum
import datetime as dt
from sqlalchemy import (
BigInteger,
Integer,
Float,
DateTime,
Enum,
ForeignKey,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from greek_lang.database.base import Base
class ReviewState(enum.StrEnum):
learning = "learning"
review = "review"
lapsed = "lapsed"
class UserWordProgress(Base):
__tablename__ = "srs_progress"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), index=True, nullable=False
)
word_id: Mapped[int] = mapped_column(
ForeignKey("glossary_word.id"), index=True, nullable=False
)
due_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), index=True, server_default=func.now(), nullable=False
)
interval_days: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
ease: Mapped[float] = mapped_column(Float, nullable=False, default=2.5)
reps: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
lrn_step: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
state: Mapped[ReviewState] = mapped_column(
Enum(ReviewState, native_enum=False),
nullable=False,
default=ReviewState.learning,
)
__table_args__ = (UniqueConstraint("user_id", "word_id", name="uq_srs_user_word"),)
class ReviewLog(Base):
__tablename__ = "srs_review_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), index=True, nullable=False
)
word_id: Mapped[int] = mapped_column(
ForeignKey("glossary_word.id"), index=True, nullable=False
)
ts: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
grade: Mapped[int] = mapped_column(Integer, nullable=False) # 0..5
prev_interval: Mapped[int] = mapped_column(Integer, nullable=False)
new_interval: Mapped[int] = mapped_column(Integer, nullable=False)
prev_ease: Mapped[float] = mapped_column(Float, nullable=False)
new_ease: Mapped[float] = mapped_column(Float, nullable=False)

View File

@ -0,0 +1,28 @@
from collections.abc import Sequence
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from greek_lang.glossaries.models import GlossaryWord
from .models import UserWordProgress
async def pick_due_words(
db: AsyncSession,
user_id: int,
limit: int = 10,
) -> Sequence[tuple[GlossaryWord, UserWordProgress]]:
stmt = (
select(GlossaryWord, UserWordProgress)
.join(UserWordProgress, UserWordProgress.word_id == GlossaryWord.id)
.where(
UserWordProgress.user_id == user_id,
UserWordProgress.due_at <= func.now(),
)
.order_by(UserWordProgress.due_at)
.limit(limit)
)
result = await db.execute(stmt)
return result.tuples().all()

View File

@ -28,7 +28,7 @@ async def translate(
source_lang=source_lang, source_lang=source_lang,
target_lang=target_lang, target_lang=target_lang,
) )
pronon = await get_pronunciation(text="έμπορος", source_lang=source_lang) pronon = await get_pronunciation(text=word_response.lemma, source_lang=source_lang)
async with db_session_maker() as db_session, db_session.begin(): async with db_session_maker() as db_session, db_session.begin():
values = { values = {