From 7a764d88453172584d5cb07db8d646e9366d0135 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 02:40:57 +0200 Subject: [PATCH] feat: a real site footer, and better retrieval queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The links I put in the save bar are gone — that bar was right as it was, and a row of navigation crammed above it was clutter in the one place a person is trying to finish a question. The footer is where going somewhere else belongs. `SiteFooter` replaces the copyright line: four columns — Study, Library, Find, PedsHub — with About, Contact, Account and Settings among them, and the standing note that this is revision material rather than clinical guidance, said once at the bottom of every page. A test asserts every link points at a route that actually exists, because a footer full of dead links is worse than a short one: the reader learns not to trust any of them. Two retrieval faults the writing found A bare condition name is a thin query. "Rickets" alone retrieved five passages about *Rickettsia* — an embedding has little to go on in one word, and the nearest neighbours of a short string are whatever looks like it. Asking as "Rickets in children: definition, causes, clinical features, diagnosis and management" took the contamination from five passages to none, so both the pipeline and the generated route now ask that way. And a category that names a department rather than a condition retrieves chapter headings and whatever sits near them. "Pediatric Nephrology" passed the material check with entirely irrelevant passages, and an article called that is a department, not something to revise. Those names are now excluded from the topic list. Both were found by an agent writing articles and reporting what looked wrong, rather than by anything automated noticing. 247 frontend tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/article_writer.py | 4 +- backend/scripts/article_pipeline.py | 23 ++++++- frontend/src/App.jsx | 5 +- frontend/src/components/SiteFooter.css | 38 +++++++++++ frontend/src/components/SiteFooter.jsx | 76 +++++++++++++++++++++ frontend/src/components/SiteFooter.test.jsx | 34 +++++++++ frontend/src/index.css | 8 --- frontend/src/pages/QuestionEditPage.css | 9 +-- frontend/src/pages/QuestionEditPage.jsx | 9 --- 9 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/SiteFooter.css create mode 100644 frontend/src/components/SiteFooter.jsx create mode 100644 frontend/src/components/SiteFooter.test.jsx diff --git a/backend/app/services/article_writer.py b/backend/app/services/article_writer.py index d73c966..559cf0c 100644 --- a/backend/app/services/article_writer.py +++ b/backend/app/services/article_writer.py @@ -143,7 +143,9 @@ def write_article(db: Session, topic: str, category_id: int | None = None, Returns a small report rather than the article, because the caller is a background run over hundreds of topics and wants to know what happened. """ - passages = clinical_library.search(topic, limit=PASSAGES, folder_contains=SHELF) + passages = clinical_library.search( + f"{topic} in children: definition, causes, clinical features, diagnosis and management", + limit=PASSAGES, folder_contains=SHELF) source_chars = sum(len(p["text"]) for p in passages) if len(passages) < 3 or source_chars < MIN_SOURCE_CHARS: # Too little to ground an article. Writing one anyway would produce diff --git a/backend/scripts/article_pipeline.py b/backend/scripts/article_pipeline.py index 1e89261..b287ebf 100644 --- a/backend/scripts/article_pipeline.py +++ b/backend/scripts/article_pipeline.py @@ -19,6 +19,7 @@ generated route had. import argparse import json import pathlib +import re import sys import uuid @@ -33,6 +34,22 @@ from app.services.article_writer import ( VARIANTS = ("short", "long", "clinical") +# A bare condition name is a thin query. "Rickets" alone retrieved five passages +# about Rickettsia — an embedding has little to go on in one word, and the +# nearest neighbours of a short string are whatever looks like it. Saying what +# kind of thing is wanted removes the collision entirely. +QUERY_SHAPE = "{topic} in children: definition, causes, clinical features, diagnosis and management" + +# Category names that are a shelf rather than a condition. They retrieve chapter +# headings and whatever happens to sit near them, and an article called +# "Pediatric Nephrology" is a department, not something to revise. +UMBRELLA = re.compile( + r"^(pediatric|paediatric)\b|\b(medicine|surgery|disorder|disorders|care|health|" + r"nephrology|neurology|cardiology|oncology|dermatology|psychiatry|radiology|" + r"pulmonology|endocrinology|gastroenterology|rheumatology|urology|" + r"hematology|immunology|genetics|orthopedics|ophthalmology)$", + re.I) + def cmd_topics(args): """Conditions that still have no article, biggest first.""" @@ -47,7 +64,8 @@ def cmd_topics(args): ORDER BY uses DESC, c.name """)).fetchall() have = {row[0] for row in db.query(Article.slug).all()} - todo = [(cid, name, uses) for cid, name, uses in rows if slugify(name) not in have] + todo = [(cid, name, uses) for cid, name, uses in rows + if slugify(name) not in have and not UMBRELLA.search(name.strip())] for category_id, name, uses in todo[:args.limit]: print(f"{category_id}\t{uses}\t{name}") print(f"\n# {len(todo)} topics without an article", file=sys.stderr) @@ -58,7 +76,8 @@ def cmd_topics(args): def cmd_fetch(args): """Everything needed to write one article, and nothing that writes it.""" - passages = clinical_library.search(args.topic, limit=PASSAGES, folder_contains=SHELF) + passages = clinical_library.search(QUERY_SHAPE.format(topic=args.topic), + limit=PASSAGES, folder_contains=SHELF) chars = sum(len(p["text"]) for p in passages) payload = { "topic": args.topic, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 63e94c8..e1aec17 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom import { AuthProvider, useAuth } from './context/AuthContext' import { ThemeProvider } from './context/ThemeContext' import Navbar from './components/Navbar' +import SiteFooter from './components/SiteFooter' const LoginPage = lazy(() => import('./pages/LoginPage')) const RegisterPage = lazy(() => import('./pages/RegisterPage')) @@ -57,9 +58,7 @@ function AppLayout() {
-
-
© {new Date().getFullYear()} PedsHub
-
+ ) } diff --git a/frontend/src/components/SiteFooter.css b/frontend/src/components/SiteFooter.css new file mode 100644 index 0000000..c9062cb --- /dev/null +++ b/frontend/src/components/SiteFooter.css @@ -0,0 +1,38 @@ +/* A footer you can navigate from. */ + +.site-footer { + margin-top: 56px; + padding: 30px 0 calc(28px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); + background: var(--card-bg); + text-align: left; + color: var(--text-muted); + font-size: 0.84rem; +} + +.sf-columns { + display: grid; gap: 22px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + margin-bottom: 22px; +} +.sf-column h2 { + margin: 0 0 8px; font-size: 0.71rem; font-weight: 700; + letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); +} +.sf-column ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.sf-column a { color: var(--text); text-decoration: none; font-size: 0.85rem; } +.sf-column a:hover { color: var(--primary); text-decoration: underline; } + +.sf-base { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding-top: 16px; border-top: 1px solid var(--border); + font-size: 0.78rem; color: var(--text-subtle); +} +.sf-mark { font-weight: 700; color: var(--text-muted); } +/* Worth saying once, at the bottom of every page, and only once. */ +.sf-note { margin-left: auto; } + +@media (max-width: 640px) { + .sf-columns { grid-template-columns: repeat(2, 1fr); } + .sf-note { margin-left: 0; } +} diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx new file mode 100644 index 0000000..e2861d6 --- /dev/null +++ b/frontend/src/components/SiteFooter.jsx @@ -0,0 +1,76 @@ +import { Link } from 'react-router-dom' +import './SiteFooter.css' + +/** + * The footer, as a way around rather than a copyright line. + * + * Only routes that exist are listed. A footer full of dead links is worse than + * a short one, because the reader learns not to trust any of them. + */ +const COLUMNS = [ + { + heading: 'Study', + links: [ + { to: '/', label: 'Dashboard' }, + { to: '/question-bank', label: 'Question bank' }, + { to: '/study-plans', label: 'Study plans' }, + { to: '/quizzes', label: 'Quizzes' }, + ], + }, + { + heading: 'Library', + links: [ + { to: '/articles', label: 'Reading' }, + { to: '/flashcards', label: 'Cards' }, + { to: '/courses', label: 'Courses' }, + { to: '/media', label: 'Images' }, + ], + }, + { + heading: 'Find', + links: [ + { to: '/search', label: 'Search' }, + { to: '/ai', label: 'AI Mode' }, + { to: '/analysis', label: 'Analysis' }, + ], + }, + { + heading: 'PedsHub', + links: [ + { to: '/home', label: 'About' }, + { to: '/home#contact', label: 'Contact' }, + { to: '/account', label: 'Account' }, + { to: '/settings', label: 'Settings' }, + ], + }, +] + +export default function SiteFooter() { + return ( +
+
+ +
+ 🏥 PedsHub + © {new Date().getFullYear()} + + Study material for exam revision. Not a substitute for clinical judgement. + +
+
+
+ ) +} diff --git a/frontend/src/components/SiteFooter.test.jsx b/frontend/src/components/SiteFooter.test.jsx new file mode 100644 index 0000000..58e0855 --- /dev/null +++ b/frontend/src/components/SiteFooter.test.jsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import SiteFooter from './SiteFooter' + +const ROUTES = ['/home', '/login', '/register', '/', '/quizzes', '/question-bank', '/analysis', + '/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles', + '/courses', '/account', '/settings', '/categories', '/editorial'] + +describe('site footer', () => { + it('is a way around, grouped by what you are trying to do', () => { + render() + const nav = screen.getByRole('navigation', { name: 'Footer' }) + for (const heading of ['Study', 'Library', 'Find', 'PedsHub']) { + expect(within(nav).getByRole('heading', { name: heading })).toBeInTheDocument() + } + expect(within(nav).getByRole('link', { name: 'About' })).toBeInTheDocument() + }) + + it('points at no route that does not exist', () => { + render() + // A footer full of dead links is worse than a short one: the reader learns + // not to trust any of them. + for (const link of screen.getAllByRole('link')) { + const path = link.getAttribute('href').split('#')[0] + expect(ROUTES, `${path} is not a route`).toContain(path) + } + }) + + it('says once, at the bottom, what this material is not', () => { + render() + expect(screen.getByText(/Not a substitute for clinical judgement/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/index.css b/frontend/src/index.css index e042e36..dafe150 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -652,14 +652,6 @@ body { .empty-state { text-align: center; padding: 48px; color: var(--text-subtle); } /* ── Footer ─────────────────────────────────────────────────── */ -.site-footer { - text-align: center; - padding: 24px 0 32px; - margin-top: 48px; - border-top: 1px solid var(--border); - color: var(--text-subtle); - font-size: 0.8rem; -} /* ── Quiz page layout ───────────────────────────────────────── */ .quiz-bottom { padding-bottom: 48px; } diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css index 89bf6ce..3096901 100644 --- a/frontend/src/pages/QuestionEditPage.css +++ b/frontend/src/pages/QuestionEditPage.css @@ -113,14 +113,7 @@ .qe-preview[open] > summary { border-bottom: 1px solid var(--border); } .qe-preview .rich-text { padding: 12px 14px; font-size: 0.9rem; } -/* Footer links. The save bar is where the eye already is when a question is - finished, so the ways onward belong beside it rather than back at the top. */ -.qe-bar-links { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; padding-bottom: 10px; } -.qe-bar-links a { - font-size: 0.78rem; color: var(--text-muted); text-decoration: none; -} -.qe-bar-links a:hover { color: var(--primary); text-decoration: underline; } -.qe-bar-links .qe-bar-sep { color: var(--text-subtle); font-size: 0.7rem; } + @media (max-width: 900px) { .qe-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx index de4c596..d34aa54 100644 --- a/frontend/src/pages/QuestionEditPage.jsx +++ b/frontend/src/pages/QuestionEditPage.jsx @@ -413,15 +413,6 @@ export default function QuestionEditPage({ mode = 'edit' }) {
-
- ← {backLabel} - - Question bank - Question manager - Image bank - Taxonomy - Reading -
{status || (isCreate ? 'Not saved yet' : `Question #${id}`)}