diff --git a/backend/alembic/versions/v4b5c6d7e8f9_exams.py b/backend/alembic/versions/v4b5c6d7e8f9_exams.py
new file mode 100644
index 0000000..ad6313d
--- /dev/null
+++ b/backend/alembic/versions/v4b5c6d7e8f9_exams.py
@@ -0,0 +1,59 @@
+"""Exams as real data, with a per-user active exam.
+
+"Pediatrics Boards" was a hardcoded checkbox that filtered nothing. Exams are now
+rows users can switch between, and question membership is a link table because a
+question can count towards more than one exam.
+
+Revision ID: v4b5c6d7e8f9
+Revises: u3a4b5c6d7e8
+"""
+from alembic import op
+
+revision = "v4b5c6d7e8f9"
+down_revision = "u3a4b5c6d7e8"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS exams (
+ id SERIAL PRIMARY KEY,
+ slug VARCHAR(80) UNIQUE NOT NULL,
+ name VARCHAR(160) NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 100,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS question_exam_links (
+ id SERIAL PRIMARY KEY,
+ question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
+ exam_id INTEGER NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
+ CONSTRAINT uq_question_exam UNIQUE (question_id, exam_id)
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_question_exam_links_question_id ON question_exam_links(question_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_question_exam_links_exam_id ON question_exam_links(exam_id)")
+ op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS active_exam_id INTEGER REFERENCES exams(id) ON DELETE SET NULL")
+
+ op.execute("""
+ INSERT INTO exams (slug, name, sort_order) VALUES
+ ('pediatrics-boards', 'Pediatrics Boards', 10),
+ ('usmle-step-2-ck', 'USMLE Step 2 CK', 20)
+ ON CONFLICT (slug) DO NOTHING
+ """)
+ # Everything in the bank today was written for the paediatrics boards.
+ op.execute("""
+ INSERT INTO question_exam_links (question_id, exam_id)
+ SELECT q.id, e.id FROM questions q
+ CROSS JOIN exams e WHERE e.slug = 'pediatrics-boards'
+ ON CONFLICT DO NOTHING
+ """)
+
+
+def downgrade():
+ op.execute("ALTER TABLE users DROP COLUMN IF EXISTS active_exam_id")
+ op.execute("DROP TABLE IF EXISTS question_exam_links")
+ op.execute("DROP TABLE IF EXISTS exams")
diff --git a/backend/alembic/versions/w5c6d7e8f9a0_article_views.py b/backend/alembic/versions/w5c6d7e8f9a0_article_views.py
new file mode 100644
index 0000000..67704f5
--- /dev/null
+++ b/backend/alembic/versions/w5c6d7e8f9a0_article_views.py
@@ -0,0 +1,28 @@
+"""Track which articles a learner has opened, for "recently viewed".
+
+Revision ID: w5c6d7e8f9a0
+Revises: v4b5c6d7e8f9
+"""
+from alembic import op
+
+revision = "w5c6d7e8f9a0"
+down_revision = "v4b5c6d7e8f9"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS article_views (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
+ viewed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT uq_article_view UNIQUE (user_id, article_id)
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_article_views_user ON article_views(user_id, viewed_at DESC)")
+
+
+def downgrade():
+ op.execute("DROP TABLE IF EXISTS article_views")
diff --git a/backend/app/main.py b/backend/app/main.py
index b2fdf93..76f0f72 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -10,7 +10,7 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
-from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
+from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
from app.routers import study_tools, uploads, articles, comments, share, collections
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@@ -618,6 +618,7 @@ app.include_router(uploads.router)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
+app.include_router(exams.router, prefix="/api/exams", tags=["exams"])
app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
app.include_router(share.router, prefix="/api/share", tags=["share"])
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
diff --git a/backend/app/models/article.py b/backend/app/models/article.py
index a5f1d20..6dcf30a 100644
--- a/backend/app/models/article.py
+++ b/backend/app/models/article.py
@@ -44,3 +44,15 @@ class QuestionArticleLink(Base):
created_at = Column(DateTime, default=datetime.utcnow)
article = relationship("Article", back_populates="links")
+
+
+class ArticleView(Base):
+ """Last time a learner opened an article — one row per user and article."""
+
+ __tablename__ = "article_views"
+ __table_args__ = (UniqueConstraint("user_id", "article_id", name="uq_article_view"),)
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
+ viewed_at = Column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/models/exam.py b/backend/app/models/exam.py
new file mode 100644
index 0000000..b0e163a
--- /dev/null
+++ b/backend/app/models/exam.py
@@ -0,0 +1,32 @@
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
+
+from app.database import Base
+
+
+class Exam(Base):
+ """A study objective — the top of the hierarchy, above systems and disciplines.
+
+ A question can sit under more than one exam (paediatric cardiology counts for
+ both a paediatrics board and a step exam), so membership is a link table
+ rather than a column on the question.
+ """
+
+ __tablename__ = "exams"
+
+ id = Column(Integer, primary_key=True, index=True)
+ slug = Column(String(80), unique=True, nullable=False, index=True)
+ name = Column(String(160), nullable=False)
+ sort_order = Column(Integer, default=100)
+ is_active = Column(Integer, default=1) # 0 hides it from the switcher
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class QuestionExamLink(Base):
+ __tablename__ = "question_exam_links"
+ __table_args__ = (UniqueConstraint("question_id", "exam_id", name="uq_question_exam"),)
+
+ id = Column(Integer, primary_key=True, index=True)
+ question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
+ exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 7ab004f..e088ad0 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,10 +1,11 @@
from datetime import datetime
-from sqlalchemy import Column, Integer, String, DateTime, Boolean
+from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import false as sa_false
from app.database import Base
+from app.models.exam import Exam # noqa — users.active_exam_id FK needs the table in metadata.
class User(Base):
@@ -15,6 +16,8 @@ class User(Base):
hashed_password = Column(String, nullable=False)
name = Column(String, nullable=False)
role = Column(String, default="user") # admin, moderator, user
+ # Which exam the learner is studying for; scopes the bank they see.
+ active_exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True)
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false())
created_at = Column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index 085aec6..61a74b7 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -8,7 +8,7 @@ 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.article import Article, ArticleView, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
from app.models.section import Section
@@ -19,6 +19,7 @@ from app.models.question_category import QuestionCategory
from app.utils.auth import get_current_user, require_moderator
import logging
+from datetime import datetime
router = APIRouter()
log = logging.getLogger(__name__)
@@ -143,6 +144,24 @@ def _article_json(article: Article) -> dict:
}
+
+def _record_view(db, user, article) -> None:
+ """Remember that this learner opened the article; one row per user/article.
+
+ Best effort: a reading page must not fail because a bookkeeping write did.
+ """
+ try:
+ view = db.query(ArticleView).filter_by(user_id=user.id, article_id=article.id).first()
+ if view:
+ view.viewed_at = datetime.utcnow()
+ else:
+ db.add(ArticleView(user_id=user.id, article_id=article.id))
+ db.commit()
+ except Exception:
+ db.rollback()
+ log.warning("Could not record article view", exc_info=True)
+
+
def _reembed(db, article) -> None:
"""Embed on write. A failure is not fatal — the retry task sweeps it up."""
try:
@@ -225,6 +244,26 @@ def articles_for_question(
return result
+@router.get("/recent")
+def recently_viewed(
+ limit: int = Query(5, ge=1, le=20),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Articles this learner opened most recently, newest first."""
+ rows = (
+ db.query(Article, ArticleView.viewed_at)
+ .join(ArticleView, ArticleView.article_id == Article.id)
+ .filter(ArticleView.user_id == current_user.id, Article.status == "published")
+ .order_by(ArticleView.viewed_at.desc())
+ .limit(limit)
+ .all()
+ )
+ return [{"id": article.id, "title": article.title, "slug": article.slug,
+ "viewed_at": viewed_at.isoformat() if viewed_at else None}
+ for article, viewed_at in rows]
+
+
@router.get("/{article_id}")
def get_article(
article_id: int,
@@ -236,6 +275,7 @@ def get_article(
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found")
+ _record_view(db, current_user, article)
data = _article_json(article)
categories = db.query(QuestionCategory).all()
data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else []
diff --git a/backend/app/routers/exams.py b/backend/app/routers/exams.py
new file mode 100644
index 0000000..9456d28
--- /dev/null
+++ b/backend/app/routers/exams.py
@@ -0,0 +1,75 @@
+"""Exams — the study objective a learner is working towards.
+
+An exam sits above systems and disciplines: the same paediatric cardiology
+question can count towards a paediatrics board and a step exam, so membership is
+a link table rather than a column on the question.
+"""
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.exam import Exam, QuestionExamLink
+from app.models.user import User
+from app.utils.auth import get_current_user, require_moderator
+
+router = APIRouter()
+
+
+class ExamWrite(BaseModel):
+ name: str
+ slug: str
+ sort_order: int = 100
+ is_active: int = 1
+
+
+@router.get("/")
+def list_exams(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+ """Every selectable exam, with how many bank questions it covers."""
+ counts = dict(
+ db.query(QuestionExamLink.exam_id, func.count(QuestionExamLink.question_id))
+ .group_by(QuestionExamLink.exam_id).all()
+ )
+ exams = db.query(Exam).filter(Exam.is_active == 1).order_by(Exam.sort_order, Exam.name).all()
+ return {
+ "active_exam_id": current_user.active_exam_id,
+ "exams": [{"id": e.id, "slug": e.slug, "name": e.name,
+ "question_count": counts.get(e.id, 0)} for e in exams],
+ }
+
+
+class ActiveExam(BaseModel):
+ exam_id: int | None = None
+
+
+@router.put("/active")
+def set_active_exam(
+ data: ActiveExam,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Switch which exam the learner is studying for. Persisted on the user."""
+ if data.exam_id is not None:
+ exam = db.get(Exam, data.exam_id)
+ if not exam or not exam.is_active:
+ raise HTTPException(404, "Exam not found")
+ current_user.active_exam_id = data.exam_id
+ db.commit()
+ return {"active_exam_id": current_user.active_exam_id}
+
+
+@router.post("/", status_code=201)
+def create_exam(
+ data: ExamWrite,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator),
+):
+ if db.query(Exam.id).filter(Exam.slug == data.slug).first():
+ raise HTTPException(409, "That slug is already in use")
+ exam = Exam(slug=data.slug.strip().lower(), name=data.name.strip(),
+ sort_order=data.sort_order, is_active=data.is_active)
+ db.add(exam)
+ db.commit()
+ db.refresh(exam)
+ return {"id": exam.id, "slug": exam.slug, "name": exam.name}
diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py
index 769eafc..2c2dda0 100644
--- a/backend/app/routers/questions.py
+++ b/backend/app/routers/questions.py
@@ -10,7 +10,7 @@ from typing import Literal
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import StreamingResponse
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from sqlalchemy import cast, String, or_, select, text as sa_text, func
from sqlalchemy.orm import Session
@@ -23,9 +23,9 @@ from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.quiz import Quiz
from app.models.user import User
from app.models.favorite import Favorite
-from app.services.search_service import hybrid_question_ids
+from app.services.search_service import hybrid_ids, hybrid_question_ids
from app.services.quiz_builder import (bank_query, category_descendants, filtered_bank_query,
- CreateFromBankRequest, GenerateTestRequest, create_saved_test, generate_test)
+ CreateFromBankRequest, GenerateTestRequest, create_saved_test, exam_scope_predicate, generate_test)
from app.utils.auth import get_current_user, require_moderator
from app.utils.category_grants import (assert_can_manage_category, assert_can_manage_questions,
is_question_manager, manageable_categories, question_in_scope, require_question_manager)
@@ -34,6 +34,10 @@ router = APIRouter()
# Ranked retrieval is capped so a broad query cannot pull the whole bank.
MAX_SEARCH_RESULTS = 500
+# A matched test stays a study aid, not a dump of the bank.
+MAX_MATCHED_QUESTIONS = 30
+MAX_UPLOAD_BYTES = 10 * 1024 * 1024
+MAX_QUERY_CHARS = 6000
def parse_category_ids(value):
@@ -261,6 +265,9 @@ def get_question_bank(
):
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
query = bank_query(db, current_user)
+ exam_filter = exam_scope_predicate(db, current_user)
+ if exam_filter is not None:
+ query = query.filter(exam_filter)
if my_questions:
query = query.filter(Question.user_id == current_user.id)
@@ -530,6 +537,97 @@ def create_builder_quiz(data: GenerateTestRequest, db: Session = Depends(get_db)
return generate_test(db, current_user, data)
+class DescribeRequest(BaseModel):
+ """Free-text description of what the learner wants to study."""
+
+ text: str = Field(min_length=10, max_length=2000)
+ count: int = Field(default=20, ge=1, le=MAX_MATCHED_QUESTIONS)
+ mode: Literal["learning", "timed"] = "learning"
+ title: str | None = Field(default=None, max_length=200)
+
+
+def _test_from_matches(db, current_user, matched_ids, count, mode, title):
+ """Build a saved test from bank questions that already exist.
+
+ Deliberately a matcher, not a generator: these are the educator-reviewed
+ questions in the bank, ranked against the request. Nothing is invented.
+ """
+ if not matched_ids:
+ raise HTTPException(400, "No questions in the bank match that closely enough")
+ chosen = matched_ids[:count]
+ return create_saved_test(
+ db, current_user,
+ GenerateTestRequest(title=title.strip(), count=len(chosen), mode=mode,
+ time_limit_minutes=None, is_shared=False),
+ chosen,
+ )
+
+
+@router.post("/builder/describe")
+def build_test_from_description(
+ data: DescribeRequest,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Turn "what I want to study" into a test of matching bank questions."""
+ visible = {row[0] for row in bank_query(db, current_user).with_entities(Question.id).all()}
+ ranked, _ = hybrid_ids(db, data.text.strip(), "question", limit=MAX_SEARCH_RESULTS)
+ matched = [question_id for question_id in ranked if question_id in visible]
+ title = data.title or f"Study: {data.text.strip()[:60]}"
+ created = _test_from_matches(db, current_user, matched, data.count, data.mode, title)
+ return {**created, "matched": len(matched)}
+
+
+@router.post("/builder/from-upload")
+def build_test_from_upload(
+ file: UploadFile = File(...),
+ count: int = Form(20),
+ mode: str = Form("learning"),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Match an uploaded document against the bank and build a test from it.
+
+ The document is read in memory and never stored: it is a search query, not
+ a source of new questions, so there is nothing to keep or delete later.
+ """
+ if mode not in ("learning", "timed"):
+ raise HTTPException(400, "Mode must be learning or timed")
+ if not 1 <= count <= MAX_MATCHED_QUESTIONS:
+ raise HTTPException(400, f"Choose between 1 and {MAX_MATCHED_QUESTIONS} questions")
+
+ raw = file.file.read(MAX_UPLOAD_BYTES + 1)
+ if len(raw) > MAX_UPLOAD_BYTES:
+ raise HTTPException(413, f"Keep the file under {MAX_UPLOAD_BYTES // (1024 * 1024)} MB")
+ if not raw:
+ raise HTTPException(400, "That file is empty")
+
+ name = (file.filename or "").lower()
+ if name.endswith(".pdf"):
+ try:
+ import fitz
+
+ with fitz.open(stream=raw, filetype="pdf") as document:
+ text = " ".join(page.get_text() for page in document)[:MAX_QUERY_CHARS]
+ except Exception:
+ raise HTTPException(400, "That PDF could not be read")
+ else:
+ try:
+ text = raw.decode("utf-8", errors="ignore")[:MAX_QUERY_CHARS]
+ except Exception:
+ raise HTTPException(400, "Upload a PDF or a text file")
+
+ if len(text.strip()) < 40:
+ raise HTTPException(400, "That file has too little text to match against")
+
+ visible = {row[0] for row in bank_query(db, current_user).with_entities(Question.id).all()}
+ ranked, _ = hybrid_ids(db, text.strip(), "question", limit=MAX_SEARCH_RESULTS)
+ matched = [question_id for question_id in ranked if question_id in visible]
+ title = f"From {file.filename or 'upload'}"[:200]
+ created = _test_from_matches(db, current_user, matched, count, mode, title)
+ return {**created, "matched": len(matched)}
+
+
@router.post("/from-bank")
def create_quiz_from_bank(data: CreateFromBankRequest, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py
index 1a292be..12e0ec3 100644
--- a/backend/app/services/quiz_builder.py
+++ b/backend/app/services/quiz_builder.py
@@ -62,6 +62,26 @@ def bank_question_predicate(user):
)
+def exam_scope_predicate(db, user):
+ """Limit the bank to the learner's active exam, if they have chosen one.
+
+ No selection means the whole bank, so existing behaviour is unchanged until
+ someone picks an exam. A question with no exam links stays visible rather
+ than disappearing, since unlinked content is unclassified, not excluded.
+ """
+ from sqlalchemy import select as sa_select
+
+ from app.models.exam import QuestionExamLink
+
+ exam_id = getattr(user, "active_exam_id", None)
+ if not exam_id:
+ return None
+ in_exam = sa_select(QuestionExamLink.question_id).where(QuestionExamLink.exam_id == exam_id)
+ unlinked = ~sa_select(QuestionExamLink.question_id).where(
+ QuestionExamLink.question_id == Question.id).exists()
+ return Question.id.in_(in_exam) | unlinked
+
+
def bank_query(db, user):
return db.query(Question).filter(bank_question_predicate(user))
diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py
index ffa10f4..c3261aa 100644
--- a/backend/app/services/search_service.py
+++ b/backend/app/services/search_service.py
@@ -18,6 +18,7 @@ question a learner searching a concept wants.
import hashlib
import json
import logging
+import re
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
@@ -35,6 +36,38 @@ SEMANTIC_FLOOR = 0.45
QUERY_CACHE_TTL = 24 * 3600
+# Enough prose that it is a document, not a query.
+LONG_TEXT_CHARS = 300
+# Words carried by every clinical vignette, so useless for discriminating.
+STOPWORDS = {
+ "the", "and", "for", "with", "that", "this", "from", "have", "has", "had",
+ "are", "was", "were", "been", "being", "which", "their", "there", "would",
+ "could", "should", "about", "after", "before", "during", "into", "over",
+ "under", "than", "then", "them", "they", "you", "your", "his", "her",
+ "its", "not", "but", "all", "any", "one", "two", "who", "whom", "what",
+ "when", "where", "how", "why", "most", "more", "also", "other", "some",
+ "such", "only", "own", "same", "each", "both", "will", "can", "may",
+ "old", "year", "years", "month", "months", "day", "days", "patient",
+ "following", "next", "step", "best", "likely", "presents", "history",
+}
+
+
+def _query_terms(text: str, limit: int = 18) -> str:
+ """Reduce a document to its most distinctive terms, OR-joined.
+
+ A whole document handed to `websearch_to_tsquery` becomes one enormous
+ conjunction that matches nothing. The salient nouns, joined with OR, are
+ what actually discriminate between bank questions.
+ """
+ from collections import Counter
+
+ words = re.findall(r"[a-zA-Z][a-zA-Z-]{3,}", text.lower())
+ counts = Counter(word for word in words if word not in STOPWORDS)
+ if not counts:
+ return ""
+ return " or ".join(word for word, _ in counts.most_common(limit))
+
+
def _is_postgres(db: Session) -> bool:
return db.bind is not None and db.bind.dialect.name == "postgresql"
@@ -56,12 +89,21 @@ def _lexical_ranked(db: Session, query_text: str, pool: int, kind: str = "questi
as a sticky setting.
"""
table, columns = CORPORA[kind]
+ if len(query_text) > LONG_TEXT_CHARS:
+ query_text = _query_terms(query_text) or query_text[:200]
if not _is_postgres(db):
# SQLite (tests): substring matching keeps the fusion path exercised.
- where = " OR ".join(f"lower({column}) LIKE :like" for column in columns)
+ # Mirror the OR semantics of the Postgres path on any input length.
+ terms = [t for t in re.split(r"[^a-z0-9]+", query_text.strip('"').lower()) if len(t) > 3][:8]
+ clauses, params = [], {"pool": pool}
+ for index, term in enumerate(terms):
+ params[f"t{index}"] = f"%{term}%"
+ clauses.append(" OR ".join(f"lower({column}) LIKE :t{index}" for column in columns))
+ if not clauses:
+ return []
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()
+ f"SELECT id FROM {table} WHERE {' OR '.join(clauses)} ORDER BY id LIMIT :pool"
+ ), params).fetchall()
return [row[0] for row in rows]
rows = db.execute(sa_text(f"""
SELECT id FROM {table}
diff --git a/backend/tests/test_ai_mode_matching.py b/backend/tests/test_ai_mode_matching.py
new file mode 100644
index 0000000..41823df
--- /dev/null
+++ b/backend/tests/test_ai_mode_matching.py
@@ -0,0 +1,118 @@
+"""AI-mode entry points build tests from existing bank questions, never new ones.
+
+Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
+"""
+import os
+os.environ["DATABASE_URL"] = "sqlite:///:memory:"
+
+import io
+import unittest
+from unittest.mock import patch
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+from sqlalchemy.pool import StaticPool
+
+from app.database import Base, get_db
+from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
+from app.models.question import Question
+from app.models.quiz import Quiz
+from app.models.user import User
+from app.routers import questions
+from app.utils.auth import get_current_user
+
+
+class AiModeMatchingTests(unittest.TestCase):
+ def setUp(self):
+ self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+ Base.metadata.create_all(self.engine)
+ self.db = Session(self.engine)
+
+ self.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
+ self.other = User(id=2, name="Other", email="other@example.test", hashed_password="unused")
+ self.db.add_all([self.user, self.other])
+ for qid, text, owner, shared in [
+ (1, "A child with fever and a seizure", 1, 1),
+ (2, "An infant with jaundice", 1, 1),
+ (3, "A private question of someone else", 2, 0),
+ ]:
+ self.db.add(Question(id=qid, user_id=owner, is_shared=shared, question_text=text,
+ question_type="mcq", options=["yes", "no"], correct_answer="yes"))
+ self.db.commit()
+
+ app = FastAPI()
+ app.include_router(questions.router, prefix="/questions")
+ app.dependency_overrides[get_db] = lambda: self.db
+ app.dependency_overrides[get_current_user] = lambda: self.user
+ self.client = TestClient(app)
+
+ def tearDown(self):
+ self.client.close()
+ self.db.close()
+ self.engine.dispose()
+
+ def describe(self, **body):
+ # Wording overlaps the stems literally: the SQLite fallback has no
+ # stemming, so "seizures" would not reach "seizure" as Postgres would.
+ return self.client.post("/questions/builder/describe",
+ json={"text": "I want to study fever and seizure in a child", **body})
+
+ def test_a_description_builds_a_test_from_existing_bank_questions(self):
+ before = self.db.query(Question).count()
+ response = self.describe(count=5)
+ self.assertEqual(response.status_code, 200, response.text)
+ body = response.json()
+ self.assertGreater(body["questions_count"], 0)
+ # Matching must never invent questions.
+ self.assertEqual(self.db.query(Question).count(), before)
+ quiz = self.db.get(Quiz, body["id"])
+ self.assertIsNotNone(quiz)
+
+ def test_matches_exclude_questions_the_user_cannot_see(self):
+ with patch.object(questions, "hybrid_ids", return_value=([3, 1], set())):
+ response = self.describe(count=5)
+ self.assertEqual(response.status_code, 200, response.text)
+ # Question 3 belongs to someone else and is private, so only 1 survives.
+ self.assertEqual(response.json()["matched"], 1)
+
+ def test_no_match_is_reported_rather_than_returning_an_empty_test(self):
+ with patch.object(questions, "hybrid_ids", return_value=([], set())):
+ response = self.describe()
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("match", response.json()["detail"].lower())
+
+ def test_description_and_count_are_bounded(self):
+ self.assertEqual(self.describe(count=0).status_code, 422)
+ self.assertEqual(self.describe(count=999).status_code, 422)
+ self.assertEqual(self.client.post("/questions/builder/describe",
+ json={"text": "short"}).status_code, 422)
+
+ def upload(self, content=b"febrile seizure in a toddler with fever, and jaundice in a newborn infant",
+ filename="notes.txt", **data):
+ return self.client.post("/questions/builder/from-upload",
+ files={"file": (filename, io.BytesIO(content), "text/plain")},
+ data={"count": "5", "mode": "learning", **data})
+
+ def test_an_upload_is_matched_against_the_bank_and_not_stored(self):
+ response = self.upload()
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertGreater(response.json()["questions_count"], 0)
+ # The document is a query, so nothing new is persisted.
+ self.assertEqual(self.db.query(Question).count(), 3)
+
+ def test_upload_limits_are_enforced(self):
+ oversized = b"x" * (questions.MAX_UPLOAD_BYTES + 10)
+ self.assertEqual(self.upload(content=oversized).status_code, 413)
+ self.assertEqual(self.upload(content=b"").status_code, 400)
+ self.assertEqual(self.upload(content=b"too short").status_code, 400)
+ self.assertEqual(self.upload(count="99").status_code, 400)
+ self.assertEqual(self.upload(mode="nonsense").status_code, 400)
+
+ def test_a_matched_test_is_capped(self):
+ self.assertLessEqual(questions.MAX_MATCHED_QUESTIONS, 30)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_exams.py b/backend/tests/test_exams.py
new file mode 100644
index 0000000..88d7f2c
--- /dev/null
+++ b/backend/tests/test_exams.py
@@ -0,0 +1,106 @@
+"""Exams are selectable data, and the choice scopes the bank.
+
+Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
+"""
+import os
+os.environ["DATABASE_URL"] = "sqlite:///:memory:"
+
+import unittest
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+from sqlalchemy.pool import StaticPool
+
+from app.database import Base, get_db
+from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
+from app.models.exam import Exam, QuestionExamLink
+from app.models.question import Question
+from app.models.user import User
+from app.routers import exams, questions
+from app.utils.auth import get_current_user
+
+
+class ExamTests(unittest.TestCase):
+ def setUp(self):
+ self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+ Base.metadata.create_all(self.engine)
+ self.db = Session(self.engine)
+
+ self.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
+ self.mod = User(id=2, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
+ self.db.add_all([self.user, self.mod])
+ self.db.add_all([
+ Exam(id=1, slug="pediatrics-boards", name="Pediatrics Boards", sort_order=10),
+ Exam(id=2, slug="usmle-step-2-ck", name="USMLE Step 2 CK", sort_order=20),
+ Exam(id=3, slug="retired", name="Retired exam", sort_order=30, is_active=0),
+ ])
+ self.db.flush()
+ for qid in (1, 2, 3):
+ self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Question {qid}",
+ question_type="mcq", options=["yes", "no"], correct_answer="yes"))
+ self.db.flush()
+ # 1 → boards, 2 → step 2, 3 → unlinked (unclassified, not excluded)
+ self.db.add_all([QuestionExamLink(question_id=1, exam_id=1),
+ QuestionExamLink(question_id=2, exam_id=2)])
+ self.db.commit()
+
+ app = FastAPI()
+ app.include_router(exams.router, prefix="/exams")
+ app.include_router(questions.router, prefix="/questions")
+ app.dependency_overrides[get_db] = lambda: self.db
+ app.dependency_overrides[get_current_user] = lambda: self.user
+ self.client = TestClient(app)
+
+ def tearDown(self):
+ self.client.close()
+ self.db.close()
+ self.engine.dispose()
+
+ def bank_ids(self):
+ return {q["id"] for q in self.client.get("/questions/bank").json()["questions"]}
+
+ def test_lists_active_exams_with_their_question_counts(self):
+ body = self.client.get("/exams/").json()
+ self.assertEqual([(e["name"], e["question_count"]) for e in body["exams"]],
+ [("Pediatrics Boards", 1), ("USMLE Step 2 CK", 1)])
+ self.assertNotIn("Retired exam", [e["name"] for e in body["exams"]])
+ self.assertIsNone(body["active_exam_id"])
+
+ def test_the_choice_is_stored_on_the_user(self):
+ response = self.client.put("/exams/active", json={"exam_id": 2})
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(self.db.get(User, 1).active_exam_id, 2)
+ self.assertEqual(self.client.get("/exams/").json()["active_exam_id"], 2)
+
+ # And can be cleared again.
+ self.client.put("/exams/active", json={"exam_id": None})
+ self.assertIsNone(self.db.get(User, 1).active_exam_id)
+
+ def test_an_unknown_or_inactive_exam_is_refused(self):
+ self.assertEqual(self.client.put("/exams/active", json={"exam_id": 999}).status_code, 404)
+ self.assertEqual(self.client.put("/exams/active", json={"exam_id": 3}).status_code, 404)
+
+ def test_the_active_exam_scopes_the_bank(self):
+ self.assertEqual(self.bank_ids(), {1, 2, 3}) # no selection yet: everything
+
+ self.user.active_exam_id = 1
+ self.db.commit()
+ # Question 2 belongs to another exam; 3 is unlinked so stays visible.
+ self.assertEqual(self.bank_ids(), {1, 3})
+
+ self.user.active_exam_id = 2
+ self.db.commit()
+ self.assertEqual(self.bank_ids(), {2, 3})
+
+ def test_creating_an_exam_is_moderator_only(self):
+ body = {"name": "USMLE Step 1", "slug": "usmle-step-1"}
+ self.assertEqual(self.client.post("/exams/", json=body).status_code, 403)
+ self.user = self.mod
+ self.assertEqual(self.client.post("/exams/", json=body).status_code, 201)
+ self.assertEqual(self.client.post("/exams/", json=body).status_code, 409)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/components/ContinueStudy.css b/frontend/src/components/ContinueStudy.css
new file mode 100644
index 0000000..341e718
--- /dev/null
+++ b/frontend/src/components/ContinueStudy.css
@@ -0,0 +1,36 @@
+/* "Continue your study" — sessions in flight and recent reading, side by side. */
+
+.cs-panel { margin-bottom: 20px; }
+.cs-heading { margin: 0 0 12px; font-size: 1.15rem; font-weight: 650; }
+.cs-exam { color: var(--primary); text-decoration: underline; text-underline-offset: 3px; }
+
+.cs-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; align-items: start; }
+.cs-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
+.cs-card-head {
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
+ padding: 13px 16px; border-bottom: 1px solid var(--border);
+}
+.cs-card-head h3 { margin: 0; font-size: .95rem; font-weight: 650; }
+.cs-card-head a { font-size: .82rem; color: var(--primary); text-decoration: none; }
+.cs-card-head a:hover { text-decoration: underline; }
+
+.cs-list { list-style: none; margin: 0; padding: 0; }
+.cs-list > li { padding: 13px 16px; border-bottom: 1px solid var(--border); }
+.cs-list > li:last-child { border-bottom: 0; }
+
+.cs-session-title { font-size: .88rem; margin-bottom: 9px; overflow-wrap: anywhere; }
+.cs-session-title strong { font-weight: 650; }
+.cs-session-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.cs-track { flex: 1; min-width: 90px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
+.cs-fill { display: block; height: 100%; background: var(--primary); }
+.cs-fill.is-done { background: var(--correct-fg); }
+.cs-count { font-size: .8rem; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
+
+.cs-articles a { display: flex; align-items: center; gap: 9px; font-size: .88rem; color: var(--text); text-decoration: none; }
+.cs-articles a:hover { color: var(--primary); }
+.cs-doc { color: var(--text-subtle); }
+
+@media (max-width: 640px) {
+ .cs-grid { grid-template-columns: 1fr; }
+ .cs-session-row .btn { flex: 1; }
+}
diff --git a/frontend/src/components/ContinueStudy.jsx b/frontend/src/components/ContinueStudy.jsx
new file mode 100644
index 0000000..a92625d
--- /dev/null
+++ b/frontend/src/components/ContinueStudy.jsx
@@ -0,0 +1,94 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api/client'
+import './ContinueStudy.css'
+
+/**
+ * Pick-up-where-you-left-off panel: the sessions in flight and the articles
+ * most recently opened, so returning to study is one click rather than a hunt.
+ */
+export default function ContinueStudy() {
+ const [sessions, setSessions] = useState([])
+ const [articles, setArticles] = useState([])
+ const [examName, setExamName] = useState('')
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let active = true
+ Promise.all([
+ api.get('/quizzes/sessions').catch(() => ({ data: [] })),
+ api.get('/articles/recent', { params: { limit: 5 } }).catch(() => ({ data: [] })),
+ api.get('/exams/').catch(() => ({ data: { exams: [], active_exam_id: null } })),
+ ]).then(([sessionRes, articleRes, examRes]) => {
+ if (!active) return
+ const rows = Array.isArray(sessionRes.data) ? sessionRes.data : []
+ // In-flight first, then anything recently finished.
+ setSessions(rows.filter(r => r.state !== 'not_started').slice(0, 3))
+ setArticles(Array.isArray(articleRes.data) ? articleRes.data : [])
+ const exams = examRes.data.exams || []
+ setExamName(exams.find(e => e.id === examRes.data.active_exam_id)?.name || '')
+ }).finally(() => { if (active) setLoading(false) })
+ return () => { active = false }
+ }, [])
+
+ if (loading || (sessions.length === 0 && articles.length === 0)) return null
+
+ return (
+
+ Continue your study{examName && <> for {examName}>}
+
+
+ Latest question sessions
+ See all
+
+ {sessions.map(row => {
+ const total = row.total || row.questions_count || 0
+ const answered = Math.min(row.answered || 0, total)
+ const done = row.state === 'completed'
+ return (
+
+ Recently viewed articles
+ {articles.map(article => (
+
+