Revert extraction to simple standard mode; PREP 2013 will be separate option later

- Completely reverted quiz_tasks.py to simple standard extraction with chunking
  (no more two-phase detection that broke PREP 2012/2014)
- OCR normalization kept: 'Pref erred'→'Preferred', 'ltem'→'Item'
- The extraction prompt already handles both 'Correct Answer: X' and
  'Preferred Response: X' inline formats — no special detection needed
- PREP 2013 (separate answer key) will be implemented as a separate option
  user selects at extraction time, not automatic detection

Also in this commit:
- Fixed quiz delete 500 error (source_quiz_id attribute name)
- Added trash bin (soft delete, restore, permanent delete)
- Added hide/publish toggle per quiz (moderators see all, users see published only)
- Quiz progress saved to Redis — survives logout, works cross-browser
- Resume in-progress quiz from any browser
- ConfirmButton component replaces all window.confirm/prompt
- Delete own attempts endpoint
- TrashPage, AdminPage trash/jobs links in Settings

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 05:04:22 +02:00
parent 45add79db3
commit 92fdf55cd9

View file

@ -2,13 +2,15 @@
import json import json
import logging import logging
import time import time
import os
from app.tasks import celery_app from app.tasks import celery_app
from app.database import SessionLocal from app.database import SessionLocal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EXPIRE_SECONDS = 3600 # keep progress for 1 hour EXPIRE_SECONDS = 3600
CHUNK_PAGES = 50
def _redis(): def _redis():
@ -24,6 +26,16 @@ def _push_step(r, job_id: str, step: str, message: str):
r.expire(key, EXPIRE_SECONDS) r.expire(key, EXPIRE_SECONDS)
def _normalize_ocr(text: str) -> str:
"""Fix common OCR artifacts in PREP PDFs."""
return (text
.replace("Pref erred", "Preferred")
.replace("Pre ferred", "Preferred")
.replace("Prefer red", "Preferred")
.replace("ltem", "Item")
.replace("ltcm", "Item"))
@celery_app.task(name="extract_quiz", bind=True) @celery_app.task(name="extract_quiz", bind=True)
def extract_quiz( def extract_quiz(
self, self,
@ -36,10 +48,8 @@ def extract_quiz(
model_id: str | None, model_id: str | None,
question_category_id: int | None, question_category_id: int | None,
): ):
"""Background quiz extraction task with live progress reporting."""
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)
# Ensure user job index exists (in case set before task ran)
r.lpush(f"extraction:user_jobs:{user_id}", job_id) r.lpush(f"extraction:user_jobs:{user_id}", job_id)
r.expire(f"extraction:user_jobs:{user_id}", 86400) r.expire(f"extraction:user_jobs:{user_id}", 86400)
@ -52,14 +62,12 @@ def extract_quiz(
from app.models.question import Question from app.models.question import Question
from app.models.quiz_question_link import QuizQuestionLink from app.models.quiz_question_link import QuizQuestionLink
from app.config import settings from app.config import settings
import os
_push_step(r, job_id, "start", "Starting extraction…") _push_step(r, job_id, "start", "Starting extraction…")
section = db.query(Section).filter(Section.id == section_id).first() section = db.query(Section).filter(Section.id == section_id).first()
if not section: if not section:
raise ValueError("Section not found") raise ValueError("Section not found")
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first() document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
if not document: if not document:
raise ValueError("Document not found") raise ValueError("Document not found")
@ -67,7 +75,7 @@ def extract_quiz(
total_pages = section.end_page - section.start_page + 1 total_pages = section.end_page - section.start_page + 1
_push_step(r, job_id, "text", f"Loading text from pages {section.start_page}{section.end_page} ({total_pages} pages)…") _push_step(r, job_id, "text", f"Loading text from pages {section.start_page}{section.end_page} ({total_pages} pages)…")
# Determine model first (used for chunk logging) # Determine model
if model_id: if model_id:
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first() config = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
@ -77,10 +85,7 @@ def extract_quiz(
model_id, api_key = ai_service.get_model_for_task(db, "extraction") model_id, api_key = ai_service.get_model_for_task(db, "extraction")
model_name = model_id model_name = model_id
# For large page ranges, process in chunks of 50 pages to avoid truncation # Split into 50-page chunks
CHUNK_PAGES = 50
import json as _json
if total_pages <= CHUNK_PAGES: if total_pages <= CHUNK_PAGES:
chunks = [(section.start_page, section.end_page)] chunks = [(section.start_page, section.end_page)]
else: else:
@ -95,163 +100,44 @@ def extract_quiz(
if n_chunks > 1: if n_chunks > 1:
_push_step(r, job_id, "text", f"Large section: splitting into {n_chunks} chunks of up to {CHUNK_PAGES} pages each.") _push_step(r, job_id, "text", f"Large section: splitting into {n_chunks} chunks of up to {CHUNK_PAGES} pages each.")
def _normalize_ocr(text: str) -> str:
"""Normalize common OCR artifacts in PREP PDFs."""
return (text
.replace("Pref erred", "Preferred")
.replace("Pre ferred", "Preferred")
.replace("Prefer red", "Preferred")
.replace("ltem", "Item")
.replace("ltcm", "Item"))
# --- Detect PDF format ---
# First check if document has INLINE correct answers (standard PREP 2012 format).
# If yes → always use standard extraction, even if "Preferred Response:" appears
# later in explanations.
first_50_content = vector_service.get_pages_text(
document_id=section.document_id,
start_page=section.start_page,
end_page=min(section.start_page + 49, section.end_page),
)
has_inline_answers = bool(first_50_content and (
"Correct Answer:" in first_50_content or
"correct answer:" in first_50_content.lower()
))
# Only look for end-of-document answer key if there are NO inline answers
answer_section_start = None
if not has_inline_answers:
scan_step = 10
for scan_p in range(section.start_page, section.end_page, scan_step):
scan_end = min(scan_p + scan_step - 1, section.end_page)
raw_chunk = vector_service.get_pages_text(
document_id=section.document_id, start_page=scan_p, end_page=scan_end,
)
if not raw_chunk:
continue
scan_chunk = _normalize_ocr(raw_chunk)
if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower():
answer_section_start = max(section.start_page, scan_p - 5)
break
has_end_answer_key = answer_section_start is not None
if has_inline_answers:
_push_step(r, job_id, "ai", "Detected inline answer format (Correct Answer: X). Using standard extraction.")
elif has_end_answer_key:
pass # logged below
if has_end_answer_key:
_push_step(r, job_id, "ai", f"Detected separate answer key section starting around page {answer_section_start}. Using two-phase extraction.")
# Restrict question chunks to pages strictly BEFORE the answer section
# Cap the end page so no chunk bleeds into the answer section
q_end = answer_section_start - 1
chunks = [(s, min(e, q_end)) for s, e in chunks if s <= q_end]
if not chunks:
chunks = [(section.start_page, q_end)]
n_chunks = len(chunks) # update display count after filtering
all_valid_questions = [] all_valid_questions = []
all_skipped = [] all_skipped = []
if has_end_answer_key: for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
# === PHASE 1: Extract questions (allow null correct_answer) === if n_chunks > 1:
raw_questions = [] _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}{model_name}")
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): else:
_push_step(r, job_id, "ai", f"Phase 1 Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p} (questions)…") _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, 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,
) )
if not chunk_content: chunk_skipped = chunk_data[0].pop("skipped", []) if chunk_data else []
continue chunk_valid = [q for q in chunk_data if q.get("correct_answer")]
try: all_valid_questions.extend(chunk_valid)
chunk_qs = ai_service.extract_questions_no_answers( all_skipped.extend(chunk_skipped)
_normalize_ocr(chunk_content), page_info=f"{start_p}-{end_p}", _push_step(r, job_id, "ai",
page_ref=start_p, model_id=model_id, api_key=api_key, f" Pages {start_p}{end_p}: {len(chunk_valid)} questions"
) f"{f', {len(chunk_skipped)} skipped' if chunk_skipped else ''}. "
raw_questions.extend(chunk_qs) f"Total: {len(all_valid_questions)}.")
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p}: {len(chunk_qs)} questions found. Total: {len(raw_questions)}.") except Exception as e:
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" Pages {start_p}{end_p} failed: {e}. Continuing…")
# === PHASE 2: Extract answer key from the answer section ===
_push_step(r, job_id, "ai", f"Phase 2 Extracting answer key from pages {answer_section_start}{section.end_page}")
# Process answer section in chunks too (it may be large)
answer_key: dict = {}
for ans_start in range(answer_section_start, section.end_page + 1, CHUNK_PAGES):
ans_end = min(ans_start + CHUNK_PAGES - 1, section.end_page)
answer_content = vector_service.get_pages_text(
document_id=section.document_id,
start_page=ans_start,
end_page=ans_end,
)
if not answer_content:
continue
chunk_key = ai_service.extract_answer_key(
_normalize_ocr(answer_content), page_info=f"{ans_start}-{ans_end}",
model_id=model_id, api_key=api_key,
)
answer_key.update(chunk_key)
_push_step(r, job_id, "ai", f" Answer pages {ans_start}{ans_end}: +{len(chunk_key)} answers. Total: {len(answer_key)}.")
_push_step(r, job_id, "ai", f" Answer key complete: {len(answer_key)} items.")
# === PHASE 3: Match questions to answers ===
_push_step(r, job_id, "ai", "Phase 3 Matching questions to answers…")
for q in raw_questions:
item_num = q.get("item_number")
if not item_num:
# No item number — skip
all_skipped.append(q.get("question_text", "")[:120])
continue
letter = answer_key.get(item_num)
if not letter:
all_skipped.append(q.get("question_text", "")[:120])
continue
# Resolve letter → full option text
options = q.get("options") or []
letter_idx = ord(letter.upper()) - ord("A")
if 0 <= letter_idx < len(options):
q["correct_answer"] = options[letter_idx]
all_valid_questions.append(q)
else:
all_skipped.append(q.get("question_text", "")[:120])
_push_step(r, job_id, "ai", f"Matching complete: {len(all_valid_questions)} matched, {len(all_skipped)} unmatched.")
else:
# === STANDARD: inline correct answer format ===
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(
chunk_content, page_info=f"{start_p}-{end_p}",
page_ref=start_p, model_id=model_id, api_key=api_key,
)
chunk_skipped = []
if chunk_data and chunk_data[0].get("skipped"):
chunk_skipped = chunk_data[0].pop("skipped")
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 extracted{f', {len(chunk_skipped)} skipped' if chunk_skipped else ''}. Total so far: {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
_push_step(r, job_id, "ai", f"Extraction complete: {len(valid_questions)} total valid questions{f', {len(skipped)} skipped' if skipped else ''}.") _push_step(r, job_id, "ai", f"Extraction complete: {len(valid_questions)} valid questions{f', {len(skipped)} skipped' if skipped else ''}.")
if not valid_questions: if not valid_questions:
raise ValueError("No valid questions extracted. The AI could not find questions with correct answers in this page range.") raise ValueError("No valid questions extracted. The AI could not find questions with correct answers in this page range.")
@ -276,7 +162,7 @@ def extract_quiz(
questions_count=len(valid_questions), questions_count=len(valid_questions),
mode=mode, mode=mode,
time_limit_minutes=time_limit_minutes, time_limit_minutes=time_limit_minutes,
skipped_questions=_json.dumps(skipped) if skipped else None, skipped_questions=json.dumps(skipped) if skipped else None,
) )
db.add(quiz) db.add(quiz)
db.flush() db.flush()
@ -307,14 +193,13 @@ def extract_quiz(
db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=question.id, position=pos)) db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=question.id, position=pos))
try: try:
embedding_service.embed_question(question) embedding_service.embed_question(question)
except Exception: except Exception as e:
pass logger.warning(f"Embedding failed for question {question.id}: {e}")
db.commit() db.commit()
db.refresh(quiz) db.refresh(quiz)
_push_step(r, job_id, "done", f"Quiz ready! {len(valid_questions)} questions extracted and saved.") _push_step(r, job_id, "done", f"Quiz ready! {len(valid_questions)} questions extracted and saved.")
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS) r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
r.set(f"extraction:quiz_id:{job_id}", str(quiz.id), ex=EXPIRE_SECONDS) r.set(f"extraction:quiz_id:{job_id}", str(quiz.id), ex=EXPIRE_SECONDS)
return quiz.id return quiz.id