File size: 22,414 Bytes
a2b9ad1
 
 
 
 
 
 
 
191750c
 
 
 
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
 
 
 
 
a2b9ad1
 
191750c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2b9ad1
 
 
 
191750c
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
 
 
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
 
 
a2b9ad1
 
 
 
 
 
 
 
 
 
191750c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2b9ad1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191750c
a2b9ad1
 
 
 
 
 
 
 
 
191750c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2b9ad1
191750c
 
a2b9ad1
191750c
 
 
 
 
 
 
 
 
 
 
 
 
 
a2b9ad1
 
 
191750c
 
 
a2b9ad1
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
"""Reference articles from TeachMeAnatomy and Kenhub for requests the slides do not cover.

Both sites refuse automated requests to their search pages but publish
sitemaps, so the app matches the request against the article URLs listed in
the sitemaps, downloads the best matching articles, and hands their text to
Gemini together with each page's title and URL, so the answer can name its
source exactly. Kenhub's robots.txt content signal is "use=reference": the
tutor summarizes and cites the pages, it does not reproduce them.

Pages are looked up per anatomical structure ("knee joint", "ankle joint"):
a page qualifies only through the words that name the structure, never
through a generic word such as injury, fracture, layers, or histology, so a
question about the knee cannot pull in a page about a brachial plexus injury.
"""

from __future__ import annotations

import gzip
import html
import logging
import math
import re
import threading
import time
import urllib.request
from collections import Counter, OrderedDict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field

log = logging.getLogger("anatomy.web")

USER_AGENT = "CreightonAnatomyTutor/1.0 (educational use; fetches one article per student question)"
FETCH_TIMEOUT = 15             # seconds per download
CATALOG_TTL = 24 * 3600        # seconds before the sitemaps are downloaded again
RETRY_DELAY = 120              # seconds before a failed sitemap download is retried
MAX_PAGES = 4                  # articles handed to Gemini per request
MAX_TOPICS = 3                 # structures looked up per request
MAX_PAGE_CHARS = 12_000        # article text kept per page
PAGE_CACHE_SIZE = 64

TOKEN_RE = re.compile(r"[a-z0-9]+")
LOC_RE = re.compile(r"<loc>\s*(.*?)\s*</loc>", re.DOTALL)
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.IGNORECASE | re.DOTALL)
ARTICLE_RE = re.compile(r"<article\b.*?</article>", re.IGNORECASE | re.DOTALL)
DROP_RE = re.compile(r"<(script|style|noscript|svg|nav|footer|header|form|button|iframe|figure|aside)\b.*?</\1>", re.IGNORECASE | re.DOTALL)
HEADING_RE = re.compile(r"<h([1-4])[^>]*>(.*?)</h\1>", re.IGNORECASE | re.DOTALL)
ITEM_RE = re.compile(r"<li\b[^>]*>", re.IGNORECASE)
BREAK_RE = re.compile(r"</?(p|div|br|tr|ul|ol|table|section)\b[^>]*>", re.IGNORECASE)
TAG_RE = re.compile(r"<[^>]+>", re.DOTALL)
MD_HEADING_RE = re.compile(r"(?m)^(#{1,4}) (.*)$")
CLINICAL_HEADING_RE = re.compile(r"clinic|injur|patholog|disorder|syndrome|palsy|fracture|lesion", re.IGNORECASE)


@dataclass(frozen=True)
class Site:
    name: str
    sitemap: str
    article: re.Pattern      # article URLs to keep
    children: re.Pattern | None = None   # sub-sitemaps to follow when the sitemap is an index


SITES = (
    Site(
        "TeachMeAnatomy",
        "https://teachmeanatomy.info/sitemap_index.xml",
        # Articles live three or more levels deep (/upper-limb/nerves/brachial-plexus/); two-level URLs are category
        # index pages (/upper-limb/nerves/, /encyclopaedia/d/). Skip the Spanish site and 3D models.
        re.compile(r"^https://teachmeanatomy\.info/(?!es/|3d-model/)[a-z0-9-]+/[a-z0-9-]+/[a-z0-9-]+(?:/[a-z0-9-]+)*/?$"),
        re.compile(r"/page-sitemap\d*\.xml$"),
    ),
    Site(
        "Kenhub",
        "https://www.kenhub.com/sitemap.xml.gz",
        re.compile(r"^https://www\.kenhub\.com/en/library/(?:anatomy|physiology|pathology|education)/[a-z0-9-]+$"),
    ),
)


@dataclass
class Page:
    site: str
    url: str
    title: str = ""
    text: str = ""

    @property
    def label(self) -> str:
        return f"{self.site}: {self.title}"


