fix: a deck keeps the article's filing, and the card fills the window
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:
parent
4aa1352da7
commit
2bc1b9fbd8
9 changed files with 100 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue