Async extraction with live progress + chunked large PDFs
Extraction is now fully async via Celery — UI shows a live progress panel,
job continues even if page is closed. Large documents are processed in
50-page chunks to extract all questions (not just first ~50 pages).
Backend:
- app/tasks/quiz_tasks.py: new Celery task 'extract_quiz'
- Writes step-by-step progress to Redis (extraction:steps:{job_id})
- Splits large page ranges into 50-page chunks, processes each separately
- Reports per-chunk results and running total
- Falls back to synchronous if Celery/Redis unavailable
- POST /quizzes/ now returns {job_id, status:"pending"} immediately
- GET /quizzes/job/{job_id} polls progress: steps[], status, quiz_id on completion
- Celery task list updated to include quiz_tasks
Frontend (DocumentDetailPage):
- ExtractionProgress modal component: monospace step log, auto-scrolls, spinner
- Polls job status every 2 seconds via /quizzes/job/{job_id}
- "Open Quiz →" button appears when done
- "✕ closes — job continues in background" shown while running
- beforeunload warning when job is active (preventing accidental close)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5021a3241
commit
96a1a259f0
4 changed files with 376 additions and 14 deletions
|
|
@ -17,25 +17,33 @@ from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remov
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=QuizResponse)
|
@router.post("/")
|
||||||
def create_quiz(
|
def create_quiz(
|
||||||
quiz_data: QuizCreate,
|
quiz_data: QuizCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_moderator),
|
current_user: User = Depends(require_moderator),
|
||||||
):
|
):
|
||||||
"""Create quiz by extracting questions from PDF section. Moderator/Admin only."""
|
"""Start async quiz extraction. Returns {job_id} immediately; poll /quizzes/job/{job_id} for progress."""
|
||||||
|
import uuid
|
||||||
section = db.query(Section).filter(Section.id == quiz_data.section_id).first()
|
section = db.query(Section).filter(Section.id == quiz_data.section_id).first()
|
||||||
if not section:
|
if not section:
|
||||||
raise HTTPException(status_code=404, detail="Section not found")
|
raise HTTPException(status_code=404, detail="Section not found")
|
||||||
if not current_user.is_admin and section.document.user_id != current_user.id:
|
if not current_user.is_admin and section.document.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Not your document")
|
raise HTTPException(status_code=403, detail="Not your document")
|
||||||
|
|
||||||
if quiz_data.mode not in ("timed", "learning"):
|
if quiz_data.mode not in ("timed", "learning"):
|
||||||
raise HTTPException(status_code=400, detail="Mode must be 'timed' or 'learning'")
|
raise HTTPException(status_code=400, detail="Mode must be 'timed' or 'learning'")
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
quiz = quiz_service.create_quiz_from_section(
|
from app.tasks.quiz_tasks import extract_quiz
|
||||||
db=db,
|
import redis as redis_lib
|
||||||
|
from app.config import settings
|
||||||
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
|
||||||
|
|
||||||
|
extract_quiz.delay(
|
||||||
|
job_id=job_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
section_id=quiz_data.section_id,
|
section_id=quiz_data.section_id,
|
||||||
title=quiz_data.title,
|
title=quiz_data.title,
|
||||||
|
|
@ -44,11 +52,39 @@ def create_quiz(
|
||||||
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,
|
||||||
)
|
)
|
||||||
return quiz
|
except Exception:
|
||||||
except ValueError as e:
|
# Celery/Redis unavailable — fall back to synchronous extraction
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
try:
|
||||||
except RuntimeError as e:
|
quiz = quiz_service.create_quiz_from_section(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
db=db, user_id=current_user.id,
|
||||||
|
section_id=quiz_data.section_id, title=quiz_data.title,
|
||||||
|
mode=quiz_data.mode, time_limit_minutes=quiz_data.time_limit_minutes,
|
||||||
|
model_id=quiz_data.model_id, question_category_id=quiz_data.question_category_id,
|
||||||
|
)
|
||||||
|
return {"job_id": job_id, "status": "completed", "quiz_id": quiz.id}
|
||||||
|
except (ValueError, RuntimeError) as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
return {"job_id": job_id, "status": "pending"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/job/{job_id}")
|
||||||
|
def get_extraction_job(job_id: str, current_user: User = Depends(require_moderator)):
|
||||||
|
"""Poll extraction job progress. Returns steps list, status, and quiz_id when done."""
|
||||||
|
import json as _json
|
||||||
|
import redis as redis_lib
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
status = r.get(f"extraction:status:{job_id}") or "unknown"
|
||||||
|
raw_steps = r.lrange(f"extraction:steps:{job_id}", 0, -1)
|
||||||
|
steps = [_json.loads(s) for s in raw_steps]
|
||||||
|
result = {"job_id": job_id, "status": status, "steps": steps}
|
||||||
|
if status == "completed":
|
||||||
|
result["quiz_id"] = int(r.get(f"extraction:quiz_id:{job_id}") or 0)
|
||||||
|
if status == "failed":
|
||||||
|
result["error"] = r.get(f"extraction:error:{job_id}") or "Unknown error"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ celery_app = Celery(
|
||||||
"quiz_tasks",
|
"quiz_tasks",
|
||||||
broker=settings.REDIS_URL,
|
broker=settings.REDIS_URL,
|
||||||
backend=settings.REDIS_URL,
|
backend=settings.REDIS_URL,
|
||||||
include=["app.tasks.pdf_tasks"],
|
include=["app.tasks.pdf_tasks", "app.tasks.quiz_tasks"],
|
||||||
)
|
)
|
||||||
celery_app.conf.task_serializer = "json"
|
celery_app.conf.task_serializer = "json"
|
||||||
celery_app.conf.result_serializer = "json"
|
celery_app.conf.result_serializer = "json"
|
||||||
|
|
|
||||||
209
backend/app/tasks/quiz_tasks.py
Normal file
209
backend/app/tasks/quiz_tasks.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
"""Async quiz extraction task with step-by-step progress reporting via Redis."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.tasks import celery_app
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EXPIRE_SECONDS = 3600 # keep progress for 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
def _redis():
|
||||||
|
import redis
|
||||||
|
from app.config import settings
|
||||||
|
return redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _push_step(r, job_id: str, step: str, message: str):
|
||||||
|
key = f"extraction:steps:{job_id}"
|
||||||
|
entry = json.dumps({"step": step, "message": message, "ts": time.time()})
|
||||||
|
r.rpush(key, entry)
|
||||||
|
r.expire(key, EXPIRE_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="extract_quiz", bind=True)
|
||||||
|
def extract_quiz(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
user_id: int,
|
||||||
|
section_id: int,
|
||||||
|
title: str,
|
||||||
|
mode: str,
|
||||||
|
time_limit_minutes: int | None,
|
||||||
|
model_id: str | None,
|
||||||
|
question_category_id: int | None,
|
||||||
|
):
|
||||||
|
"""Background quiz extraction task with live progress reporting."""
|
||||||
|
r = _redis()
|
||||||
|
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.models.section import Section
|
||||||
|
from app.models.pdf_document import PDFDocument
|
||||||
|
from app.services import ai_service, vector_service, pdf_service, embedding_service
|
||||||
|
from app.models.quiz import Quiz
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.models.quiz_question_link import QuizQuestionLink
|
||||||
|
from app.config import settings
|
||||||
|
import os
|
||||||
|
|
||||||
|
_push_step(r, job_id, "start", "Starting extraction…")
|
||||||
|
|
||||||
|
section = db.query(Section).filter(Section.id == section_id).first()
|
||||||
|
if not section:
|
||||||
|
raise ValueError("Section not found")
|
||||||
|
|
||||||
|
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
|
||||||
|
if not document:
|
||||||
|
raise ValueError("Document not found")
|
||||||
|
|
||||||
|
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)…")
|
||||||
|
|
||||||
|
# Determine model first (used for chunk logging)
|
||||||
|
if model_id:
|
||||||
|
from app.models.ai_model_config import AIModelConfig
|
||||||
|
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
||||||
|
api_key = config.api_key if config and config.api_key else None
|
||||||
|
model_name = config.name if config else model_id
|
||||||
|
else:
|
||||||
|
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
|
||||||
|
model_name = model_id
|
||||||
|
|
||||||
|
# For large page ranges, process in chunks of 50 pages to avoid truncation
|
||||||
|
CHUNK_PAGES = 50
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
if total_pages <= CHUNK_PAGES:
|
||||||
|
chunks = [(section.start_page, section.end_page)]
|
||||||
|
else:
|
||||||
|
chunks = []
|
||||||
|
p = section.start_page
|
||||||
|
while p <= section.end_page:
|
||||||
|
end = min(p + CHUNK_PAGES - 1, section.end_page)
|
||||||
|
chunks.append((p, end))
|
||||||
|
p = end + 1
|
||||||
|
|
||||||
|
n_chunks = len(chunks)
|
||||||
|
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.")
|
||||||
|
|
||||||
|
all_valid_questions = []
|
||||||
|
all_skipped = []
|
||||||
|
|
||||||
|
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
|
||||||
|
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 ''}.")
|
||||||
|
|
||||||
|
if not valid_questions:
|
||||||
|
raise ValueError("No valid questions extracted. The AI could not find questions with correct answers in this page range.")
|
||||||
|
|
||||||
|
# Extract images
|
||||||
|
_push_step(r, job_id, "images", "Extracting question images…")
|
||||||
|
file_path = os.path.join(settings.UPLOAD_DIR, document.filename)
|
||||||
|
page_images = {}
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
page_images = pdf_service.extract_all_images(
|
||||||
|
file_path, document.id, section.start_page, section.end_page
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_push_step(r, job_id, "images", f"Image extraction skipped: {e}")
|
||||||
|
|
||||||
|
# Create quiz
|
||||||
|
quiz = Quiz(
|
||||||
|
section_id=section_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
questions_count=len(valid_questions),
|
||||||
|
mode=mode,
|
||||||
|
time_limit_minutes=time_limit_minutes,
|
||||||
|
skipped_questions=_json.dumps(skipped) if skipped else None,
|
||||||
|
)
|
||||||
|
db.add(quiz)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
_push_step(r, job_id, "save", f"Saving {len(valid_questions)} questions and generating embeddings…")
|
||||||
|
|
||||||
|
for pos, q in enumerate(valid_questions):
|
||||||
|
page_ref = q.get("page_reference")
|
||||||
|
image_path = None
|
||||||
|
if page_ref and page_ref in page_images and page_images[page_ref]:
|
||||||
|
image_path = page_images[page_ref].pop(0)
|
||||||
|
if not page_images[page_ref]:
|
||||||
|
del page_images[page_ref]
|
||||||
|
|
||||||
|
question = Question(
|
||||||
|
source_quiz_id=quiz.id,
|
||||||
|
question_category_id=question_category_id,
|
||||||
|
question_text=q["question_text"],
|
||||||
|
question_type=q["question_type"],
|
||||||
|
options=q.get("options"),
|
||||||
|
correct_answer=q["correct_answer"],
|
||||||
|
explanation=q.get("explanation", ""),
|
||||||
|
page_reference=page_ref,
|
||||||
|
image_path=image_path,
|
||||||
|
)
|
||||||
|
db.add(question)
|
||||||
|
db.flush()
|
||||||
|
db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=question.id, position=pos))
|
||||||
|
try:
|
||||||
|
embedding_service.embed_question(question)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quiz)
|
||||||
|
|
||||||
|
_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:quiz_id:{job_id}", str(quiz.id), ex=EXPIRE_SECONDS)
|
||||||
|
return quiz.id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Quiz extraction failed for job {job_id}")
|
||||||
|
_push_step(r, job_id, "error", f"Extraction failed: {e}")
|
||||||
|
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||||
|
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -1,8 +1,99 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
|
function ExtractionProgress({ jobId, onDone, onClose }) {
|
||||||
|
const [steps, setSteps] = useState([])
|
||||||
|
const [status, setStatus] = useState('pending')
|
||||||
|
const [quizId, setQuizId] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const intervalRef = useRef(null)
|
||||||
|
const bottomRef = useRef(null)
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/quizzes/job/${jobId}`)
|
||||||
|
setSteps(res.data.steps || [])
|
||||||
|
setStatus(res.data.status)
|
||||||
|
if (res.data.status === 'completed') {
|
||||||
|
setQuizId(res.data.quiz_id)
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
if (res.data.status === 'failed') {
|
||||||
|
setError(res.data.error || 'Extraction failed')
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}, [jobId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
poll()
|
||||||
|
intervalRef.current = setInterval(poll, 2000)
|
||||||
|
return () => clearInterval(intervalRef.current)
|
||||||
|
}, [poll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [steps.length])
|
||||||
|
|
||||||
|
const stepIcon = (step) => {
|
||||||
|
if (step === 'error') return '✗'
|
||||||
|
if (step === 'done') return '✓'
|
||||||
|
if (step === 'ai') return '🤖'
|
||||||
|
if (step === 'text') return '📄'
|
||||||
|
if (step === 'images') return '🖼'
|
||||||
|
if (step === 'save') return '💾'
|
||||||
|
return '→'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||||
|
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 520, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.35)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>
|
||||||
|
{status === 'completed' ? '✓ Extraction Complete' : status === 'failed' ? '✗ Extraction Failed' : '🤖 Extracting Questions…'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} title="Close — job runs in background" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--bg)', borderRadius: 8, padding: '12px 16px', maxHeight: 280, overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.82rem' }}>
|
||||||
|
{steps.length === 0 && <div style={{ color: 'var(--text-muted)' }}>Starting…</div>}
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8, color: s.step === 'error' ? 'var(--wrong-fg)' : s.step === 'done' ? 'var(--correct-fg)' : 'var(--text)' }}>
|
||||||
|
<span style={{ flexShrink: 0, fontSize: '0.9rem' }}>{stepIcon(s.step)}</span>
|
||||||
|
<span style={{ lineHeight: 1.5 }}>{s.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(status === 'pending' || status === 'running') && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-muted)' }}>
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
|
||||||
|
<span>Working…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error" style={{ marginTop: 12 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||||
|
{status === 'completed' || status === 'failed' ? '' : '✕ closes this panel — job continues in background'}
|
||||||
|
</span>
|
||||||
|
{status === 'completed' && quizId && (
|
||||||
|
<button className="btn btn-primary" onClick={() => onDone(quizId)}>
|
||||||
|
Open Quiz →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(status === 'failed' || status === 'completed') && (
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DocumentDetailPage() {
|
export default function DocumentDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -12,6 +103,7 @@ export default function DocumentDetailPage() {
|
||||||
const [sectionForm, setSectionForm] = useState({ name: '', start_page: 1, end_page: 10 })
|
const [sectionForm, setSectionForm] = useState({ name: '', start_page: 1, end_page: 10 })
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [deletingSection, setDeletingSection] = useState(null)
|
const [deletingSection, setDeletingSection] = useState(null)
|
||||||
|
const [activeJob, setActiveJob] = useState(null) // {jobId, sectionName}
|
||||||
const [generating, setGenerating] = useState(null)
|
const [generating, setGenerating] = useState(null)
|
||||||
const [quizTitle, setQuizTitle] = useState('')
|
const [quizTitle, setQuizTitle] = useState('')
|
||||||
const [quizMode, setQuizMode] = useState('timed')
|
const [quizMode, setQuizMode] = useState('timed')
|
||||||
|
|
@ -85,9 +177,19 @@ export default function DocumentDetailPage() {
|
||||||
model_id: selectedModelId || null,
|
model_id: selectedModelId || null,
|
||||||
question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null,
|
question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null,
|
||||||
})
|
})
|
||||||
navigate(`/quizzes/${res.data.id}`)
|
// Async: show progress panel
|
||||||
|
if (res.data.job_id) {
|
||||||
|
setActiveJob({ jobId: res.data.job_id, sectionName })
|
||||||
|
// If already completed (sync fallback), navigate directly
|
||||||
|
if (res.data.status === 'completed' && res.data.quiz_id) {
|
||||||
|
navigate(`/quizzes/${res.data.quiz_id}`)
|
||||||
|
}
|
||||||
|
} else if (res.data.id) {
|
||||||
|
navigate(`/quizzes/${res.data.id}`)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Failed to generate quiz. Check AI model config.')
|
setError(err.response?.data?.detail || 'Failed to start extraction. Check AI model config.')
|
||||||
|
} finally {
|
||||||
setGenerating(null)
|
setGenerating(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,11 +222,26 @@ export default function DocumentDetailPage() {
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn if user tries to close while a job is running
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeJob) return
|
||||||
|
const handler = (e) => { e.preventDefault(); e.returnValue = '' }
|
||||||
|
window.addEventListener('beforeunload', handler)
|
||||||
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
|
}, [activeJob])
|
||||||
|
|
||||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{activeJob && (
|
||||||
|
<ExtractionProgress
|
||||||
|
jobId={activeJob.jobId}
|
||||||
|
onDone={(quizId) => { setActiveJob(null); navigate(`/quizzes/${quizId}`) }}
|
||||||
|
onClose={() => setActiveJob(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue