fix: a deck keeps the article's filing, and the card fills the window
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 29s

Four things a deck got wrong.

The category. Every deck written from an article is filed where the
article is filed — and the list drew all of them "Uncategorized",
because the router defines its own FlashcardDeckResponse that shadows
the one in schemas/, and that one has no category_id. So the field was
set on the row, returned by nothing, and an educator refiled by hand
what the system had already filed correctly. The shared schema was
imported by no module at all, so it is gone rather than left as a
second definition to read past next time.

The size. Fifteen cards is the per-chunk default, and an article is one
chunk however long it is — a ten-section piece and a two-paragraph stub
both asked for fifteen. Now roughly a card per 150 words, floored at 12
so a short article still makes a deck and capped at 30 so one call stays
inside the model's output.

The card. Set at list-item size inside a frame that fills the window, so
a two-line question sat in the middle of an acre of white. The face
scales with the window and stops at a comfortable measure; the back is
set smaller than the front, as prose rather than a headline.

And the contract snapshot, which still owed the jobs endpoint from the
last commit.

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 03:58:59 +02:00
parent 4aa1352da7
commit 2bc1b9fbd8
9 changed files with 100 additions and 46 deletions

View file

@ -45,6 +45,11 @@ class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None = None
# Where the deck is filed. It was set on every deck written from an
# article — and left out of this schema, so the list drew every one of
# them as "Uncategorized" and an educator refiled a deck the system had
# already filed correctly.
category_id: int | None = None
user_id: int
card_count: int
is_shared: int = 0

View file

@ -1,32 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int
title: str
model_id: str | None = None
class FlashcardResponse(BaseModel):
id: int
front: str
back: str
page_reference: int | None = None
image_path: str | None = None
class Config:
from_attributes = True
class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None = None
category_id: int | None = None
user_id: int
card_count: int
created_at: datetime
class Config:
from_attributes = True
class FlashcardDeckDetail(FlashcardDeckResponse):
cards: list[FlashcardResponse] = []

View file

@ -1204,7 +1204,16 @@ def generate_article_cards(self, job_id: str, user_id: int, article_id: int,
article.title, article.summary, article.content,
*[f"## {s['title']}\n{s['content']}" for s in (article.sections or [])],
]))
cards = extraction_modes.generate_flashcards(content, "article", None, ai_model_id, ai_api_key)
# As many cards as the article has substance for. Fifteen is the
# per-chunk default, and an article is one chunk however long it is —
# so a ten-section piece and a two-paragraph stub asked for the same
# fifteen. Roughly a card per 150 words, floored so a short article
# still makes a usable deck and capped so one call stays inside the
# model's output.
words = len(content.split())
wanted = max(12, min(30, round(words / 150)))
cards = extraction_modes.generate_flashcards(
content, "article", None, ai_model_id, ai_api_key, n=wanted)
if not cards:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "No cards could be generated")

View file

