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
This commit is contained in:
Daniel 2026-09-10 02:01:35 +02:00
parent db5af8d661
commit 25109d756d
15 changed files with 790 additions and 93 deletions

View file

@ -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}")

View file

@ -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"

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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),

View file

@ -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,
}

View file

@ -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)

View file

@ -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.")

View file

@ -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() {
<Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/questions/new" element={<QuestionEditPage mode="create" />} />
<Route path="/questions/:id" element={<QuestionEditPage />} />
</Route>
</Route>

View file

@ -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; }

View file

@ -709,12 +709,13 @@ export default function QuestionBankPage() {
{/* Filters — one row per facet, each opening a search + checklist panel. */}
<div className="bank-layout">
<div className="bank-filters-bar">
<button type="button" className="btn btn-secondary btn-sm" aria-expanded={filtersOpen}
<button type="button" className="bank-filters-toggle" aria-expanded={filtersOpen}
onClick={() => setFiltersOpen(v => !v)}>
Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
<span aria-hidden="true"></span> Filters
{activeFilterCount > 0 && <span className="bank-filters-count">{activeFilterCount}</span>}
</button>
{activeFilterCount > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={resetFilters}>Reset</button>
<button type="button" className="bank-filters-clear" onClick={resetFilters}>Clear all</button>
)}
<span className="bank-filters-summary">{total} question{total !== 1 ? 's' : ''}</span>
</div>

View file

@ -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%; }
}

View file

