From a704542a14fcf2d30dc607cb6525bc7db281b696 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 01:16:22 +0200 Subject: [PATCH] feat: the session drawer on a phone; and extraction says what actually failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/tasks/quiz_tasks.py | 21 +++++++-- frontend/src/index.css | 12 ----- frontend/src/pages/QuizPage.jsx | 73 ++++++++++++++++++++++++------- frontend/src/pages/QuizPlayer.css | 53 +++++++++++++++++++++- 4 files changed, 126 insertions(+), 33 deletions(-) diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 3ccd39d..552fec7 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -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 diff --git a/frontend/src/index.css b/frontend/src/index.css index 3f73d7c..e66cd24 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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) { diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 5d10b58..27b0144 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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 && ( )} @@ -1295,19 +1297,6 @@ const timerStarted = timeLeft !== null ) } - const QuestionDot = ({ q, i }) => { - const isActive = i === currentIdx - const isDone = !!answers[q.id] - return ( - - ) - } return (
@@ -1418,6 +1407,50 @@ const timerStarted = timeLeft !== null
+ {/* ── 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 && ( +
e.target === e.currentTarget && setNavOpen(false)}> +
+
+ +
+ + +
+
+ + {drawerTab === 'questions' ? ( + <> +
+ {quiz.title} + {answeredCount}/{totalCount} +
+
+ {questions.map((q, i) => )} +
+ + ) : ( + + )} +
+
+ )} + {/* Two-column layout: content + desktop sidebar */}
{/* Main content */} @@ -1426,7 +1459,14 @@ const timerStarted = timeLeft !== null {hasRail ? (

Question{currentIdx + 1} of {totalCount}

) : ( - + /* 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. */ + )}
@@ -1437,7 +1477,6 @@ const timerStarted = timeLeft !== null
- {!hasRail && navOpen &&
{questions.map((q, i) => )}
} {current && (