pdf-quiz-generator/frontend/src/pages/DocumentDetailPage.jsx
Daniel 96a1a259f0 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>
2026-04-01 01:55:24 +02:00

421 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
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() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const [doc, setDoc] = useState(null)
const [loading, setLoading] = useState(true)
const [sectionForm, setSectionForm] = useState({ name: '', start_page: 1, end_page: 10 })
const [creating, setCreating] = useState(false)
const [deletingSection, setDeletingSection] = useState(null)
const [activeJob, setActiveJob] = useState(null) // {jobId, sectionName}
const [generating, setGenerating] = useState(null)
const [quizTitle, setQuizTitle] = useState('')
const [quizMode, setQuizMode] = useState('timed')
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
const [selectedModelId, setSelectedModelId] = useState('')
const [availableModels, setAvailableModels] = useState([])
const [questionCategories, setQuestionCategories] = useState([])
const [selectedQuestionCategoryId, setSelectedQuestionCategoryId] = useState('')
const [error, setError] = useState('')
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const fetchDoc = () => {
api.get(`/documents/${id}`).then(res => {
setDoc(res.data)
setLoading(false)
}).catch(() => navigate('/'))
}
useEffect(() => {
fetchDoc()
Promise.all([
api.get('/admin/models/available?task=extraction').catch(() => ({ data: [] })),
api.get('/question-categories/').catch(() => ({ data: [] })),
]).then(([modelsRes, catsRes]) => {
setAvailableModels(modelsRes.data)
const def = modelsRes.data.find(m => m.is_default)
if (def) setSelectedModelId(def.model_id)
setQuestionCategories(catsRes.data)
})
}, [id])
useEffect(() => {
if (!doc || doc.status !== 'processing') return
const interval = setInterval(() => {
api.get(`/documents/${id}/status`).then(res => {
if (res.data.status !== 'processing') {
fetchDoc()
clearInterval(interval)
}
})
}, 3000)
return () => clearInterval(interval)
}, [doc?.status])
const createSection = async (e) => {
e.preventDefault()
setCreating(true)
setError('')
try {
await api.post(`/documents/${id}/sections`, sectionForm)
fetchDoc()
setSectionForm({ name: '', start_page: 1, end_page: 10 })
} catch (err) {
setError(err.response?.data?.detail || 'Failed to create section')
} finally {
setCreating(false)
}
}
const generateQuiz = async (sectionId, sectionName) => {
setGenerating(sectionId)
setError('')
try {
const title = quizTitle || `Quiz: ${sectionName}`
const res = await api.post('/quizzes/', {
section_id: sectionId,
title,
mode: quizMode,
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
model_id: selectedModelId || null,
question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null,
})
// 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) {
setError(err.response?.data?.detail || 'Failed to start extraction. Check AI model config.')
} finally {
setGenerating(null)
}
}
const deleteSection = async (sectionId) => {
if (!confirm('Delete this section? Associated quizzes will also be deleted.')) return
setDeletingSection(sectionId)
try {
await api.delete(`/documents/${id}/sections/${sectionId}`)
fetchDoc()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete section')
} finally { setDeletingSection(null) }
}
const createCategoryAndSelect = async (name) => {
if (!name.trim()) return
try {
const res = await api.post('/question-categories/', { name: name.trim() })
setQuestionCategories(prev => [...prev, res.data])
setSelectedQuestionCategoryId(String(res.data.id))
} catch (err) {
setError(err.response?.data?.detail || 'Failed to create category')
}
}
const deleteDoc = async () => {
if (!confirm('Delete this document and all its quizzes?')) return
await api.delete(`/documents/${id}`)
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 (!doc) return null
return (
<div>
{activeJob && (
<ExtractionProgress
jobId={activeJob.jobId}
onDone={(quizId) => { setActiveJob(null); navigate(`/quizzes/${quizId}`) }}
onClose={() => setActiveJob(null)}
/>
)}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>{doc.original_filename}</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem' }}>
{doc.total_pages ? `${doc.total_pages} pages` : ''} · Uploaded {new Date(doc.uploaded_at).toLocaleDateString()}
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
{isModerator && <button className="btn btn-danger btn-sm" onClick={deleteDoc}>Delete</button>}
</div>
</div>
{doc.status === 'processing' && (
<div style={{ marginTop: 16, textAlign: 'center', color: '#64748b' }}>
<div className="spinner"></div>
<p style={{ marginTop: 8 }}>Processing PDF and extracting text... This may take a while for large files.</p>
</div>
)}
{doc.status === 'error' && (
<div className="alert alert-error" style={{ marginTop: 16 }}>
Error: {doc.error_message || 'Unknown error'}
</div>
)}
</div>
{doc.status === 'ready' && (
<>
{error && <div className="alert alert-error">{error}</div>}
{/* Moderators can create sections */}
{isModerator && (
<div className="card">
<h2>Create Section</h2>
<p style={{ color: '#64748b', marginBottom: 16, fontSize: '0.9rem' }}>
Define page ranges the AI will extract all questions from those pages.
</p>
<form onSubmit={createSection}>
<div className="grid-2">
<div className="form-group">
<label>Section Name</label>
<input
type="text"
value={sectionForm.name}
onChange={e => setSectionForm({ ...sectionForm, name: e.target.value })}
placeholder="e.g., Chapter 1 (pages 150)"
required
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Start Page</label>
<input type="number" min={1} max={doc.total_pages || 9999} value={sectionForm.start_page}
onChange={e => setSectionForm({ ...sectionForm, start_page: parseInt(e.target.value) || 1 })} />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>End Page</label>
<input type="number" min={1} max={doc.total_pages || 9999} value={sectionForm.end_page}
onChange={e => setSectionForm({ ...sectionForm, end_page: parseInt(e.target.value) || 10 })} />
</div>
</div>
</div>
<button className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Section'}
</button>
</form>
</div>
)}
{/* Sections list */}
{doc.sections && doc.sections.length > 0 && (
<div className="card">
<h2>Sections</h2>
{/* Quiz settings (moderator only) */}
{isModerator && (
<div style={{ background: '#f8fafc', padding: 16, borderRadius: 8, marginBottom: 16 }}>
<strong style={{ display: 'block', marginBottom: 12 }}>Quiz Settings</strong>
<div className="grid-2">
<div className="form-group">
<label>Quiz Title (optional)</label>
<input type="text" value={quizTitle} onChange={e => setQuizTitle(e.target.value)}
placeholder="Auto-generated if empty" />
</div>
<div className="form-group">
<label>Mode</label>
<select value={quizMode} onChange={e => setQuizMode(e.target.value)}>
<option value="timed">Timed answers hidden, timer counts down</option>
<option value="learning">Learning answers + explanations shown inline</option>
</select>
</div>
{quizMode === 'timed' && (
<div className="form-group">
<label>Time Limit (minutes, optional)</label>
<input type="number" min={1} value={timeLimitMinutes}
onChange={e => setTimeLimitMinutes(e.target.value)}
placeholder="Leave blank for no limit" />
</div>
)}
{availableModels.length > 1 && (
<div className="form-group">
<label>Extraction Model</label>
<select value={selectedModelId} onChange={e => setSelectedModelId(e.target.value)}>
{availableModels.map(m => (
<option key={m.model_id} value={m.model_id}>
{m.name}{m.is_default ? ' (default)' : ''}
</option>
))}
</select>
</div>
)}
<div className="form-group">
<label>Add Questions to Bank Category <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<div style={{ display: 'flex', gap: 8 }}>
<select value={selectedQuestionCategoryId} onChange={e => setSelectedQuestionCategoryId(e.target.value)} style={{ flex: 1 }}>
<option value=""> No category </option>
{questionCategories.map(c => (
<option key={c.id} value={c.id}>{c.name}{c.question_count > 0 ? ` (${c.question_count})` : ''}</option>
))}
</select>
<button type="button" className="btn btn-secondary btn-sm" style={{ whiteSpace: 'nowrap' }}
onClick={() => {
const name = prompt('New category name:')
if (name) createCategoryAndSelect(name)
}}>
+ New
</button>
</div>
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
Extracted questions will be tagged with this category in the Question Bank.
</p>
</div>
</div>
</div>
)}
{doc.sections.map(section => (
<div className="section-item" key={section.id}>
<div>
<strong>{section.name}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
Pages {section.start_page}{section.end_page}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{isModerator && (
<>
<button
className="btn btn-primary btn-sm"
onClick={() => generateQuiz(section.id, section.name)}
disabled={generating === section.id}
>
{generating === section.id ? (
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract & Create Quiz'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => deleteSection(section.id)}
disabled={deletingSection === section.id}
title="Delete this section"
>
{deletingSection === section.id ? '…' : 'Delete'}
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}