diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index bc609bc..e1287a0 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -15,7 +15,7 @@ class QuizCreate(BaseModel): extraction_mode: str = "standard" # standard — current working mode (inline Correct Answer / Preferred Response) # questions_only — extract Q+options only, no answers (admin fills later) - # two_step — separate answer key section (PREP 2013 style) + # two_step — separate answer key section (2013-style layout) # regex — AI analyses format then extracts answer key with regex # ai_decide — AI reads a sample and decides which approach to use diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 3a119eb..13c6166 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -16,7 +16,7 @@ def _proxy_model(model_id: str) -> str: return f"openai/{model_id}" return model_id -EXTRACTION_PROMPT = """You are extracting questions from a PREP (Pediatric Review and Education Program) exam PDF. +EXTRACTION_PROMPT = """You are extracting questions from a pediatric board review exam PDF. These PDFs follow a strict format: 1. A numbered question with a clinical vignette (patient scenario) @@ -70,7 +70,7 @@ CRITICAL RULES — follow exactly: Content from page(s) {page_info}: {content}""" -ANSWER_KEY_PROMPT = """Extract the answer key from this PREP exam content. +ANSWER_KEY_PROMPT = """Extract the answer key from this board review exam content. The answer key lists items with their correct answer letters, like: "Item 193 Preferred Response: D" @@ -217,13 +217,18 @@ def extract_questions( def _call_model(prompt: str, model_id: str | None, api_key: str | None, - timeout: int = 180) -> str: + timeout: int = 180, max_tokens: int | None = None) -> str: """Call the configured LLM and return raw text response. The timeout is not optional in practice: every other call in this module has one, and this one did not. A stalled connection to the proxy hung the caller for good — which an interactive request survives by the user giving up, and an unattended run of several hundred topics does not. + + `max_tokens` is worth setting for the same reason. Left unset, the request + reserves the model's full output ceiling — 64k on the current default — and + a provider that bills against reserved capacity refuses the whole call when + the balance is below that, however short the answer would actually be. """ use_model = _proxy_model(model_id or settings.LITELLM_MODEL) use_key = api_key or settings.LITELLM_API_KEY @@ -233,6 +238,8 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None, "temperature": 0.1, "timeout": timeout, } + if max_tokens: + kwargs["max_tokens"] = max_tokens if use_key: kwargs["api_key"] = use_key if settings.LITELLM_API_BASE: diff --git a/backend/app/services/article_writer.py b/backend/app/services/article_writer.py index a966bc6..d73c966 100644 --- a/backend/app/services/article_writer.py +++ b/backend/app/services/article_writer.py @@ -36,6 +36,9 @@ MIN_SOURCE_CHARS = 3000 # Generous for a long article, short enough that a stalled call is noticed in # minutes rather than discovered hours later with nothing written since. WRITE_TIMEOUT = 150 +# Three views of one topic, the long one about 550 words. Four thousand tokens is +# comfortable for that and keeps each request small enough to be affordable. +WRITE_MAX_TOKENS = 4000 # Long enough to be worth reading, short enough that nobody skims past the point. LONG_WORDS = 550 SHELF = "Pediatrics" @@ -152,7 +155,8 @@ def write_article(db: Session, topic: str, category_id: int | None = None, model_id, api_key = get_model_for_task(db, "extraction") # One topic must not be able to stall a run of five hundred. - raw = _call_model(_prompt(topic, passages), model_id, api_key, timeout=WRITE_TIMEOUT) + raw = _call_model(_prompt(topic, passages), model_id, api_key, + timeout=WRITE_TIMEOUT, max_tokens=WRITE_MAX_TOKENS) text = raw.strip() if text.startswith("```"): text = re.sub(r"^```[a-z]*\n?|```$", "", text).strip() diff --git a/backend/app/services/extraction_modes.py b/backend/app/services/extraction_modes.py index 4bfd956..6525387 100644 --- a/backend/app/services/extraction_modes.py +++ b/backend/app/services/extraction_modes.py @@ -3,7 +3,7 @@ Modes ----- questions_only Extract Q+options with no answers. User fills answers later via QuizEditPage. -two_step Separate answer key section (PREP 2013): Phase 1 = questions, Phase 2 = key, Phase 3 = match. +two_step Separate answer key section (2013-style): Phase 1 = questions, Phase 2 = key, Phase 3 = match. regex AI generates a regex pattern for the document's answer format, then we apply it. ai_decide AI samples the document and picks standard / two_step / questions_only. generate AI reads plain text/study material and creates MCQ questions from scratch. @@ -25,7 +25,7 @@ def _normalize(text: str) -> str: # ─── QUESTIONS ONLY ────────────────────────────────────────────────────────── -QUESTIONS_ONLY_PROMPT = """Extract every question from this PREP exam content. +QUESTIONS_ONLY_PROMPT = """Extract every question from this board review exam content. Do NOT look for correct answers — we only need the question text and answer options. Return ONLY JSON: @@ -195,7 +195,7 @@ def extract_two_step( ) -> tuple[list[dict], list[str]]: """ Two-phase extraction for PDFs with questions in the first half - and a separate answer key section (e.g. PREP 2013 "Preferred Response:"). + and a separate answer key section (e.g. a 2013-style "Preferred Response:"). Returns (valid_questions, skipped_list). Raises ValueError if answer section not found or no questions matched. @@ -291,7 +291,7 @@ def extract_two_step( # ─── REGEX MODE ────────────────────────────────────────────────────────────── -REGEX_ANALYSIS_PROMPT = """Look at this PREP exam PDF content and identify the pattern used to mark correct answers. +REGEX_ANALYSIS_PROMPT = """Look at this board review exam PDF content and identify the pattern used to mark correct answers. Describe: 1. The exact text pattern before the correct answer letter (e.g. "Correct Answer:" or "Preferred Response:") diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py index 973d2ae..521087a 100644 --- a/backend/app/services/pdf_service.py +++ b/backend/app/services/pdf_service.py @@ -40,9 +40,9 @@ def extract_text_for_range(file_path: str, start: int, end: int) -> str: # MD5 hashes of known repeated branding images (logos, headers) to skip during extraction. -# These appear on every page of PREP PDFs and are not clinical images. +# These appear on every page of the source PDFs and are not clinical images. _SKIP_IMAGE_HASHES = { - "f48b094ec260f0aa8d7c52bc3cf562e4", # AAP logo (34300 bytes, appears 869 times across PREP PDFs) + "f48b094ec260f0aa8d7c52bc3cf562e4", # AAP logo (34300 bytes, appears 869 times across the source PDFs) "82c449d72791fe181fc9964bb8efad0f", # Sepsis document header/logo (20397 bytes, repeated per page) } diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index ce43155..c38ce17 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -29,7 +29,7 @@ def _push_step(r, job_id: str, step: str, message: str): def _normalize_ocr(text: str) -> str: - """Fix common OCR artifacts in PREP PDFs.""" + """Fix common OCR artifacts in the source PDFs.""" return (text .replace("Pref erred", "Preferred") .replace("Pre ferred", "Preferred") diff --git a/backend/scripts/convert_tags_to_categories.py b/backend/scripts/convert_tags_to_categories.py index 37593bf..1c8588a 100644 --- a/backend/scripts/convert_tags_to_categories.py +++ b/backend/scripts/convert_tags_to_categories.py @@ -187,7 +187,7 @@ def main(): systems = specific else: # Untagged and Pediatrics-only questions fall back to General Pediatrics; - # PREP categories are being retired so nothing is left dangling. + # The source-set categories are being retired so nothing is left dangling. systems = ["General Pediatrics"] if not (subjects.get(qid) or diseases.get(qid)): skipped += 1 @@ -208,10 +208,10 @@ def main(): for cid in sorted(extras - existing): db.add(QuestionCategoryLink(question_id=qid, category_id=cid)) links_added += 1 - # PREP provenance becomes a keyword tag; the question categories are retired. - prep_categories = db.query(QuestionCategory).filter(QuestionCategory.name.ilike("prep %")).all() - prep_tagged = 0 - for category in prep_categories: + # Provenance becomes a keyword tag; the question categories are retired. + source_categories = db.query(QuestionCategory).filter(QuestionCategory.name.ilike("prep %")).all() + source_tagged = 0 + for category in source_categories: linked = {row[0] for row in db.query(QuestionCategoryLink.question_id).filter_by(category_id=category.id).all()} linked |= {row[0] for row in db.query(Question.id).filter(Question.question_category_id == category.id).all()} linked.discard(None) @@ -225,11 +225,11 @@ def main(): for qid in linked: db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (:q, :t) " "ON CONFLICT DO NOTHING"), {"q": qid, "t": tag_id}) - prep_tagged += len(linked) + source_tagged += len(linked) db.delete(category) db.commit() print(f"Reassigned primary for {changed} questions; added {links_added} extra links; " - f"tagged {prep_tagged} question-links across {len(prep_categories)} retired PREP categories; " + f"tagged {source_tagged} question-links across {len(source_categories)} retired source categories; " f"skipped {skipped}; {db.query(QuestionCategory).count()} categories total.") finally: db.close() diff --git a/backend/scripts/fix_lab_formatting.py b/backend/scripts/fix_lab_formatting.py index eb4a9b8..bb6d36c 100644 --- a/backend/scripts/fix_lab_formatting.py +++ b/backend/scripts/fix_lab_formatting.py @@ -1,6 +1,6 @@ """Repair OCR-damaged units and turn inline lab panels into markdown tables. -The PREP PDFs were scanned, so the extracted stems carry two separate injuries. +The source PDFs were scanned, so the extracted stems carry two separate injuries. 1. Unit corruption. The scanner confuses letter pairs that share a shape — "m" reads as "rn" or "in", "µ" as "p" or "4" — and superscripts are lost diff --git a/backend/scripts/rename_study_plans.py b/backend/scripts/rename_study_plans.py new file mode 100644 index 0000000..e2bdd49 --- /dev/null +++ b/backend/scripts/rename_study_plans.py @@ -0,0 +1,108 @@ +"""Rename the study plans away from the vendor's programme name. + +"PREP" is the American Academy of Pediatrics' trademark for its own product. +The plans here are our own sets of questions grouped by year, so they get names +that describe what they are: "Board Review 2021", and "Mixed Review" for the +plan that draws from every year at once. + +Slugs change with them, which is safe because a study plan is reached by id and +the slug is not a public address. Quizzes a learner already generated from a +block are renamed too, so a title in their history matches the plan it came from +rather than referring to something that no longer exists. So are the year tags, +which appear in the question bank's filters and are as visible as the plans. + + docker compose exec backend python -m scripts.rename_study_plans + docker compose exec backend python -m scripts.rename_study_plans --apply +""" +import re +import sys + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal + +YEAR_NAME = "Board Review {year}" +YEAR_SLUG = "board-review-{year}" +MIXED_NAME = "Mixed Review" +MIXED_SLUG = "mixed-review" + + +def planned(db): + """(id, old name, new name, old slug, new slug) for everything to rename.""" + rows = db.execute(sa_text( + "SELECT id, slug, name, kind FROM study_plans ORDER BY sort_order, name")).fetchall() + changes = [] + for plan in rows: + year = re.search(r"(\d{4})", plan.name) + if plan.kind == "mixed" or "mixed" in plan.name.lower(): + new_name, new_slug = MIXED_NAME, MIXED_SLUG + elif year: + new_name = YEAR_NAME.format(year=year.group(1)) + new_slug = YEAR_SLUG.format(year=year.group(1)) + else: + continue + if (new_name, new_slug) != (plan.name, plan.slug): + changes.append((plan.id, plan.name, new_name, plan.slug, new_slug)) + return changes + + +def main(): + apply_changes = "--apply" in sys.argv + db = SessionLocal() + try: + # No early return when the plans are already done: the quizzes and the + # year tags are renamed by the same pass, and a second run has to be able + # to finish what a first one left. + changes = planned(db) + print(f" plans to rename: {len(changes)}\n") + for _pid, old_name, new_name, old_slug, new_slug in changes: + print(f" {old_name:<16} -> {new_name:<22} ({old_slug} -> {new_slug})") + + quizzes = db.execute(sa_text( + "SELECT id, title FROM quizzes WHERE title LIKE '%PREP%'")).fetchall() + print(f"\n quizzes already generated from a block: {len(quizzes)}") + + tags = db.execute(sa_text( + "SELECT id, name FROM question_tags WHERE name ~ '^PREP [0-9]{4}$'")).fetchall() + print(f" year tags shown in the bank's filters : {len(tags)}") + + if not apply_changes: + print("\n Re-run with --apply to rename them.") + return 0 + + for plan_id, _old_name, new_name, _old_slug, new_slug in changes: + db.execute(sa_text("UPDATE study_plans SET name = :n, slug = :s WHERE id = :i"), + {"n": new_name, "s": new_slug, "i": plan_id}) + + renamed = 0 + for quiz in quizzes: + title = quiz.title + year = re.search(r"PREP (\d{4})", title) + if year: + title = title.replace(f"PREP {year.group(1)}", YEAR_NAME.format(year=year.group(1))) + else: + title = title.replace("PREP Mixed", MIXED_NAME).replace("PREP", "Board Review") + if title != quiz.title: + db.execute(sa_text("UPDATE quizzes SET title = :t WHERE id = :i"), + {"t": title, "i": quiz.id}) + renamed += 1 + retagged = 0 + for tag in tags: + year = re.search(r"(\d{4})", tag.name) + if not year: + continue + db.execute(sa_text("UPDATE question_tags SET name = :n WHERE id = :i"), + {"n": YEAR_NAME.format(year=year.group(1)), "i": tag.id}) + retagged += 1 + + db.commit() + print(f"\n plans renamed : {len(changes)}") + print(f" quizzes renamed : {renamed}") + print(f" tags renamed : {retagged}") + finally: + db.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/scripts/seed_prep_study_plans.py b/backend/scripts/seed_study_plans.py similarity index 74% rename from backend/scripts/seed_prep_study_plans.py rename to backend/scripts/seed_study_plans.py index 8ab646e..157d39b 100644 --- a/backend/scripts/seed_prep_study_plans.py +++ b/backend/scripts/seed_study_plans.py @@ -1,14 +1,14 @@ -"""Turn the PREP question sets into study plans of numbered blocks. +"""Turn the year question sets into study plans of numbered blocks. -One plan per PREP year, split into blocks of BLOCK_SIZE, plus a mixed plan that +One plan per year, split into blocks of BLOCK_SIZE, plus a mixed plan that draws MIXED_SIZE questions at random across every year. Block membership is snapshotted, not a live filter: a plan you are part-way through must not reshuffle between visits. Re-running updates block contents for years that changed, and leaves the mixed plan's draw alone unless --reshuffle. - docker compose exec backend python -m scripts.seed_prep_study_plans - docker compose exec backend python -m scripts.seed_prep_study_plans --apply + docker compose exec backend python -m scripts.seed_study_plans + docker compose exec backend python -m scripts.seed_study_plans --apply """ import random import re @@ -22,14 +22,22 @@ from app.models.study_plan import StudyPlan, StudyPlanBlock BLOCK_SIZE = 50 MIXED_SIZE = 300 -MIXED_SLUG = "prep-mixed" +MIXED_SLUG = "mixed-review" +# The imported material is tagged with the source programme's name; the plans +# built from it are ours and are named for what they are. +YEAR_NAME = "Board Review {year}" +YEAR_SLUG = "board-review-{year}" def prep_tags(db): - """PREP year tags, newest first — 'PREP 2021', not 'Preparticipation Exam'.""" + """Year tags, newest first. + + The tags still carry the source programme's name because that is what the + imported material was labelled with; the plans built from them do not. + """ rows = db.execute(sa_text(""" SELECT t.id, t.name FROM question_tags t - WHERE t.name ~ '^PREP [0-9]{4}$' + WHERE t.name ~ '^(Board Review|PREP) [0-9]{4}$' ORDER BY t.name DESC """)).fetchall() return [(row[0], row[1]) for row in rows] @@ -72,7 +80,7 @@ def main(): exam_id = exam.id if exam else None tags = prep_tags(db) if not tags: - print("No 'PREP ' tags found; nothing to do.") + print("No year tags found; nothing to do.") return everything, summary = [], [] @@ -83,7 +91,7 @@ def main(): summary.append((name, len(ids), len(chunks))) if apply_changes: year = re.search(r"(\d{4})", name).group(1) - plan = upsert_plan(db, f"prep-{year}", name, + plan = upsert_plan(db, YEAR_SLUG.format(year=year), YEAR_NAME.format(year=year), f"{len(ids)} questions in {len(chunks)} blocks of up to {BLOCK_SIZE}.", "set", order, exam_id) set_blocks(db, plan, chunks) @@ -96,8 +104,8 @@ def main(): random.shuffle(pool) mixed_ids = pool[:MIXED_SIZE] if apply_changes: - plan = upsert_plan(db, MIXED_SLUG, "PREP Mixed", - f"{MIXED_SIZE} questions drawn at random from every PREP year.", + plan = upsert_plan(db, MIXED_SLUG, "Mixed Review", + f"{MIXED_SIZE} questions drawn at random from every year.", "mixed", 0, exam_id) if draw_needed: set_blocks(db, plan, [mixed_ids]) @@ -105,8 +113,10 @@ def main(): print("APPLIED" if apply_changes else "DRY RUN") for name, count, blocks in summary: - print(f" {name:12s} {count:4d} questions -> {blocks} blocks") - print(f" {'PREP Mixed':12s} {len(mixed_ids) or MIXED_SIZE:4d} questions -> 1 block" + year = re.search(r"(\d{4})", name) + shown = YEAR_NAME.format(year=year.group(1)) if year else name + print(f" {shown:18s} {count:4d} questions -> {blocks} blocks") + print(f" {'Mixed Review':18s} {len(mixed_ids) or MIXED_SIZE:4d} questions -> 1 block" f"{'' if draw_needed else ' (existing draw kept)'}") if not apply_changes: print("\n Re-run with --apply to write these plans.") diff --git a/backend/scripts/tag_prep_quizzes.py b/backend/scripts/tag_source_quizzes.py similarity index 84% rename from backend/scripts/tag_prep_quizzes.py rename to backend/scripts/tag_source_quizzes.py index 887c92a..2ad5d23 100644 --- a/backend/scripts/tag_prep_quizzes.py +++ b/backend/scripts/tag_source_quizzes.py @@ -1,7 +1,7 @@ -"""Tag questions by their PREP source quiz and retire any leftover PREP categories. +"""Tag questions by their source quiz and retire any leftover source categories. -Run after the tag→category conversion: PREP provenance moves from categories to -keyword tags named after the PREP quiz (e.g. 'PREP 2020'). +Run after the tag→category conversion: provenance moves from categories to +keyword tags named after the source quiz (e.g. 'Board Review 2020'). """ import re import sys @@ -20,7 +20,7 @@ def main(): tagged = 0 for quiz in prep_quizzes: year = re.search(r"\b(19|20)\d{2}\b", quiz.title or "") - tag_name = f"PREP {year.group(0)}" if year else (quiz.title or f"PREP {quiz.id}").strip() + tag_name = f"Board Review {year.group(0)}" if year else (quiz.title or f"Board Review {quiz.id}").strip() db.execute(text("INSERT INTO question_tags (name, type) VALUES (:name, 'keyword') " "ON CONFLICT (LOWER(name), type) DO NOTHING"), {"name": tag_name}) tag_id = db.execute(text("SELECT id FROM question_tags WHERE LOWER(name) = LOWER(:name) AND type = 'keyword'"), diff --git a/backend/scripts/triage_question_images.py b/backend/scripts/triage_question_images.py index e076269..e724cd6 100644 --- a/backend/scripts/triage_question_images.py +++ b/backend/scripts/triage_question_images.py @@ -11,7 +11,7 @@ Three groups, decided in this order: 1. the stem itself refers to a figure → the image belongs there, leave it; 2. only the explanation refers to one → the image belongs to the explanation, so it comes off the stem; - (PREP labels its figures by where they are printed — "Item Q37A" beside the + (the source material labels its figures by where they are printed — "Item Q37A" beside the question, "Item C37B" beside the critique — which decides most of these;) 3. neither says anything → ask a vision model what the picture shows and which half of the question it illustrates. @@ -65,7 +65,7 @@ STEM_CUE = re.compile( # printed with the critique, not with the vignette. EXPLANATION_CUE = STEM_CUE -# The strongest signal in this bank, and one specific to PREP: figures are +# The strongest signal in this bank, and one specific to the source: figures are # labelled by where they are printed. "Item Q37A" is a figure beside the # question; "Item C37B" is a figure beside the critique. The stem citing an # Item Q settles it on its own, and only a critique cites an Item C. diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index e8ce071..33a5bce 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -12,7 +12,7 @@ const categories = [ { id: 1, name: 'Pediatrics', question_count: 30, breadcrumbs: [{ id: 1, name: 'Pediatrics' }] }, { id: 2, name: 'Neonatal', question_count: 10, breadcrumbs: [{ id: 1, name: 'Pediatrics' }, { id: 2, name: 'Neonatal' }] }, ] -const TAGS = { subjects: [{ id: 7, name: 'Cardiology' }], keywords: [{ id: 8, name: 'PREP 2019' }] } +const TAGS = { subjects: [{ id: 7, name: 'Cardiology' }], keywords: [{ id: 8, name: 'Board Review 2019' }] } function setupCount(count = 30) { api.get.mockImplementation(url => { if (url === '/question-categories/') return Promise.resolve({ data: categories }) diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index 24d10f0..949796c 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -406,16 +406,16 @@ export default function DocumentDetailPage() { - +

