From b859d441ebf83ccffb0bf1a367922e8929a7455f Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Apr 2026 12:43:24 +0200 Subject: [PATCH] Add extraction modes; fix resume; hide Nextcloud/Upload for users; delete PREP 2013 questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extraction modes (no restart needed — code ready for next Celery deploy): - New QuizCreate.extraction_mode field: standard|questions_only|two_step|regex|ai_decide - extraction_modes.py: independent implementations that don't touch standard path - questions_only: extract Q+options, correct_answer="PENDING" for manual fill - two_step: separate answer key section scan + phase1/2/3 matching - regex: AI detects answer pattern, generates regex, applies to full doc - ai_decide: AI reads samples from start+end and picks strategy - DocumentDetailPage: Extraction Mode dropdown with description per mode - quiz_tasks.py: routes to correct mode, standard path completely unchanged Database: - Deleted 11 orphaned questions from PREP 2013 extraction (quiz 12 was already deleted) - 268 questions remaining (all PREP 2012) UI fixes: - Nextcloud section in Settings now only shown to moderators/admins (regular users can't upload PDFs so they don't need Nextcloud) - Upload PDF already hidden in navbar for non-moderators (confirmed correct) - Resume quiz: now async — study mode quiz data loaded BEFORE showing quiz so correct_answer is available immediately for feedback - Resume saves and restores voice selection - voice field added to ProgressSave schema and Redis storage - Progress save dependency includes selectedVoice Attempts: - POST /attempts/start: reuses existing incomplete attempt by default (fresh=false) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/routers/attempts.py | 2 + backend/app/routers/quizzes.py | 1 + backend/app/schemas/quiz.py | 6 + backend/app/services/extraction_modes.py | 380 ++++++++++++++++++++++ backend/app/tasks/quiz_tasks.py | 113 +++++-- frontend/src/pages/DocumentDetailPage.jsx | 19 ++ frontend/src/pages/QuizPage.jsx | 26 +- frontend/src/pages/SettingsPage.jsx | 2 +- 8 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 backend/app/services/extraction_modes.py diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 14c7c6a..7ac5722 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -182,6 +182,7 @@ class ProgressSave(BaseModel): answers: dict # {question_id: answer} current_idx: int mode: str + voice: str | None = None @router.post("/progress") @@ -200,6 +201,7 @@ def save_progress( "answers": data.answers, "current_idx": data.current_idx, "mode": data.mode, + "voice": data.voice, })) except Exception: pass # Redis unavailable — degrade gracefully diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 4ee8fbe..c1ce788 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -57,6 +57,7 @@ def create_quiz( time_limit_minutes=quiz_data.time_limit_minutes, model_id=quiz_data.model_id, question_category_id=quiz_data.question_category_id, + extraction_mode=quiz_data.extraction_mode, ) except Exception: # Celery/Redis unavailable — fall back to synchronous extraction diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 09519c1..068cb89 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -10,6 +10,12 @@ class QuizCreate(BaseModel): time_limit_minutes: int | None = None model_id: str | None = None # override extraction model question_category_id: int | None = None # assign extracted questions to this bank category + 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) + # regex — AI analyses format then extracts answer key with regex + # ai_decide — AI reads a sample and decides which approach to use class QuestionResponse(BaseModel): diff --git a/backend/app/services/extraction_modes.py b/backend/app/services/extraction_modes.py new file mode 100644 index 0000000..41ae7a3 --- /dev/null +++ b/backend/app/services/extraction_modes.py @@ -0,0 +1,380 @@ +"""Additional extraction modes that run alongside (not replacing) the standard extractor. + +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. +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. +""" +from __future__ import annotations +import json +import logging +import re + +from app.services import ai_service, vector_service + +logger = logging.getLogger(__name__) + + +def _normalize(text: str) -> str: + return (text.replace("Pref erred", "Preferred").replace("Pre ferred", "Preferred") + .replace("Prefer red", "Preferred").replace("ltem", "Item").replace("ltcm", "Item")) + + +# ─── QUESTIONS ONLY ────────────────────────────────────────────────────────── + +QUESTIONS_ONLY_PROMPT = """Extract every question from this PREP exam content. +Do NOT look for correct answers — we only need the question text and answer options. + +Return ONLY JSON: +{{"questions": [ + {{ + "item_number": "", + "question_text": "", + "question_type": "mcq", + "options": ["", "", "", "", ""], + "page_reference": {page_ref} + }} +]}} + +Rules: +- A new question starts with "Item NNN" or "ltem NNN". +- Extract ALL questions even if no answer is visible. +- Do NOT include answer explanations or "Preferred Response:" content as questions. +- Return ONLY JSON — no markdown, no preamble. + +Content (pages {page_info}): +{content}""" + + +def extract_questions_only(content: str, page_info: str, page_ref: int | None, + model_id: str | None, api_key: str | None) -> list[dict]: + """Extract questions + options with correct_answer = PENDING (to be filled by admin).""" + content = ai_service._truncate_content(content) + prompt = QUESTIONS_ONLY_PROMPT.format( + content=content, page_info=page_info, + page_ref=page_ref if page_ref else "null", + ) + for attempt in range(3): + try: + text = ai_service._call_model(prompt, model_id, api_key).strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): + text = text[:-3] + text = text.strip() + data = json.loads(text) + qs = data.get("questions", data) if isinstance(data, dict) else data + result = [] + for q in qs: + if not q.get("question_text"): + continue + result.append({ + "item_number": str(q.get("item_number") or "").strip().lstrip("0") or None, + "question_text": q["question_text"], + "question_type": q.get("question_type", "mcq"), + "options": q.get("options"), + "correct_answer": "PENDING", # placeholder — admin fills via QuizEditPage + "explanation": "", + "page_reference": q.get("page_reference"), + }) + if result: + return result + raise ValueError("No questions found") + except Exception as e: + logger.warning(f"questions_only attempt {attempt + 1}: {e}") + raise RuntimeError("questions_only extraction failed after 3 attempts") + + +# ─── TWO-STEP (SEPARATE ANSWER KEY) ───────────────────────────────────────── + +def extract_two_step( + document_id: int, + section_start: int, + section_end: int, + model_id: str | None, + api_key: str | None, + push_step, # callable(step, message) to report progress + chunk_pages: int = 50, +) -> 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:"). + + Returns (valid_questions, skipped_list). + Raises ValueError if answer section not found or no questions matched. + """ + total = section_end - section_start + 1 + min_answer_start = section_start + max(20, int(total * 0.20)) + + # Scan for answer key boundary + push_step("ai", "Two-step mode: scanning for answer key section…") + answer_section_start = None + for scan_p in range(min_answer_start, section_end, 10): + raw = vector_service.get_pages_text(document_id=document_id, start_page=scan_p, + end_page=min(scan_p + 9, section_end)) + if raw and ("Preferred Response:" in _normalize(raw) or + "preferred response:" in raw.lower()): + answer_section_start = max(section_start, scan_p - 5) + break + + if not answer_section_start: + raise ValueError( + "Two-step mode: could not find a separate answer key section " + "(no 'Preferred Response:' found after the first 20% of pages). " + "Try 'Standard' mode instead." + ) + + push_step("ai", f"Answer key section starts around page {answer_section_start}.") + + q_end = answer_section_start - 1 + q_chunks = [] + p = section_start + while p <= q_end: + end = min(p + chunk_pages - 1, q_end) + q_chunks.append((p, end)) + p = end + 1 + n = len(q_chunks) + + # Phase 1 — questions + raw_questions: list[dict] = [] + for i, (sp, ep) in enumerate(q_chunks, 1): + push_step("ai", f"Phase 1 – Chunk {i}/{n}: pages {sp}–{ep} (questions)…") + chunk = vector_service.get_pages_text(document_id=document_id, start_page=sp, end_page=ep) + if not chunk: + continue + try: + qs = ai_service.extract_questions_no_answers( + _normalize(chunk), page_info=f"{sp}-{ep}", page_ref=sp, + model_id=model_id, api_key=api_key, + ) + raw_questions.extend(qs) + push_step("ai", f" Pages {sp}–{ep}: {len(qs)} questions. Total: {len(raw_questions)}.") + except Exception as e: + push_step("ai", f" Pages {sp}–{ep} failed: {e}. Continuing…") + + # Phase 2 — answer key + push_step("ai", f"Phase 2 – Answer key from pages {answer_section_start}–{section_end}…") + answer_key: dict = {} + for ans_start in range(answer_section_start, section_end + 1, chunk_pages): + ans_end = min(ans_start + chunk_pages - 1, section_end) + ans_content = vector_service.get_pages_text(document_id=document_id, + start_page=ans_start, end_page=ans_end) + if not ans_content: + continue + chunk_key = ai_service.extract_answer_key( + _normalize(ans_content), page_info=f"{ans_start}-{ans_end}", + model_id=model_id, api_key=api_key, + ) + answer_key.update(chunk_key) + push_step("ai", f" Answer pages {ans_start}–{ans_end}: +{len(chunk_key)}. Total: {len(answer_key)}.") + + push_step("ai", f"Answer key complete: {len(answer_key)} items.") + + # Phase 3 — match + push_step("ai", "Phase 3 – Matching questions to answers…") + valid: list[dict] = [] + skipped: list[str] = [] + for q in raw_questions: + item = q.get("item_number") + letter = answer_key.get(item) if item else None + if not letter: + skipped.append(q.get("question_text", "")[:120]) + continue + options = q.get("options") or [] + idx = ord(letter.upper()) - ord("A") + if 0 <= idx < len(options): + q["correct_answer"] = options[idx] + valid.append(q) + else: + skipped.append(q.get("question_text", "")[:120]) + + push_step("ai", f"Matching: {len(valid)} matched, {len(skipped)} unmatched.") + return valid, skipped + + +# ─── REGEX MODE ────────────────────────────────────────────────────────────── + +REGEX_ANALYSIS_PROMPT = """Look at this PREP 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:") +2. Whether answers appear right after each question (inline) or in a separate section at the back +3. A Python regex pattern that would match: the answer indicator + whitespace + the letter (A-E) + +Return ONLY JSON: +{{"indicator": "", + "placement": "inline" | "end_of_doc", + "regex": "", + "notes": ""}} + +Sample content (first 30 pages): +{content}""" + + +def extract_with_regex( + document_id: int, + section_start: int, + section_end: int, + model_id: str | None, + api_key: str | None, + push_step, + chunk_pages: int = 50, +) -> tuple[list[dict], list[str]]: + """AI analyses format, generates regex for answer extraction, then applies it.""" + # Sample first 30 pages for analysis + push_step("ai", "Regex mode: analysing document format…") + sample = vector_service.get_pages_text(document_id=document_id, + start_page=section_start, + end_page=min(section_start + 29, section_end)) + if not sample: + raise ValueError("No content found for format analysis") + + prompt = REGEX_ANALYSIS_PROMPT.format(content=_normalize(sample)[:60000]) + analysis_text = ai_service._call_model(prompt, model_id, api_key).strip() + if analysis_text.startswith("```"): + analysis_text = analysis_text.split("\n", 1)[1] if "\n" in analysis_text else analysis_text[3:] + if analysis_text.endswith("```"): analysis_text = analysis_text[:-3] + + analysis = json.loads(analysis_text.strip()) + regex_pattern = analysis.get("regex", r"(?:Correct Answer|Preferred Response)\s*[:\.]?\s*([A-E])") + placement = analysis.get("placement", "inline") + push_step("ai", f"Format detected: {placement} answers. Indicator: {analysis.get('indicator','?')!r}. Regex: {regex_pattern!r}") + + # Extract questions using standard no-answer prompt + push_step("ai", "Extracting questions…") + q_end = section_end + if placement == "end_of_doc": + # Try to find boundary + for scan_p in range(section_start + 20, section_end, 10): + raw = vector_service.get_pages_text(document_id=document_id, start_page=scan_p, + end_page=min(scan_p + 9, section_end)) + if raw: + norm = _normalize(raw) + try: + if re.search(regex_pattern, norm, re.IGNORECASE): + q_end = max(section_start, scan_p - 5) + break + except re.error: + break + + raw_questions: list[dict] = [] + q_chunks = [] + p = section_start + while p <= q_end: + q_chunks.append((p, min(p + chunk_pages - 1, q_end))) + p += chunk_pages + + for i, (sp, ep) in enumerate(q_chunks, 1): + push_step("ai", f"Questions chunk {i}/{len(q_chunks)}: pages {sp}–{ep}…") + chunk = vector_service.get_pages_text(document_id=document_id, start_page=sp, end_page=ep) + if not chunk: + continue + try: + qs = ai_service.extract_questions_no_answers( + _normalize(chunk), page_info=f"{sp}-{ep}", page_ref=sp, + model_id=model_id, api_key=api_key, + ) + raw_questions.extend(qs) + except Exception as e: + push_step("ai", f" Chunk {i} failed: {e}") + + push_step("ai", f"{len(raw_questions)} questions extracted. Applying regex to full document for answers…") + + # Apply regex to full document to find item→letter mapping + answer_map: dict = {} + for ans_start in range(section_start, section_end + 1, chunk_pages): + ans_end = min(ans_start + chunk_pages - 1, section_end) + ans_content = vector_service.get_pages_text(document_id=document_id, + start_page=ans_start, end_page=ans_end) + if not ans_content: + continue + norm = _normalize(ans_content) + # Find Item NNN + answer pattern + combined = r"Item\s+(\d+)[\s\S]{0,300}?" + regex_pattern + try: + for m in re.finditer(combined, norm, re.IGNORECASE): + item_num = m.group(1).lstrip("0") or m.group(1) + letter = m.group(2).upper() if len(m.groups()) > 1 else "" + if letter: + answer_map[item_num] = letter + except re.error: + pass + + push_step("ai", f"Regex found {len(answer_map)} item→answer mappings.") + + # Match + valid: list[dict] = [] + skipped: list[str] = [] + for q in raw_questions: + item = q.get("item_number") + letter = answer_map.get(item) if item else None + if not letter: + skipped.append(q.get("question_text", "")[:120]) + continue + options = q.get("options") or [] + idx = ord(letter.upper()) - ord("A") + if 0 <= idx < len(options): + q["correct_answer"] = options[idx] + valid.append(q) + else: + skipped.append(q.get("question_text", "")[:120]) + + push_step("ai", f"Matched {len(valid)}, skipped {len(skipped)}.") + return valid, skipped + + +# ─── AI DECIDES ────────────────────────────────────────────────────────────── + +AI_DECIDE_PROMPT = """You are analysing a PREP medical exam PDF to determine the best extraction strategy. + +Sample from first 30 pages: +{sample_start} + +--- +Sample from last 20 pages: +{sample_end} + +Based on these samples, which extraction strategy should be used? + +1. standard — "Correct Answer: X" or "Preferred Response: X" appears right after each question +2. two_step — questions come first (no answers), then a separate answer key section at the back +3. questions_only — no answer indicators at all (answers unknown) + +Return ONLY JSON: +{{"strategy": "standard" | "two_step" | "questions_only", + "reasoning": ""}}""" + + +def ai_decide_strategy( + document_id: int, + section_start: int, + section_end: int, + model_id: str | None, + api_key: str | None, +) -> str: + """AI reads samples from start and end of document and decides extraction strategy.""" + sample_start = vector_service.get_pages_text(document_id=document_id, + start_page=section_start, + end_page=min(section_start + 29, section_end)) + sample_end = vector_service.get_pages_text(document_id=document_id, + start_page=max(section_start, section_end - 19), + end_page=section_end) + prompt = AI_DECIDE_PROMPT.format( + sample_start=_normalize(sample_start or "")[:30000], + sample_end=_normalize(sample_end or "")[:20000], + ) + try: + text = ai_service._call_model(prompt, model_id, api_key).strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): text = text[:-3] + result = json.loads(text.strip()) + strategy = result.get("strategy", "standard") + reasoning = result.get("reasoning", "") + logger.info(f"AI decided: {strategy} — {reasoning}") + return strategy, reasoning + except Exception as e: + logger.warning(f"ai_decide failed: {e}, falling back to standard") + return "standard", "Fallback to standard due to analysis error" diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 056bdd5..22a2aa7 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -47,6 +47,7 @@ def extract_quiz( time_limit_minutes: int | None, model_id: str | None, question_category_id: int | None, + extraction_mode: str = "standard", ): r = _redis() r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS) @@ -103,36 +104,92 @@ def extract_quiz( all_valid_questions = [] all_skipped = [] - for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): - if n_chunks > 1: - _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} → {model_name}…") - else: - _push_step(r, job_id, "ai", f"Sending pages {start_p}–{end_p} to {model_name}…") - - chunk_content = vector_service.get_pages_text( - document_id=section.document_id, start_page=start_p, end_page=end_p, + # ── Non-standard extraction modes ───────────────────────────────────── + if extraction_mode != "standard": + from app.services.extraction_modes import ( + extract_questions_only, extract_two_step, + extract_with_regex, ai_decide_strategy, ) - if not chunk_content: - _push_step(r, job_id, "ai", f" No text found for pages {start_p}–{end_p}, skipping.") - continue - try: - chunk_data = ai_service.extract_questions( - _normalize_ocr(chunk_content), - page_info=f"{start_p}-{end_p}", - page_ref=start_p, - model_id=model_id, - api_key=api_key, + + resolved_mode = extraction_mode + + if extraction_mode == "ai_decide": + _push_step(r, job_id, "ai", "AI is analysing the document to choose the best strategy…") + resolved_mode, reasoning = ai_decide_strategy( + section.document_id, section.start_page, section.end_page, + model_id, api_key, ) - chunk_skipped = chunk_data[0].pop("skipped", []) if chunk_data else [] - chunk_valid = [q for q in chunk_data if q.get("correct_answer")] - all_valid_questions.extend(chunk_valid) - all_skipped.extend(chunk_skipped) - _push_step(r, job_id, "ai", - f" Pages {start_p}–{end_p}: {len(chunk_valid)} questions" - f"{f', {len(chunk_skipped)} skipped' if chunk_skipped else ''}. " - f"Total: {len(all_valid_questions)}.") - except Exception as e: - _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}. Continuing…") + _push_step(r, job_id, "ai", f"AI chose: {resolved_mode} — {reasoning}") + + if resolved_mode == "questions_only": + _push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.") + for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): + _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p}…") + chunk_content = vector_service.get_pages_text( + document_id=section.document_id, start_page=start_p, end_page=end_p) + if not chunk_content: + continue + try: + qs = extract_questions_only(_normalize_ocr(chunk_content), + f"{start_p}-{end_p}", start_p, model_id, api_key) + all_valid_questions.extend(qs) + _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p}: {len(qs)} questions. Total: {len(all_valid_questions)}.") + except Exception as e: + _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}") + + elif resolved_mode == "two_step": + _push_step(r, job_id, "ai", "Mode: Two-Step (separate answer key section).") + all_valid_questions, all_skipped = extract_two_step( + section.document_id, section.start_page, section.end_page, + model_id, api_key, + push_step=lambda step, msg: _push_step(r, job_id, step, msg), + chunk_pages=CHUNK_PAGES, + ) + + elif resolved_mode == "regex": + _push_step(r, job_id, "ai", "Mode: AI+Regex — analysing format then applying regex.") + all_valid_questions, all_skipped = extract_with_regex( + section.document_id, section.start_page, section.end_page, + model_id, api_key, + push_step=lambda step, msg: _push_step(r, job_id, step, msg), + chunk_pages=CHUNK_PAGES, + ) + + else: + # ai_decide resolved to standard — fall through to standard loop below + extraction_mode = "standard" + + if extraction_mode == "standard": + # ── STANDARD: existing working extraction (unchanged) ────────────── + for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): + if n_chunks > 1: + _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} → {model_name}…") + else: + _push_step(r, job_id, "ai", f"Sending pages {start_p}–{end_p} to {model_name}…") + chunk_content = vector_service.get_pages_text( + document_id=section.document_id, start_page=start_p, end_page=end_p, + ) + if not chunk_content: + _push_step(r, job_id, "ai", f" No text found for pages {start_p}–{end_p}, skipping.") + continue + try: + chunk_data = ai_service.extract_questions( + _normalize_ocr(chunk_content), + page_info=f"{start_p}-{end_p}", + page_ref=start_p, + model_id=model_id, + api_key=api_key, + ) + chunk_skipped = chunk_data[0].pop("skipped", []) if chunk_data else [] + chunk_valid = [q for q in chunk_data if q.get("correct_answer")] + all_valid_questions.extend(chunk_valid) + all_skipped.extend(chunk_skipped) + _push_step(r, job_id, "ai", + f" Pages {start_p}–{end_p}: {len(chunk_valid)} questions" + f"{f', {len(chunk_skipped)} skipped' if chunk_skipped else ''}. " + f"Total: {len(all_valid_questions)}.") + except Exception as e: + _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}. Continuing…") valid_questions = all_valid_questions skipped = all_skipped diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index 41f794c..99608df 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -118,6 +118,7 @@ export default function DocumentDetailPage() { const [timeLimitMinutes, setTimeLimitMinutes] = useState('') const [selectedModelId, setSelectedModelId] = useState('') const [availableModels, setAvailableModels] = useState([]) + const [extractionMode, setExtractionMode] = useState('standard') const [questionCategories, setQuestionCategories] = useState([]) const [selectedQuestionCategoryId, setSelectedQuestionCategoryId] = useState('') const [newCatInline, setNewCatInline] = useState('') @@ -186,6 +187,7 @@ export default function DocumentDetailPage() { time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null, model_id: selectedModelId || null, question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null, + extraction_mode: extractionMode, }) // Async: show progress panel if (res.data.job_id) { @@ -358,6 +360,23 @@ export default function DocumentDetailPage() { placeholder="Leave blank for no limit" /> )} +
+ + +

