pdf-quiz-generator/frontend/src/pages/QuizPage.jsx
Daniel e5e31f6eba Two-phase extraction for end-of-document answer keys (PREP 2013); scroll fix; bug audit
Two-phase extraction:
- Detects end-of-document answer key format by scanning last 40 pages for
  "Preferred Response:" (PREP 2013, 2014 etc use this vs PREP 2012 inline "Correct Answer:")
- Phase 1: Extract questions with item_number field, allow null correct_answer
- Phase 2: Extract answer key (item_number → letter) from last 40% of document
- Phase 3: Match questions to answers by item number, resolve letter → full option text
- Unmatched questions go to skipped list with reason shown in Jobs page
- Standard inline format (PREP 2012) unchanged

Updated extraction prompts:
- item_number field added to all extractions for cross-referencing
- Image content rule: "Item CXXXB" figure references must NOT be treated as new questions
- Recognises both "Correct Answer: X" and "Preferred Response: X"
- ANSWER_KEY_PROMPT: dedicated prompt for extracting answer key tables

Quiz navigation scroll:
- Clicking Next, Previous, or question number now scrolls the question card
  into view (smooth scroll to start of question-card div)

Code: extract_questions_no_answers(), extract_answer_key(), _call_model() added to ai_service.py

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 03:22:26 +02:00

444 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 TTSButton({ text, voice, onActiveChange }) {
const [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null)
// Stop audio when question changes (component unmounts)
useEffect(() => {
return () => {
audioRef.current?.pause()
onActiveChange?.(false)
}
}, [])
const setStateAndNotify = (s) => {
setState(s)
onActiveChange?.(s !== 'idle')
}
const speak = async () => {
if (state === 'playing') {
audioRef.current?.pause()
setStateAndNotify('idle')
return
}
if (state === 'loading') return
try {
setStateAndNotify('loading')
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const audio = new Audio(url)
audioRef.current = audio
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
audio.onerror = () => setStateAndNotify('idle')
audio.play()
setStateAndNotify('playing')
} catch { setStateAndNotify('idle') }
}
const label = state === 'loading' ? '⏳ Loading...' : state === 'playing' ? '⏹ Stop' : '🔊 Listen'
const bg = state === 'playing' ? '#ef4444' : state === 'loading' ? '#e2e8f0' : '#e0e7ff'
const color = state === 'playing' ? 'white' : state === 'loading' ? '#64748b' : '#3730a3'
return (
<button onClick={speak} title={label} disabled={state === 'loading'} style={{
background: bg, color, border: 'none', borderRadius: '6px',
padding: '4px 10px', cursor: state === 'loading' ? 'not-allowed' : 'pointer',
fontSize: '0.8rem', marginLeft: 8, whiteSpace: 'nowrap',
}}>
{label}
</button>
)
}
function TimerDisplay({ seconds, total }) {
const pct = total > 0 ? (seconds / total) * 100 : 100
const mins = Math.floor(seconds / 60), secs = seconds % 60
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
</div>
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
</div>
</div>
)
}
function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const skipped = quiz.skipped_questions ? JSON.parse(quiz.skipped_questions) : []
return (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
{quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
</p>
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Timed, answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => (
<div key={mode} onClick={() => onStart(mode, selectedVoice)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{desc}</div>
</div>
))}
</div>
{voices.length > 0 && (
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: 16 }}>
<label style={{ fontSize: '0.85rem', color: '#64748b', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
</select>
</div>
)}
</div>
</div>
)
}
export default function QuizPage() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [quiz, setQuiz] = useState(null)
const [voices, setVoices] = useState([])
const [selectedVoice, setSelectedVoice] = useState('')
const [ttsActive, setTtsActive] = useState(false)
const [quizMode, setQuizMode] = useState(null)
const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [attemptId, setAttemptId] = useState(null)
const [timeLeft, setTimeLeft] = useState(null)
const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false)
const timerRef = useRef(null)
const toastRef = useRef(null)
const hasStarted = useRef(false)
const showToast = (msg) => {
setToast(msg)
clearTimeout(toastRef.current)
toastRef.current = setTimeout(() => setToast(''), 3000)
}
useEffect(() => {
const load = async () => {
try {
const [quizRes, voicesRes] = await Promise.all([
api.get(`/quizzes/${id}`),
api.get('/tts/voices').catch(() => ({ data: [] })),
])
setQuiz(quizRes.data)
setVoices(voicesRes.data)
const def = voicesRes.data.find(v => v.is_default)
if (def) setSelectedVoice(def.id)
} catch { navigate('/') }
finally { setLoading(false) }
}
load()
return () => clearInterval(timerRef.current)
}, [id])
const startQuiz = async (mode, voice) => {
if (hasStarted.current) return
hasStarted.current = true
setSelectedVoice(voice)
setQuizMode(mode)
// Fetch full question data with study mode if needed
try {
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`)
setQuiz(quizRes.data)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
if (mode === 'exam' && quizRes.data.time_limit_minutes) {
const secs = quizRes.data.time_limit_minutes * 60
setTimeLeft(secs); setTotalTime(secs)
}
} catch { navigate('/') }
}
useEffect(() => {
if (timeLeft === null) return
if (timeLeft <= 0) { handleSubmit(true); return }
timerRef.current = setInterval(() => {
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
}, 1000)
return () => clearInterval(timerRef.current)
}, [timeLeft === null])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
const safeNavigate = (targetIdx) => {
setCurrentIdx(targetIdx)
setTimeout(() => {
document.querySelector('.question-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}
const handleSubmit = useCallback(async (autoSubmit = false) => {
if (!attemptId || submitting) return
if (!autoSubmit && Object.keys(answers).length === 0) {
showToast('No answers selected — submitting with 0 answered.')
await new Promise(r => setTimeout(r, 1200))
}
clearInterval(timerRef.current)
setSubmitting(true)
try {
const submission = {
answers: Object.entries(answers).map(([qid, answer]) => ({
question_id: parseInt(qid), user_answer: answer,
})),
}
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
navigate(`/results/${attemptId}`, { state: { result: res.data } })
} catch (err) {
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
} finally { setSubmitting(false) }
}, [attemptId, answers, submitting])
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null
if (!quizMode) return (
<div>
{isModerator && (
<div style={{ textAlign: 'right', marginBottom: 8 }}>
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div>
)}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
</div>
)
const isStudy = quizMode === 'study'
const questions = quiz.questions || []
const current = questions[currentIdx]
const answeredCount = Object.keys(answers).length
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
const QuestionDot = ({ q, i }) => {
const isActive = i === currentIdx
const isDone = !!answers[q.id]
return (
<button key={q.id} onClick={() => { safeNavigate(i); setNavOpen(false) }} style={{
width: 34, height: 34, borderRadius: '50%', border: isActive ? '2px solid var(--primary)' : 'none',
cursor: 'pointer', fontSize: '0.78rem', fontWeight: 600, flexShrink: 0,
background: isActive ? 'var(--primary)' : isDone ? 'var(--correct-bg)' : 'var(--border)',
color: isActive ? 'white' : isDone ? 'var(--correct-fg)' : 'var(--text-muted)',
transition: 'background 0.1s',
}}>{i + 1}</button>
)
}
return (
<div className="quiz-bottom">
{toast && (
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(15,23,42,0.92)', color: '#f1f5f9',
padding: '10px 20px', borderRadius: 24, fontSize: '0.85rem',
zIndex: 500, whiteSpace: 'nowrap', pointerEvents: 'none',
animation: 'fadeInUp 0.2s ease',
}}>
{toast}
</div>
)}
{/* Header */}
<div className="card" style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
}}>
{isStudy ? '📖 Study' : '🎯 Exam'}
</span>
<span>Q {currentIdx + 1} / {totalCount}</span>
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div>
</div>
{voices.length > 1 && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>🔊</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
disabled={ttsActive}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.78rem', opacity: ttsActive ? 0.5 : 1, background: 'var(--input-bg)', color: 'var(--text)' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
)}
</div>
<div className="progress-bar" style={{ marginBottom: 16 }}>
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{/* Two-column layout: content + desktop sidebar */}
<div className="quiz-layout">
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{current && (
<div className="question-card">
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
{voices.length > 0 && (
<div style={{ marginBottom: 10 }}>
<TTSButton
key={`${current.id}_${answers[current.id] || ''}`}
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
voice={selectedVoice}
onActiveChange={setTtsActive}
/>
</div>
)}
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{current.image_path && (
<div style={{ margin: '10px 0' }}>
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.target.style.display = 'none'} />
</div>
)}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 8 }}>
{current.options.map((opt, i) => {
const isSelected = answers[current.id] === opt
const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt === current.correct_answer
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>{opt}</span>
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>}
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
) : (
<input type="text" placeholder="Type your answer..."
value={answers[current.id] || ''}
onChange={e => setAnswer(current.id, e.target.value)}
style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} />
)}
{isStudy && answers[current.id] && (
<>
{current.explanation && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
<strong>Explanation:</strong> {current.explanation}
</div>
)}
{current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer}
</div>
)}
</>
)}
</div>
)}
{/* Prev / Next + mobile nav toggle */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, gap: 8 }}>
<button className="btn btn-secondary"
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}> Prev</button>
{/* Mobile-only nav toggle */}
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
onClick={() => setNavOpen(v => !v)}>
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
</button>
{isLast ? (
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </button>
)}
</div>
{/* Mobile: collapsible number grid */}
{navOpen && (
<div className="quiz-nav-mobile-grid">
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
</div>
)}
{answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<button className="btn btn-secondary btn-sm" onClick={() => handleSubmit(false)} disabled={submitting}>
Submit now ({answeredCount}/{totalCount} answered)
</button>
</div>
)}
</div>
{/* Desktop sidebar */}
<div className="quiz-sidebar">
<div style={{ fontWeight: 700, fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: 12 }}>
Questions
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
</div>
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginRight: 4 }} />{answeredCount} answered</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--border)', marginRight: 4 }} />{totalCount - answeredCount} remaining</span>
</div>
{answeredCount > 0 && !isLast && (
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
)}
</div>
</div>
</div>
</div>
)
}