STOP_WORDS = set("""
a about an and any are as at be been but by can could describe did do does each explain for from give had has have
how i if in into is it its list me name of on or please should show so tell than that the their them then there
these they this those to us vs was we were what when where which while who whom whose why will with would you your
between compare define identify happen happens
""".split())
PREFIX_LENGTH = 5      # "retina" and "retinal" share the token "retin"

# Words that describe what is asked about a structure rather than naming the structure. They help rank the
# pages of a structure ("knee joint" before "knee jerk reflex") but can never make a page qualify on their
# own: "injury" alone would match "upper and lower brachial plexus injury" for a question about the knee.
GENERIC_WORDS = """
injury injuries fracture fractures dislocation dislocations sprain sprains strain strains tear tears rupture
lesion lesions damage trauma pain syndrome syndromes disease diseases disorder disorders condition conditions
clinical correlation correlations relevance significance application applications rule rules sign signs test
tests exam examination classification grade grades score method technique procedure procedures surgery
treatment management diagnosis anatomy anatomical overview introduction summary function functions structure
structures part parts region regions area areas layer layers histology system systems type types cell cells
tissue tissues blood supply innervation drainage course relation relations boundary boundaries border borders
content contents development embryology muscle muscles nerve nerves artery arteries vein veins vessel vessels
bone bones joint joints ligament ligaments tendon tendons movement movements action actions origin insertion
human body organ organs
""".split()

# The type of a structure. Generic on its own, but a page about a different type of structure with the same name
# is a different structure: "deltoid ligament" (ankle) is not "deltoid muscle" (shoulder).
TYPE_WORDS = """
muscle muscles nerve nerves artery arteries vein veins ligament ligaments tendon tendons bone bones joint joints
ganglion bursa cartilage duct gland fascia membrane
""".split()

# Technical joint names, and the common names the articles are filed under.
JOINT_NAMES = {
    "talocrural": "ankle", "glenohumeral": "shoulder", "tibiofemoral": "knee", "femorotibial": "knee",
    "acetabulofemoral": "hip", "coxofemoral": "hip", "radiocarpal": "wrist", "humeroulnar": "elbow",
    "humeroradial": "elbow",
}

# Names the slides use for structures that both sites file under another name.
SYNONYMS = {"calcaneal tendon": "achilles tendon", "tendo calcaneus": "achilles tendon"}

# Position words narrow a name but are often left out of article titles ("lateral ankle ligaments" is covered by
# the ankle joint article), so a page may lack them; every other naming word must appear in the page's URL.
POSITION_WORDS = """
anterior posterior superior inferior medial lateral proximal distal deep superficial internal external left right
upper lower common greater lesser major minor long short
""".split()


def tokenize(text: str) -> list[str]:
    """Lower-case words without stop words (URL slugs are full of "of" and "the"), trailing plural s dropped,
    plus a five-letter prefix for longer words so that inflections such as retina/retinal still match."""
    tokens = []
    for tok in TOKEN_RE.findall(text.lower()):
        if tok in STOP_WORDS:
            continue
        if len(tok) > 3 and tok.endswith("s"):
            tok = tok[:-1]
        tokens.append(tok)
        if len(tok) > PREFIX_LENGTH:
            tokens.append(tok[:PREFIX_LENGTH])
    return tokens


class BM25:
    """Small BM25 index over the URL slugs."""

    def __init__(self, documents: list[list[str]], k1: float = 1.5, b: float = 0.75):
        self.k1, self.b = k1, b
        self.doc_len = [len(d) for d in documents]
        self.avg_len = (sum(self.doc_len) / len(documents)) if documents else 1.0
        self.postings: dict[str, list[tuple[int, int]]] = {}
        for idx, doc in enumerate(documents):
            for term, freq in Counter(doc).items():
                self.postings.setdefault(term, []).append((idx, freq))
        n = len(documents)
        self.idf = {t: math.log(1 + (n - len(p) + 0.5) / (len(p) + 0.5)) for t, p in self.postings.items()}

    def scores(self, query: list[str]) -> list[float]:
        out = [0.0] * len(self.doc_len)
        for term in set(query):
            idf = self.idf.get(term)
            if idf is None:
                continue
            for idx, freq in self.postings[term]:
                denom = freq + self.k1 * (1 - self.b + self.b * self.doc_len[idx] / self.avg_len)
                out[idx] += idf * freq * (self.k1 + 1) / denom
        return out


