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 sqlalchemy.orm import relationship
from app.database import Base 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.""" """Educator-authored topic reading with stable section IDs for linking."""
__tablename__ = "articles" __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 import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
from app.models.embeddable import Embeddable
class FlashcardDeck(Base): class FlashcardDeck(Base):
__tablename__ = "flashcard_decks" __tablename__ = "flashcard_decks"
@ -30,7 +31,7 @@ class FlashcardDeckRating(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="ratings") deck = relationship("FlashcardDeck", back_populates="ratings")
class Flashcard(Base): class Flashcard(Base, Embeddable):
__tablename__ = "flashcards" __tablename__ = "flashcards"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False) 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 sqlalchemy.orm import Session
from app.database import get_db 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.article import Article, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question 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.models.question_category import QuestionCategory
from app.utils.auth import get_current_user, require_moderator from app.utils.auth import get_current_user, require_moderator
import logging
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__)
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") 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("/") @router.get("/")
def list_articles( def list_articles(
category_id: int | None = Query(None), category_id: int | None = Query(None),
@ -150,8 +165,16 @@ def list_articles(
if category_id: if category_id:
query = query.filter(Article.category_id == category_id) query = query.filter(Article.category_id == category_id)
if q and q.strip(): 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() 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: if not current_user.is_moderator:
articles = [a for a in articles if a.status == "published"] articles = [a for a in articles if a.status == "published"]
return [_article_json(a) for a in articles] return [_article_json(a) for a in articles]
@ -178,6 +201,7 @@ def create_article(
db.add(article) db.add(article)
db.commit() db.commit()
db.refresh(article) db.refresh(article)
_reembed(db, article)
return _article_json(article) return _article_json(article)
@ -305,6 +329,7 @@ def update_article(
).delete(synchronize_session=False) ).delete(synchronize_session=False)
db.commit() db.commit()
db.refresh(article) db.refresh(article)
_reembed(db, article)
return _article_json(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.question_category import QuestionCategory
from app.models.user import User from app.models.user import User
from app.services.quiz_builder import category_descendants 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.services.quiz_builder import bank_question_predicate
from app.utils.auth import get_current_user, require_moderator 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) query = query.filter(Flashcard.deck_id == deck_id)
if q and q.strip(): if q and q.strip():
phrase = q.strip() # Hybrid retrieval, same as questions and articles.
query = query.filter( ranked, _ = hybrid_ids(db, q.strip(), "flashcard", limit=500)
or_( if not ranked:
Flashcard.front.ilike(f"%{phrase}%"), return {"total": 0, "cards": []}
Flashcard.back.ilike(f"%{phrase}%"), query = query.filter(Flashcard.id.in_(ranked))
)
)
# Tag filter # Tag filter
if tag_ids: if tag_ids:

View file

@ -604,6 +604,44 @@ def bulk_question_action(
return {"updated": updated, "action": data.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") @router.get("/manage/summary")
def question_manager_summary( def question_manager_summary(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View file

@ -105,6 +105,37 @@ def generate_embedding(text: str) -> list[float] | None:
return 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: def embed_question(question) -> bool:
"""Generate and store an embedding, recording which model produced it. """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 noise. `stale_embedding_counts` reports the mix; the regenerate task re-embeds
anything not on the active model. anything not on the active model.
""" """
text = _text_for_question(question.question_text, question.options) return embed_record(question, "question")
model = _get_embedding_model()
emb = generate_embedding(text)
if emb: def embeddable_models() -> dict:
question.embedding = emb """The ORM class behind each embeddable kind."""
question.embedding_model = model from app.models.article import Article
question.embedded_at = datetime.utcnow() from app.models.flashcard import Flashcard
return True from app.models.question import Question
return False
return {"question": Question, "article": Article, "flashcard": Flashcard}
def stale_embedding_counts(db) -> dict: 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 sqlalchemy import func
from app.models.question import Question
active = _get_embedding_model() active = _get_embedding_model()
total = db.query(func.count(Question.id)).scalar() or 0 by_kind, totals = {}, {"total": 0, "missing": 0, "stale": 0, "current": 0}
missing = db.query(func.count(Question.id)).filter(Question.embedding.is_(None)).scalar() or 0 for kind, model in embeddable_models().items():
stale = db.query(func.count(Question.id)).filter( total = db.query(func.count(model.id)).scalar() or 0
Question.embedding.isnot(None), missing = db.query(func.count(model.id)).filter(model.embedding.is_(None)).scalar() or 0
(Question.embedding_model.is_(None)) | (Question.embedding_model != active), stale = db.query(func.count(model.id)).filter(
model.embedding.isnot(None),
(model.embedding_model.is_(None)) | (model.embedding_model != active),
).scalar() or 0 ).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 { return {
"active_model": active, "active_model": active,
"total": total, **totals,
"missing": missing, "by_kind": by_kind,
"stale": stale, "needs_regeneration": totals["missing"] + totals["stale"] > 0,
"current": max(0, total - missing - stale),
"needs_regeneration": missing + 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" return db.bind is not None and db.bind.dialect.name == "postgresql"
def _lexical_ranked(db: Session, query_text: str, pool: int) -> list[int]: # Where each searchable kind lives, and which columns a SQLite fallback scans.
"""Question ids by full-text relevance, best first.""" 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): if not _is_postgres(db):
# SQLite (tests): substring matching keeps the fusion path exercised. # SQLite (tests): substring matching keeps the fusion path exercised.
rows = db.execute(sa_text(""" where = " OR ".join(f"lower({column}) LIKE :like" for column in columns)
SELECT id FROM questions rows = db.execute(sa_text(
WHERE lower(question_text) LIKE :like OR lower(CAST(options AS TEXT)) LIKE :like f"SELECT id FROM {table} WHERE {where} ORDER BY id LIMIT :pool"
ORDER BY id LIMIT :pool ), {"like": f"%{query_text.strip(chr(34)).lower()}%", "pool": pool}).fetchall()
"""), {"like": f"%{query_text.lower()}%", "pool": pool}).fetchall()
return [row[0] for row in rows] return [row[0] for row in rows]
rows = db.execute(sa_text(""" rows = db.execute(sa_text(f"""
SELECT id FROM questions SELECT id FROM {table}
WHERE search_vector @@ websearch_to_tsquery('english', :q) WHERE search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', :q)) DESC, id ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', :q)) DESC, id
LIMIT :pool LIMIT :pool
@ -90,17 +104,21 @@ def _query_embedding(query_text: str) -> list[float] | None:
return embedding return embedding
def _semantic_ranked(db: Session, query_text: str, pool: int) -> list[int]: def _semantic_ranked(db: Session, query_text: str, pool: int, kind: str = "question") -> list[int]:
"""Question ids by embedding similarity, nearest first.""" """Row ids by embedding similarity, nearest first."""
if not _is_postgres(db): if not _is_postgres(db):
return [] 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) embedding = _query_embedding(query_text)
if not embedding: if not embedding:
return [] return []
table, _ = CORPORA[kind]
literal = "[" + ",".join(str(float(value)) for value in embedding) + "]" 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 SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS similarity
FROM questions FROM {table}
WHERE embedding IS NOT NULL WHERE embedding IS NOT NULL
ORDER BY embedding <=> CAST(:vec AS vector) ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT :pool 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] 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]]: def hybrid_ids(db: Session, query_text: str, kind: str = "question",
"""Return (ids best-first, ids the semantic ranker contributed). 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 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. string could never be returned no matter how well it scored.
""" """
query_text = (query_text or "").strip() query_text = (query_text or "").strip()
if not query_text: if not query_text or kind not in CORPORA:
return [], set() return [], set()
pool = max(MIN_POOL, limit * POOL_MULTIPLIER) pool = max(MIN_POOL, limit * POOL_MULTIPLIER)
try: try:
lexical = _lexical_ranked(db, query_text, pool) lexical = _lexical_ranked(db, query_text, pool, kind)
except Exception: 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 = [] lexical = []
try: try:
semantic = _semantic_ranked(db, query_text, pool) semantic = _semantic_ranked(db, query_text, pool, kind)
except Exception: 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 = [] semantic = []
scores: dict[int, float] = {} 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)) ordered = sorted(scores, key=lambda qid: (-scores[qid], qid))
return ordered[:limit], set(semantic) 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() db = SessionLocal()
try: try:
from app.models.question import Question
from app.services import embedding_service from app.services import embedding_service
active = embedding_service._get_embedding_model() active = embedding_service._get_embedding_model()
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 = ( pending = (
db.query(Question) db.query(model)
.filter( .filter(
(Question.embedding.is_(None)) (model.embedding.is_(None))
| (Question.embedding_model.is_(None)) | (model.embedding_model.is_(None))
| (Question.embedding_model != active) | (model.embedding_model != active)
) )
.limit(batch) .limit(batch)
.all() .all()
) )
embedded = 0 pending_total += len(pending)
for question in pending: for row in pending:
try: try:
if embedding_service.embed_question(question): if embedding_service.embed_record(row, kind):
embedded += 1 embedded_total += 1
except Exception: except Exception:
logger.warning("Retry embedding failed for question %s", question.id, exc_info=True) logger.warning("Retry embedding failed for %s %s", kind, row.id, exc_info=True)
if embedded: if embedded_total:
db.commit() db.commit()
logger.info("Backfilled %s embeddings (%s pending in this batch)", embedded, len(pending)) logger.info("Backfilled %s embeddings (%s pending)", embedded_total, pending_total)
return {"pending": len(pending), "embedded": embedded, "model": active} return {"pending": pending_total, "embedded": embedded_total, "model": active}
finally: finally:
db.close() 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.models.question import Question
from app.services import embedding_service from app.services import embedding_service
query = db.query(Question)
if stale_only:
active = embedding_service._get_embedding_model() 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( query = query.filter(
(Question.embedding.is_(None)) (model.embedding.is_(None))
| (Question.embedding_model.is_(None)) | (model.embedding_model.is_(None))
| (Question.embedding_model != active) | (model.embedding_model != active)
) )
questions = query.all() pending.extend((kind, row) for row in query.all())
total = len(questions) total = len(pending)
scope = "missing or stale" if stale_only else "all" 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 ok = 0
for i, q in enumerate(questions): for i, (kind, row) in enumerate(pending):
try: try:
if embedding_service.embed_question(q): if embedding_service.embed_record(row, kind):
ok += 1 ok += 1
if (i + 1) % 50 == 0: if (i + 1) % 50 == 0:
db.commit() db.commit()
_push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)") _push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)")
except Exception as e: 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() db.commit()
_push_step(r, job_id, "done", f"Done — {ok}/{total} questions re-embedded.") _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 QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage')) const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
const CategoriesPage = lazy(() => import('./pages/CategoriesPage')) const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage'))
const AnalysisPage = lazy(() => import('./pages/AnalysisPage')) const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
const JobsPage = lazy(() => import('./pages/JobsPage')) const JobsPage = lazy(() => import('./pages/JobsPage'))
const TrashPage = lazy(() => import('./pages/TrashPage')) const TrashPage = lazy(() => import('./pages/TrashPage'))
@ -116,6 +117,8 @@ function AppRoutes() {
<Route path="/jobs" element={<JobsPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} /> <Route path="/trash" element={<TrashPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/questions/new" element={<QuestionEditPage mode="create" />} />
<Route path="/questions/:id" element={<QuestionEditPage />} />
</Route> </Route>
</Route> </Route>

View file

@ -49,8 +49,29 @@ details[open] > .category-tree-branch .category-tree-chevron { transform: rotate
} }
/* ── Filter facets ────────────────────────────────────────────────── */ /* ── Filter facets ────────────────────────────────────────────────── */
.bank-filters-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } .bank-filters-bar {
.bank-filters-summary { margin-left: auto; font-size: .8rem; color: var(--text-muted); } 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 { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); }
.bank-facets .facet-row:first-child { border-top: 0; } .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. */} {/* Filters — one row per facet, each opening a search + checklist panel. */}
<div className="bank-layout"> <div className="bank-layout">
<div className="bank-filters-bar"> <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)}> onClick={() => setFiltersOpen(v => !v)}>
Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''} <span aria-hidden="true"></span> Filters
{activeFilterCount > 0 && <span className="bank-filters-count">{activeFilterCount}</span>}
</button> </button>
{activeFilterCount > 0 && ( {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> <span className="bank-filters-summary">{total} question{total !== 1 ? 's' : ''}</span>
</div> </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>
)
}