pdf-quiz-generator/backend/app/models/flashcard.py
Daniel 25109d756d feat: hybrid search for articles and cards; full-page question editor
Retrieval generalised beyond questions
`_text_for_question`, `embed_question` and `hybrid_question_ids` all hardcoded
the questions table, so there was nothing to call for an article or a card. That
layer is now corpus-agnostic:
- `Embeddable` mixin gives articles and flashcards the same embedding,
  embedding_model and embedded_at columns questions have, plus a weighted
  full-text vector (migration u3a4b5c6d7e8).
- `embed_record(row, kind)` is one code path for all three — they share an
  embedding space, so they must share the model and provenance rules too.
- `hybrid_ids(db, query, kind)` ranks any corpus; `hybrid_question_ids` stays as
  a thin alias for existing callers.
- Article and flashcard search moved off `ILIKE '%term%'`, which could not find
  a jaundice article from "yellow newborn".
- The retry task and full regeneration now sweep every corpus, and the health
  report breaks down current/stale/missing per kind.
- Articles embed on create and on edit, with failures left to the retry task.

Quoted phrases replace the keyword-only mode
`websearch_to_tsquery` already gives "absence seizure" exact-phrase semantics,
and the semantic ranker sits out a quoted query. That covers the one case a
keyword-only toggle was for — exact lookup — per query rather than as a sticky
setting whose every position returns a subset of the default.

Full-page question editor (/questions/new, /questions/:id)
Editing happened in a cramped modal. There is now a page with room for the stem,
per-option explanations, a searchable category picker with primary plus extras,
difficulty, and images. It shows the question's id with a copy button, and
Duplicate creates a variant without retyping the stem. `GET /questions/detail/{id}`
backs it, pathed under /detail/ so it cannot shadow the static routes.

Question bank filter bar restyled — the toggle and count read as one control
instead of two grey pills crowding the result count.

Tests: 101 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:01:35 +02:00

62 lines
3.2 KiB
Python

from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.database import Base
from app.models.embeddable import Embeddable
class FlashcardDeck(Base):
__tablename__ = "flashcard_decks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True)
# Same tree as questions and articles, so one category filters all three.
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
card_count = Column(Integer, default=0)
is_shared = Column(Integer, default=0) # 0 = private, 1 = shared
created_at = Column(DateTime, default=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True)
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
ratings = relationship("FlashcardDeckRating", back_populates="deck", cascade="all, delete-orphan")
user = relationship("User")
class FlashcardDeckRating(Base):
__tablename__ = "flashcard_deck_ratings"
__table_args__ = (UniqueConstraint("user_id", "deck_id", name="uq_deck_rating_user"),)
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
rating = Column(Integer, nullable=False) # 1-5
created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="ratings")
class Flashcard(Base, Embeddable):
__tablename__ = "flashcards"
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
front = Column(Text, nullable=False)
back = Column(Text, nullable=False)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="cards")
class FlashcardQuestionLink(Base):
__tablename__ = "flashcard_question_links"
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)
id = Column(Integer, primary_key=True, index=True)
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
class FlashcardArticleLink(Base):
__tablename__ = "flashcard_article_links"
__table_args__ = (UniqueConstraint("flashcard_id", "article_id", "article_section_id", name="uq_card_article_section"),)
id = Column(Integer, primary_key=True, index=True)
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
article_section_id = Column(String(64), nullable=True)