feat: a real site footer, and better retrieval queries

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 02:40:57 +02:00
parent 16d799a273
commit 7a764d8845
9 changed files with 175 additions and 31 deletions

View file

@ -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

View file

@ -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,

View file

@ -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() {
<div className="container">
<Outlet />
</div>
<footer className="site-footer">
<div className="container">© {new Date().getFullYear()} PedsHub</div>
</footer>
<SiteFooter />
</>
)
}

View file

@ -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; }
}

View file

@ -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 (
<footer className="site-footer">
<div className="container">
<nav className="sf-columns" aria-label="Footer">
{COLUMNS.map(column => (
<div key={column.heading} className="sf-column">
<h2>{column.heading}</h2>
<ul>
{column.links.map(link => (
<li key={link.to + link.label}>
<Link to={link.to}>{link.label}</Link>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="sf-base">
<span className="sf-mark">🏥 PedsHub</span>
<span>© {new Date().getFullYear()}</span>
<span className="sf-note">
Study material for exam revision. Not a substitute for clinical judgement.
</span>
</div>
</div>
</footer>
)
}

View file

@ -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(<MemoryRouter><SiteFooter /></MemoryRouter>)
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(<MemoryRouter><SiteFooter /></MemoryRouter>)
// 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(<MemoryRouter><SiteFooter /></MemoryRouter>)
expect(screen.getByText(/Not a substitute for clinical judgement/)).toBeInTheDocument()
})
})

View file

@ -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; }

View file

@ -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; }

View file

@ -413,15 +413,6 @@ export default function QuestionEditPage({ mode = 'edit' }) {
</div>
<div className="qe-bar">
<div className="qe-bar-links">
<Link to={backTo}> {backLabel}</Link>
<span className="qe-bar-sep" aria-hidden="true">·</span>
<Link to="/question-bank">Question bank</Link>
<Link to="/questions/manage">Question manager</Link>
<Link to="/media">Image bank</Link>
<Link to="/categories">Taxonomy</Link>
<Link to="/articles">Reading</Link>
</div>
<div className="qe-bar-inner">
<span className="qe-bar-status" role="status" aria-live="polite">
{status || (isCreate ? 'Not saved yet' : `Question #${id}`)}