def fetch(url: str, timeout: int = FETCH_TIMEOUT) -> bytes:
    request = urllib.request.Request(
        url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en"}
    )
    with urllib.request.urlopen(request, timeout=timeout) as response:
        data = response.read()
    if data[:2] == b"\x1f\x8b":
        data = gzip.decompress(data)
    return data


def sitemap_urls(site: Site) -> list[str]:
    """Article URLs listed in a site's sitemap (one level of sub-sitemaps is followed)."""
    xml = fetch(site.sitemap).decode("utf-8", "ignore")
    locations = [html.unescape(u) for u in LOC_RE.findall(xml)]
    if "<sitemapindex" in xml:
        urls: list[str] = []
        for child in locations:
            if site.children is None or site.children.search(child):
                urls += [html.unescape(u) for u in LOC_RE.findall(fetch(child).decode("utf-8", "ignore"))]
        locations = urls
    return [u for u in locations if site.article.match(u)]


def url_tokens(url: str) -> list[str]:
    path = url.split("://", 1)[-1].split("/", 1)[-1]
    return tokenize(path.replace("-", " ").replace("/", " "))


def url_path(url: str) -> str:
    return url.split("://", 1)[-1].split("/", 1)[-1].replace("-", " ").replace("/", " ")


def singular(tok: str) -> str:
    """"joints" -> "joint", "arches" -> "arch", "sinuses" -> "sinus", Latin "menisci" -> "meniscus",
    "vertebrae" -> "vertebra"; words ending in -us, -is, or -ss are left alone ("trapezius", "pelvis")."""
    if len(tok) > 4 and tok.endswith("ae"):
        return tok[:-1]
    if len(tok) > 4 and tok.endswith("i"):
        return tok[:-1] + "us"
    if len(tok) > 4 and tok.endswith(("ches", "shes", "xes", "zes", "uses", "sses")):
        return tok[:-2]
    if len(tok) > 3 and tok.endswith("s") and not tok.endswith(("us", "is", "ss")):
        return tok[:-1]
    return tok


def words(text: str) -> list[str]:
    """Whole words of a text, stop words dropped, singular, technical joint names replaced by common ones."""
    out = []
    for tok in TOKEN_RE.findall(text.lower()):
        if tok in STOP_WORDS:
            continue
        tok = singular(JOINT_NAMES.get(tok, tok))
        out.append(tok)
    return out


def same_word(a: str, b: str) -> bool:
    """Equal, or the same stem with a short ending: "retina"/"retinal", "cochlea"/"cochlear"."""
    if a == b:
        return True
    short, long_ = sorted((a, b), key=len)
    return len(short) >= 5 and len(long_) - len(short) <= 2 and long_.startswith(short)


GENERIC = frozenset(w for text in GENERIC_WORDS for w in words(text))
POSITION = frozenset(w for text in POSITION_WORDS for w in words(text))
TYPES = frozenset(w for text in TYPE_WORDS for w in words(text))


@dataclass
class Catalog:
    """Article URLs of both sites with a BM25 index over their slugs, refreshed daily."""

    # ((site name, url) entries, BM25 index over their slugs, whole words of each slug): replaced as one object
    data: tuple[list[tuple[str, str]], BM25 | None, list[frozenset]] = ([], None, [])
    next_load: float = 0.0
    error: str = ""
    lock: threading.Lock = field(default_factory=threading.Lock)

    def ensure(self) -> str:
        """Load or refresh the catalog when due. Returns a note about sites that could not be reached."""
        with self.lock:
            now = time.monotonic()
            if now < self.next_load:
                return self.error
            self.next_load = now + RETRY_DELAY
            entries: list[tuple[str, str]] = []
            errors: list[str] = []
            for site in SITES:
                try:
                    urls = sitemap_urls(site)
                    log.info("%s: %d articles listed", site.name, len(urls))
                    entries += [(site.name, url) for url in urls]
                except Exception as err:
                    log.warning("%s sitemap failed: %s", site.name, err)
                    errors.append(f"{site.name} could not be reached")
            if entries:
                self.data = (
                    entries,
                    BM25([url_tokens(url) for _, url in entries]),
                    [frozenset(words(url_path(url))) for _, url in entries],
                )
                if not errors:
                    self.next_load = now + CATALOG_TTL
            self.error = "; ".join(errors)
            return self.error


CATALOG = Catalog()
_page_cache: OrderedDict[str, Page] = OrderedDict()
_page_lock = threading.Lock()


def warm_up() -> None:
    """Download the sitemaps in the background so the first fallback request is fast."""
    threading.Thread(target=CATALOG.ensure, name="web-catalog", daemon=True).start()


