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
This commit is contained in:
Daniel 2026-09-12 21:50:31 +02:00
parent ddd8c4c4e3
commit 04abd78380
9 changed files with 367 additions and 9 deletions

View file

@ -0,0 +1,46 @@
"""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")

View file

@ -9,7 +9,9 @@ from app.models.favorite import Favorite
from app.models.user_note import UserNote
from app.models.lab_reference import LabReference, LabReferenceCardLink
from app.models.article import Article, ArticleTopicClaim, QuestionArticleLink
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
from app.models.flashcard import (FlashcardDeck, Flashcard, FlashcardDeckRating,
FlashcardQuestionLink, FlashcardArticleLink,
FlashcardReview)
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.collection import UserCollection, UserCollectionQuestion
@ -33,6 +35,7 @@ __all__ = [
"Flashcard",
"FlashcardDeckRating",
"FlashcardQuestionLink",
"FlashcardReview",
"FlashcardArticleLink",
"QuestionCategory",
"QuestionCategoryLink",

View file

@ -43,6 +43,34 @@ class Flashcard(Base, Embeddable):
deck = relationship("FlashcardDeck", back_populates="cards")
class FlashcardReview(Base):
"""One verdict on one card, kept so the deck can come back at the right time.
Cards had no memory at all: "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 ones you happened to remember to
skip.
A log rather than a row per card. What the scheduler needs is the *latest*
verdict and how long ago it was, which a log gives; and keeping the history
means a card answered wrongly three times in a row can eventually be
treated differently from one missed once, without a migration to add the
column that would have recorded it.
"""
__tablename__ = "flashcard_reviews"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"),
nullable=False, index=True)
#: "known" or "again". Two outcomes, because a self-graded scale of four
#: asks a learner to rate their own recall on a scale they have not
#: calibrated, and the extra resolution is noise.
outcome = Column(String(10), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class FlashcardQuestionLink(Base):
__tablename__ = "flashcard_question_links"
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)

View file

@ -1,5 +1,6 @@
"""Flashcard decks and cards — generate, browse, edit, delete."""
"""Flashcard decks and cards — generate, browse, edit, delete, and schedule."""
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
@ -16,6 +17,7 @@ from app.models.question import Question
from app.models.section import Section
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.services import card_review
from app.services.quiz_builder import category_descendants
from app.services.search_service import hybrid_ids
from app.services.quiz_builder import bank_question_predicate
@ -244,6 +246,71 @@ def get_flashcard_deck(
return deck
class CardVerdict(BaseModel):
"""What a learner said about a card. Two answers, deliberately."""
outcome: Literal["known", "again"]
@router.get("/{deck_id}/study")
def study_deck(deck_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""The deck in the order it should be sat, with what is due said out loud.
Ordering happens here rather than in the page because it needs this
learner's history, and a page that fetched the whole review log to sort
twenty cards would be downloading a year of answers to draw one screen.
"""
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
raise HTTPException(status_code=403, detail="Not your deck")
cards = db.query(Flashcard).filter(Flashcard.deck_id == deck_id).order_by(Flashcard.id).all()
ordered, counts = card_review.study_order(db, current_user.id, cards)
return {
"deck": {"id": deck.id, "title": deck.title, "card_count": len(cards)},
**counts,
"cards": [{"id": card.id, "front": card.front, "back": card.back,
"image_path": card.image_path} for card in ordered],
}
@router.post("/cards/{card_id}/review")
def review_card(card_id: int, data: CardVerdict, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Record how a card went, so the deck can come back at the right time."""
card = db.get(Flashcard, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
deck = db.get(FlashcardDeck, card.deck_id)
if deck and deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
raise HTTPException(status_code=403, detail="Not your deck")
card_review.record(db, current_user.id, card_id, data.outcome)
return {"card_id": card_id, "outcome": data.outcome}
@router.get("/questions/{question_id}/cards")
def cards_for_question(question_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""The cards an educator tied to this question.
The link is read from the question's end only. A card that listed the
questions it belongs to would hand a learner revising the deck the shape of
the exam and the answer, since a card's back is an answer.
"""
rows = db.query(Flashcard, FlashcardDeck).join(
FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).join(
FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
FlashcardQuestionLink.question_id == question_id,
FlashcardDeck.deleted_at.is_(None)).all()
return [{"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title,
"front": card.front}
for card, deck in rows
if deck.user_id == current_user.id or deck.is_shared or current_user.is_admin]
@router.delete("/{deck_id}", status_code=204)
def delete_flashcard_deck(
deck_id: int,

View file

@ -0,0 +1,93 @@
"""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

View file

@ -11,20 +11,32 @@ import api from '../api/client'
*/
export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
const [links, setLinks] = useState(null)
//: Cards tied to this question, read from the question's end only. A card
//: that listed the questions it belongs to would hand a learner revising the
//: deck the shape of the exam and the answer, since a card's back is one.
const [cards, setCards] = useState([])
useEffect(() => {
if (!questionId) { setLinks([]); return }
if (!questionId) { setLinks([]); setCards([]); return }
api.get(`/questions/${questionId}/articles`)
.then(res => setLinks(res.data)).catch(() => setLinks([]))
api.get(`/flashcards/questions/${questionId}/cards`)
.then(res => setCards(res.data || [])).catch(() => setCards([]))
}, [questionId])
if (!links || links.length === 0) return null
if ((!links || links.length === 0) && cards.length === 0) return null
// Under the right answer in the player, the same links are a row of chips
// one control, plainly a door to an article rather than a headed list,
// which at that point in the page reads as another section of explanation.
if (variant === 'chips') {
// Decks are listed once each, however many of their cards are tied here:
// three chips saying "Board Review" is three doors to the same room.
const decks = []
for (const card of cards) {
if (!decks.some(deck => deck.deck_id === card.deck_id)) decks.push(card)
}
return (
<div className="question-reading-chips" data-testid="question-reading">
{links.map(link => (
{(links || []).map(link => (
<Link key={`${link.article_id}-${link.section_id || 'all'}`}
className="question-reading-chip"
to={link.section_id ? `/articles/${link.article_id}?section=${link.section_id}` : `/articles/${link.article_id}`}>
@ -32,6 +44,13 @@ export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
{link.section_title || link.title}
</Link>
))}
{decks.map(card => (
<Link key={`deck-${card.deck_id}`} className="question-reading-chip is-cards"
to={`/flashcards/${card.deck_id}/study`}>
<span aria-hidden="true"></span>
{card.deck_title}
</Link>
))}
</div>
)
}

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../api/client'
import RichText from '../components/RichText'
export default function FlashcardStudyPage() {
const { deckId } = useParams()
@ -14,10 +15,18 @@ export default function FlashcardStudyPage() {
const [mode, setMode] = useState('all') // 'all' | 'review'
const [shuffled, setShuffled] = useState(false)
const [cardOrder, setCardOrder] = useState([])
//: What the deck looked like when it was opened: due, never seen, settled.
//: Not recomputed as the session goes a counter that falls as you work is
//: encouraging, one that rearranges itself under you is not.
const [counts, setCounts] = useState({ due: 0, unseen: 0, settled: 0 })
// The order comes from the server, because it depends on this learner's own
// history: what is due first, then what has never been seen, then the rest.
// Sorting here would mean downloading a year of answers to draw one screen.
useEffect(() => {
api.get(`/flashcards/${deckId}`).then(res => {
setDeck(res.data)
api.get(`/flashcards/${deckId}/study`).then(res => {
setDeck({ ...res.data.deck, cards: res.data.cards })
setCounts({ due: res.data.due, unseen: res.data.unseen, settled: res.data.settled })
setCardOrder(res.data.cards.map((_, i) => i))
setLoading(false)
}).catch(() => navigate('/flashcards'))
@ -39,10 +48,19 @@ export default function FlashcardStudyPage() {
if (currentIdx > 0) setCurrentIdx(i => i - 1)
}, [currentIdx])
// Both verdicts are sent as well as held: the set in this component is what
// draws the progress bar, and the row on the server is what decides when the
// card comes round again. It was only ever the first, so a deck of two
// hundred was two hundred cards every time.
const record = (cardId, outcome) => {
api.post(`/flashcards/cards/${cardId}/review`, { outcome }).catch(() => {})
}
const markKnown = () => {
if (currentCard) {
setKnown(s => { const n = new Set(s); n.add(currentCard.id); return n })
setReview(s => { const n = new Set(s); n.delete(currentCard.id); return n })
record(currentCard.id, 'known')
}
next()
}
@ -51,6 +69,7 @@ export default function FlashcardStudyPage() {
if (currentCard) {
setReview(s => { const n = new Set(s); n.add(currentCard.id); return n })
setKnown(s => { const n = new Set(s); n.delete(currentCard.id); return n })
record(currentCard.id, 'again')
}
next()
}
@ -103,7 +122,8 @@ export default function FlashcardStudyPage() {
<div>
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{cards.length} cards · {known.size} known · {review.size} to review
{cards.length} cards · {counts.due} due · {counts.unseen} new
{known.size > 0 && ` · ${known.size} answered`}
</p>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
@ -156,7 +176,11 @@ export default function FlashcardStudyPage() {
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
</div>
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
{flipped ? currentCard.back : currentCard.front}
{/* Rendered, not printed. A card's two faces are prose like
everything else here, so `[[264|respiratory failure]]`,
`==key points==` and a figure all work on a card which is
most of what "link cards to things" turns out to mean. */}
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
</p>
{!flipped && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>

View file

@ -0,0 +1,71 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, expect, it, vi } from 'vitest'
import FlashcardStudyPage from './FlashcardStudyPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
vi.mock('../utils/uploads', () => ({
markdownImageUrl: (src) => `/uploads/${src}`,
uploadUrl: (src, attemptId, width) => `/uploads/${src}${width ? `?w=${width}` : ''}`,
THUMB_WIDTHS: [256, 640],
}))
const DECK = {
deck: { id: 3, title: 'Board Review', card_count: 2 },
due: 1, unseen: 1, settled: 0,
cards: [
{ id: 11, front: 'Hereditary angioedema', back: 'Low **C1 inhibitor**; see [[264|respiratory failure]].' },
{ id: 12, front: 'Second card', back: 'Second back' },
],
}
const mount = () => render(
<MemoryRouter initialEntries={['/flashcards/3/study']}>
<Routes>
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/flashcards" element={<h1>Decks</h1>} />
</Routes>
</MemoryRouter>
)
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: DECK })
api.post.mockResolvedValue({ data: {} })
})
it('asks the server for the order and says what is due', async () => {
mount()
await screen.findByText('Board Review')
// Ordering depends on this learner's own history, so the server does it
// sorting here would mean downloading a year of answers to draw one screen.
expect(api.get).toHaveBeenCalledWith('/flashcards/3/study')
expect(screen.getByText(/1 due/)).toBeInTheDocument()
expect(screen.getByText(/1 new/)).toBeInTheDocument()
})
it('remembers a verdict on the server, not just in the page', async () => {
mount()
// The verdicts appear once the card is turned over: there is nothing to say
// about a card whose answer you have not seen.
await userEvent.click(await screen.findByText('Hereditary angioedema'))
await userEvent.click(screen.getByRole('button', { name: /Got it/ }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/flashcards/cards/11/review', { outcome: 'known' }))
await userEvent.click(screen.getByText('Second card'))
await userEvent.click(screen.getByRole('button', { name: /Review again/ }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/flashcards/cards/12/review', { outcome: 'again' }))
})
it('renders a card face as prose, so a card can carry a link', async () => {
mount()
await screen.findByText('Hereditary angioedema')
await userEvent.click(screen.getByText('Hereditary angioedema'))
const back = await screen.findByText(/Low/)
expect(within(back.closest('.rich-text')).getByText('C1 inhibitor').tagName).toBe('STRONG')
expect(screen.getByText('respiratory failure')).toBeInTheDocument()
})

View file

@ -956,3 +956,10 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
@media (max-width: 640px) {
.question-reading-chips { margin-left: 0; }
}
/* A deck chip sits beside the reading chips and is told apart by its icon
rather than by a heading over a second group: they are the same offer the
thing to go and read next and two labelled sections would make a decision
out of it. */
.question-reading-chip.is-cards span { color: #8a6417; }
.question-reading-chip.is-cards:hover { border-color: #b8860b; color: #8a6417; }
.question-reading-chip.is-cards:hover span { color: #8a6417; }