"""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"\s*(.*?)\s*", re.DOTALL) TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) H1_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) ARTICLE_RE = re.compile(r"", re.IGNORECASE | re.DOTALL) DROP_RE = re.compile(r"<(script|style|noscript|svg|nav|footer|header|form|button|iframe|figure|aside)\b.*?", re.IGNORECASE | re.DOTALL) HEADING_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) ITEM_RE = re.compile(r"]*>", re.IGNORECASE) BREAK_RE = re.compile(r"]*>", 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 " 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
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