@ -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 <div className="loading"><div className="spinner" /> Loading</div>
if (!form) return <div className="qe-page"><p className="qe-error" role="alert">{error || 'Question not found'}</p></div>
return (
<div className="qe-page">
<div className="qe-top">
<div className="qe-title">
<Link className="qe-back" to="/question-bank"> Question bank</Link>
<h1>{isCreate ? 'New question' : 'Edit question'}</h1>
{!isCreate && (
<div className="qe-idline">
<span>ID</span>
<code className="qe-id">#{id}</code>
<button type="button" className="qe-copy-id" title="Copy question ID"
onClick={() => navigator.clipboard?.writeText(String(id))}>Copy</button>
</div>
)}
</div>
{!isCreate && (
<div className="qe-top-actions">
<button className="btn btn-secondary" disabled={copying} onClick={duplicate}>
{copying ? 'Copying…' : 'Duplicate'}
</button>
</div>
)}
</div>
<div className="qe-grid">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="qe-card">
<h2>Question</h2>
<div className="qe-card-body">
<label className="qe-field">
<span>Stem</span>
<textarea value={form.question_text} aria-label="Question text"
onChange={e => setField('question_text', e.target.value)} />
</label>
<label className="qe-field">
<span>Type</span>
<select value={form.question_type} aria-label="Question type"
onChange={e => setField('question_type', e.target.value)}>
<option value="mcq">Multiple choice</option>
<option value="true_false">True / False</option>
<option value="fill_blank">Fill in the blank</option>
</select>
</label>
</div>
</section>
{form.question_type === 'mcq' && (
<section className="qe-card">
<h2>Options</h2>
<div className="qe-card-body">
{form.options.map((option, index) => (
<div key={index} className={`qe-option${option && option === form.correct_answer ? ' is-correct' : ''}`}>
<span className="qe-option-letter">{LETTERS[index]}</span>
<div className="qe-option-main">
<input value={option} aria-label={`Option ${LETTERS[index]}`}
onChange={e => setOption(index, e.target.value)} />
<textarea value={form.option_explanations[option] || ''}
placeholder="Why this option is right or wrong (optional)"
aria-label={`Explanation for option ${LETTERS[index]}`}
onChange={e => setForm(f => ({
...f, option_explanations: { ...f.option_explanations, [option]: e.target.value },
}))} />
<div className="qe-option-tools">
<label className="qe-correct-toggle">
<input type="radio" name="correct" checked={!!option && option === form.correct_answer}
onChange={() => setField('correct_answer', option)} />
Correct answer
</label>
<button type="button" className="btn btn-secondary btn-sm"
aria-label={`Remove option ${LETTERS[index]}`}
onClick={() => setForm(f => ({ ...f, options: f.options.filter((_, i) => i !== index) }))}>
Remove
</button>
</div>
</div>
</div>
))}
{form.options.length < LETTERS.length && (
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
)}
</div>
</section>
)}
<section className="qe-card">
<h2>Explanation</h2>
<div className="qe-card-body">
<label className="qe-field">
<span>Overall explanation</span>
<textarea value={form.explanation} aria-label="Explanation"
onChange={e => setField('explanation', e.target.value)} />
</label>
</div>
</section>
</div>
<aside className="qe-aside">
<section className="qe-card">
<h2>Categories</h2>
<div className="qe-card-body">
<p className="qe-primary-note">
The primary category decides where the question is filed; extras make it findable
from other topics too.
</p>
<label className="qe-field">
<span>Primary</span>
<select value={form.question_category_id} aria-label="Primary category"
onChange={e => setField('question_category_id', e.target.value)}>
<option value="">Uncategorized</option>
{categories.map(c => (
<option key={c.id} value={c.id}>
{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}
</option>
))}
</select>
</label>
<span className="qe-field" style={{ marginBottom: 6 }}><span>Also appears in</span></span>
<input className="qe-cat-search" value={catQuery} placeholder="Search categories…"
aria-label="Search categories" onChange={e => setCatQuery(e.target.value)} />
<div className="qe-cat-list">
{visibleCategories.map(c => (
<label key={c.id}>
<input type="checkbox" checked={form.extraCategoryIds.includes(c.id)}
disabled={c.id === Number(form.question_category_id)}
onChange={() => toggleExtra(c.id)} />
<span>
{c.name}
{(c.breadcrumbs || []).length > 1 && (
<span className="qe-cat-crumb"> · {(c.breadcrumbs || []).slice(0, -1).map(b => b.name).join(' ')}</span>
)}
</span>
<span className="qe-cat-count">{c.question_count}</span>
</label>
))}
{visibleCategories.length === 0 && <p className="qe-primary-note">Nothing matches that search.</p>}
</div>
{form.extraCategoryIds.length > 0 && (
<div className="qe-chips">
{form.extraCategoryIds.map(categoryId => (
<span key={categoryId} className="qe-chip">
{nameOf(categoryId) || `#${categoryId}`}
<button type="button" aria-label={`Remove ${nameOf(categoryId) || categoryId}`}
onClick={() => toggleExtra(categoryId)}></button>
</span>
))}
</div>
)}
</div>
</section>
<section className="qe-card">
<h2>Difficulty</h2>
<div className="qe-card-body">
<label className="qe-field" style={{ marginBottom: 0 }}>
<span className="sr-only">Difficulty</span>
<select value={form.difficulty} aria-label="Difficulty"
onChange={e => setField('difficulty', e.target.value)}>
{DIFFICULTIES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
</div>
</section>
<section className="qe-card">
<h2>Images</h2>
<div className="qe-card-body">
<label className="qe-field">
<span>Question image</span>
<input value={form.image_path} placeholder="Image ID or filename"
aria-label="Question image" onChange={e => setField('image_path', e.target.value)} />
</label>
{form.image_path && (
<div className="qe-image">
<img src={`/uploads/${form.image_path}`} alt="Question"
title={form.image_path}
onError={e => { e.currentTarget.style.display = 'none' }} />
<span className="qe-image-meta">{form.image_path}</span>
</div>
)}
<label className="qe-field" style={{ marginTop: 12 }}>
<span>Explanation image</span>
<input value={form.explanation_image_path} placeholder="Image ID or filename"
aria-label="Explanation image" onChange={e => setField('explanation_image_path', e.target.value)} />
</label>
<Link className="btn btn-secondary btn-sm" to="/images">Browse image bank</Link>
</div>
</section>
</aside>
</div>
<div className="qe-bar">
<div className="qe-bar-inner">
<span className="qe-bar-status" role="status" aria-live="polite">
{status || (isCreate ? 'Not saved yet' : `Question #${id}`)}
</span>
<Link className="btn btn-secondary" to="/question-bank">Cancel</Link>
<button className="btn btn-primary qe-save" disabled={saving} onClick={save}>
{saving ? 'Saving…' : isCreate ? 'Create question' : 'Save changes'}
</button>
</div>
{error && <p className="qe-error" role="alert">{error}</p>}
</div>
</div>
)
}