feat: the session drawer on a phone; and extraction says what actually failed
The phone had a dot grid dropped under the top bar — a different thing in a different place doing the rail's job worse. It is a drawer holding the same rail the desktop has, with the site's own menu on the other tab, because the alternative is a second hamburger elsewhere for the same purpose. The dot grid and its styles are gone. And the extraction pipeline was run end to end against a three-question PDF rather than reasoned about. It works: three questions, stems, options, correct answers and explanations, landing in a draft batch and not in the bank. But the run found a real bug on the way. A document's text is read from the search index, not from the file. When that index is missing — never processed, or lost to a restart — every page is skipped and the job fails with "the AI could not find questions with correct answers in this page range". That is the wrong diagnosis, and it sends people to change the model, the prompt and the page range, none of which is the problem. The two failures are now counted apart and named apart: no stored text says so and says to re-process; a model that found nothing says that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2d828c3c03
commit
a704542a14
4 changed files with 126 additions and 33 deletions
|
|
@ -102,6 +102,9 @@ def extract_quiz(
|
|||
_push_step(r, job_id, "text", f"Large section: splitting into {n_chunks} chunks of up to {CHUNK_PAGES} pages each.")
|
||||
|
||||
all_valid_questions = []
|
||||
# A page whose text is missing is a different failure from a page the
|
||||
# model found nothing in, and the two were reported as one.
|
||||
pages_without_text = 0
|
||||
all_skipped = []
|
||||
|
||||
# ── Non-standard extraction modes ─────────────────────────────────────
|
||||
|
|
@ -206,7 +209,6 @@ def extract_quiz(
|
|||
extraction_mode = "standard"
|
||||
|
||||
if extraction_mode == "standard":
|
||||
# ── STANDARD: existing working extraction (unchanged) ──────────────
|
||||
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
|
||||
if r.get(f"extraction:status:{job_id}") == "cancelled":
|
||||
_push_step(r, job_id, "cancelled", "Job cancelled.")
|
||||
|
|
@ -219,7 +221,11 @@ def extract_quiz(
|
|||
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.")
|
||||
pages_without_text += 1
|
||||
_push_step(r, job_id, "ai",
|
||||
f" No stored text for pages {start_p}–{end_p}. The document's text is"
|
||||
f" read from the search index, not the file, so this usually means it"
|
||||
f" was never processed or its index was lost.")
|
||||
continue
|
||||
try:
|
||||
chunk_data = ai_service.extract_questions(
|
||||
|
|
@ -246,7 +252,16 @@ def extract_quiz(
|
|||
_push_step(r, job_id, "ai", f"Extraction complete: {len(valid_questions)} 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.")
|
||||
# Blaming the model for a document that was never indexed sent
|
||||
# people to change the model, the prompt and the page range, none
|
||||
# of which was the problem.
|
||||
if pages_without_text:
|
||||
raise ValueError(
|
||||
"This document has no stored text to read. Its pages are indexed when it is"
|
||||
" uploaded, and that index is what extraction reads — not the file. Re-process"
|
||||
" the document and try again.")
|
||||
raise ValueError(
|
||||
"The model found no questions with a marked correct answer in these pages.")
|
||||
|
||||
# Refresh DB connection — it may have gone stale during long LLM extraction
|
||||
from sqlalchemy import text as _text
|
||||
|
|
|
|||
|
|
@ -758,18 +758,6 @@ html, body { overflow-x: hidden; }
|
|||
|
||||
/* Mobile: hide sidebar, show toggle button */
|
||||
.quiz-nav-toggle { display: none; }
|
||||
.quiz-nav-mobile-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
animation: fadeInUp 0.15s ease;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ export default function QuizPage() {
|
|||
const [totalTime, setTotalTime] = useState(null)
|
||||
const [toast, setToast] = useState('')
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const [drawerTab, setDrawerTab] = useState('questions')
|
||||
// The session rail is the navigator whenever there is room for it; the
|
||||
// dropdown only exists for screens too narrow to show it. Matches the
|
||||
// 1150px breakpoint in QuizPlayer.css that hides the rail.
|
||||
|
|
@ -1218,8 +1219,9 @@ const timerStarted = timeLeft !== null
|
|||
every question, a button that unfolds the same list is noise. */}
|
||||
{!hasRail && (
|
||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||
onClick={() => setNavOpen(v => !v)}>
|
||||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||||
aria-label={`Question ${currentIdx + 1} of ${totalCount} — open the session`}
|
||||
onClick={() => { setDrawerTab('questions'); setNavOpen(true) }}>
|
||||
{currentIdx + 1} / {totalCount} ☰
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -1295,19 +1297,6 @@ const timerStarted = timeLeft !== null
|
|||
)
|
||||
}
|
||||
|
||||
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 quiz-player is-boxed">
|
||||
|
|
@ -1418,6 +1407,50 @@ const timerStarted = timeLeft !== null
|
|||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{/* ── The session, on a phone ────────────────────────────────────
|
||||
The desktop keeps the rail permanently beside the question. A phone
|
||||
has no room for it, so it is a drawer holding the same list — with
|
||||
the site's own menu on the other tab, because the alternative is a
|
||||
second hamburger somewhere else for the same purpose. */}
|
||||
{!hasRail && navOpen && (
|
||||
<div className="quiz-drawer" onClick={e => e.target === e.currentTarget && setNavOpen(false)}>
|
||||
<div className="quiz-drawer-panel" role="dialog" aria-modal="true" aria-label="Session">
|
||||
<div className="quiz-drawer-head">
|
||||
<button type="button" className="quiz-drawer-close" aria-label="Close"
|
||||
onClick={() => setNavOpen(false)}>✕</button>
|
||||
<div className="quiz-drawer-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={drawerTab === 'menu'}
|
||||
onClick={() => setDrawerTab('menu')}>Main menu</button>
|
||||
<button type="button" role="tab" aria-selected={drawerTab === 'questions'}
|
||||
onClick={() => setDrawerTab('questions')}>Questions</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drawerTab === 'questions' ? (
|
||||
<>
|
||||
<div className="quiz-drawer-title">
|
||||
<strong>{quiz.title}</strong>
|
||||
<small>{answeredCount}/{totalCount}</small>
|
||||
</div>
|
||||
<div className="quiz-rail-list quiz-drawer-list">
|
||||
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<nav className="quiz-drawer-menu" aria-label="Main menu">
|
||||
{[
|
||||
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/questions', 'Qbank'],
|
||||
['/study-plans', 'Study plans'], ['/articles', 'Reading'],
|
||||
['/flashcards', 'Cards'], ['/settings', 'Settings'],
|
||||
].map(([to, label]) => (
|
||||
<Link key={to} to={to} onClick={() => setNavOpen(false)}>{label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column layout: content + desktop sidebar */}
|
||||
<div className="quiz-layout">
|
||||
{/* Main content */}
|
||||
|
|
@ -1426,7 +1459,14 @@ const timerStarted = timeLeft !== null
|
|||
{hasRail ? (
|
||||
<p className="quiz-question-select is-static"><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount}</p>
|
||||
) : (
|
||||
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} ▾</button>
|
||||
/* Opens the same rail the desktop has, as a drawer. A dot grid
|
||||
dropped under the bar was a different thing in a different
|
||||
place doing the same job worse. */
|
||||
<button type="button" className="quiz-question-select" aria-expanded={navOpen}
|
||||
aria-label={`Question ${currentIdx + 1} of ${totalCount} — open the session`}
|
||||
onClick={() => setNavOpen(true)}>
|
||||
<small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} ☰
|
||||
</button>
|
||||
)}
|
||||
<div className="quiz-top-actions">
|
||||
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}>⌨ <span>Shortcuts</span></button>
|
||||
|
|
@ -1437,7 +1477,6 @@ const timerStarted = timeLeft !== null
|
|||
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
{!hasRail && navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>}
|
||||
|
||||
{current && (
|
||||
<div className="question-card" style={{
|
||||
|
|
|
|||
|
|
@ -135,7 +135,6 @@
|
|||
.quiz-submit-error { padding: 14px; color: #962b3e; background: #fff0f2; margin-bottom: 14px; }
|
||||
.quiz-review-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin: 20px 0; }
|
||||
.quiz-tool-body .quiz-complete-confirm { background: #496fa5; color: white; padding: 12px 20px; }
|
||||
.quiz-player .quiz-nav-mobile-grid { box-shadow: none; border-radius: 0; margin-bottom: 16px; }
|
||||
.quiz-player .manual-highlight-toolbar { position: static; width: auto; padding: 0; background: transparent; border: 0; box-shadow: none; }
|
||||
.quiz-results .review-card { box-shadow: none; border: 0; border-bottom: 1px solid #e8eaf1; padding: 20px 0 32px; border-radius: 0; }
|
||||
.quiz-results .score-display { padding: 20px 10px; }
|
||||
|
|
@ -371,3 +370,55 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
text-decoration: line-through;
|
||||
color: var(--wrong-fg); border-color: var(--wrong-bd); background: var(--wrong-bg);
|
||||
}
|
||||
|
||||
/* ── The session drawer ───────────────────────────────────────────────
|
||||
The desktop keeps the rail beside the question permanently. A phone has
|
||||
no room, so it is a drawer holding the same list — and the site's own
|
||||
menu on the other tab, because the alternative is a second hamburger
|
||||
elsewhere doing the same job. */
|
||||
.quiz-drawer {
|
||||
position: fixed; inset: 0; z-index: 1200;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
}
|
||||
.quiz-drawer-panel {
|
||||
display: flex; flex-direction: column;
|
||||
width: min(340px, 92vw); height: 100%;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 12px 0 40px rgba(15, 23, 42, 0.2);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.quiz-drawer-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border); flex: none;
|
||||
}
|
||||
.quiz-drawer-close {
|
||||
flex: none; width: 34px; height: 34px;
|
||||
background: none; border: 0; border-radius: 8px;
|
||||
font-size: 1rem; cursor: pointer; color: var(--text-muted);
|
||||
}
|
||||
.quiz-drawer-tabs { display: flex; flex: 1; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.quiz-drawer-tabs button {
|
||||
flex: 1; padding: 9px 6px; min-height: 40px;
|
||||
font: inherit; font-size: 0.84rem; font-weight: 600;
|
||||
background: var(--card-bg); border: 0; cursor: pointer; color: var(--text-muted);
|
||||
}
|
||||
.quiz-drawer-tabs button[aria-selected="true"] { background: var(--option-sel-bg); color: var(--primary); }
|
||||
|
||||
.quiz-drawer-title {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||
padding: 12px 14px 8px; flex: none;
|
||||
}
|
||||
.quiz-drawer-title strong { font-size: 0.9rem; font-weight: 650; }
|
||||
.quiz-drawer-title small { font-size: 0.78rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* The list is the only thing that scrolls. */
|
||||
.quiz-drawer-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
|
||||
.quiz-drawer-menu { display: flex; flex-direction: column; padding: 6px; overflow-y: auto; }
|
||||
.quiz-drawer-menu a {
|
||||
padding: 12px 10px; min-height: 44px;
|
||||
font-size: 0.9rem; text-decoration: none; color: var(--text);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.quiz-drawer-menu a:hover { background: var(--bg); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue