pdf-quiz-generator/backend/app/services/card_review.py
Daniel 04abd78380 feat: cards remember, render as prose, and are reachable from a question
Three things the card system did not have.

**Spaced repetition.** There was none. "Known" and "to review" were React state
that vanished on reload, so a deck of two hundred was two hundred cards every
time and the only spacing was whichever cards a learner remembered to skip.
Verdicts are now kept, and the deck comes back in the order the learner's own
history calls for: due first, most decayed first, then never seen, then the
rest — because somebody who has met the whole deck recently should still get a
deck rather than a screen saying come back on Thursday.

It borrows the question player's arithmetic rather than choosing its own.
`recall_probability`, `DUE_RECALL`, the thirty-day half-life: two schedulers
with two ideas of "due", in one product that shows a learner one readiness
number, is how the number stops meaning anything. Two outcomes and no
four-point scale — a scale asks a learner to rate their own recall in units
they have never calibrated, and the extra resolution is noise.

**Cards are prose.** Both faces go through the same renderer as everything
else, so a card can carry `[[264|respiratory failure]]`, a `==key point==`, a
teaching tip or a figure. That is most of what "link cards to things" turns out
to mean.

**A deck is reachable from the question.** Beside the topic-reading chip under
the correct answer, one chip per linked deck. Read from the question's end
only, deliberately: a card that listed the questions it belongs to would hand a
learner revising the deck the shape of the exam, and the answer with it.

Migration m3d4e5f6a7b8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:50:31 +02:00

93 lines
3.5 KiB
Python

"""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