pdf-quiz-generator/backend/alembic/versions/v4b5c6d7e8f9_exams.py
Daniel 48f3ded222 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
2026-09-10 02:08:38 +02:00

59 lines
2.1 KiB
Python

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