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>
421 lines
18 KiB
JavaScript
421 lines
18 KiB
JavaScript
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 1–50)"
|
||
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>
|
||
)
|
||
}
|