feat: exams as real data, AI-mode matching, and a continue-study panel

Exams (migration v4b5c6d7e8f9)
"Pediatrics Boards" was a hardcoded checkbox that filtered nothing. Exams are now
rows: Pediatrics Boards and USMLE Step 2 CK ship seeded, and everything already
in the bank is linked to the boards. Membership is a link table, not a column,
because one paediatric cardiology question can count towards several exams.

The learner's choice lives on `users.active_exam_id`, so it follows them between
devices instead of sitting in one browser's storage. Choosing an exam scopes the
bank; a question with no exam links stays visible, since unlinked content is
unclassified rather than excluded. A switcher sits in the navbar.

AI mode — matching, never generating
Both entry points build a test from the educator-reviewed questions that already
exist, ranked against the request. Nothing is invented:
- POST /questions/builder/describe turns "what I want to study" into a test.
- POST /questions/builder/from-upload matches a document against the bank. The
  file is read in memory and never stored — it is a search query, not a source
  of questions, so there is nothing to retain or expire. 10 MB cap, 30 questions.

Handing a whole document to `websearch_to_tsquery` builds one enormous
conjunction that matches nothing, so text over 300 characters is reduced to its
most distinctive terms, OR-joined, before it reaches the lexical ranker.

Continue your study (migration w5c6d7e8f9a0)
A dashboard panel with the sessions in flight and the articles most recently
opened. `article_views` records one row per learner and article, written best
effort so a reading page never fails because a bookkeeping write did.

Tests: 5 new exam tests (active exams and counts, choice persisted and cleared,
unknown/inactive refused, bank scoping including unlinked questions, moderator-only
creation) and 7 for AI-mode matching (no questions created, invisible questions
excluded, no-match reported rather than an empty test, upload limits enforced).
Full suites green: 113 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
Daniel 2026-09-10 02:08:38 +02:00
parent 25109d756d
commit 48f3ded222
19 changed files with 830 additions and 9 deletions

View file

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

View file

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

View file

@ -10,7 +10,7 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else # Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal 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.routers import study_tools, uploads, articles, comments, share, collections
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler 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(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) 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(comments.router, prefix="/api/comments", tags=["comments"])
app.include_router(share.router, prefix="/api/share", tags=["share"]) app.include_router(share.router, prefix="/api/share", tags=["share"])
app.include_router(collections.router, prefix="/api/collections", tags=["collections"]) app.include_router(collections.router, prefix="/api/collections", tags=["collections"])

View file

@ -44,3 +44,15 @@ class QuestionArticleLink(Base):
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
article = relationship("Article", back_populates="links") 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)

View file

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

View file

@ -1,10 +1,11 @@
from datetime import datetime 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.orm import relationship
from sqlalchemy.sql import false as sa_false from sqlalchemy.sql import false as sa_false
from app.database import Base 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): class User(Base):
@ -15,6 +16,8 @@ class User(Base):
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
role = Column(String, default="user") # admin, moderator, user 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 is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false()) reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false())
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)

View file

@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.services.search_service import hybrid_ids from app.services.search_service import hybrid_ids
from app.services import embedding_service 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.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question from app.models.question import Question
from app.models.section import Section 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 from app.utils.auth import get_current_user, require_moderator
import logging import logging
from datetime import datetime
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) 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: def _reembed(db, article) -> None:
"""Embed on write. A failure is not fatal — the retry task sweeps it up.""" """Embed on write. A failure is not fatal — the retry task sweeps it up."""
try: try:
@ -225,6 +244,26 @@ def articles_for_question(
return result 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}") @router.get("/{article_id}")
def get_article( def get_article(
article_id: int, article_id: int,
@ -236,6 +275,7 @@ def get_article(
raise HTTPException(404, "Article not found") raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id: if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found") raise HTTPException(404, "Article not found")
_record_view(db, current_user, article)
data = _article_json(article) data = _article_json(article)
categories = db.query(QuestionCategory).all() categories = db.query(QuestionCategory).all()
data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else [] data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else []

View file

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

View file

@ -10,7 +10,7 @@ from typing import Literal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import StreamingResponse 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 import cast, String, or_, select, text as sa_text, func
from sqlalchemy.orm import Session 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.quiz import Quiz
from app.models.user import User from app.models.user import User
from app.models.favorite import Favorite 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, 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.auth import get_current_user, require_moderator
from app.utils.category_grants import (assert_can_manage_category, assert_can_manage_questions, 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) 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. # Ranked retrieval is capped so a broad query cannot pull the whole bank.
MAX_SEARCH_RESULTS = 500 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): 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.""" """List all questions across all quizzes. Supports keyword filter and quiz filter."""
query = bank_query(db, current_user) 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: if my_questions:
query = query.filter(Question.user_id == current_user.id) 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) 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") @router.post("/from-bank")
def create_quiz_from_bank(data: CreateFromBankRequest, db: Session = Depends(get_db), def create_quiz_from_bank(data: CreateFromBankRequest, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)): current_user: User = Depends(get_current_user)):

View file

@ -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): def bank_query(db, user):
return db.query(Question).filter(bank_question_predicate(user)) return db.query(Question).filter(bank_question_predicate(user))

View file

@ -18,6 +18,7 @@ question a learner searching a concept wants.
import hashlib import hashlib
import json import json
import logging import logging
import re
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -35,6 +36,38 @@ SEMANTIC_FLOOR = 0.45
QUERY_CACHE_TTL = 24 * 3600 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: def _is_postgres(db: Session) -> bool:
return db.bind is not None and db.bind.dialect.name == "postgresql" return db.bind is not None and db.bind.dialect.name == "postgresql"
@ -56,12 +89,21 @@ def _lexical_ranked(db: Session, query_text: str, pool: int, kind: str = "questi
as a sticky setting. as a sticky setting.
""" """
table, columns = CORPORA[kind] 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): if not _is_postgres(db):
# SQLite (tests): substring matching keeps the fusion path exercised. # 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( rows = db.execute(sa_text(
f"SELECT id FROM {table} WHERE {where} ORDER BY id LIMIT :pool" f"SELECT id FROM {table} WHERE {' OR '.join(clauses)} ORDER BY id LIMIT :pool"
), {"like": f"%{query_text.strip(chr(34)).lower()}%", "pool": pool}).fetchall() ), params).fetchall()
return [row[0] for row in rows] return [row[0] for row in rows]
rows = db.execute(sa_text(f""" rows = db.execute(sa_text(f"""
SELECT id FROM {table} SELECT id FROM {table}

View file

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

106
backend/tests/test_exams.py Normal file
View file

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

View file

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

View file

@ -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 (
<section className="cs-panel">
<h2 className="cs-heading">
Continue your study{examName && <> for <span className="cs-exam">{examName}</span></>}
</h2>
<div className="cs-grid">
{sessions.length > 0 && (
<div className="cs-card">
<div className="cs-card-head">
<h3>Latest question sessions</h3>
<Link to="/quizzes">See all</Link>
</div>
<ul className="cs-list">
{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 (
<li key={row.quiz_id}>
<div className="cs-session-title">
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
</div>
<div className="cs-session-row">
<span className="cs-track">
<span className={`cs-fill${done ? ' is-done' : ''}`}
style={{ width: total ? `${(answered / total) * 100}%` : 0 }} />
</span>
<span className="cs-count">{answered}/{total}</span>
<Link className="btn btn-secondary btn-sm"
to={done ? `/results/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
{done ? 'Review' : 'Resume'}
</Link>
</div>
</li>
)
})}
</ul>
</div>
)}
{articles.length > 0 && (
<div className="cs-card">
<div className="cs-card-head"><h3>Recently viewed articles</h3></div>
<ul className="cs-list cs-articles">
{articles.map(article => (
<li key={article.id}>
<Link to={`/articles/${article.id}`}>
<span className="cs-doc" aria-hidden="true"></span> {article.title}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</section>
)
}

View file

@ -0,0 +1,44 @@
import { useState, useEffect } from 'react'
import api from '../api/client'
/**
* Which exam the learner is studying for. The choice is stored on the user, so
* it follows them between devices rather than living in this browser.
*/
export default function ExamSwitcher({ onChange }) {
const [exams, setExams] = useState([])
const [activeId, setActiveId] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
api.get('/exams/')
.then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) })
.catch(() => setExams([]))
}, [])
const choose = async (value) => {
const examId = value === '' ? null : Number(value)
setBusy(true)
try {
await api.put('/exams/active', { exam_id: examId })
setActiveId(examId)
onChange?.(examId)
} catch { /* leave the previous selection showing */ }
finally { setBusy(false) }
}
if (exams.length === 0) return null
return (
<label className="exam-switcher">
<span className="sr-only">Studying for</span>
<select value={activeId ?? ''} disabled={busy} aria-label="Studying for"
onChange={e => choose(e.target.value)}>
<option value="">All content</option>
{exams.map(exam => (
<option key={exam.id} value={exam.id}>{exam.name} ({exam.question_count})</option>
))}
</select>
</label>
)
}

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import ExamSwitcher from './ExamSwitcher'
function JobsBadge({ jobs }) { function JobsBadge({ jobs }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -114,6 +115,7 @@ export default function Navbar({ onSignIn, onRegister }) {
<div className="navbar"> <div className="navbar">
<div className="container navbar-inner"> <div className="container navbar-inner">
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link> <Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
{user && <ExamSwitcher onChange={() => window.location.reload()} />}
{user ? ( {user ? (
<> <>

View file

@ -769,3 +769,12 @@ body {
background: var(--input-bg); color: var(--text-muted); background: var(--input-bg); color: var(--text-muted);
} }
.deck-category select:hover { color: var(--text); } .deck-category select:hover { color: var(--text); }
/* Exam switcher — the study objective, above systems and disciplines. */
.exam-switcher select {
background: rgba(255,255,255,.12); color: var(--navbar-fg);
border: 1px solid rgba(255,255,255,.22); border-radius: 8px;
padding: 5px 9px; font-size: .8rem; font-weight: 600; max-width: 210px;
}
.exam-switcher select option { color: #1e293b; background: #fff; }
@media (max-width: 720px) { .exam-switcher select { max-width: 140px; font-size: .74rem; } }

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import ContinueStudy from '../components/ContinueStudy'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import LineChart from '../components/LineChart' import LineChart from '../components/LineChart'
@ -59,6 +60,7 @@ export default function DashboardPage() {
return ( return (
<div> <div>
<ContinueStudy />
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
</div> </div>