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
93 lines
3.5 KiB
Python
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
|