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:
parent
16d799a273
commit
7a764d8845
9 changed files with 175 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
38
frontend/src/components/SiteFooter.css
Normal file
38
frontend/src/components/SiteFooter.css
Normal 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; }
|
||||
}
|
||||
76
frontend/src/components/SiteFooter.jsx
Normal file
76
frontend/src/components/SiteFooter.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/SiteFooter.test.jsx
Normal file
34
frontend/src/components/SiteFooter.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}`)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue