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
62 lines
3.2 KiB
Python
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)
|