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 && (