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:
parent
25109d756d
commit
48f3ded222
19 changed files with 830 additions and 9 deletions
59
backend/alembic/versions/v4b5c6d7e8f9_exams.py
Normal file
59
backend/alembic/versions/v4b5c6d7e8f9_exams.py
Normal 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")
|
||||
28
backend/alembic/versions/w5c6d7e8f9a0_article_views.py
Normal file
28
backend/alembic/versions/w5c6d7e8f9a0_article_views.py
Normal 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")
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
32
backend/app/models/exam.py
Normal file
32
backend/app/models/exam.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
75
backend/app/routers/exams.py
Normal file
75
backend/app/routers/exams.py
Normal 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}
|
||||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
118
backend/tests/test_ai_mode_matching.py
Normal file
118
backend/tests/test_ai_mode_matching.py
Normal 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
106
backend/tests/test_exams.py
Normal 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()
|
||||
36
frontend/src/components/ContinueStudy.css
Normal file
36
frontend/src/components/ContinueStudy.css
Normal 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; }
|
||||
}
|
||||
94
frontend/src/components/ContinueStudy.jsx
Normal file
94
frontend/src/components/ContinueStudy.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/ExamSwitcher.jsx
Normal file
44
frontend/src/components/ExamSwitcher.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) {
|
|||
<div className="navbar">
|
||||
<div className="container navbar-inner">
|
||||
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
|
||||
{user && <ExamSwitcher onChange={() => window.location.reload()} />}
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<ContinueStudy />
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue