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:
parent
e5e31f6eba
commit
d07be64f59
2 changed files with 85 additions and 27 deletions
47
README.md
47
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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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…")
|
||||
|
|
|
|||
Loading…
Reference in a new issue