From 2bc1b9fbd834519ca03d306c497fc66f7d3aaf02 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 03:58:59 +0200 Subject: [PATCH] fix: a deck keeps the article's filing, and the card fills the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/flashcards.py | 5 +++ backend/app/schemas/flashcard.py | 32 -------------------- backend/app/tasks/quiz_tasks.py | 11 ++++++- backend/tests/api-contract.json | 10 ++++++ frontend/src/components/ArticleLink.css | 18 +++++++++++ frontend/src/components/ArticleLink.jsx | 13 ++++++++ frontend/src/pages/ArticleSplitView.test.jsx | 13 ++++++-- frontend/src/pages/FlashcardStudyPage.css | 28 +++++++++++++++-- frontend/src/pages/FlashcardStudyPage.jsx | 16 +++++----- 9 files changed, 100 insertions(+), 46 deletions(-) delete mode 100644 backend/app/schemas/flashcard.py diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index 01d4b61..7c8caf6 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -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 diff --git a/backend/app/schemas/flashcard.py b/backend/app/schemas/flashcard.py deleted file mode 100644 index 72c9a57..0000000 --- a/backend/app/schemas/flashcard.py +++ /dev/null @@ -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] = [] diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index c4ad423..5e1be0b 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -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") diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json index fbaf5c4..c5680d5 100644 --- a/backend/tests/api-contract.json +++ b/backend/tests/api-contract.json @@ -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": [ diff --git a/frontend/src/components/ArticleLink.css b/frontend/src/components/ArticleLink.css index 5b65117..32bfa39 100644 --- a/frontend/src/components/ArticleLink.css +++ b/frontend/src/components/ArticleLink.css @@ -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); } diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx index 987c356..2a07ded 100644 --- a/frontend/src/components/ArticleLink.jsx +++ b/frontend/src/components/ArticleLink.jsx @@ -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'} + {/* 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 && ( + + )} {split && !split.inPane && (