@ -399,6 +399,16 @@
"422"
]
},
"DELETE /api/v1/quizzes/jobs/{job_id}": {
"body": false,
"params": [
"path:job_id"
],
"responses": [
"204",
"422"
]
},
"DELETE /api/v1/quizzes/{quiz_id}": {
"body": false,
"params": [

View file

@ -79,3 +79,21 @@
@media (hover: none) {
.al-card { width: min(340px, calc(100vw - 32px)); }
}
/* The article, named, inside the pane's own hover card. Clicking it replaces
what the pane is showing which is the thing somebody reading in a pane
actually wants, and was the one action with no label on it. */
.al-card-open {
display: flex; align-items: center; gap: 8px;
margin-top: 2px; padding: 8px 10px; width: 100%;
text-align: left; cursor: pointer; font: inherit; font-size: .82rem;
background: var(--bg); color: var(--primary);
border: 1px solid var(--border); border-radius: 8px;
}
.al-card-open:hover { border-color: var(--primary); }
.al-card-open-name {
flex: 1; min-width: 0; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
text-decoration: underline; text-underline-offset: 2px;
}
.al-card-open-hint { flex: none; font-size: .72rem; color: var(--text-muted); }

View file

@ -171,6 +171,19 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
{preview.section_count} section{preview.section_count === 1 ? '' : 's'}
{preview.status !== 'published' && ' · draft'}
</span>
{/* Inside the pane, the article names itself and the name is the
control: clicking it replaces what the pane is showing. "Split
view" would be a second pane there is nowhere to put, and leaving
only "New tab" made the card look like it had one way out when
the useful one read it here, keep your place was unlabelled. */}
{split?.inPane && (
<button type="button" className="al-card-open"
onClick={() => { hideNow(); split.open(slug) }}>
<span aria-hidden="true"></span>
<span className="al-card-open-name">{preview.title}</span>
<span className="al-card-open-hint">Read here</span>
</button>
)}
<span className="al-card-actions">
{split && !split.inPane && (
<button type="button" className="al-card-action"

View file

@ -108,10 +108,17 @@ describe('reading a cross-reference beside the article', () => {
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
await userEvent.hover(within(pane).getByRole('link', { name: 'sepsis' }))
const card = await screen.findByRole('tooltip')
// A tab, and nothing else: "beside what you are reading" is a question with
// one answer for somebody who is already beside what they were reading.
expect(within(card).getByRole('link', { name: /new tab/i })).toBeInTheDocument()
// No Split view there is no second pane to put it in. What it offers
// instead is the article by name, and clicking that name replaces what the
// pane is showing.
expect(within(card).queryByRole('button', { name: /split view/i })).not.toBeInTheDocument()
expect(within(card).getByRole('link', { name: /new tab/i })).toBeInTheDocument()
const open = within(card).getByRole('button', { name: /Sepsis/ })
expect(open).toHaveTextContent('Read here')
await userEvent.click(open)
expect(await screen.findByRole('region', { name: 'Split view: Sepsis' })).toBeInTheDocument()
expect(document.querySelectorAll('.article-split-pane')).toHaveLength(1)
})
it('follows a link inside the pane in the pane, not into a third column', async () => {

View file

@ -28,7 +28,7 @@ body:has(.fc-study) .site-footer { display: none; }
/* The header and its section strip, and nothing else. */
height: calc(100dvh - 98px);
display: flex; flex-direction: column;
padding: 0 24px;
padding: 0 16px;
}
.fc-study-main {
flex: 1; min-height: 0;
@ -54,14 +54,34 @@ body:has(.fc-study) .site-footer { display: none; }
.fc-card {
flex: 1; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; padding: 40px 32px; text-align: center; cursor: pointer;
gap: 16px; padding: 48px 32px; text-align: center; cursor: pointer;
background: var(--card-bg); border: 2px solid var(--border);
border-radius: var(--card-radius); box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
transition: border-color .2s;
}
.fc-card.is-flipped { border-color: var(--primary); }
.fc-card .imgfig { margin: 0; }
.fc-face { max-width: 62ch; }
.fc-figure { margin-top: 4px; }
/* The kicker, the words, the nudge. The words are the point of the screen and
were the smallest thing on it: a card set at 1.15rem inside a frame that
fills the window reads as a caption for a picture that is not there. They
scale with the window now, and stop at a comfortable measure so a long back
does not run the full width of a desk monitor. */
.fc-kicker {
font-size: .72rem; color: var(--text-muted); text-transform: uppercase;
font-weight: 700; letter-spacing: .05em;
}
.fc-face {
width: 100%; max-width: 66ch;
font-size: clamp(1.3rem, 2.4vw, 1.9rem); line-height: 1.5; font-weight: 600;
}
.fc-face.is-back {
font-size: clamp(1.05rem, 1.7vw, 1.35rem); line-height: 1.65; font-weight: 400;
}
.fc-face p { margin: 0 0 .6em; }
.fc-face p:last-child { margin-bottom: 0; }
.fc-hint { font-size: .8rem; color: var(--text-muted); margin-top: 8px; }
/* The three things, in the player's own shape: the way out at the left, the
navigation in the middle, the verdict beside it. Nothing here ends anything
@ -100,5 +120,7 @@ body:has(.fc-study) .site-footer { display: none; }
nothing else for that button to mean here. Only the padding changes. */
.fc-study { padding: 0 12px; }
.fc-card { padding: 24px 16px; }
.fc-face { font-size: clamp(1.15rem, 5vw, 1.5rem); }
.fc-face.is-back { font-size: clamp(1rem, 4vw, 1.15rem); }
.fc-keys { display: none; }
}

View file

@ -208,29 +208,31 @@ export default function FlashcardStudyPage() {
else reveal()
}}
>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 8, textTransform: 'uppercase', fontWeight: 700, letterSpacing: '0.05em' }}>
<div className="fc-kicker">
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
</div>
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
{/* The card's words, at the size of the thing you are actually
doing. They were set at list-item size inside a frame that
fills the window, so a two-line question sat in the middle of
an acre of white. */}
<div className={flipped ? 'fc-face is-back' : 'fc-face'}>
{/* Rendered, not printed. A card's two faces are prose like
everything else here, so `[[264|respiratory failure]]`,
`==key points==` and a figure all work on a card which is
most of what "link cards to things" turns out to mean. */}
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
</p>
</div>
{/* The picture on the card. A card could carry one the column is
there, the editor accepts one, the API returns it and no view
in the app drew it, so every image anybody attached to a card
was stored and never seen. Small until clicked, like every
other figure. */}
{currentCard.image_path && (
<div style={{ marginTop: 12 }}>
<div className="fc-figure">
<ImageFigure src={currentCard.image_path} alt={currentCard.front} showLabel={false} />
</div>
)}
{!flipped && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
)}
{!flipped && <div className="fc-hint">Tap to reveal answer</div>}
</div>
{/* The verdict bar, pinned to the foot of the deck the way the