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 -
{status || (isCreate ? 'Not saved yet' : `Question #${id}`)}