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:
parent
db5af8d661
commit
25109d756d
15 changed files with 790 additions and 93 deletions
|
|
@ -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}")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
29
backend/app/models/embeddable.py
Normal file
29
backend/app/models/embeddable.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
104
frontend/src/pages/QuestionEditPage.css
Normal file
104
frontend/src/pages/QuestionEditPage.css
Normal 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%; }
|
||||
}
|
||||
360
frontend/src/pages/QuestionEditPage.jsx
Normal file
360
frontend/src/pages/QuestionEditPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue