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

+ +
+ {sessions.length > 0 && ( +
+
+

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 ( +
  • +
    + {row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'} {row.title} +
    +
    + + + + {answered}/{total} + + {done ? 'Review' : 'Resume'} + +
    +
  • + ) + })} +
+
+ )} + + {articles.length > 0 && ( +
+

Recently viewed articles

+
    + {articles.map(article => ( +
  • + + {article.title} + +
  • + ))} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/ExamSwitcher.jsx b/frontend/src/components/ExamSwitcher.jsx new file mode 100644 index 0000000..5d655dd --- /dev/null +++ b/frontend/src/components/ExamSwitcher.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 5383f6d..76a2e48 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Link, useLocation } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' +import ExamSwitcher from './ExamSwitcher' function JobsBadge({ jobs }) { const [open, setOpen] = useState(false) @@ -114,6 +115,7 @@ export default function Navbar({ onSignIn, onRegister }) {
setMenuOpen(false)}>🏥 PedsHub + {user && window.location.reload()} />} {user ? ( <> diff --git a/frontend/src/index.css b/frontend/src/index.css index 368d935..2096835 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -769,3 +769,12 @@ body { background: var(--input-bg); color: var(--text-muted); } .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; } } diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 859be95..ffcb7c8 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import ContinueStudy from '../components/ContinueStudy' import { Link } from 'react-router-dom' import api from '../api/client' import LineChart from '../components/LineChart' @@ -59,6 +60,7 @@ export default function DashboardPage() { return (
+

{greetingText}