"""When a card should come round again. The same arithmetic the question player uses, pointed at cards, and for the same reason: two schedulers with two ideas of what "due" means, in one product that shows a learner one readiness number, is how the number stops meaning anything. So this imports the constants rather than choosing its own. A card answered correctly decays past `DUE_RECALL` at about three and a half weeks; a card answered wrongly is below it immediately. There is no separate ease factor and no four-point self-grading scale: a scale asks a learner to rate their own recall on units they have never calibrated, and the extra resolution is noise dressed as precision. What a study session is, then: everything due, oldest evidence first, then cards never seen, then — only if the deck is short of both — the rest, so that opening a deck always gives you a deck. """ from datetime import datetime from sqlalchemy import func from sqlalchemy.orm import Session from app.models.flashcard import Flashcard, FlashcardReview from app.services.quiz_builder import DUE_RECALL, recall_probability KNOWN = "known" AGAIN = "again" OUTCOMES = (KNOWN, AGAIN) def latest(db: Session, user_id: int, card_ids) -> dict[int, tuple[bool, float]]: """Each card's most recent verdict and how many days ago, for this learner. One query for the whole deck, not one per card: a deck of two hundred is two hundred round trips otherwise, which is how a study page ends up waiting a second before it can draw anything. """ if not card_ids: return {} now = datetime.utcnow() newest = db.query( FlashcardReview.flashcard_id.label("card"), func.max(FlashcardReview.id).label("row"), ).filter( FlashcardReview.user_id == user_id, FlashcardReview.flashcard_id.in_(card_ids), ).group_by(FlashcardReview.flashcard_id).subquery() rows = db.query(FlashcardReview).join(newest, FlashcardReview.id == newest.c.row).all() out = {} for row in rows: when = row.created_at or now age = max(0.0, (now - when).total_seconds() / 86400.0) out[row.flashcard_id] = (row.outcome == KNOWN, age) return out def study_order(db: Session, user_id: int, cards: list[Flashcard]) -> tuple[list[Flashcard], dict]: """The deck in the order it should be sat, and what to say about it. Due first — the most decayed first inside that — then never seen, then everything else. A learner who has met the whole deck recently still gets a deck rather than an empty screen saying come back on Thursday, because a card they choose to look at again is not a mistake to prevent. """ history = latest(db, user_id, [card.id for card in cards]) due, unseen, rest = [], [], [] for card in cards: seen = history.get(card.id) if seen is None: unseen.append(card) continue recall = recall_probability(seen[0], seen[1]) (due if recall < DUE_RECALL else rest).append((recall, card)) due.sort(key=lambda pair: pair[0]) rest.sort(key=lambda pair: -pair[0]) ordered = [card for _, card in due] + unseen + [card for _, card in rest] return ordered, { "due": len(due), "unseen": len(unseen), "settled": len(rest), "reviewed": len(history), } def record(db: Session, user_id: int, card_id: int, outcome: str) -> FlashcardReview: row = FlashcardReview(user_id=user_id, flashcard_id=card_id, outcome=outcome) db.add(row) db.commit() db.refresh(row) return row