- {extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'} + {extractionMode === 'standard' && 'Best for 2012 and 2014 sets, and most PDFs with answers inline.'} {extractionMode === 'questions_only' && 'Extracts questions + options only. Answer each question manually in Edit mode.'} {extractionMode === 'ai_answer' && 'For Q&A PDFs with no answer key. AI extracts questions then determines the correct answer and explanation from document context + medical knowledge.'} - {extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'} + {extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (2013-style layout).'} {extractionMode === 'regex' && 'AI detects the answer pattern, then uses regex for fast reliable extraction.'} {extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy (standard, two_step, or ai_answer).'} {extractionMode === 'generate' && 'For textbook chapters, lecture notes, or any material without a Q&A format. AI creates MCQ questions with correct answers from the text.'} diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index f507ee1..6092533 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -32,7 +32,7 @@ const FEATURES = [ { icon: '📄', title: 'Quiz from Any PDF', - desc: 'Upload PREP materials, textbook chapters, or lecture slides. AI extracts questions with answers and explanations — no formatting required.', + desc: 'Upload board review material, textbook chapters, or lecture slides. AI extracts questions with answers and explanations — no formatting required.', }, { icon: '🎓', @@ -394,7 +394,7 @@ export default function LandingPage() {

- Upload any PDF — PREP materials, lecture notes, textbook chapters. + Upload any PDF — board review material, lecture notes, textbook chapters. AI extracts questions, reads them aloud, and explains every answer.

diff --git a/frontend/src/pages/StudyPlanPage.test.jsx b/frontend/src/pages/StudyPlanPage.test.jsx index a2ffb8b..c68d91a 100644 --- a/frontend/src/pages/StudyPlanPage.test.jsx +++ b/frontend/src/pages/StudyPlanPage.test.jsx @@ -11,14 +11,14 @@ let currentUser = { id: 1, name: 'Learner', is_moderator: false } vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser }) })) const plans = [ - { id: 1, slug: 'prep-2025', name: 'PREP 2025', kind: 'set', exam_name: 'Pediatrics Boards', + { id: 1, slug: 'board-review-2025', name: 'Board Review 2025', kind: 'set', exam_name: 'Pediatrics Boards', is_published: true, block_count: 4, question_count: 200, blocks_completed: 1 }, - { id: 13, slug: 'prep-mixed', name: 'PREP Mixed', kind: 'mixed', exam_name: null, + { id: 13, slug: 'mixed-review', name: 'Mixed Review', kind: 'mixed', exam_name: null, is_published: true, block_count: 1, question_count: 300, blocks_completed: 0 }, ] const plan = { - id: 1, slug: 'prep-2025', name: 'PREP 2025', description: null, kind: 'set', is_published: true, + id: 1, slug: 'board-review-2025', name: 'Board Review 2025', description: null, kind: 'set', is_published: true, blocks: [ { id: 10, position: 0, title: 'Block 1', question_count: 50, quiz_id: 77, completed: true, articles: [{ link_id: 100, article_id: 5, slug: 'asthma', title: 'Asthma', status: 'published', read: true }] }, @@ -53,7 +53,7 @@ describe('study plans', () => { it('states progress in blocks, which is something you can act on', async () => { mountList() - const card = (await screen.findByText('PREP 2025')).closest('.plan-card') + const card = (await screen.findByText('Board Review 2025')).closest('.plan-card') expect(within(card).getByText('1 of 4 blocks done')).toBeInTheDocument() expect(within(card).getByText(/4 blocks · 200 questions/)).toBeInTheDocument() }) @@ -120,13 +120,13 @@ describe('study plans, as an educator', () => { it('creates a plan as a draft, because an empty plan is not for a learner', async () => { mountList() - await screen.findByText('PREP 2025') + await screen.findByText('Board Review 2025') api.post.mockResolvedValue({ data: { id: 20 } }) await userEvent.click(screen.getByRole('button', { name: 'New plan' })) - await userEvent.type(screen.getByLabelText('New plan name'), 'PREP 2026') + await userEvent.type(screen.getByLabelText('New plan name'), 'Board Review 2026') await userEvent.click(screen.getByRole('button', { name: 'Create' })) await waitFor(() => expect(api.post).toHaveBeenCalledWith('/study-plans/', { - name: 'PREP 2026', slug: 'prep-2026', kind: 'set', is_published: false, + name: 'Board Review 2026', slug: 'board-review-2026', kind: 'set', is_published: false, })) }) diff --git a/frontend/src/pages/StudyPlansPage.jsx b/frontend/src/pages/StudyPlansPage.jsx index a474b5e..9540a89 100644 --- a/frontend/src/pages/StudyPlansPage.jsx +++ b/frontend/src/pages/StudyPlansPage.jsx @@ -65,7 +65,7 @@ export default function StudyPlansPage() { {creating && (
- setName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') create() }} /> {/* Created unpublished: a plan with no blocks is not something to