Add extraction modes; fix resume; hide Nextcloud/Upload for users; delete PREP 2013 questions

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) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 12:43:24 +02:00
parent 84c4917d91
commit b859d441eb
8 changed files with 513 additions and 36 deletions

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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": "<digits only, or null>",
"question_text": "<full vignette + stem>",
"question_type": "mcq",
"options": ["<A>", "<B>", "<C>", "<D>", "<E>"],
"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": "<text before letter>",
"placement": "inline" | "end_of_doc",
"regex": "<python regex with one capture group for the letter>",
"notes": "<any relevant observation>"}}
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": "<one sentence>"}}"""
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"

View file

@ -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

View file

@ -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" />
</div>
)}
<div className="form-group">
<label>Extraction Mode</label>
<select value={extractionMode} onChange={e => setExtractionMode(e.target.value)}>
<option value="standard">Standard inline answers (Correct Answer / Preferred Response)</option>
<option value="questions_only">Questions Only no answers (fill in manually later)</option>
<option value="two_step">Two-Step separate answer key section (PREP 2013 style)</option>
<option value="regex">AI + Regex AI analyses format then applies regex for answers</option>
<option value="ai_decide">AI Decides AI reads the document and picks best strategy</option>
</select>
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
{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.'}
</p>
</div>
{availableModels.length > 1 && (
<div className="form-group">
<label>Extraction Model</label>

View file

@ -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 <div className="loading"><div className="spinner"></div> Loading quiz...</div>
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 (

View file

@ -219,7 +219,7 @@ export default function SettingsPage() {
</div>
<ProfileSection user={user} />
<AppearanceSection />
<NextcloudSection />
{isModerator && <NextcloudSection />}
{(isAdmin || isModerator) && <AdminSection />}
</div>
)