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() {
-
+
>
)
}
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 (
+
+ )
+}
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
-