From 25109d756dc83451e94992777b628bd1d163d359 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 02:01:35 +0200 Subject: [PATCH] feat: hybrid search for articles and cards; full-page question editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m --- .../u3a4b5c6d7e8_embed_articles_cards.py | 49 +++ backend/app/models/article.py | 3 +- backend/app/models/embeddable.py | 29 ++ backend/app/models/flashcard.py | 3 +- backend/app/routers/articles.py | 27 +- backend/app/routers/flashcards.py | 13 +- backend/app/routers/questions.py | 38 ++ backend/app/services/embedding_service.py | 84 ++-- backend/app/services/search_service.py | 66 +++- backend/app/tasks/quiz_tasks.py | 72 ++-- frontend/src/App.jsx | 3 + frontend/src/pages/QuestionBankPage.css | 25 +- frontend/src/pages/QuestionBankPage.jsx | 7 +- frontend/src/pages/QuestionEditPage.css | 104 +++++ frontend/src/pages/QuestionEditPage.jsx | 360 ++++++++++++++++++ 15 files changed, 790 insertions(+), 93 deletions(-) create mode 100644 backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py create mode 100644 backend/app/models/embeddable.py create mode 100644 frontend/src/pages/QuestionEditPage.css create mode 100644 frontend/src/pages/QuestionEditPage.jsx diff --git a/backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py b/backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py new file mode 100644 index 0000000..68b99d9 --- /dev/null +++ b/backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py @@ -0,0 +1,49 @@ +"""Give articles and flashcards the same retrieval columns as questions. + +Both were searched with `ILIKE '%term%'`, so "yellow newborn" could not find a +jaundice article. They now carry a full-text vector and an embedding, with the +same provenance columns that make a model change visible. + +Revision ID: u3a4b5c6d7e8 +Revises: t2f3a4b5c697 +""" +from alembic import op + +revision = "u3a4b5c6d7e8" +down_revision = "t2f3a4b5c697" +branch_labels = None +depends_on = None + +TARGETS = { + # table: expression whose text is indexed, weighted title/name first + "articles": """ + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(summary, '')), 'B') || + setweight(to_tsvector('english', coalesce(content, '')), 'C') + """, + "flashcards": """ + setweight(to_tsvector('english', coalesce(front, '')), 'A') || + setweight(to_tsvector('english', coalesce(back, '')), 'B') + """, +} + + +def upgrade(): + for table, expression in TARGETS.items(): + op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedding vector(1024)") + op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(120)") + op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedded_at TIMESTAMP") + op.execute(f""" + ALTER TABLE {table} ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS ({expression}) STORED + """) + op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_search_vector ON {table} USING GIN (search_vector)") + op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_embedding_model ON {table}(embedding_model)") + + +def downgrade(): + for table in TARGETS: + op.execute(f"DROP INDEX IF EXISTS ix_{table}_embedding_model") + op.execute(f"DROP INDEX IF EXISTS ix_{table}_search_vector") + for column in ("search_vector", "embedded_at", "embedding_model", "embedding"): + op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column}") diff --git a/backend/app/models/article.py b/backend/app/models/article.py index 9cd7bef..a5f1d20 100644 --- a/backend/app/models/article.py +++ b/backend/app/models/article.py @@ -4,9 +4,10 @@ from sqlalchemy import Column, Integer, String, Text, JSON, DateTime, ForeignKey from sqlalchemy.orm import relationship from app.database import Base +from app.models.embeddable import Embeddable -class Article(Base): +class Article(Base, Embeddable): """Educator-authored topic reading with stable section IDs for linking.""" __tablename__ = "articles" diff --git a/backend/app/models/embeddable.py b/backend/app/models/embeddable.py new file mode 100644 index 0000000..b344c3a --- /dev/null +++ b/backend/app/models/embeddable.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pgvector.sqlalchemy import Vector +from sqlalchemy import Column, DateTime, String +from sqlalchemy.orm import declared_attr, deferred + +from app.config import settings + + +class Embeddable: + """Retrieval columns shared by every searchable corpus. + + `embedding_model` is what makes a model change detectable: vectors from two + models share no space, so a mixed corpus returns meaningless distances. + Deferred because a vector is large and never wanted in a list query. + """ + + # Mixin columns must be declared attributes, one per mapped class. + @declared_attr + def embedding(cls): + return deferred(Column(Vector(settings.EMBEDDING_DIMENSIONS), nullable=True)) + + @declared_attr + def embedding_model(cls): + return Column(String(120), nullable=True, index=True) + + @declared_attr + def embedded_at(cls): + return Column(DateTime, nullable=True) diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py index 7980af8..39a313b 100644 --- a/backend/app/models/flashcard.py +++ b/backend/app/models/flashcard.py @@ -2,6 +2,7 @@ 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" @@ -30,7 +31,7 @@ class FlashcardDeckRating(Base): created_at = Column(DateTime, default=datetime.utcnow) deck = relationship("FlashcardDeck", back_populates="ratings") -class Flashcard(Base): +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) diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py index c925a80..085aec6 100644 --- a/backend/app/routers/articles.py +++ b/backend/app/routers/articles.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, field_validator from sqlalchemy.orm import Session from app.database import get_db +from app.services.search_service import hybrid_ids +from app.services import embedding_service from app.models.article import Article, QuestionArticleLink from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink from app.models.question import Question @@ -16,7 +18,10 @@ from app.services.quiz_builder import category_breadcrumbs from app.models.question_category import QuestionCategory from app.utils.auth import get_current_user, require_moderator +import logging + router = APIRouter() +log = logging.getLogger(__name__) SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -138,6 +143,16 @@ def _article_json(article: Article) -> dict: } +def _reembed(db, article) -> None: + """Embed on write. A failure is not fatal — the retry task sweeps it up.""" + try: + if embedding_service.embed_record(article, "article"): + db.commit() + except Exception: + db.rollback() + log.warning("Could not embed article %s; leaving it for the retry task", article.id, exc_info=True) + + @router.get("/") def list_articles( category_id: int | None = Query(None), @@ -150,8 +165,16 @@ def list_articles( if category_id: query = query.filter(Article.category_id == category_id) if q and q.strip(): - query = query.filter(Article.title.ilike(f"%{q.strip()}%")) + # Hybrid retrieval, same as the question bank: a title substring match + # could not find an article that says the same thing in other words. + ranked, _ = hybrid_ids(db, q.strip(), "article", limit=200) + if not ranked: + return [] + query = query.filter(Article.id.in_(ranked)) + rank_of = {article_id: position for position, article_id in enumerate(ranked)} articles = query.order_by(Article.updated_at.desc()).all() + if q and q.strip(): + articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of))) if not current_user.is_moderator: articles = [a for a in articles if a.status == "published"] return [_article_json(a) for a in articles] @@ -178,6 +201,7 @@ def create_article( db.add(article) db.commit() db.refresh(article) + _reembed(db, article) return _article_json(article) @@ -305,6 +329,7 @@ def update_article( ).delete(synchronize_session=False) db.commit() db.refresh(article) + _reembed(db, article) return _article_json(article) diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index a6083ee..79d08f6 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -17,6 +17,7 @@ from app.models.section import Section from app.models.question_category import QuestionCategory from app.models.user import User 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 from app.utils.auth import get_current_user, require_moderator @@ -398,13 +399,11 @@ def browse_flashcards( query = query.filter(Flashcard.deck_id == deck_id) if q and q.strip(): - phrase = q.strip() - query = query.filter( - or_( - Flashcard.front.ilike(f"%{phrase}%"), - Flashcard.back.ilike(f"%{phrase}%"), - ) - ) + # Hybrid retrieval, same as questions and articles. + ranked, _ = hybrid_ids(db, q.strip(), "flashcard", limit=500) + if not ranked: + return {"total": 0, "cards": []} + query = query.filter(Flashcard.id.in_(ranked)) # Tag filter if tag_ids: diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index a9d67eb..769eafc 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -604,6 +604,44 @@ def bulk_question_action( return {"updated": updated, "action": data.action} +@router.get("/detail/{question_id}") +def get_question_detail( + question_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """One question, for the full-page editor. + + Path is /detail/{id} rather than /{id} so it cannot shadow the static + routes above it as more are added. + """ + question = bank_query(db, current_user).filter(Question.id == question_id).first() + if not question: + raise HTTPException(404, "Question not found") + extra = [row[0] for row in db.query(QuestionCategoryLink.category_id).filter( + QuestionCategoryLink.question_id == question.id).all()] + category = db.get(QuestionCategory, question.question_category_id) if question.question_category_id else None + return { + "id": question.id, + "question_text": question.question_text, + "question_type": question.question_type, + "options": question.options, + "correct_answer": question.correct_answer, + "explanation": question.explanation, + "option_explanations": question.option_explanations, + "key_points": question.key_points, + "difficulty": question.difficulty, + "question_category_id": question.question_category_id, + "question_category_name": category.name if category else None, + "category_ids": sorted(set(extra) | ({question.question_category_id} if question.question_category_id else set())), + "image_path": question.image_path, + "explanation_image_path": question.explanation_image_path, + "user_id": question.user_id, + "is_shared": question.is_shared if question.is_shared is not None else 1, + "source_quiz_id": question.source_quiz_id, + } + + @router.get("/manage/summary") def question_manager_summary( db: Session = Depends(get_db), diff --git a/backend/app/services/embedding_service.py b/backend/app/services/embedding_service.py index d315f39..6f4fcc6 100644 --- a/backend/app/services/embedding_service.py +++ b/backend/app/services/embedding_service.py @@ -105,6 +105,37 @@ def generate_embedding(text: str) -> list[float] | None: return None +# What each embeddable type contributes to its vector. Adding a type here is +# all that is needed for the retry task and the health report to cover it. +EMBEDDABLE = { + "question": lambda row: _join(row.question_text, *(row.options or [])), + "article": lambda row: _join(row.title, row.summary, row.content), + "flashcard": lambda row: _join(row.front, row.back), +} + + +def _join(*parts) -> str: + return " ".join(part for part in parts if part)[:4000] + + +def embed_record(row, kind: str) -> bool: + """Embed any supported row, stamping the model that produced the vector. + + One code path for questions, articles and cards: they share an embedding + space, so they must share the model and the provenance rules too. + """ + build = EMBEDDABLE.get(kind) + if build is None: + raise ValueError(f"Unknown embeddable kind: {kind}") + embedding = generate_embedding(build(row)) + if not embedding: + return False + row.embedding = embedding + row.embedding_model = _get_embedding_model() + row.embedded_at = datetime.utcnow() + return True + + def embed_question(question) -> bool: """Generate and store an embedding, recording which model produced it. @@ -113,35 +144,42 @@ def embed_question(question) -> bool: noise. `stale_embedding_counts` reports the mix; the regenerate task re-embeds anything not on the active model. """ - text = _text_for_question(question.question_text, question.options) - model = _get_embedding_model() - emb = generate_embedding(text) - if emb: - question.embedding = emb - question.embedding_model = model - question.embedded_at = datetime.utcnow() - return True - return False + return embed_record(question, "question") + + +def embeddable_models() -> dict: + """The ORM class behind each embeddable kind.""" + from app.models.article import Article + from app.models.flashcard import Flashcard + from app.models.question import Question + + return {"question": Question, "article": Article, "flashcard": Flashcard} def stale_embedding_counts(db) -> dict: - """How much of the bank is searchable semantically, and how much is stale.""" + """How much of each corpus is semantically searchable, and how much is stale. + + A vector from another model sits in a different space, so "stale" is not a + performance nicety — those rows return meaningless distances until re-embedded. + """ from sqlalchemy import func - from app.models.question import Question - active = _get_embedding_model() - total = db.query(func.count(Question.id)).scalar() or 0 - missing = db.query(func.count(Question.id)).filter(Question.embedding.is_(None)).scalar() or 0 - stale = db.query(func.count(Question.id)).filter( - Question.embedding.isnot(None), - (Question.embedding_model.is_(None)) | (Question.embedding_model != active), - ).scalar() or 0 + by_kind, totals = {}, {"total": 0, "missing": 0, "stale": 0, "current": 0} + for kind, model in embeddable_models().items(): + total = db.query(func.count(model.id)).scalar() or 0 + missing = db.query(func.count(model.id)).filter(model.embedding.is_(None)).scalar() or 0 + stale = db.query(func.count(model.id)).filter( + model.embedding.isnot(None), + (model.embedding_model.is_(None)) | (model.embedding_model != active), + ).scalar() or 0 + current = max(0, total - missing - stale) + by_kind[kind] = {"total": total, "missing": missing, "stale": stale, "current": current} + for key, value in (("total", total), ("missing", missing), ("stale", stale), ("current", current)): + totals[key] += value return { "active_model": active, - "total": total, - "missing": missing, - "stale": stale, - "current": max(0, total - missing - stale), - "needs_regeneration": missing + stale > 0, + **totals, + "by_kind": by_kind, + "needs_regeneration": totals["missing"] + totals["stale"] > 0, } diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py index 9a21b00..ffa10f4 100644 --- a/backend/app/services/search_service.py +++ b/backend/app/services/search_service.py @@ -39,18 +39,32 @@ def _is_postgres(db: Session) -> bool: return db.bind is not None and db.bind.dialect.name == "postgresql" -def _lexical_ranked(db: Session, query_text: str, pool: int) -> list[int]: - """Question ids by full-text relevance, best first.""" +# Where each searchable kind lives, and which columns a SQLite fallback scans. +CORPORA = { + "question": ("questions", ("question_text", "CAST(options AS TEXT)")), + "article": ("articles", ("title", "summary", "content")), + "flashcard": ("flashcards", ("front", "back")), +} + + +def _lexical_ranked(db: Session, query_text: str, pool: int, kind: str = "question") -> list[int]: + """Row ids by full-text relevance, best first. + + `websearch_to_tsquery` gives quoted phrases exact-match semantics for free: + "absence seizure" matches the phrase, bare words match either. That covers + the one case a keyword-only mode was ever needed for, per query rather than + as a sticky setting. + """ + table, columns = CORPORA[kind] if not _is_postgres(db): # SQLite (tests): substring matching keeps the fusion path exercised. - rows = db.execute(sa_text(""" - SELECT id FROM questions - WHERE lower(question_text) LIKE :like OR lower(CAST(options AS TEXT)) LIKE :like - ORDER BY id LIMIT :pool - """), {"like": f"%{query_text.lower()}%", "pool": pool}).fetchall() + where = " OR ".join(f"lower({column}) LIKE :like" for column in columns) + rows = db.execute(sa_text( + f"SELECT id FROM {table} WHERE {where} ORDER BY id LIMIT :pool" + ), {"like": f"%{query_text.strip(chr(34)).lower()}%", "pool": pool}).fetchall() return [row[0] for row in rows] - rows = db.execute(sa_text(""" - SELECT id FROM questions + rows = db.execute(sa_text(f""" + SELECT id FROM {table} WHERE search_vector @@ websearch_to_tsquery('english', :q) ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', :q)) DESC, id LIMIT :pool @@ -90,17 +104,21 @@ def _query_embedding(query_text: str) -> list[float] | None: return embedding -def _semantic_ranked(db: Session, query_text: str, pool: int) -> list[int]: - """Question ids by embedding similarity, nearest first.""" +def _semantic_ranked(db: Session, query_text: str, pool: int, kind: str = "question") -> list[int]: + """Row ids by embedding similarity, nearest first.""" if not _is_postgres(db): return [] + # A quoted phrase asks for an exact lookup, so the fuzzy ranker sits it out. + if query_text.startswith('"') and query_text.endswith('"') and len(query_text) > 2: + return [] embedding = _query_embedding(query_text) if not embedding: return [] + table, _ = CORPORA[kind] literal = "[" + ",".join(str(float(value)) for value in embedding) + "]" - rows = db.execute(sa_text(""" + rows = db.execute(sa_text(f""" SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS similarity - FROM questions + FROM {table} WHERE embedding IS NOT NULL ORDER BY embedding <=> CAST(:vec AS vector) LIMIT :pool @@ -108,27 +126,28 @@ def _semantic_ranked(db: Session, query_text: str, pool: int) -> list[int]: return [row.id for row in rows if float(row.similarity) >= SEMANTIC_FLOOR] -def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple[list[int], set[int]]: - """Return (ids best-first, ids the semantic ranker contributed). +def hybrid_ids(db: Session, query_text: str, kind: str = "question", + limit: int = 200) -> tuple[list[int], set[int]]: + """Return (ids best-first, ids the semantic ranker contributed), for any corpus. The result is the *union* of both rankers. An earlier implementation - intersected them, so a question that matched the meaning but not the literal + intersected them, so a row that matched the meaning but not the literal string could never be returned no matter how well it scored. """ query_text = (query_text or "").strip() - if not query_text: + if not query_text or kind not in CORPORA: return [], set() pool = max(MIN_POOL, limit * POOL_MULTIPLIER) try: - lexical = _lexical_ranked(db, query_text, pool) + lexical = _lexical_ranked(db, query_text, pool, kind) except Exception: - logger.warning("Lexical search unavailable; falling back to semantic only", exc_info=True) + logger.warning("Lexical search unavailable for %s; semantic only", kind, exc_info=True) lexical = [] try: - semantic = _semantic_ranked(db, query_text, pool) + semantic = _semantic_ranked(db, query_text, pool, kind) except Exception: - logger.warning("Semantic search unavailable; falling back to lexical only", exc_info=True) + logger.warning("Semantic search unavailable for %s; lexical only", kind, exc_info=True) semantic = [] scores: dict[int, float] = {} @@ -138,3 +157,8 @@ def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple ordered = sorted(scores, key=lambda qid: (-scores[qid], qid)) return ordered[:limit], set(semantic) + + +def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple[list[int], set[int]]: + """Questions, for callers that predate the multi-corpus signature.""" + return hybrid_ids(db, query_text, "question", limit) diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 8339541..ce43155 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -539,31 +539,33 @@ def retry_missing_embeddings(batch: int = 200) -> dict: """ db = SessionLocal() try: - from app.models.question import Question from app.services import embedding_service active = embedding_service._get_embedding_model() - pending = ( - db.query(Question) - .filter( - (Question.embedding.is_(None)) - | (Question.embedding_model.is_(None)) - | (Question.embedding_model != active) + pending_total, embedded_total = 0, 0 + # Every embeddable corpus, so an article or card is not left behind. + for kind, model in embedding_service.embeddable_models().items(): + pending = ( + db.query(model) + .filter( + (model.embedding.is_(None)) + | (model.embedding_model.is_(None)) + | (model.embedding_model != active) + ) + .limit(batch) + .all() ) - .limit(batch) - .all() - ) - embedded = 0 - for question in pending: - try: - if embedding_service.embed_question(question): - embedded += 1 - except Exception: - logger.warning("Retry embedding failed for question %s", question.id, exc_info=True) - if embedded: + pending_total += len(pending) + for row in pending: + try: + if embedding_service.embed_record(row, kind): + embedded_total += 1 + except Exception: + logger.warning("Retry embedding failed for %s %s", kind, row.id, exc_info=True) + if embedded_total: db.commit() - logger.info("Backfilled %s embeddings (%s pending in this batch)", embedded, len(pending)) - return {"pending": len(pending), "embedded": embedded, "model": active} + logger.info("Backfilled %s embeddings (%s pending)", embedded_total, pending_total) + return {"pending": pending_total, "embedded": embedded_total, "model": active} finally: db.close() @@ -587,29 +589,31 @@ def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = Tr from app.models.question import Question from app.services import embedding_service - query = db.query(Question) - if stale_only: - active = embedding_service._get_embedding_model() - query = query.filter( - (Question.embedding.is_(None)) - | (Question.embedding_model.is_(None)) - | (Question.embedding_model != active) - ) - questions = query.all() - total = len(questions) + active = embedding_service._get_embedding_model() + pending = [] + for kind, model in embedding_service.embeddable_models().items(): + query = db.query(model) + if stale_only: + query = query.filter( + (model.embedding.is_(None)) + | (model.embedding_model.is_(None)) + | (model.embedding_model != active) + ) + pending.extend((kind, row) for row in query.all()) + total = len(pending) scope = "missing or stale" if stale_only else "all" - _push_step(r, job_id, "start", f"Regenerating embeddings for {total} {scope} questions…") + _push_step(r, job_id, "start", f"Regenerating embeddings for {total} {scope} records…") ok = 0 - for i, q in enumerate(questions): + for i, (kind, row) in enumerate(pending): try: - if embedding_service.embed_question(q): + if embedding_service.embed_record(row, kind): ok += 1 if (i + 1) % 50 == 0: db.commit() _push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)") except Exception as e: - logger.warning(f"Embedding failed for question {q.id}: {e}") + logger.warning(f"Embedding failed for {kind} {row.id}: {e}") db.commit() _push_step(r, job_id, "done", f"Done — {ok}/{total} questions re-embedded.") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 739bcd3..afcc319 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage')) const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage')) const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage')) const CategoriesPage = lazy(() => import('./pages/CategoriesPage')) +const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage')) const AnalysisPage = lazy(() => import('./pages/AnalysisPage')) const JobsPage = lazy(() => import('./pages/JobsPage')) const TrashPage = lazy(() => import('./pages/TrashPage')) @@ -116,6 +117,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/pages/QuestionBankPage.css b/frontend/src/pages/QuestionBankPage.css index e7664b8..91a627c 100644 --- a/frontend/src/pages/QuestionBankPage.css +++ b/frontend/src/pages/QuestionBankPage.css @@ -49,8 +49,29 @@ details[open] > .category-tree-branch .category-tree-chevron { transform: rotate } /* ── Filter facets ────────────────────────────────────────────────── */ -.bank-filters-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } -.bank-filters-summary { margin-left: auto; font-size: .8rem; color: var(--text-muted); } +.bank-filters-bar { + display: flex; gap: 10px; align-items: center; flex-wrap: wrap; + padding: 10px 14px; margin-bottom: 12px; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; +} +.bank-filters-toggle { + display: inline-flex; align-items: center; gap: 7px; + background: none; border: 1px solid var(--border); border-radius: 8px; + padding: 7px 13px; font: inherit; font-size: .86rem; font-weight: 600; + color: var(--text); cursor: pointer; +} +.bank-filters-toggle:hover { border-color: var(--primary); color: var(--primary); } +.bank-filters-toggle[aria-expanded='true'] { background: var(--option-sel-bg); border-color: var(--primary); color: var(--primary); } +.bank-filters-count { + font-size: .72rem; font-weight: 700; border-radius: 999px; padding: 1px 7px; + background: var(--primary); color: var(--primary-fg); +} +.bank-filters-clear { + background: none; border: none; padding: 4px 2px; font: inherit; + font-size: .78rem; color: var(--text-muted); cursor: pointer; text-decoration: underline; +} +.bank-filters-clear:hover { color: var(--wrong-fg); } +.bank-filters-summary { margin-left: auto; font-size: .82rem; color: var(--text-muted); } .bank-facets { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); } .bank-facets .facet-row:first-child { border-top: 0; } diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index e0a2567..388c15a 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -709,12 +709,13 @@ export default function QuestionBankPage() { {/* Filters — one row per facet, each opening a search + checklist panel. */}
- {activeFilterCount > 0 && ( - + )} {total} question{total !== 1 ? 's' : ''}
diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css new file mode 100644 index 0000000..aff4104 --- /dev/null +++ b/frontend/src/pages/QuestionEditPage.css @@ -0,0 +1,104 @@ +/* Full-page question editor — room to read the stem and pick categories, + instead of a cramped modal. Mobile-first: the aside drops below the form. */ + +.qe-page { max-width: 1180px; margin: 0 auto; padding-bottom: 90px; } + +.qe-top { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } +.qe-back { font-size: 0.85rem; color: var(--primary); text-decoration: none; } +.qe-title { flex: 1; min-width: 200px; } +.qe-title h1 { margin: 6px 0 4px; font-size: 1.3rem; } +.qe-idline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.78rem; color: var(--text-muted); } +.qe-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.76rem; + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; +} +.qe-copy-id { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 0.76rem; padding: 2px 4px; } +.qe-copy-id:hover { color: var(--primary); } +.qe-top-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.qe-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; align-items: start; } +.qe-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.qe-card > h2 { margin: 0; padding: 14px 16px; font-size: 1rem; font-weight: 650; border-bottom: 1px solid var(--border); } +.qe-card-body { padding: 16px; } +.qe-aside { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 14px; } + +.qe-field { display: block; margin-bottom: 14px; } +.qe-field > span { + display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); margin-bottom: 6px; +} +.qe-field input, .qe-field select, .qe-field textarea { + width: 100%; padding: 10px 12px; font-size: 0.92rem; font-family: inherit; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.qe-field textarea { min-height: 120px; resize: vertical; line-height: 1.6; } + +/* ── Options ──────────────────────────────────────────────────────── */ +.qe-option { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; } +.qe-option-letter { + flex-shrink: 0; width: 30px; height: 30px; border-radius: 50%; margin-top: 4px; + display: inline-flex; align-items: center; justify-content: center; + font-size: 0.8rem; font-weight: 650; background: var(--bg); color: var(--text-muted); + border: 1px solid var(--border); +} +.qe-option.is-correct .qe-option-letter { background: var(--correct-bg); color: var(--correct-fg); border-color: var(--correct-bd); } +.qe-option-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } +.qe-option-main input, .qe-option-main textarea { + width: 100%; padding: 9px 11px; font-size: 0.9rem; font-family: inherit; + border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); +} +.qe-option-main textarea { min-height: 56px; resize: vertical; font-size: 0.84rem; } +.qe-option-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.qe-correct-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.78rem; color: var(--text-muted); cursor: pointer; } + +/* ── Category picker ──────────────────────────────────────────────── */ +.qe-cat-search { + width: 100%; padding: 8px 11px; font-size: 0.86rem; margin-bottom: 8px; + border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); +} +.qe-cat-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; } +.qe-cat-list label { + display: flex; align-items: center; gap: 9px; padding: 7px 4px; + font-size: 0.86rem; cursor: pointer; border-radius: 6px; +} +.qe-cat-list label:hover { background: var(--bg); } +.qe-cat-crumb { color: var(--text-subtle); font-size: 0.74rem; } +.qe-cat-count { margin-left: auto; color: var(--text-subtle); font-size: 0.76rem; } +.qe-primary-note { font-size: 0.76rem; color: var(--text-muted); margin: 0 0 8px; line-height: 1.5; } +.qe-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.qe-chip { + display: inline-flex; align-items: center; gap: 6px; font-size: 0.76rem; + background: var(--option-sel-bg); color: var(--primary); border-radius: 6px; padding: 3px 9px; +} +.qe-chip button { background: none; border: none; cursor: pointer; color: inherit; font-size: 0.8rem; padding: 0; line-height: 1; } + +/* ── Images ───────────────────────────────────────────────────────── */ +.qe-image { display: flex; gap: 10px; align-items: flex-start; flex-wrap: wrap; } +.qe-image img { max-width: 160px; max-height: 110px; border-radius: 8px; border: 1px solid var(--border); } +.qe-image-meta { font-size: 0.74rem; color: var(--text-muted); } + +/* ── Save bar ─────────────────────────────────────────────────────── */ +.qe-bar { + position: sticky; bottom: 0; z-index: 40; + margin-top: 16px; margin-inline: calc(50% - 50vw); + padding-inline: max(16px, calc(50vw - 590px)); + background: var(--card-bg); border-top: 1px solid var(--border); + box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06); +} +.qe-bar-inner { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 12px 0 calc(12px + env(safe-area-inset-bottom)); } +.qe-bar-status { font-size: 0.82rem; color: var(--text-muted); } +.qe-save { margin-left: auto; min-width: 140px; } +.qe-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 10px 0 0; } + +@media (max-width: 900px) { + .qe-grid { grid-template-columns: 1fr; } + .qe-aside { position: static; } +} +@media (max-width: 640px) { + .qe-page { padding-bottom: 110px; } + .qe-top-actions { width: 100%; } + .qe-top-actions .btn { flex: 1; } + .qe-save { flex: 1; margin-left: 0; } + .qe-bar-status { width: 100%; } +} diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx new file mode 100644 index 0000000..3f80486 --- /dev/null +++ b/frontend/src/pages/QuestionEditPage.jsx @@ -0,0 +1,360 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import api from '../api/client' +import './QuestionEditPage.css' + +const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] +const DIFFICULTIES = [['', 'Not set'], ['easy', 'Easy'], ['medium', 'Medium'], ['hard', 'Hard']] + +const apiError = (err, fallback) => { + const detail = err?.response?.data?.detail + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback + return fallback +} + +const blank = () => ({ + question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', + explanation: '', question_category_id: '', extraCategoryIds: [], option_explanations: {}, + difficulty: '', image_path: '', explanation_image_path: '', +}) + +/** Full-page create/edit for one question. `mode` is 'create' or 'edit'. */ +export default function QuestionEditPage({ mode = 'edit' }) { + const { id } = useParams() + const navigate = useNavigate() + const isCreate = mode === 'create' + + const [form, setForm] = useState(isCreate ? blank() : null) + const [categories, setCategories] = useState([]) + const [catQuery, setCatQuery] = useState('') + const [loading, setLoading] = useState(!isCreate) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [copying, setCopying] = useState(false) + + useEffect(() => { + api.get('/question-categories').then(res => setCategories(res.data || [])).catch(() => setCategories([])) + }, []) + + const load = useCallback(() => { + if (isCreate) return + setLoading(true) + api.get(`/questions/detail/${id}`) + .then(res => { + const found = res.data + setForm({ + question_text: found.question_text || '', + question_type: found.question_type || 'mcq', + options: found.options ? [...found.options] : [], + correct_answer: found.correct_answer || '', + explanation: found.explanation || '', + question_category_id: found.question_category_id || '', + extraCategoryIds: (found.category_ids || []).filter(c => c !== found.question_category_id), + option_explanations: { ...(found.option_explanations || {}) }, + difficulty: found.difficulty || '', + image_path: found.image_path || '', + explanation_image_path: found.explanation_image_path || '', + }) + }) + .catch(err => setError(apiError(err, 'Could not load this question'))) + .finally(() => setLoading(false)) + }, [id, isCreate]) + + useEffect(() => { load() }, [load]) + + const setField = (key, value) => setForm(f => ({ ...f, [key]: value })) + + const setOption = (index, value) => setForm(f => { + const options = [...f.options] + const previous = options[index] + options[index] = value + const explanations = { ...f.option_explanations } + if (previous in explanations) { explanations[value] = explanations[previous]; delete explanations[previous] } + return { + ...f, options, option_explanations: explanations, + correct_answer: f.correct_answer === previous ? value : f.correct_answer, + } + }) + + const visibleCategories = useMemo(() => { + const needle = catQuery.trim().toLowerCase() + if (!needle) return categories + return categories.filter(c => + [c.name, ...(c.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(needle)) + }, [categories, catQuery]) + + const nameOf = (categoryId) => categories.find(c => c.id === categoryId)?.name + + const toggleExtra = (categoryId) => setForm(f => ({ + ...f, + extraCategoryIds: f.extraCategoryIds.includes(categoryId) + ? f.extraCategoryIds.filter(c => c !== categoryId) + : [...f.extraCategoryIds, categoryId], + })) + + const payload = () => ({ + question_text: form.question_text.trim(), + question_type: form.question_type, + options: form.question_type === 'mcq' ? form.options.filter(o => o.trim()) : null, + correct_answer: form.correct_answer.trim(), + explanation: form.explanation || null, + question_category_id: form.question_category_id === '' ? null : Number(form.question_category_id), + additional_category_ids: form.extraCategoryIds.filter(c => c !== Number(form.question_category_id)), + option_explanations: form.option_explanations, + difficulty: form.difficulty || null, + image_path: form.image_path || null, + explanation_image_path: form.explanation_image_path || null, + }) + + const validate = () => { + if (!form.question_text.trim()) return 'The question needs text' + if (!form.correct_answer.trim()) return 'Mark which option is correct' + if (form.question_type === 'mcq') { + const filled = form.options.filter(o => o.trim()) + if (filled.length < 2) return 'A multiple-choice question needs at least two options' + if (!filled.includes(form.correct_answer.trim())) return 'The correct answer must be one of the options' + } + return '' + } + + const save = async () => { + const problem = validate() + if (problem) { setError(problem); return } + setSaving(true); setError(''); setStatus('') + try { + if (isCreate) { + const res = await api.post('/questions/create', payload()) + navigate(`/questions/${res.data.id}`) + } else { + await api.patch(`/questions/${id}`, payload()) + setStatus('Saved') + } + } catch (err) { setError(apiError(err, 'Could not save this question')) } + finally { setSaving(false) } + } + + /** Duplicate into a new question, so a variant does not mean retyping the stem. */ + const duplicate = async () => { + setCopying(true); setError('') + try { + const res = await api.post('/questions/create', { + ...payload(), question_text: `${form.question_text.trim()} (copy)`, + }) + navigate(`/questions/${res.data.id}`) + } catch (err) { setError(apiError(err, 'Could not duplicate this question')) } + finally { setCopying(false) } + } + + if (loading) return
Loading…
+ if (!form) return

{error || 'Question not found'}

+ + return ( +
+
+
+ ← Question bank +

{isCreate ? 'New question' : 'Edit question'}

+ {!isCreate && ( +
+ ID + #{id} + +
+ )} +
+ {!isCreate && ( +
+ +
+ )} +
+ +
+
+
+

Question

+
+