pdf-quiz-generator/backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.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

46 lines
1.8 KiB
Python

"""Cards remember when they were last answered
"Known" and "to review" were React state: they 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. This is the log the scheduler reads.
A log rather than a row per card, because what the scheduler needs is the
latest verdict and its age — and keeping the history means a card missed three
times running can later be treated differently from one missed once, without a
migration to add the column that would have recorded it.
Revision ID: m3d4e5f6a7b8
Revises: l2c3d4e5f6a7
"""
import sqlalchemy as sa
from alembic import op
revision = "m3d4e5f6a7b8"
down_revision = "l2c3d4e5f6a7"
branch_labels = None
depends_on = None
def upgrade() -> None:
if "flashcard_reviews" in sa.inspect(op.get_bind()).get_table_names():
return
op.create_table(
"flashcard_reviews",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("flashcard_id", sa.Integer(),
sa.ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False),
sa.Column("outcome", sa.String(length=10), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_flashcard_reviews_user_id", "flashcard_reviews", ["user_id"])
op.create_index("ix_flashcard_reviews_flashcard_id", "flashcard_reviews", ["flashcard_id"])
# The scheduler asks "this learner's latest verdict per card", which is this
# index read backwards.
op.create_index("ix_flashcard_reviews_recent", "flashcard_reviews",
["user_id", "flashcard_id", "created_at"])
def downgrade() -> None:
op.drop_table("flashcard_reviews")