diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index b1843dd..c4ad423 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -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() diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx index 37a3eba..4b1fdab 100644 --- a/frontend/src/components/ArticleReader.jsx +++ b/frontend/src/components/ArticleReader.jsx @@ -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' diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css index 2b5ca2d..5293fbe 100644 --- a/frontend/src/pages/AiModePage.css +++ b/frontend/src/pages/AiModePage.css @@ -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; } diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx index 1acab9e..3710c07 100644 --- a/frontend/src/pages/CategoriesPage.jsx +++ b/frontend/src/pages/CategoriesPage.jsx @@ -383,7 +383,10 @@ export default function CategoriesPage() { return (
A document goes in, a model proposes questions, you read them, and the