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:
parent
84c4917d91
commit
b859d441eb
8 changed files with 513 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
380
backend/app/services/extraction_modes.py
Normal file
380
backend/app/services/extraction_modes.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
<ProfileSection user={user} />
|
||||
<AppearanceSection />
|
||||
<NextcloudSection />
|
||||
{isModerator && <NextcloudSection />}
|
||||
{(isAdmin || isModerator) && <AdminSection />}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue