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