diff --git a/backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py b/backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py
new file mode 100644
index 0000000..68b99d9
--- /dev/null
+++ b/backend/alembic/versions/u3a4b5c6d7e8_embed_articles_cards.py
@@ -0,0 +1,49 @@
+"""Give articles and flashcards the same retrieval columns as questions.
+
+Both were searched with `ILIKE '%term%'`, so "yellow newborn" could not find a
+jaundice article. They now carry a full-text vector and an embedding, with the
+same provenance columns that make a model change visible.
+
+Revision ID: u3a4b5c6d7e8
+Revises: t2f3a4b5c697
+"""
+from alembic import op
+
+revision = "u3a4b5c6d7e8"
+down_revision = "t2f3a4b5c697"
+branch_labels = None
+depends_on = None
+
+TARGETS = {
+ # table: expression whose text is indexed, weighted title/name first
+ "articles": """
+ setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
+ setweight(to_tsvector('english', coalesce(content, '')), 'C')
+ """,
+ "flashcards": """
+ setweight(to_tsvector('english', coalesce(front, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(back, '')), 'B')
+ """,
+}
+
+
+def upgrade():
+ for table, expression in TARGETS.items():
+ op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedding vector(1024)")
+ op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(120)")
+ op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedded_at TIMESTAMP")
+ op.execute(f"""
+ ALTER TABLE {table} ADD COLUMN IF NOT EXISTS search_vector tsvector
+ GENERATED ALWAYS AS ({expression}) STORED
+ """)
+ op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_search_vector ON {table} USING GIN (search_vector)")
+ op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_embedding_model ON {table}(embedding_model)")
+
+
+def downgrade():
+ for table in TARGETS:
+ op.execute(f"DROP INDEX IF EXISTS ix_{table}_embedding_model")
+ op.execute(f"DROP INDEX IF EXISTS ix_{table}_search_vector")
+ for column in ("search_vector", "embedded_at", "embedding_model", "embedding"):
+ op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column}")
diff --git a/backend/app/models/article.py b/backend/app/models/article.py
index 9cd7bef..a5f1d20 100644
--- a/backend/app/models/article.py
+++ b/backend/app/models/article.py
@@ -4,9 +4,10 @@ from sqlalchemy import Column, Integer, String, Text, JSON, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
+from app.models.embeddable import Embeddable
-class Article(Base):
+class Article(Base, Embeddable):
"""Educator-authored topic reading with stable section IDs for linking."""
__tablename__ = "articles"
diff --git a/backend/app/models/embeddable.py b/backend/app/models/embeddable.py
new file mode 100644
index 0000000..b344c3a
--- /dev/null
+++ b/backend/app/models/embeddable.py
@@ -0,0 +1,29 @@
+from datetime import datetime
+
+from pgvector.sqlalchemy import Vector
+from sqlalchemy import Column, DateTime, String
+from sqlalchemy.orm import declared_attr, deferred
+
+from app.config import settings
+
+
+class Embeddable:
+ """Retrieval columns shared by every searchable corpus.
+
+ `embedding_model` is what makes a model change detectable: vectors from two
+ models share no space, so a mixed corpus returns meaningless distances.
+ Deferred because a vector is large and never wanted in a list query.
+ """
+
+ # Mixin columns must be declared attributes, one per mapped class.
+ @declared_attr
+ def embedding(cls):
+ return deferred(Column(Vector(settings.EMBEDDING_DIMENSIONS), nullable=True))
+
+ @declared_attr
+ def embedding_model(cls):
+ return Column(String(120), nullable=True, index=True)
+
+ @declared_attr
+ def embedded_at(cls):
+ return Column(DateTime, nullable=True)
diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py
index 7980af8..39a313b 100644
--- a/backend/app/models/flashcard.py
+++ b/backend/app/models/flashcard.py
@@ -2,6 +2,7 @@ from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.database import Base
+from app.models.embeddable import Embeddable
class FlashcardDeck(Base):
__tablename__ = "flashcard_decks"
@@ -30,7 +31,7 @@ class FlashcardDeckRating(Base):
created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="ratings")
-class Flashcard(Base):
+class Flashcard(Base, Embeddable):
__tablename__ = "flashcards"
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index c925a80..085aec6 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -6,6 +6,8 @@ from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
+from app.services.search_service import hybrid_ids
+from app.services import embedding_service
from app.models.article import Article, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
@@ -16,7 +18,10 @@ from app.services.quiz_builder import category_breadcrumbs
from app.models.question_category import QuestionCategory
from app.utils.auth import get_current_user, require_moderator
+import logging
+
router = APIRouter()
+log = logging.getLogger(__name__)
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
@@ -138,6 +143,16 @@ def _article_json(article: Article) -> dict:
}
+def _reembed(db, article) -> None:
+ """Embed on write. A failure is not fatal — the retry task sweeps it up."""
+ try:
+ if embedding_service.embed_record(article, "article"):
+ db.commit()
+ except Exception:
+ db.rollback()
+ log.warning("Could not embed article %s; leaving it for the retry task", article.id, exc_info=True)
+
+
@router.get("/")
def list_articles(
category_id: int | None = Query(None),
@@ -150,8 +165,16 @@ def list_articles(
if category_id:
query = query.filter(Article.category_id == category_id)
if q and q.strip():
- query = query.filter(Article.title.ilike(f"%{q.strip()}%"))
+ # Hybrid retrieval, same as the question bank: a title substring match
+ # could not find an article that says the same thing in other words.
+ ranked, _ = hybrid_ids(db, q.strip(), "article", limit=200)
+ if not ranked:
+ return []
+ query = query.filter(Article.id.in_(ranked))
+ rank_of = {article_id: position for position, article_id in enumerate(ranked)}
articles = query.order_by(Article.updated_at.desc()).all()
+ if q and q.strip():
+ articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
if not current_user.is_moderator:
articles = [a for a in articles if a.status == "published"]
return [_article_json(a) for a in articles]
@@ -178,6 +201,7 @@ def create_article(
db.add(article)
db.commit()
db.refresh(article)
+ _reembed(db, article)
return _article_json(article)
@@ -305,6 +329,7 @@ def update_article(
).delete(synchronize_session=False)
db.commit()
db.refresh(article)
+ _reembed(db, article)
return _article_json(article)
diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py
index a6083ee..79d08f6 100644
--- a/backend/app/routers/flashcards.py
+++ b/backend/app/routers/flashcards.py
@@ -17,6 +17,7 @@ from app.models.section import Section
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.services.quiz_builder import category_descendants
+from app.services.search_service import hybrid_ids
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import get_current_user, require_moderator
@@ -398,13 +399,11 @@ def browse_flashcards(
query = query.filter(Flashcard.deck_id == deck_id)
if q and q.strip():
- phrase = q.strip()
- query = query.filter(
- or_(
- Flashcard.front.ilike(f"%{phrase}%"),
- Flashcard.back.ilike(f"%{phrase}%"),
- )
- )
+ # Hybrid retrieval, same as questions and articles.
+ ranked, _ = hybrid_ids(db, q.strip(), "flashcard", limit=500)
+ if not ranked:
+ return {"total": 0, "cards": []}
+ query = query.filter(Flashcard.id.in_(ranked))
# Tag filter
if tag_ids:
diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py
index a9d67eb..769eafc 100644
--- a/backend/app/routers/questions.py
+++ b/backend/app/routers/questions.py
@@ -604,6 +604,44 @@ def bulk_question_action(
return {"updated": updated, "action": data.action}
+@router.get("/detail/{question_id}")
+def get_question_detail(
+ question_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """One question, for the full-page editor.
+
+ Path is /detail/{id} rather than /{id} so it cannot shadow the static
+ routes above it as more are added.
+ """
+ question = bank_query(db, current_user).filter(Question.id == question_id).first()
+ if not question:
+ raise HTTPException(404, "Question not found")
+ extra = [row[0] for row in db.query(QuestionCategoryLink.category_id).filter(
+ QuestionCategoryLink.question_id == question.id).all()]
+ category = db.get(QuestionCategory, question.question_category_id) if question.question_category_id else None
+ return {
+ "id": question.id,
+ "question_text": question.question_text,
+ "question_type": question.question_type,
+ "options": question.options,
+ "correct_answer": question.correct_answer,
+ "explanation": question.explanation,
+ "option_explanations": question.option_explanations,
+ "key_points": question.key_points,
+ "difficulty": question.difficulty,
+ "question_category_id": question.question_category_id,
+ "question_category_name": category.name if category else None,
+ "category_ids": sorted(set(extra) | ({question.question_category_id} if question.question_category_id else set())),
+ "image_path": question.image_path,
+ "explanation_image_path": question.explanation_image_path,
+ "user_id": question.user_id,
+ "is_shared": question.is_shared if question.is_shared is not None else 1,
+ "source_quiz_id": question.source_quiz_id,
+ }
+
+
@router.get("/manage/summary")
def question_manager_summary(
db: Session = Depends(get_db),
diff --git a/backend/app/services/embedding_service.py b/backend/app/services/embedding_service.py
index d315f39..6f4fcc6 100644
--- a/backend/app/services/embedding_service.py
+++ b/backend/app/services/embedding_service.py
@@ -105,6 +105,37 @@ def generate_embedding(text: str) -> list[float] | None:
return None
+# What each embeddable type contributes to its vector. Adding a type here is
+# all that is needed for the retry task and the health report to cover it.
+EMBEDDABLE = {
+ "question": lambda row: _join(row.question_text, *(row.options or [])),
+ "article": lambda row: _join(row.title, row.summary, row.content),
+ "flashcard": lambda row: _join(row.front, row.back),
+}
+
+
+def _join(*parts) -> str:
+ return " ".join(part for part in parts if part)[:4000]
+
+
+def embed_record(row, kind: str) -> bool:
+ """Embed any supported row, stamping the model that produced the vector.
+
+ One code path for questions, articles and cards: they share an embedding
+ space, so they must share the model and the provenance rules too.
+ """
+ build = EMBEDDABLE.get(kind)
+ if build is None:
+ raise ValueError(f"Unknown embeddable kind: {kind}")
+ embedding = generate_embedding(build(row))
+ if not embedding:
+ return False
+ row.embedding = embedding
+ row.embedding_model = _get_embedding_model()
+ row.embedded_at = datetime.utcnow()
+ return True
+
+
def embed_question(question) -> bool:
"""Generate and store an embedding, recording which model produced it.
@@ -113,35 +144,42 @@ def embed_question(question) -> bool:
noise. `stale_embedding_counts` reports the mix; the regenerate task re-embeds
anything not on the active model.
"""
- text = _text_for_question(question.question_text, question.options)
- model = _get_embedding_model()
- emb = generate_embedding(text)
- if emb:
- question.embedding = emb
- question.embedding_model = model
- question.embedded_at = datetime.utcnow()
- return True
- return False
+ return embed_record(question, "question")
+
+
+def embeddable_models() -> dict:
+ """The ORM class behind each embeddable kind."""
+ from app.models.article import Article
+ from app.models.flashcard import Flashcard
+ from app.models.question import Question
+
+ return {"question": Question, "article": Article, "flashcard": Flashcard}
def stale_embedding_counts(db) -> dict:
- """How much of the bank is searchable semantically, and how much is stale."""
+ """How much of each corpus is semantically searchable, and how much is stale.
+
+ A vector from another model sits in a different space, so "stale" is not a
+ performance nicety — those rows return meaningless distances until re-embedded.
+ """
from sqlalchemy import func
- from app.models.question import Question
-
active = _get_embedding_model()
- total = db.query(func.count(Question.id)).scalar() or 0
- missing = db.query(func.count(Question.id)).filter(Question.embedding.is_(None)).scalar() or 0
- stale = db.query(func.count(Question.id)).filter(
- Question.embedding.isnot(None),
- (Question.embedding_model.is_(None)) | (Question.embedding_model != active),
- ).scalar() or 0
+ by_kind, totals = {}, {"total": 0, "missing": 0, "stale": 0, "current": 0}
+ for kind, model in embeddable_models().items():
+ total = db.query(func.count(model.id)).scalar() or 0
+ missing = db.query(func.count(model.id)).filter(model.embedding.is_(None)).scalar() or 0
+ stale = db.query(func.count(model.id)).filter(
+ model.embedding.isnot(None),
+ (model.embedding_model.is_(None)) | (model.embedding_model != active),
+ ).scalar() or 0
+ current = max(0, total - missing - stale)
+ by_kind[kind] = {"total": total, "missing": missing, "stale": stale, "current": current}
+ for key, value in (("total", total), ("missing", missing), ("stale", stale), ("current", current)):
+ totals[key] += value
return {
"active_model": active,
- "total": total,
- "missing": missing,
- "stale": stale,
- "current": max(0, total - missing - stale),
- "needs_regeneration": missing + stale > 0,
+ **totals,
+ "by_kind": by_kind,
+ "needs_regeneration": totals["missing"] + totals["stale"] > 0,
}
diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py
index 9a21b00..ffa10f4 100644
--- a/backend/app/services/search_service.py
+++ b/backend/app/services/search_service.py
@@ -39,18 +39,32 @@ def _is_postgres(db: Session) -> bool:
return db.bind is not None and db.bind.dialect.name == "postgresql"
-def _lexical_ranked(db: Session, query_text: str, pool: int) -> list[int]:
- """Question ids by full-text relevance, best first."""
+# Where each searchable kind lives, and which columns a SQLite fallback scans.
+CORPORA = {
+ "question": ("questions", ("question_text", "CAST(options AS TEXT)")),
+ "article": ("articles", ("title", "summary", "content")),
+ "flashcard": ("flashcards", ("front", "back")),
+}
+
+
+def _lexical_ranked(db: Session, query_text: str, pool: int, kind: str = "question") -> list[int]:
+ """Row ids by full-text relevance, best first.
+
+ `websearch_to_tsquery` gives quoted phrases exact-match semantics for free:
+ "absence seizure" matches the phrase, bare words match either. That covers
+ the one case a keyword-only mode was ever needed for, per query rather than
+ as a sticky setting.
+ """
+ table, columns = CORPORA[kind]
if not _is_postgres(db):
# SQLite (tests): substring matching keeps the fusion path exercised.
- rows = db.execute(sa_text("""
- SELECT id FROM questions
- WHERE lower(question_text) LIKE :like OR lower(CAST(options AS TEXT)) LIKE :like
- ORDER BY id LIMIT :pool
- """), {"like": f"%{query_text.lower()}%", "pool": pool}).fetchall()
+ where = " OR ".join(f"lower({column}) LIKE :like" for column in columns)
+ rows = db.execute(sa_text(
+ f"SELECT id FROM {table} WHERE {where} ORDER BY id LIMIT :pool"
+ ), {"like": f"%{query_text.strip(chr(34)).lower()}%", "pool": pool}).fetchall()
return [row[0] for row in rows]
- rows = db.execute(sa_text("""
- SELECT id FROM questions
+ rows = db.execute(sa_text(f"""
+ SELECT id FROM {table}
WHERE search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', :q)) DESC, id
LIMIT :pool
@@ -90,17 +104,21 @@ def _query_embedding(query_text: str) -> list[float] | None:
return embedding
-def _semantic_ranked(db: Session, query_text: str, pool: int) -> list[int]:
- """Question ids by embedding similarity, nearest first."""
+def _semantic_ranked(db: Session, query_text: str, pool: int, kind: str = "question") -> list[int]:
+ """Row ids by embedding similarity, nearest first."""
if not _is_postgres(db):
return []
+ # A quoted phrase asks for an exact lookup, so the fuzzy ranker sits it out.
+ if query_text.startswith('"') and query_text.endswith('"') and len(query_text) > 2:
+ return []
embedding = _query_embedding(query_text)
if not embedding:
return []
+ table, _ = CORPORA[kind]
literal = "[" + ",".join(str(float(value)) for value in embedding) + "]"
- rows = db.execute(sa_text("""
+ rows = db.execute(sa_text(f"""
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS similarity
- FROM questions
+ FROM {table}
WHERE embedding IS NOT NULL
ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT :pool
@@ -108,27 +126,28 @@ def _semantic_ranked(db: Session, query_text: str, pool: int) -> list[int]:
return [row.id for row in rows if float(row.similarity) >= SEMANTIC_FLOOR]
-def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple[list[int], set[int]]:
- """Return (ids best-first, ids the semantic ranker contributed).
+def hybrid_ids(db: Session, query_text: str, kind: str = "question",
+ limit: int = 200) -> tuple[list[int], set[int]]:
+ """Return (ids best-first, ids the semantic ranker contributed), for any corpus.
The result is the *union* of both rankers. An earlier implementation
- intersected them, so a question that matched the meaning but not the literal
+ intersected them, so a row that matched the meaning but not the literal
string could never be returned no matter how well it scored.
"""
query_text = (query_text or "").strip()
- if not query_text:
+ if not query_text or kind not in CORPORA:
return [], set()
pool = max(MIN_POOL, limit * POOL_MULTIPLIER)
try:
- lexical = _lexical_ranked(db, query_text, pool)
+ lexical = _lexical_ranked(db, query_text, pool, kind)
except Exception:
- logger.warning("Lexical search unavailable; falling back to semantic only", exc_info=True)
+ logger.warning("Lexical search unavailable for %s; semantic only", kind, exc_info=True)
lexical = []
try:
- semantic = _semantic_ranked(db, query_text, pool)
+ semantic = _semantic_ranked(db, query_text, pool, kind)
except Exception:
- logger.warning("Semantic search unavailable; falling back to lexical only", exc_info=True)
+ logger.warning("Semantic search unavailable for %s; lexical only", kind, exc_info=True)
semantic = []
scores: dict[int, float] = {}
@@ -138,3 +157,8 @@ def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple
ordered = sorted(scores, key=lambda qid: (-scores[qid], qid))
return ordered[:limit], set(semantic)
+
+
+def hybrid_question_ids(db: Session, query_text: str, limit: int = 200) -> tuple[list[int], set[int]]:
+ """Questions, for callers that predate the multi-corpus signature."""
+ return hybrid_ids(db, query_text, "question", limit)
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 8339541..ce43155 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -539,31 +539,33 @@ def retry_missing_embeddings(batch: int = 200) -> dict:
"""
db = SessionLocal()
try:
- from app.models.question import Question
from app.services import embedding_service
active = embedding_service._get_embedding_model()
- pending = (
- db.query(Question)
- .filter(
- (Question.embedding.is_(None))
- | (Question.embedding_model.is_(None))
- | (Question.embedding_model != active)
+ pending_total, embedded_total = 0, 0
+ # Every embeddable corpus, so an article or card is not left behind.
+ for kind, model in embedding_service.embeddable_models().items():
+ pending = (
+ db.query(model)
+ .filter(
+ (model.embedding.is_(None))
+ | (model.embedding_model.is_(None))
+ | (model.embedding_model != active)
+ )
+ .limit(batch)
+ .all()
)
- .limit(batch)
- .all()
- )
- embedded = 0
- for question in pending:
- try:
- if embedding_service.embed_question(question):
- embedded += 1
- except Exception:
- logger.warning("Retry embedding failed for question %s", question.id, exc_info=True)
- if embedded:
+ pending_total += len(pending)
+ for row in pending:
+ try:
+ if embedding_service.embed_record(row, kind):
+ embedded_total += 1
+ except Exception:
+ logger.warning("Retry embedding failed for %s %s", kind, row.id, exc_info=True)
+ if embedded_total:
db.commit()
- logger.info("Backfilled %s embeddings (%s pending in this batch)", embedded, len(pending))
- return {"pending": len(pending), "embedded": embedded, "model": active}
+ logger.info("Backfilled %s embeddings (%s pending)", embedded_total, pending_total)
+ return {"pending": pending_total, "embedded": embedded_total, "model": active}
finally:
db.close()
@@ -587,29 +589,31 @@ def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = Tr
from app.models.question import Question
from app.services import embedding_service
- query = db.query(Question)
- if stale_only:
- active = embedding_service._get_embedding_model()
- query = query.filter(
- (Question.embedding.is_(None))
- | (Question.embedding_model.is_(None))
- | (Question.embedding_model != active)
- )
- questions = query.all()
- total = len(questions)
+ active = embedding_service._get_embedding_model()
+ pending = []
+ for kind, model in embedding_service.embeddable_models().items():
+ query = db.query(model)
+ if stale_only:
+ query = query.filter(
+ (model.embedding.is_(None))
+ | (model.embedding_model.is_(None))
+ | (model.embedding_model != active)
+ )
+ pending.extend((kind, row) for row in query.all())
+ total = len(pending)
scope = "missing or stale" if stale_only else "all"
- _push_step(r, job_id, "start", f"Regenerating embeddings for {total} {scope} questions…")
+ _push_step(r, job_id, "start", f"Regenerating embeddings for {total} {scope} records…")
ok = 0
- for i, q in enumerate(questions):
+ for i, (kind, row) in enumerate(pending):
try:
- if embedding_service.embed_question(q):
+ if embedding_service.embed_record(row, kind):
ok += 1
if (i + 1) % 50 == 0:
db.commit()
_push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)")
except Exception as e:
- logger.warning(f"Embedding failed for question {q.id}: {e}")
+ logger.warning(f"Embedding failed for {kind} {row.id}: {e}")
db.commit()
_push_step(r, job_id, "done", f"Done — {ok}/{total} questions re-embedded.")
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 739bcd3..afcc319 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -19,6 +19,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
+const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage'))
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
const JobsPage = lazy(() => import('./pages/JobsPage'))
const TrashPage = lazy(() => import('./pages/TrashPage'))
@@ -116,6 +117,8 @@ function AppRoutes() {
{error || 'Question not found'}
#{id}
+
+ {error}
} +