Fix two-phase extraction boundary detection; README CLI docs; scroll fix

Two-phase extraction improvements:
- Auto-detect answer section boundary by scanning in 10-page steps for
  'Preferred Response:' — finds exact page where questions end and answers begin
  (PREP 2013 answers start at page ~68, not at the end of the file)
- Restrict Phase 1 question chunks to pages BEFORE the answer section
- Extract answer key from answer section in CHUNKS (50 pages each) to handle
  large answer sections — accumulates all item→letter mappings
- Previous version used last 40% which missed items 1-~135 for PREP 2013

README: full CLI extraction documentation:
- list-sections: find document and section IDs
- extract <section_id> [--bg] [--title] [--mode] [--user]
- jobs / jobs --user <email>
- Explanation of auto-format detection (inline vs separate answer key)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 03:36:12 +02:00
parent e5e31f6eba
commit d07be64f59
2 changed files with 85 additions and 27 deletions

View file

@ -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 1767
# Doc 4: prep-PREP2013.pdf (ready, 227 pages)
# Section 7: 'Questions 1-100' pages 1100
# 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 1767)
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
```

View file

@ -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…")