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}
|
answers: dict # {question_id: answer}
|
||||||
current_idx: int
|
current_idx: int
|
||||||
mode: str
|
mode: str
|
||||||
|
voice: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/progress")
|
@router.post("/progress")
|
||||||
|
|
@ -200,6 +201,7 @@ def save_progress(
|
||||||
"answers": data.answers,
|
"answers": data.answers,
|
||||||
"current_idx": data.current_idx,
|
"current_idx": data.current_idx,
|
||||||
"mode": data.mode,
|
"mode": data.mode,
|
||||||
|
"voice": data.voice,
|
||||||
}))
|
}))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Redis unavailable — degrade gracefully
|
pass # Redis unavailable — degrade gracefully
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ def create_quiz(
|
||||||
time_limit_minutes=quiz_data.time_limit_minutes,
|
time_limit_minutes=quiz_data.time_limit_minutes,
|
||||||
model_id=quiz_data.model_id,
|
model_id=quiz_data.model_id,
|
||||||
question_category_id=quiz_data.question_category_id,
|
question_category_id=quiz_data.question_category_id,
|
||||||
|
extraction_mode=quiz_data.extraction_mode,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Celery/Redis unavailable — fall back to synchronous extraction
|
# Celery/Redis unavailable — fall back to synchronous extraction
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ class QuizCreate(BaseModel):
|
||||||
time_limit_minutes: int | None = None
|
time_limit_minutes: int | None = None
|
||||||
model_id: str | None = None # override extraction model
|
model_id: str | None = None # override extraction model
|
||||||
question_category_id: int | None = None # assign extracted questions to this bank category
|
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):
|
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,
|
time_limit_minutes: int | None,
|
||||||
model_id: str | None,
|
model_id: str | None,
|
||||||
question_category_id: int | None,
|
question_category_id: int | None,
|
||||||
|
extraction_mode: str = "standard",
|
||||||
):
|
):
|
||||||
r = _redis()
|
r = _redis()
|
||||||
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
||||||
|
|
@ -103,36 +104,92 @@ def extract_quiz(
|
||||||
all_valid_questions = []
|
all_valid_questions = []
|
||||||
all_skipped = []
|
all_skipped = []
|
||||||
|
|
||||||
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
|
# ── Non-standard extraction modes ─────────────────────────────────────
|
||||||
if n_chunks > 1:
|
if extraction_mode != "standard":
|
||||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} → {model_name}…")
|
from app.services.extraction_modes import (
|
||||||
else:
|
extract_questions_only, extract_two_step,
|
||||||
_push_step(r, job_id, "ai", f"Sending pages {start_p}–{end_p} to {model_name}…")
|
extract_with_regex, ai_decide_strategy,
|
||||||
|
|
||||||
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.")
|
resolved_mode = extraction_mode
|
||||||
continue
|
|
||||||
try:
|
if extraction_mode == "ai_decide":
|
||||||
chunk_data = ai_service.extract_questions(
|
_push_step(r, job_id, "ai", "AI is analysing the document to choose the best strategy…")
|
||||||
_normalize_ocr(chunk_content),
|
resolved_mode, reasoning = ai_decide_strategy(
|
||||||
page_info=f"{start_p}-{end_p}",
|
section.document_id, section.start_page, section.end_page,
|
||||||
page_ref=start_p,
|
model_id, api_key,
|
||||||
model_id=model_id,
|
|
||||||
api_key=api_key,
|
|
||||||
)
|
)
|
||||||
chunk_skipped = chunk_data[0].pop("skipped", []) if chunk_data else []
|
_push_step(r, job_id, "ai", f"AI chose: {resolved_mode} — {reasoning}")
|
||||||
chunk_valid = [q for q in chunk_data if q.get("correct_answer")]
|
|
||||||
all_valid_questions.extend(chunk_valid)
|
if resolved_mode == "questions_only":
|
||||||
all_skipped.extend(chunk_skipped)
|
_push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.")
|
||||||
_push_step(r, job_id, "ai",
|
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
|
||||||
f" Pages {start_p}–{end_p}: {len(chunk_valid)} questions"
|
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p}…")
|
||||||
f"{f', {len(chunk_skipped)} skipped' if chunk_skipped else ''}. "
|
chunk_content = vector_service.get_pages_text(
|
||||||
f"Total: {len(all_valid_questions)}.")
|
document_id=section.document_id, start_page=start_p, end_page=end_p)
|
||||||
except Exception as e:
|
if not chunk_content:
|
||||||
_push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}. Continuing…")
|
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
|
valid_questions = all_valid_questions
|
||||||
skipped = all_skipped
|
skipped = all_skipped
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ export default function DocumentDetailPage() {
|
||||||
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
|
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
|
||||||
const [selectedModelId, setSelectedModelId] = useState('')
|
const [selectedModelId, setSelectedModelId] = useState('')
|
||||||
const [availableModels, setAvailableModels] = useState([])
|
const [availableModels, setAvailableModels] = useState([])
|
||||||
|
const [extractionMode, setExtractionMode] = useState('standard')
|
||||||
const [questionCategories, setQuestionCategories] = useState([])
|
const [questionCategories, setQuestionCategories] = useState([])
|
||||||
const [selectedQuestionCategoryId, setSelectedQuestionCategoryId] = useState('')
|
const [selectedQuestionCategoryId, setSelectedQuestionCategoryId] = useState('')
|
||||||
const [newCatInline, setNewCatInline] = useState('')
|
const [newCatInline, setNewCatInline] = useState('')
|
||||||
|
|
@ -186,6 +187,7 @@ export default function DocumentDetailPage() {
|
||||||
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
|
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
|
||||||
model_id: selectedModelId || null,
|
model_id: selectedModelId || null,
|
||||||
question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null,
|
question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null,
|
||||||
|
extraction_mode: extractionMode,
|
||||||
})
|
})
|
||||||
// Async: show progress panel
|
// Async: show progress panel
|
||||||
if (res.data.job_id) {
|
if (res.data.job_id) {
|
||||||
|
|
@ -358,6 +360,23 @@ export default function DocumentDetailPage() {
|
||||||
placeholder="Leave blank for no limit" />
|
placeholder="Leave blank for no limit" />
|
||||||
</div>
|
</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 && (
|
{availableModels.length > 1 && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Extraction Model</label>
|
<label>Extraction Model</label>
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ useEffect(() => {
|
||||||
answers,
|
answers,
|
||||||
current_idx: currentIdx,
|
current_idx: currentIdx,
|
||||||
mode: quizMode,
|
mode: quizMode,
|
||||||
|
voice: selectedVoice || null,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, 1500) // debounce 1.5s
|
}, 1500) // debounce 1.5s
|
||||||
return () => clearTimeout(saveProgressRef.current)
|
return () => clearTimeout(saveProgressRef.current)
|
||||||
|
|
@ -265,16 +266,27 @@ useEffect(() => {
|
||||||
|
|
||||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||||||
if (!quiz) return null
|
if (!quiz) return null
|
||||||
const resumeQuiz = (saved) => {
|
const resumeQuiz = async (saved) => {
|
||||||
const mode = saved.mode || saved.quizMode
|
const mode = saved.mode || saved.quizMode
|
||||||
setQuizMode(mode)
|
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||||
setAnswers(saved.answers || {})
|
// Restore answers — ensure string keys (Redis JSON returns strings)
|
||||||
setCurrentIdx(saved.current_idx ?? saved.currentIdx ?? 0)
|
const savedAnswers = saved.answers || {}
|
||||||
setAttemptId(saved.attempt_id || saved.attemptId)
|
|
||||||
hasStarted.current = true
|
// For study mode, load quiz with correct answers BEFORE showing
|
||||||
if (mode === 'study') {
|
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 (
|
if (!quizMode) return (
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
<ProfileSection user={user} />
|
<ProfileSection user={user} />
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
<NextcloudSection />
|
{isModerator && <NextcloudSection />}
|
||||||
{(isAdmin || isModerator) && <AdminSection />}
|
{(isAdmin || isModerator) && <AdminSection />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue