diff --git a/README.md b/README.md index 877ffa9..451c6f6 100644 --- a/README.md +++ b/README.md @@ -90,17 +90,60 @@ CHROMA_PERSIST_DIR=/app/chroma_data ## CLI Management +All commands run inside the backend container: + ```bash -# Reset a user's password (e.g. locked-out admin) +# ── User management ────────────────────────────────────────────────────────── +# Reset a locked-out admin password docker compose exec backend python manage.py reset-password admin@example.com NewPassword123 -# List all users and verification status +# List all users with email verification status docker compose exec backend python manage.py list-users +# ── Quiz extraction ─────────────────────────────────────────────────────────── +# 1. Find your document ID and section ID +docker compose exec backend python manage.py list-sections + +# Output example: +# Doc 3: prep-PREP2012.pdf (ready, 767 pages) +# Section 6: 'ALL' pages 1–767 +# Doc 4: prep-PREP2013.pdf (ready, 227 pages) +# Section 7: 'Questions 1-100' pages 1–100 + +# 2a. Extract in background (Celery) — returns immediately, monitor via navbar badge +docker compose exec backend python manage.py extract 6 --bg +docker compose exec backend python manage.py extract 6 --bg --title "PREP 2012 Full" --mode timed + +# 2b. Extract inline (blocking) — shows live output in terminal +docker compose exec backend python manage.py extract 6 + +# 3. Check job status (shows progress, skipped questions, errors) +docker compose exec backend python manage.py jobs +docker compose exec backend python manage.py jobs --user admin@example.com + +# CLI extract options: +# --title "My Quiz" Custom quiz title (default: auto-generated from section name) +# --mode timed|learning Quiz mode (default: timed) +# --user email Which user owns the quiz (default: first admin) +# --bg Run in background via Celery + +# ── Embeddings ─────────────────────────────────────────────────────────────── # Regenerate all question embeddings (e.g. after switching embedding model) docker compose exec backend python manage.py reembed ``` +### How extraction works + +1. **Upload PDF** via the web UI (Upload PDF page) — the system extracts text and stores it +2. **Create a section** on the document page (define page range, e.g. pages 1–767) +3. **Extract quiz** — either from the web UI or CLI: + - The system auto-detects the PDF format: + - **Inline answers** (PREP 2012): "Correct Answer: X" after each question → standard extraction + - **Separate answer key** (PREP 2013): "Preferred Response: X" in a dedicated answer section → two-phase extraction (questions first, then answer key, then matched) + - Large sections are split into 50-page chunks automatically + - Progress shown live in the web UI extraction panel +4. Questions land in the **Question Bank** and can be assigned to categories + ## Architecture ``` diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 1e061c4..00218cf 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -95,19 +95,28 @@ def extract_quiz( 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.") - # --- Detect end-of-document answer key format (e.g. PREP 2013) --- - last_chunk_content = vector_service.get_pages_text( - document_id=section.document_id, - start_page=max(section.end_page - 40, section.start_page), - end_page=section.end_page, - ) - has_end_answer_key = bool(last_chunk_content and ( - "Preferred Response:" in last_chunk_content or - "preferred response:" in last_chunk_content.lower() - )) + # --- Detect separate answer key section (e.g. PREP 2013) --- + # Scan document in 10-page steps to find where "Preferred Response:" first appears + answer_section_start = None + 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) + scan_chunk = vector_service.get_pages_text( + document_id=section.document_id, start_page=scan_p, end_page=scan_end, + ) + if scan_chunk and ("Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower()): + # Found it — but go back a few pages to be safe + answer_section_start = max(section.start_page, scan_p - 5) + break + + has_end_answer_key = answer_section_start is not None if has_end_answer_key: - _push_step(r, job_id, "ai", "Detected end-of-document answer key format (Preferred Response). Using two-phase extraction.") + _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 BEFORE the answer section + chunks = [(s, e) for s, e in chunks if s < answer_section_start] + if not chunks: + chunks = [(section.start_page, answer_section_start - 1)] all_valid_questions = [] all_skipped = [] @@ -132,20 +141,26 @@ def extract_quiz( except Exception as e: _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}. Continuing…") - # === PHASE 2: Extract answer key from end of document === - _push_step(r, job_id, "ai", f"Phase 2 – Extracting answer key from last pages…") - # Include larger portion for answer key (last 30-40% of doc) - answer_start = max(section.start_page, int(section.end_page * 0.6)) - answer_content = vector_service.get_pages_text( - document_id=section.document_id, - start_page=answer_start, - end_page=section.end_page, - ) - answer_key = ai_service.extract_answer_key( - answer_content, page_info=f"{answer_start}-{section.end_page}", - model_id=model_id, api_key=api_key, - ) - _push_step(r, job_id, "ai", f" Answer key extracted: {len(answer_key)} answers found.") + # === 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( + 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…")