From abe16c20efe43022ac26b9073ab08a08783d546f Mon Sep 17 00:00:00 2001 From: ruslangilfanov Date: Sat, 16 Aug 2025 19:31:06 +0300 Subject: [PATCH] add spaced repetition system (SRS) models, engine, and word selector logic --- src/greek_lang/srs/__init__.py | 0 src/greek_lang/srs/engine.py | 24 +++++++++++++ src/greek_lang/srs/models.py | 66 ++++++++++++++++++++++++++++++++++ src/greek_lang/srs/selector.py | 28 +++++++++++++++ src/greek_lang/translator.py | 2 +- 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/greek_lang/srs/__init__.py create mode 100644 src/greek_lang/srs/engine.py create mode 100644 src/greek_lang/srs/models.py create mode 100644 src/greek_lang/srs/selector.py diff --git a/src/greek_lang/srs/__init__.py b/src/greek_lang/srs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/greek_lang/srs/engine.py b/src/greek_lang/srs/engine.py new file mode 100644 index 0000000..7ff9781 --- /dev/null +++ b/src/greek_lang/srs/engine.py @@ -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) diff --git a/src/greek_lang/srs/models.py b/src/greek_lang/srs/models.py new file mode 100644 index 0000000..7057a69 --- /dev/null +++ b/src/greek_lang/srs/models.py @@ -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) diff --git a/src/greek_lang/srs/selector.py b/src/greek_lang/srs/selector.py new file mode 100644 index 0000000..1cc0924 --- /dev/null +++ b/src/greek_lang/srs/selector.py @@ -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() diff --git a/src/greek_lang/translator.py b/src/greek_lang/translator.py index 833b0c4..0cbc5fb 100644 --- a/src/greek_lang/translator.py +++ b/src/greek_lang/translator.py @@ -28,7 +28,7 @@ async def translate( source_lang=source_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(): values = {