def article_text(page_html: str) -> str:
    """Plain text of an article page: the <article> element, headings and list items kept as Markdown."""
    match = ARTICLE_RE.search(page_html)
    body = match.group(0) if match else page_html
    body = DROP_RE.sub(" ", body)
    # Heading text is put on one line: Kenhub headings contain line breaks, which left "##" alone on a line.
    body = HEADING_RE.sub(
        lambda m: "\n\n" + "#" * int(m.group(1)) + " " + " ".join(TAG_RE.sub(" ", m.group(2)).split()) + "\n", body)
    body = ITEM_RE.sub("\n- ", body)
    body = BREAK_RE.sub("\n", body)
    text = html.unescape(TAG_RE.sub(" ", body))
    text = re.sub(r"[ \t\r\f\v]+", " ", text)
    text = re.sub(r"(?m)^ +| +$", "", text)
    text = re.sub(r"(?m)^-\s*$\n?", "", text)          # empty list items (media controls)
    text = re.sub(r"\n\s*\n+", "\n\n", text)
    return text.strip()


def excerpt(text: str, limit: int = MAX_PAGE_CHARS) -> str:
    """The first `limit` characters of an article, except that clinical sections ("Clinical significance",
    "Brachial plexus injury") are kept even when they come later: Kenhub puts them at the end of long articles.
    Clinical sections get at most half of the budget."""
    if len(text) <= limit:
        return text
    headings = [(m.start(), len(m.group(1)), m.group(2)) for m in MD_HEADING_RE.finditer(text)]
    sections: list[tuple[int, int]] = []
    for i, (start, level, title) in enumerate(headings):
        if not CLINICAL_HEADING_RE.search(title) or (sections and start < sections[-1][1]):
            continue
        end = next((s for s, lvl, _ in headings[i + 1:] if lvl <= level), len(text))
        sections.append((start, end))
    head_end = limit
    tail = ""
    for start, end in sections:
        if start < limit // 2:            # already inside the part that is kept
            continue
        piece = text[start:end].strip()
        if len(tail) + len(piece) > limit // 2:
            piece = piece[: max(0, limit // 2 - len(tail))]
        tail = (tail + "\n\n" + piece).strip() if piece else tail
        if len(tail) >= limit // 2:
            break
    if tail:
        head_end = limit - len(tail) - 9
    return (text[:head_end].rstrip() + ("\n\n[...]\n\n" + tail if tail else "")).strip()


def page_title(page_html: str, site: str) -> str:
    match = H1_RE.search(page_html) or TITLE_RE.search(page_html)
    title = html.unescape(TAG_RE.sub("", match.group(1))).strip() if match else ""
    title = re.sub(r"\s*\|\s*(Kenhub|TeachMeAnatomy).*$", "", title, flags=re.IGNORECASE)
    return " ".join(title.split()) or site


def load_page(page: Page) -> Page | None:
    """Download one article (cached). Returns None when it cannot be read."""
    with _page_lock:
        cached = _page_cache.get(page.url)
        if cached is not None:
            _page_cache.move_to_end(page.url)
            return cached
    try:
        page_html = fetch(page.url).decode("utf-8", "ignore")
    except Exception as err:
        log.warning("Could not fetch %s: %s", page.url, err)
        return None
    page.title = page_title(page_html, page.site)
    page.text = excerpt(article_text(page_html))
    if not page.text:
        return None
    with _page_lock:
        _page_cache[page.url] = page
        while len(_page_cache) > PAGE_CACHE_SIZE:
            _page_cache.popitem(last=False)
    return page


def topic_matches(topic: str, entries: list[tuple[str, str]], index: BM25, slug_words: list[frozenset]) -> list[int]:
    """Catalog entries about one structure, best first.

    The naming words of the phrase are its words that are not generic ("knee joint" -> knee; "tarsal tunnel" ->
    tarsal, tunnel). A page qualifies only if its URL contains every naming word except position words such as
    lateral or superior, so "tarsal tunnel" never qualifies "carpal tunnel" and "superior thoracic aperture" never
    qualifies "superior thoracic artery". When the phrase names the type of structure ("deltoid ligament"), a page
    whose URL names another type ("deltoid muscle") does not qualify. Pages that contain more naming words come
    first, then pages that name the same type, then pages whose URL has fewer other words ("retina" before
    "central retinal artery"), then the BM25 score of the whole phrase.
    """
    phrase_words = words(topic)
    naming = list(dict.fromkeys(w for w in phrase_words if w not in GENERIC))
    required = [w for w in naming if w not in POSITION] or naming[-1:]
    if not required:
        return []
    phrase = set(phrase_words)
    types = phrase & TYPES

    def has(word: str, slug: frozenset) -> bool:
        return any(same_word(word, other) for other in slug)

    found = [i for i in range(len(entries)) if all(has(w, slug_words[i]) for w in required)]
    if types:
        found = [i for i in found if not (slug_words[i] & TYPES) or slug_words[i] & types]
    if not found:
        return []
    hits = {i: sum(1 for w in naming if has(w, slug_words[i])) for i in found}
    # Naming words in the article's own slug, not only in its category path ("pelvis/bones/sacrum" is filed under
    # pelvis but is about the sacrum).
    own = {i: sum(1 for w in naming if has(w, frozenset(words(url_path(entries[i][1].rstrip("/").rsplit("/", 1)[-1])))))
           for i in found}
    typed = {i: bool(slug_words[i] & types) for i in found}
    extra = {i: sum(1 for w in slug_words[i] if not any(same_word(w, q) for q in phrase)) for i in found}
    scores = index.scores(tokenize(topic))
    return sorted(found, key=lambda i: (-hits[i], -own[i], not typed[i], extra[i], -scores[i], len(entries[i][1])))


def names_structure(topic: str, page: Page) -> bool:
    """For a phrase that names the type of structure ("deltoid ligament"), the page must mention that structure:
    the name followed within a few words by the type. Other phrases are always accepted."""
    raw = [t for t in TOKEN_RE.findall(topic.lower()) if t not in STOP_WORDS]
    type_words = [t for t in raw if singular(t) in TYPES]
    names = [t for t in raw if singular(JOINT_NAMES.get(t, t)) not in GENERIC | TYPES]
    if not type_words or not names:
        return True
    name = JOINT_NAMES.get(names[-1], names[-1])
    stem = singular(name)[:max(4, len(singular(name)) - 2)]
    kind = singular(type_words[0])
    pattern = re.compile(rf"\b{re.escape(stem)}\w*\W+(?:\w+\W+){{0,3}}{re.escape(kind)}", re.IGNORECASE)
    return bool(pattern.search(page.title + "\n" + page.text))


def question_topics(question: str) -> list[str]:
    """Structures to look up when the model named none: the question as one phrase, or, when no article matches
    the whole phrase ("ligaments of the knee and ankle"), each of its naming words ("knee", "ankle")."""
    CATALOG.ensure()
    entries, index, slug_words = CATALOG.data
    if index is None or topic_matches(question, entries, index, slug_words):
        return [question]
    naming = [w for w in dict.fromkeys(words(question)) if w not in GENERIC and w not in POSITION]
    return naming[:MAX_TOPICS] or [question]


def find_pages(topics: list[str] | str) -> tuple[list[Page], str]:
    """Articles about the given structures, text included, plus a note about sites that could not be reached.

    Each structure gets at most one page per site; pages are taken structure by structure, best first, so a
    request about the knee and the ankle gets pages about both.
    """
    if isinstance(topics, str):
        topics = [topics]
    topics = [t.strip() for t in topics if t and t.strip()][:MAX_TOPICS]
    for name, other in SYNONYMS.items():
        topics = [re.sub(rf"\b{name}\b", other, t, flags=re.IGNORECASE) for t in topics]
    error = CATALOG.ensure()
    entries, index, slug_words = CATALOG.data
    if index is None or not topics:
        return [], error
    per_topic: list[list[tuple[str, Page]]] = []
    for topic in topics:
        picks: dict[str, Page] = {}
        for idx in topic_matches(topic, entries, index, slug_words):
            site, url = entries[idx]
            picks.setdefault(site, Page(site, url))
        per_topic.append([(topic, page) for page in picks.values()])      # best page first
    chosen: list[tuple[str, Page]] = []
    seen: set[str] = set()
    for rank in range(len(SITES)):                     # every structure's best page first, then the second site
        for picks in per_topic:
            if rank < len(picks) and picks[rank][1].url not in seen and len(chosen) < MAX_PAGES:
                seen.add(picks[rank][1].url)
                chosen.append(picks[rank])
    if not chosen:
        return [], error
    with ThreadPoolExecutor(max_workers=len(chosen)) as pool:
        loaded = list(pool.map(load_page, [page for _, page in chosen]))
    pages = [page for (topic, _), page in zip(chosen, loaded) if page is not None and names_structure(topic, page)]
    if not pages and any(page is None for page in loaded):
        error = "; ".join(filter(None, [error, "the matching TeachMeAnatomy and Kenhub pages could not be downloaded"]))
    return pages, error