fix: card generation, the reader's own styles, and Tools that owned other people's work
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 25s
Tests / e2e (push) Failing after 28s

**Card generation was failing every time.** The model configured for the
flashcard task — and for extraction and keyword — was `ds-deepseek-v4-flash`,
which the AI proxy no longer serves. All three now point at the v4.1 model that
does exist, and the dead rows are gone.

The reason was reachable only by reading the job record: the panel said "Card
generation failed." and nothing else, while the proxy's actual answer sat in a
field nothing displayed. A failed job now says which model is missing and where
to change it.

**The reader had no styles of its own.** Its appearance lived in
ArticlesPage.css, imported by the reading page — so an article rendered
correctly there and as unstyled boxes anywhere else it was used. The split pane
inside AI Mode is where that finally showed: section headings drew as bare
bordered rectangles. The reader imports what it needs now.

**Settings → Tools listed the question workbench and the taxonomy**, which are
question work and belong beside the questions. Both are on the question
manager's own bar now, the taxonomy's way back leads to Questions rather than
to a settings page nobody was on, and the workbench's does too.

Also: air under AI Mode's composer, which sat flush against the footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 02:54:44 +02:00
parent 93a3b74011
commit 6305d82e95
7 changed files with 46 additions and 6 deletions

View file

@ -1,6 +1,7 @@
"""Async quiz extraction task with step-by-step progress reporting via Redis."""
import json
import logging
import re
import time
import os
@ -954,6 +955,25 @@ class ArticleDraftError(RuntimeError):
"""A refusal with a sentence the educator can act on."""
def _why(exc: Exception) -> str:
"""One sentence a person can act on, out of whatever the provider said.
A proxy answers a missing model with three hundred characters of JSON. The
part that matters is which model, and that it does not exist everything
else belongs in the log.
"""
text = str(exc)
found = re.search(r"Invalid model name passed in model=([\w.:-]+)", text)
if found:
return (f"The model configured for this job — {found.group(1)} — is not on "
"the AI proxy any more. Pick another in Admin → Models.")
if "429" in text or "rate limit" in text.lower():
return "The AI provider is rate-limiting us. Try again in a minute."
if "timed out" in text.lower() or "timeout" in text.lower():
return "The model took too long to answer."
return text[:160]
def _draft_grounding(r, job_id: str, topic: str, use_library: bool, use_pubmed: bool):
"""Source material for a draft, and the references that come with it.
@ -1221,6 +1241,10 @@ def generate_article_cards(self, job_id: str, user_id: int, article_id: int,
db.rollback()
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "Card generation failed.")
# The reason, on the line somebody actually reads. "Card generation
# failed." was all the jobs panel showed for a fortnight while the
# answer — a model the proxy had dropped — sat in a field nothing
# displayed.
_push_step(r, job_id, "error", f"Card generation failed. {_why(exc)}")
finally:
db.close()

View file

@ -1,4 +1,9 @@
import { useEffect, useRef, useState } from 'react'
// The reader carries its own appearance. These rules lived only in
// ArticlesPage.css, so the reader rendered correctly on the reading page and
// as unstyled boxes anywhere else it was used the split pane inside AI Mode
// being where that finally showed.
import '../pages/ArticlesPage.css'
import useMediaQuery from '../hooks/useMediaQuery'
import useHeaderOffset from '../hooks/useHeaderOffset'
import { useSessionDrawer } from '../context/SessionDrawer'

View file

@ -1,6 +1,8 @@
/* AI Mode: a rail of threads beside the conversation. */
.ai-page { display: grid; grid-template-columns: 250px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; }
/* A little air under the composer. It sat flush against the footer, so the
box you type into looked like part of the site's furniture. */
.ai-page { display: grid; grid-template-columns: 250px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; padding-bottom: 28px; }
/* Folded, the rail keeps only its two controls, and the conversation takes the
width back rather than leaving a column of nothing beside it. */
.ai-page.is-folded { grid-template-columns: 52px 1fr; }

View file

@ -383,7 +383,10 @@ export default function CategoriesPage() {
return (
<div className="cat-page">
<BackLink to="/settings?s=tools">Tools</BackLink>
{/* Back to the questions, because that is what a taxonomy is for and
where everybody arrives from. It used to lead to Tools, which is a
settings page nobody was on. */}
<BackLink to="/questions/manage">Questions</BackLink>
<div className="cat-header">
<div>
<h1>Taxonomy</h1>

View file

@ -174,6 +174,10 @@ export default function QuestionManagerPage() {
</div>
<div className="qm-header-actions">
<Link className="btn btn-secondary" to="/categories">Taxonomy</Link>
{/* The workbench is question work and lives beside the questions. It
was listed under Settings Tools, which is a place nobody is
when they are thinking about a PDF full of questions. */}
<Link className="btn btn-secondary" to="/tools">Workbench</Link>
<QuestionImport onImported={refresh} selectedIds={selected} />
<Link className="btn btn-primary" to="/questions/new"
state={{ from: '/questions/manage', label: 'Questions' }}>+ New question</Link>

View file

@ -247,8 +247,10 @@ function ToolsSection() {
<div className="set-cards">
{[
{ to: '/handbook', icon: '📖', label: 'Handbook', desc: 'How the parts that are not obvious work' },
{ to: '/tools', icon: '🧪', label: 'Question workbench', desc: 'PDFs in, drafts read, questions out' },
{ to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' },
/* Not the workbench and not the taxonomy. Both are question work,
both are one press from Questions in the main menu, and listing
them here as well is a second door into the same room which is
how somebody ends up in Settings wondering how to get back. */
{ to: '/access', icon: '🔑', label: 'Access', desc: 'Who may edit what' },
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted questions' },
{ to: '/jobs', icon: '📋', label: 'Extraction jobs', desc: 'Extraction history' },

View file

@ -56,7 +56,7 @@ export default function ToolsPage() {
{/* The way back. This page is a workbench rather than part of the
study flow, and without a trail out of it you are somewhere that
looks like a different application. */}
<BackLink to="/settings?s=tools">Tools in Settings</BackLink>
<BackLink to="/questions/manage">Questions</BackLink>
<h1>Tools</h1>
<p>
A document goes in, a model proposes questions, you read them, and the