diff --git a/backend/app/main.py b/backend/app/main.py
index 2257bc6..0374d93 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
-from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media
+from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@@ -642,6 +642,7 @@ app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
+app.include_router(search.router, prefix="/api/search", tags=["search"])
@app.get("/api/health")
diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py
new file mode 100644
index 0000000..e6c6b60
--- /dev/null
+++ b/backend/app/routers/search.py
@@ -0,0 +1,211 @@
+"""One query across everything a learner can see.
+
+Each corpus already has its own retrieval and its own rules about who may see
+what. This endpoint runs them together and returns one answer, rather than
+making somebody guess which of five pages holds the thing they are looking for.
+
+Two decisions worth stating:
+
+* Visibility is never re-implemented here. Questions go through the same bank
+ predicate and exam scope as the question bank, articles through the same draft
+ rule, cards through deck ownership, images through library grants. A search
+ page that had its own idea of who may see what is how private content leaks.
+* A section match is reported under its article, not beside it. Ten sections of
+ one article are one result with ten places to start reading, not ten results
+ that bury everything else.
+"""
+import logging
+import re
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import text as sa_text
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.article import Article, ArticleSectionIndex
+from app.models.flashcard import Flashcard, FlashcardDeck
+from app.models.media import MediaAsset
+from app.models.question import Question
+from app.models.user import User
+from app.routers.media import readable_libraries
+from app.services.quiz_builder import bank_query, exam_scope_predicate
+from app.services.search_service import hybrid_ids
+from app.utils.auth import get_current_user
+
+router = APIRouter()
+log = logging.getLogger(__name__)
+
+KINDS = ("article", "question", "flashcard", "media")
+# Per-corpus retrieval pool. Wider than what is shown, because visibility
+# filtering happens after ranking and can empty a page that had hits.
+POOL = 60
+
+
+def _strip_markup(value: str | None) -> str:
+ text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", value or "")
+ text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
+ text = re.sub(r"<[^>]+>", " ", text)
+ text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
+ text = re.sub(r"[*_`>|]", " ", text)
+ return re.sub(r"\s+", " ", text).strip()
+
+
+def _snippet(value: str | None, query: str, width: int = 180) -> str:
+ """A window of the text around the first query word that appears in it.
+
+ Showing the opening of every document makes results look identical; showing
+ where the match is tells you whether it is the one you meant.
+ """
+ text = _strip_markup(value)
+ if not text:
+ return ""
+ for word in sorted((w for w in re.findall(r"\w{4,}", query.lower())), key=len, reverse=True):
+ found = text.lower().find(word)
+ if found >= 0:
+ start = max(0, found - width // 3)
+ piece = text[start:start + width]
+ return ("…" if start else "") + piece.strip() + ("…" if start + width < len(text) else "")
+ return text[:width] + ("…" if len(text) > width else "")
+
+
+def _ordered(rows, ranked: list[int]):
+ """Rows back in the order retrieval put them, not the order the DB returned."""
+ position = {row_id: index for index, row_id in enumerate(ranked)}
+ return sorted(rows, key=lambda row: position.get(row.id, len(position)))
+
+
+def _articles(db, user, q, limit):
+ ranked, _ = hybrid_ids(db, q, "article", limit=POOL)
+ # A section match belongs to its article, so both rankers feed one result.
+ section_ranked, _ = hybrid_ids(db, q, "article_section", limit=POOL)
+ sections = db.query(ArticleSectionIndex).filter(
+ ArticleSectionIndex.id.in_(section_ranked)).all() if section_ranked else []
+ by_article: dict[int, list] = {}
+ for section in sections:
+ by_article.setdefault(section.article_id, []).append(section)
+
+ wanted = list(dict.fromkeys([*ranked, *by_article.keys()]))
+ if not wanted:
+ return []
+ rows = db.query(Article).filter(Article.id.in_(wanted)).all()
+ if not user.is_moderator:
+ rows = [a for a in rows if a.status == "published" or a.user_id == user.id]
+ results = []
+ for article in _ordered(rows, wanted)[:limit]:
+ results.append({
+ "id": article.id,
+ "slug": article.slug,
+ "title": article.title,
+ "snippet": _snippet(article.summary or article.content, q),
+ "status": article.status,
+ "section_count": len(article.sections or []),
+ "sections": [
+ {"section_id": s.section_id, "title": s.title, "snippet": _snippet(s.content, q)}
+ for s in by_article.get(article.id, [])[:4]
+ ],
+ })
+ return results
+
+
+def _questions(db, user, q, limit):
+ ranked, semantic = hybrid_ids(db, q, "question", limit=POOL)
+ if not ranked:
+ return []
+ query = bank_query(db, user).filter(Question.id.in_(ranked))
+ scope = exam_scope_predicate(db, user)
+ if scope is not None:
+ query = query.filter(scope)
+ rows = _ordered(query.all(), ranked)[:limit]
+ return [{
+ "id": row.id,
+ "snippet": _snippet(row.question_text, q),
+ "difficulty": row.difficulty,
+ # Worth saying: a hit nobody's words predicted came from the meaning.
+ "match": "semantic" if row.id in semantic else "keyword",
+ } for row in rows]
+
+
+def _flashcards(db, user, q, limit):
+ ranked, _ = hybrid_ids(db, q, "flashcard", limit=POOL)
+ if not ranked:
+ return []
+ own = [d.id for d in db.query(FlashcardDeck.id).filter(
+ FlashcardDeck.user_id == user.id, FlashcardDeck.deleted_at.is_(None)).all()]
+ if not own:
+ return []
+ rows = db.query(Flashcard).filter(
+ Flashcard.id.in_(ranked), Flashcard.deck_id.in_(own)).all()
+ return [{"id": row.id, "deck_id": row.deck_id, "front": row.front,
+ "snippet": _snippet(row.back, q)} for row in _ordered(rows, ranked)[:limit]]
+
+
+def _media(db, user, q, limit):
+ ranked, _ = hybrid_ids(db, q, "media", limit=POOL)
+ if not ranked:
+ return []
+ query = db.query(MediaAsset).filter(MediaAsset.id.in_(ranked))
+ scope = readable_libraries(db, user)
+ if scope is not None:
+ query = query.filter(MediaAsset.library_id.in_(scope or {0}))
+ rows = _ordered(query.all(), ranked)[:limit]
+ return [{"id": row.id, "path": row.path, "title": row.title,
+ "snippet": _snippet(row.caption or row.alt_text, q)} for row in rows]
+
+
+FINDERS = {"article": _articles, "question": _questions,
+ "flashcard": _flashcards, "media": _media}
+
+
+@router.get("")
+def search_everything(
+ q: str = Query("", max_length=500),
+ kinds: str | None = Query(None, description="Comma-separated subset of article,question,flashcard,media"),
+ limit: int = Query(10, ge=1, le=50),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Search every corpus at once, grouped by what was found."""
+ query_text = (q or "").strip()
+ wanted = [k for k in (kinds.split(",") if kinds else KINDS) if k in FINDERS]
+ empty = {"query": query_text, "results": {kind: [] for kind in wanted}, "total": 0}
+ if len(query_text) < 2:
+ return empty
+
+ results = {}
+ for kind in wanted:
+ try:
+ results[kind] = FINDERS[kind](db, current_user, query_text, limit)
+ except Exception:
+ # One corpus failing is a gap in the answer, not the end of it.
+ log.warning("Search failed for %s", kind, exc_info=True)
+ results[kind] = []
+ return {"query": query_text, "results": results,
+ "total": sum(len(rows) for rows in results.values())}
+
+
+@router.get("/suggest")
+def suggest(
+ q: str = Query("", max_length=200),
+ limit: int = Query(6, ge=1, le=12),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Titles for a typeahead — cheap enough to run on every keystroke.
+
+ Deliberately lexical and prefix-based: a typeahead is finishing the word you
+ are typing, and a semantic neighbour of half a word is noise.
+ """
+ query_text = (q or "").strip()
+ if len(query_text) < 2:
+ return {"suggestions": []}
+ pattern = f"%{query_text.lower()}%"
+ rows = db.execute(sa_text("""
+ SELECT id, slug, title, status, user_id FROM articles
+ WHERE lower(title) LIKE :pattern
+ ORDER BY CASE WHEN lower(title) LIKE :prefix THEN 0 ELSE 1 END, length(title), title
+ LIMIT :limit
+ """), {"pattern": pattern, "prefix": f"{query_text.lower()}%", "limit": limit * 2}).fetchall()
+ visible = [r for r in rows
+ if r.status == "published" or current_user.is_moderator or r.user_id == current_user.id]
+ return {"suggestions": [{"kind": "article", "id": r.id, "slug": r.slug, "title": r.title}
+ for r in visible[:limit]]}
diff --git a/backend/tests/test_global_search.py b/backend/tests/test_global_search.py
new file mode 100644
index 0000000..7aeb63d
--- /dev/null
+++ b/backend/tests/test_global_search.py
@@ -0,0 +1,117 @@
+"""One query across every corpus, on disposable SQLite; no network or AI.
+
+The point of these tests is the boundary, not the ranking: a search page that
+had its own idea of who may see what is how private content leaks.
+"""
+import unittest
+
+import test_quiz_builder as fixtures
+from app.models.article import Article, ArticleSectionIndex
+from app.models.flashcard import Flashcard, FlashcardDeck
+from app.models.question import Question
+from app.routers import search
+
+
+class GlobalSearchTests(unittest.TestCase):
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.client.app.include_router(search.router, prefix='/search')
+ self.db = self.bank.db
+
+ self.db.add_all([
+ Article(id=1, slug='febrile-seizures', title='Febrile seizures',
+ summary='A seizure with fever in a young child', content='Intro',
+ sections=[{"id": "a" * 32, "slug": "workup", "title": "Initial workup", "content": "Lumbar puncture"}],
+ status='published', user_id=3),
+ Article(id=2, slug='draft-topic', title='Febrile draft', summary='Unpublished febrile notes',
+ sections=[], status='draft', user_id=3),
+ ])
+ self.db.add(ArticleSectionIndex(id=1, article_id=1, section_id='a' * 32,
+ title='Initial workup', content='Lumbar puncture in a febrile infant'))
+ self.db.add_all([
+ FlashcardDeck(id=1, title='Mine', user_id=1),
+ FlashcardDeck(id=2, title='Theirs', user_id=2),
+ ])
+ self.db.flush()
+ self.db.add_all([
+ Flashcard(id=1, deck_id=1, front='Febrile seizure duration', back='Under 15 minutes'),
+ Flashcard(id=2, deck_id=2, front='Febrile seizure age', back='6 months to 5 years'),
+ ])
+ self.db.query(Question).filter(Question.id == 1).update(
+ {"question_text": "A child with a febrile seizure lasting two minutes"})
+ self.db.query(Question).filter(Question.id == 3).update(
+ {"question_text": "A private febrile seizure question"})
+ self.db.commit()
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def find(self, q='febrile', **params):
+ response = self.client.get('/search', params={'q': q, **params})
+ self.assertEqual(response.status_code, 200, response.text)
+ return response.json()
+
+ def test_one_query_answers_from_every_corpus(self):
+ self.bank.user = self.bank.owner
+ data = self.find()
+ self.assertEqual(data['query'], 'febrile')
+ self.assertIn(1, [row['id'] for row in data['results']['article']])
+ self.assertIn(1, [row['id'] for row in data['results']['question']])
+ self.assertIn(1, [row['id'] for row in data['results']['flashcard']])
+ self.assertEqual(data['total'], sum(len(v) for v in data['results'].values()))
+
+ def test_a_section_hit_is_reported_under_its_article(self):
+ self.bank.user = self.bank.owner
+ article = next(row for row in self.find('lumbar')['results']['article'] if row['id'] == 1)
+ # Ten sections of one article are one result with ten places to start,
+ # not ten results burying everything else.
+ self.assertEqual([s['title'] for s in article['sections']], ['Initial workup'])
+ self.assertEqual(article['sections'][0]['section_id'], 'a' * 32)
+
+ def test_drafts_stay_invisible_to_everyone_but_their_educator(self):
+ self.bank.user = self.bank.owner
+ self.assertNotIn(2, [row['id'] for row in self.find()['results']['article']])
+ self.bank.user = self.bank.mod
+ self.assertIn(2, [row['id'] for row in self.find()['results']['article']])
+
+ def test_private_questions_and_other_peoples_decks_are_not_searchable(self):
+ self.bank.user = self.bank.peer
+ data = self.find()
+ # Question 3 belongs to another user and is not shared.
+ self.assertNotIn(3, [row['id'] for row in data['results']['question']])
+ # Deck 1 belongs to the owner; the peer's search must not reach into it.
+ self.assertNotIn(1, [row['id'] for row in data['results']['flashcard']])
+
+ def test_a_query_too_short_to_mean_anything_asks_nothing(self):
+ self.bank.user = self.bank.owner
+ data = self.find('f')
+ self.assertEqual(data['total'], 0)
+ self.assertEqual(data['results']['article'], [])
+
+ def test_kinds_narrows_the_answer(self):
+ self.bank.user = self.bank.owner
+ data = self.find(kinds='article')
+ self.assertEqual(set(data['results']), {'article'})
+
+ def test_a_snippet_shows_where_the_match_is_without_markup(self):
+ self.db.query(Article).filter(Article.id == 1).update(
+ {"summary": None, "content": "## Heading\n\nSee  the **febrile** child."})
+ self.db.commit()
+ self.bank.user = self.bank.owner
+ snippet = next(row for row in self.find()['results']['article'] if row['id'] == 1)['snippet']
+ self.assertIn('febrile', snippet)
+ self.assertNotIn('##', snippet)
+ self.assertNotIn('/uploads/', snippet)
+
+ def test_suggestions_prefer_a_prefix_and_respect_drafts(self):
+ self.bank.user = self.bank.owner
+ titles = [s['title'] for s in self.client.get(
+ '/search/suggest', params={'q': 'febrile'}).json()['suggestions']]
+ self.assertEqual(titles, ['Febrile seizures'])
+ self.bank.user = self.bank.mod
+ titles = [s['title'] for s in self.client.get(
+ '/search/suggest', params={'q': 'febrile'}).json()['suggestions']]
+ self.assertIn('Febrile draft', titles)
+ self.assertEqual(self.client.get('/search/suggest', params={'q': 'f'}).json()['suggestions'], [])
diff --git a/docs/TODO.md b/docs/TODO.md
index b7753d6..9c1dfce 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -12,18 +12,22 @@ Updated 2026-09-10.
- [ ] **AI Mode (RAG chat)** — see "AI Mode design" below. Needs: conversation +
message tables, the retrieval step, the ID-citation contract, and the
chat UI with a thread rail.
-- [ ] **Global search page** — one query across questions, articles, sections,
- cards and media. Results grouped by article with the matching *sections*
- listed beneath (section index already exists). Typeahead with "Go to" and
- "Search for". Search / AI Mode toggle.
+- [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus
+ at once and `/search` groups the answer by kind, with section hits listed
+ under the article they belong to and linked to that section. A header box
+ offers "go to this article" or "search everything". Each corpus keeps its
+ own visibility rules — bank predicate and exam scope for questions, the
+ draft rule for articles, deck ownership for cards, library grants for
+ images. The Search / AI Mode toggle waits on AI Mode.
## UI fixes raised 2026-09-10
- [x] **Quiz/test categories removed** — done.
- [x] **Sessions list shows only a few** — done, with a link to full history.
- [x] **Analysis session rail full-height** — done.
-- [ ] **Articles page layout** — poor; AMBOSS stacks two menus for this. Check
- the live site before rebuilding.
+- [x] **Articles page layout** — done 2026-09-10. The page is the column
+ browser itself: topics and the articles filed under them share a column,
+ separated by icon, one column per level opened.
- [x] **Systems facet duplicates** — done 2026-09-10. 491 redundant "(Parent)"
suffixes stripped and 3 sibling pairs merged. Nesting still to do.
- [x] **"⚙ Filters2948 questions"** — done. The stylesheet was never imported.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index cd2afdf..bfe0424 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -31,6 +31,7 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
+const SearchPage = lazy(() => import('./pages/SearchPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
@@ -99,6 +100,7 @@ function AppRoutes() {
+ Search finds meaning as well as words, so “fever in a returning traveller” + reaches the right reading even when the article never uses that phrase. +
+ )} + + {error &&{error}
} + {loading &&+ {found === 0 ? `Nothing matches “${data.query}”.` : `${found} result${found === 1 ? '' : 's'} for “${data.query}”`} +
+ + {groups.map(group => { + const rows = data.results[group.kind] + if (!rows.length) return null + return ( +{row.snippet}
} + {/* Where inside the article the match is — the point of + indexing sections rather than whole documents. */} + {row.sections.length > 0 && ( +{row.snippet}
+{row.snippet}
} +