From 04abd783804ac37fcee0d7de210a262706034a74 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 21:50:31 +0200 Subject: [PATCH] feat: cards remember, render as prose, and are reachable from a question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .../m3d4e5f6a7b8_flashcard_reviews.py | 46 +++++++++ backend/app/models/__init__.py | 5 +- backend/app/models/flashcard.py | 28 ++++++ backend/app/routers/flashcards.py | 69 +++++++++++++- backend/app/services/card_review.py | 93 +++++++++++++++++++ .../src/components/QuestionReadingLinks.jsx | 25 ++++- frontend/src/pages/FlashcardStudyPage.jsx | 32 ++++++- .../src/pages/FlashcardStudyPage.test.jsx | 71 ++++++++++++++ frontend/src/pages/QuizPlayer.css | 7 ++ 9 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py create mode 100644 backend/app/services/card_review.py create mode 100644 frontend/src/pages/FlashcardStudyPage.test.jsx diff --git a/backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py b/backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py new file mode 100644 index 0000000..992d312 --- /dev/null +++ b/backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3b254a8..e7b5d7d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py index 39a313b..152f5a4 100644 --- a/backend/app/models/flashcard.py +++ b/backend/app/models/flashcard.py @@ -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"),) diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index 79d08f6..9dbbd0d 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -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, diff --git a/backend/app/services/card_review.py b/backend/app/services/card_review.py new file mode 100644 index 0000000..1696ae5 --- /dev/null +++ b/backend/app/services/card_review.py @@ -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 diff --git a/frontend/src/components/QuestionReadingLinks.jsx b/frontend/src/components/QuestionReadingLinks.jsx index 1ae7ec9..18b05fc 100644 --- a/frontend/src/components/QuestionReadingLinks.jsx +++ b/frontend/src/components/QuestionReadingLinks.jsx @@ -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 (
- {links.map(link => ( + {(links || []).map(link => ( @@ -32,6 +44,13 @@ export default function QuestionReadingLinks({ questionId, variant = 'list' }) { {link.section_title || link.title} ))} + {decks.map(card => ( + + + {card.deck_title} + + ))}
) } diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx index de43834..f4ba109 100644 --- a/frontend/src/pages/FlashcardStudyPage.jsx +++ b/frontend/src/pages/FlashcardStudyPage.jsx @@ -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() {

{deck.title}

- {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`}

@@ -156,7 +176,11 @@ export default function FlashcardStudyPage() { {flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}

- {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. */} +

{!flipped && (
Tap to reveal answer
diff --git a/frontend/src/pages/FlashcardStudyPage.test.jsx b/frontend/src/pages/FlashcardStudyPage.test.jsx new file mode 100644 index 0000000..55f140b --- /dev/null +++ b/frontend/src/pages/FlashcardStudyPage.test.jsx @@ -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( + + + } /> + Decks} /> + + +) + +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() +}) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index b8ab180..8ddc236 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -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; }