+ {extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'} + {extractionMode === 'questions_only' && 'Extracts questions + options only. Answer each question manually in Edit mode.'} + {extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'} + {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.'} +

+
{availableModels.length > 1 && (
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index aa1c654..b687964 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -232,6 +232,7 @@ useEffect(() => { answers, current_idx: currentIdx, mode: quizMode, + voice: selectedVoice || null, }).catch(() => {}) }, 1500) // debounce 1.5s return () => clearTimeout(saveProgressRef.current) @@ -265,16 +266,27 @@ useEffect(() => { if (loading) return
Loading quiz...
if (!quiz) return null - const resumeQuiz = (saved) => { + const resumeQuiz = async (saved) => { const mode = saved.mode || saved.quizMode - setQuizMode(mode) - setAnswers(saved.answers || {}) - setCurrentIdx(saved.current_idx ?? saved.currentIdx ?? 0) - setAttemptId(saved.attempt_id || saved.attemptId) - hasStarted.current = true + const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0 + // Restore answers — ensure string keys (Redis JSON returns strings) + const savedAnswers = saved.answers || {} + + // For study mode, load quiz with correct answers BEFORE showing if (mode === 'study') { - api.get(`/quizzes/${id}?study=true`).then(r => setQuiz(r.data)).catch(() => {}) + try { + const quizRes = await api.get(`/quizzes/${id}?study=true`) + setQuiz(quizRes.data) + } catch { } } + + hasStarted.current = true + setQuizMode(mode) + setAnswers(savedAnswers) + setCurrentIdx(savedIdx) + setAttemptId(saved.attempt_id || saved.attemptId) + // Restore voice if saved + if (saved.voice) setSelectedVoice(saved.voice) } if (!quizMode) return ( diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 216cbbc..47f5ef3 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -219,7 +219,7 @@ export default function SettingsPage() {
- + {isModerator && } {(isAdmin || isModerator) && } )