From 3a7598ca3aca3aee69a895a98ae0c94d5b33bfe3 Mon Sep 17 00:00:00 2001
From: Robbie Court
Date: Tue, 12 May 2026 07:03:16 +0000
Subject: [PATCH 01/17] Schlegel Fig 1: resolve remaining area overlaps
Static audit after the previous follow-up still flagged three pairwise
rect overlaps on Schlegel Fig 1:
AL x ORN 30x20 px overlap (AL covered the ORN cell drawing).
AL x ALLN 15x5 px overlap (ALLN y2 reached into AL y1).
mPN x LHN 5x20 px overlap (LHN x2 reached into mPN x1).
These were missed by the previous TL/centre/BR hit-test because the
overlap sits in the body of the rect, not at probed corners.
Two changes:
1. Reorder the AL/ALLN/ORN cluster so the cell-type rects come before
the region rect. AL is a brain region; ORN and ALLN are cell-type
drawings positioned inside the AL region in the schematic, so their
rects necessarily overlap the AL rect. Per HTML5, the first matching
in tree order wins, so putting cell types before the region
makes the visible cells route to their own term while the rest of
AL still routes to AL.
2. Trim ALLN.y2 from 260 -> 254 (one below AL.y1=255) so the small
geometric overlap with AL is gone. Trim LHN.x2 from 85 -> 79 (one
below mPN.x1=80) for the same reason, and reorder LHN before mPN.
After this commit static audit reports a single residual geometric
overlap (ORN x AL, 30x20 px); it is intentional, resolved by ordering,
and documents the cell-inside-region layout faithfully.
---
.../Olfactory system neuroanatomy/_index.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/content/en/docs/Anatomy Diagrams/Olfactory system neuroanatomy/_index.md b/content/en/docs/Anatomy Diagrams/Olfactory system neuroanatomy/_index.md
index f8b254f..b2610fe 100644
--- a/content/en/docs/Anatomy Diagrams/Olfactory system neuroanatomy/_index.md
+++ b/content/en/docs/Anatomy Diagrams/Olfactory system neuroanatomy/_index.md
@@ -9,15 +9,15 @@ description: >
- Because of recent changes to government funding, the NIH grant that supported FlyBase has been terminated.
- They are now reaching out to the community for emergency funding. Please click here for more information and to contribute:
+ Because of changes to US government funding, the foreign subaward of the NHGRI grant that supported the FlyBase curation team at the University of Cambridge has been terminated.
+ They are now reaching out to the community to assist with funding. Please click here for more information and to contribute:
Contribute to FlyBase wiki page
- Thank you in advance for your support to keep FlyBase on air and continuing to serve the Drosophila research community.
+ Thank you in advance for your support in allowing FlyBase to continue serving the Drosophila research community.
We remain grateful to Wellcome for their continued support of Virtual Fly Brain.
From afd740dbab7fbb355c45491e8fa2ef42417b2904 Mon Sep 17 00:00:00 2001
From: Clare72
Date: Fri, 12 Jun 2026 16:52:33 +0100
Subject: [PATCH 13/17] Update _index.html
---
content/en/_index.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/content/en/_index.html b/content/en/_index.html
index 8196591..1a9c07f 100644
--- a/content/en/_index.html
+++ b/content/en/_index.html
@@ -22,7 +22,7 @@
- Our colleagues at FlyBase needs your help!
+ Our colleagues at FlyBase need your help!
From 75b1ebbfadcfad5ce1735ba6c51ba717380c326f Mon Sep 17 00:00:00 2001
From: Robbie Court
Date: Mon, 22 Jun 2026 16:10:22 +0000
Subject: [PATCH 14/17] Add MCP preprint to news post and bump date
Cite McLachlan et al. (2026), bioRxiv (doi:10.64898/2026.06.16.732577),
and summarise the 30-task benchmark (25/30 MCP vs 14/30 web vs 2/30 bare;
89% vs 11% on quantification tasks). Re-date the post to 2026-06-21 so the
preprint announcement surfaces in the news feed.
---
content/en/blog/news/VFB_MCP_Tool.md | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/content/en/blog/news/VFB_MCP_Tool.md b/content/en/blog/news/VFB_MCP_Tool.md
index 592e2e8..5984c01 100644
--- a/content/en/blog/news/VFB_MCP_Tool.md
+++ b/content/en/blog/news/VFB_MCP_Tool.md
@@ -1,13 +1,19 @@
---
title: "Introducing the VFB Model Context Protocol (MCP) Tool"
linkTitle: "VFB MCP Tool Launch"
-date: 2026-02-07
+date: 2026-06-21
description: >
Explore Virtual Fly Brain data directly through your favorite LLM with the new VFB Model Context Protocol (MCP) tool - now available at vfb3-mcp.virtualflybrain.org
---
We're excited to announce the launch of the **Virtual Fly Brain Model Context Protocol (MCP) Tool** — a powerful new way to explore VFB data through Large Language Models (LLMs) like Claude, providing natural language access to VFB's comprehensive neuroanatomical database.
+## Now described in a preprint
+
+The tool and its evaluation are now described in a preprint: McLachlan et al. (2026), *VFB-MCP: Natural-Language Access to Drosophila Neuroscience Grounded by an Expert-Curated Ontology-Led Knowledgebase*, bioRxiv. [doi:10.64898/2026.06.16.732577](https://doi.org/10.64898/2026.06.16.732577).
+
+Benchmarked on 30 neuroscience tasks, an LLM equipped with the VFB MCP produced precise, verifiable and appropriately quantified answers on 25/30 tasks, against 14/30 for a web-search-assisted LLM and 2/30 for a bare LLM (Wilcoxon *p* < 0.01, Holm-corrected across all pairwise comparisons). The advantage was largest where answers required data quantification — 89% of those tasks were answered correctly with the MCP, against 11% with web search — reflecting the precision that VFB's expert-curated, ontology-backed knowledge graph brings to the recently integrated connectomes. The MCP server code is available at [VirtualFlyBrain/VFB3-MCP](https://github.com/VirtualFlyBrain/VFB3-MCP).
+
## What is the VFB MCP Tool?
The VFB MCP tool integrates Virtual Fly Brain's rich neuroanatomical data with LLM capabilities, allowing researchers to:
From e783b33cb6921b0946f83b733f53a3460f4daf80 Mon Sep 17 00:00:00 2001
From: Robbie Court
Date: Mon, 22 Jun 2026 17:28:24 +0100
Subject: [PATCH 15/17] moving to bottom of article
---
content/en/blog/news/VFB_MCP_Tool.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/content/en/blog/news/VFB_MCP_Tool.md b/content/en/blog/news/VFB_MCP_Tool.md
index 5984c01..1d0dc29 100644
--- a/content/en/blog/news/VFB_MCP_Tool.md
+++ b/content/en/blog/news/VFB_MCP_Tool.md
@@ -8,12 +8,6 @@ description: >
We're excited to announce the launch of the **Virtual Fly Brain Model Context Protocol (MCP) Tool** — a powerful new way to explore VFB data through Large Language Models (LLMs) like Claude, providing natural language access to VFB's comprehensive neuroanatomical database.
-## Now described in a preprint
-
-The tool and its evaluation are now described in a preprint: McLachlan et al. (2026), *VFB-MCP: Natural-Language Access to Drosophila Neuroscience Grounded by an Expert-Curated Ontology-Led Knowledgebase*, bioRxiv. [doi:10.64898/2026.06.16.732577](https://doi.org/10.64898/2026.06.16.732577).
-
-Benchmarked on 30 neuroscience tasks, an LLM equipped with the VFB MCP produced precise, verifiable and appropriately quantified answers on 25/30 tasks, against 14/30 for a web-search-assisted LLM and 2/30 for a bare LLM (Wilcoxon *p* < 0.01, Holm-corrected across all pairwise comparisons). The advantage was largest where answers required data quantification — 89% of those tasks were answered correctly with the MCP, against 11% with web search — reflecting the precision that VFB's expert-curated, ontology-backed knowledge graph brings to the recently integrated connectomes. The MCP server code is available at [VirtualFlyBrain/VFB3-MCP](https://github.com/VirtualFlyBrain/VFB3-MCP).
-
## What is the VFB MCP Tool?
The VFB MCP tool integrates Virtual Fly Brain's rich neuroanatomical data with LLM capabilities, allowing researchers to:
@@ -59,3 +53,9 @@ This tool democratizes access to VFB's complex neuroanatomical data, making it a
Check out our new [**VFB MCP Tutorial**](/docs/tutorials/vfb-mcp-guide/) for detailed guidance on using the tool, example queries, and best practices for exploring VFB data through your favorite LLM.
The VFB MCP tool represents an exciting new frontier in how researchers interact with neuroanatomical databases. We hope it accelerates your discoveries about *Drosophila* neurobiology!
+
+## Now described in a preprint
+
+The tool and its evaluation are now described in a preprint: McLachlan et al. (2026), *VFB-MCP: Natural-Language Access to Drosophila Neuroscience Grounded by an Expert-Curated Ontology-Led Knowledgebase*, bioRxiv. [doi:10.64898/2026.06.16.732577](https://doi.org/10.64898/2026.06.16.732577).
+
+Benchmarked on 30 neuroscience tasks, an LLM equipped with the VFB MCP produced precise, verifiable and appropriately quantified answers on 25/30 tasks, against 14/30 for a web-search-assisted LLM and 2/30 for a bare LLM (Wilcoxon *p* < 0.01, Holm-corrected across all pairwise comparisons). The advantage was largest where answers required data quantification — 89% of those tasks were answered correctly with the MCP, against 11% with web search — reflecting the precision that VFB's expert-curated, ontology-backed knowledge graph brings to the recently integrated connectomes. The MCP server code is available at [VirtualFlyBrain/VFB3-MCP](https://github.com/VirtualFlyBrain/VFB3-MCP).
\ No newline at end of file
From af8e68ba6fd838527fcd868ee33a825b0e649933 Mon Sep 17 00:00:00 2001
From: Robbie Court
Date: Mon, 22 Jun 2026 18:00:08 +0000
Subject: [PATCH 16/17] Fix invalid YAML front matter for terms with no tags
A term with an empty Tags list still had its ID-prefix tag appended onto
an empty CSV string, producing "tags: [,VFB]". The leading comma is an
empty flow-sequence node, which go-yaml rejects ("did not find expected
node content"), failing the Hugo build on VFB_00017893_v8.md.
Build the tag list properly and drop empty entries before joining.
---
vfbterms.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/vfbterms.py b/vfbterms.py
index bf715d9..193eda7 100644
--- a/vfbterms.py
+++ b/vfbterms.py
@@ -497,11 +497,14 @@ def generate_page(term_data):
url_slug = get_term_url(name, term_id)
# Tags for front matter
- tags_csv = ",".join(tags)
+ # Build a clean list and drop empties, otherwise a term with no Tags
+ # yields a leading comma (e.g. "[,VFB]") which is invalid YAML flow syntax
+ tag_list = list(tags)
if "_" in term_id:
- tags_csv += "," + term_id.split("_")[0]
+ tag_list.append(term_id.split("_")[0])
elif term_id.startswith("FB"):
- tags_csv += "," + term_id[0:4]
+ tag_list.append(term_id[0:4])
+ tags_csv = ",".join(t for t in tag_list if t)
# Thumbnails
thumbnails = get_thumbnails(term_data)
From c52b4e130711f2903fa4d1b602c6ffa288d08b8e Mon Sep 17 00:00:00 2001
From: Robbie Court
Date: Sun, 28 Jun 2026 10:54:27 +0100
Subject: [PATCH 17/17] Cleaned up rendering from VFBquery
---
vfbterms.py | 322 +++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 255 insertions(+), 67 deletions(-)
diff --git a/vfbterms.py b/vfbterms.py
index 193eda7..66951f1 100644
--- a/vfbterms.py
+++ b/vfbterms.py
@@ -9,6 +9,8 @@
import json
import traceback
import time
+import threading
+from concurrent.futures import ThreadPoolExecutor, as_completed, wait, FIRST_COMPLETED
from urllib.parse import quote
# Suppress the urllib3 warning about OpenSSL
@@ -25,17 +27,24 @@
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
-version = 8
+version = 9
API_BASE = "https://v3-cached.virtualflybrain.org/get_term_info"
STATUS_URL = "https://vfbquery.virtualflybrain.org/status"
VFB_BROWSER_BASE = "https://v2.virtualflybrain.org/org.geppetto.frontend/geppetto"
# Throttle settings — stay under 20 concurrent to keep API reliable
-MAX_ACTIVE_BEFORE_BACKOFF = 20 # Back off when this many queries are active
+MAX_ACTIVE_BEFORE_BACKOFF = 20 # Back off when the server reports this many active queries
STATUS_CHECK_INTERVAL = 10 # Seconds between status checks while waiting
MAX_CAPACITY_WAIT_SECONDS = 600 # Give up waiting for capacity after this long
-API_TIMEOUT_SECONDS = int(os.environ.get("VFB_API_TIMEOUT_SECONDS", "9000"))
+# Concurrent fetch/generate workers. Kept below MAX_ACTIVE_BEFORE_BACKOFF so our
+# own load leaves headroom for other clients; the serial loop only ever used one
+# slot, leaving the server (≈20 slots) mostly idle — this is the main speed-up.
+MAX_WORKERS = int(os.environ.get("VFB_MAX_WORKERS", "12"))
+# Per-request timeout. The old 9000s default was a workaround for serial stalls;
+# with concurrency a hung term must not pin a worker for hours. Override with
+# VFB_API_TIMEOUT_SECONDS if a genuinely heavy term needs longer.
+API_TIMEOUT_SECONDS = int(os.environ.get("VFB_API_TIMEOUT_SECONDS", "600"))
# Known ID prefixes for internal link conversion
KNOWN_PREFIXES = (
@@ -52,11 +61,12 @@ def create_session():
"""Create a requests session with retry logic and connection pooling."""
session = requests.Session()
retry = Retry(
- total=2,
- backoff_factor=120,
- status_forcelist=[500, 502, 503, 504],
+ total=3,
+ backoff_factor=5,
+ status_forcelist=[429, 500, 502, 503, 504],
+ respect_retry_after_header=True,
)
- adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
+ adapter = HTTPAdapter(max_retries=retry, pool_connections=MAX_WORKERS, pool_maxsize=MAX_WORKERS)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
@@ -78,6 +88,37 @@ def check_server_status():
print(f"WARNING: Could not check server status: {e}")
return None
+# Cached, thread-safe view of server capacity so a pool of workers doesn't each
+# poll /status before every request. One worker refreshes the cache at most once
+# per STATUS_CHECK_INTERVAL; the rest read the cached verdict.
+_status_lock = threading.Lock()
+_status_cache = {"ts": 0.0, "ok": True}
+
+def throttle(term_id=""):
+ """Pause the calling worker while the server is at/over capacity.
+
+ Reads a shared cached /status verdict (refreshed at most once per
+ STATUS_CHECK_INTERVAL). Returns once the server has headroom, or after
+ MAX_CAPACITY_WAIT_SECONDS so a single term can't block forever. With
+ MAX_WORKERS below MAX_ACTIVE_BEFORE_BACKOFF our own load never trips this;
+ it only engages when other clients are loading the server.
+ """
+ start = time.time()
+ while True:
+ now = time.time()
+ with _status_lock:
+ if (now - _status_cache["ts"]) >= STATUS_CHECK_INTERVAL:
+ st = check_server_status()
+ _status_cache["ts"] = now
+ _status_cache["ok"] = True if st is None else st[0] < MAX_ACTIVE_BEFORE_BACKOFF
+ ok = _status_cache["ok"]
+ if ok:
+ return
+ if time.time() - start >= MAX_CAPACITY_WAIT_SECONDS:
+ print(f"WARNING: proceeding with {term_id or 'request'} after waiting for capacity")
+ return
+ time.sleep(STATUS_CHECK_INTERVAL)
+
def wait_for_server_capacity(term_id=""):
"""Block until the server has capacity below our threshold.
@@ -131,8 +172,8 @@ def fetch_term_info(term_id):
Checks server capacity before making the request to avoid flooding.
"""
- # Wait until the server isn't overloaded
- wait_for_server_capacity(term_id)
+ # Wait until the server isn't overloaded (shared cached capacity check)
+ throttle(term_id)
try:
resp = session.get(API_BASE, params={"id": term_id}, timeout=API_TIMEOUT_SECONDS)
@@ -192,6 +233,32 @@ def replace_link(match):
# Match: [ ... ]( ID ) where the ID part has no spaces or parens
return re.sub(r'\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]\(([^)\s]+)\)', replace_link, text)
+# Markdown links nested inside a raw HTML block (e.g. the hero card ) are NOT
+# processed by Hugo's Goldmark renderer — it passes HTML blocks through verbatim.
+# So any `[label](id)` placed inside
…
would render as literal brackets.
+# linkify_html emits real anchors instead, mapping known ids to /reports/
+# exactly like convert_internal_links does for plain markdown.
+_MD_LINK_RE = re.compile(r'\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]\(([^)\s]+)\)')
+
+def linkify_html(text):
+ """Convert `[label](target)` markdown links to HTML anchors.
+
+ For embedding in raw HTML blocks (hero card description/comment), where
+ Goldmark won't process markdown. Known VFB ids are rewritten to /reports/;
+ other targets (already-resolved URLs) are used as-is. Newlines are collapsed
+ to spaces so the surrounding HTML block isn't terminated by a blank line.
+ """
+ if not text:
+ return ""
+
+ def repl(match):
+ label = match.group(1)
+ identifier = match.group(2)
+ href = get_report_url(identifier) if is_known_id(identifier) else identifier
+ return f'{label}'
+
+ return _MD_LINK_RE.sub(repl, text).replace("\r", " ").replace("\n", " ")
+
def format_relationships_section(relationships_text):
"""Format Meta.Relationships into markdown bullets.
@@ -305,7 +372,16 @@ def format_synonyms_table(synonyms):
return "\n".join(lines)
def format_query_preview(query, term_id):
- """Format a Query's preview_results as a markdown table with thumbnails."""
+ """Format a Query's preview_results as an HTML table with thumbnails.
+
+ Emitted as a complete raw HTML rather than a markdown table wrapped
+ in . Hugo's Goldmark does not process markdown
+ nested inside a raw HTML block, so the wrapped-markdown form leaked every
+ `[label](id)` cell as literal text and never rendered as a table. A full
+ HTML table renders verbatim under unsafe=true. The `###` heading and the
+ "View all" button stay outside the block, separated by blank lines so
+ Goldmark closes each HTML block cleanly.
+ """
preview = query.get("preview_results", {})
rows = preview.get("rows", [])
if not rows:
@@ -319,36 +395,39 @@ def format_query_preview(query, term_id):
lines.append(f'### {label} ({count} total)')
lines.append("")
lines.append('
')
- lines.append("")
- lines.append("| Thumbnail | Name | Tags |")
- lines.append("|-----------|------|------|")
+ lines.append('
')
+ lines.append('| Thumbnail | Name | Tags |
')
+ lines.append('')
for row in rows:
- # Extract name — may be markdown link like [name](id)
+ # Name — may be `[label](id)` markdown from the API; emit as an anchor
+ # because it lives inside a raw HTML block (Goldmark won't linkify it).
name_raw = row.get("label", row.get("name", ""))
- name_converted = convert_internal_links(name_raw)
+ name_md = convert_internal_links(name_raw)
row_id = row.get("id", "")
- if row_id and is_known_id(row_id) and name_converted == name_raw and name_raw:
- name_converted = f'[{name_raw}]({get_report_url(row_id)})'
+ if row_id and is_known_id(row_id) and name_md == name_raw and name_raw:
+ name_md = f'[{name_raw}]({get_report_url(row_id)})'
+ name_html = linkify_html(name_md) if "](" in name_md else name_md
- # Extract tags
+ # Tags
tags_raw = row.get("tags", "")
tags_display = tags_raw.replace("|", ", ") if tags_raw else ""
- # Extract thumbnail — may be markdown image like [](link)
+ # Thumbnail — pull the URL out of `[](link)` markdown.
thumb_raw = row.get("thumbnail", "")
thumb_html = ""
if thumb_raw:
- # Extract URL from markdown image: [](link)
img_match = re.search(r'!\[[^\]]*\]\(([^\s)]+)', thumb_raw)
if img_match:
thumb_url = img_match.group(1)
- thumb_html = f'
'
+ thumb_html = (f''
+ f'
')
- lines.append(f'| {thumb_html} | {name_converted} | {tags_display} |')
+ lines.append(f'| {thumb_html} | {name_html} | {tags_display} |
')
- lines.append("")
- lines.append("")
+ lines.append('')
+ lines.append('
')
+ lines.append('
')
lines.append("")
lines.append(f'
View all {count} results in VFB →')
lines.append("")
@@ -427,6 +506,48 @@ def format_publications(publications):
lines.append("".join(parts))
return "\n".join(lines)
+def format_xrefs(xrefs, term_name):
+ """Format Xrefs (external cross-references) into markdown links.
+
+ Each xref is {label, accession, link, icon}. The cached get_term_info
+ endpoint resolves the full external URL in `link`, so we link to it
+ directly rather than rebuilding from a link_base/accession template
+ (cf. VFBProcessTermInfoJson.xref.getLink). Link text mirrors the VFB
+ browser's "Cross References" section: "
on ".
+ """
+ if not xrefs:
+ return ""
+
+ lines = []
+ for xref in xrefs:
+ link = xref.get("link", "")
+ if not link:
+ continue
+ label = xref.get("label", "")
+ text = f'{term_name} on {label}' if (term_name and label) else (label or link)
+ accession = xref.get("accession", "")
+ suffix = f' ({accession})' if accession and accession not in ("None", "") else ""
+ lines.append(f'- [{text}]({link}){suffix}')
+ return "\n".join(lines)
+
+def format_related_tools(tools, term_id):
+ """Format RelatedTools into the VFB browser's "Graphs For" links.
+
+ Each tool is {tool, label, default_args}. These launch graph/hierarchy
+ tools on the term inside the 3D browser; link to the term there so the
+ user can run them (the browser exposes the tool set once the term loads).
+ """
+ if not tools:
+ return ""
+
+ lines = []
+ for t in tools:
+ label = t.get("label", t.get("tool", ""))
+ if not label:
+ continue
+ lines.append(f'- [{label}]({VFB_BROWSER_BASE}?id={term_id})')
+ return "\n".join(lines)
+
def build_hero_card(name, term_id, tags_badges, description_html, comment_html, thumbnails):
"""Build the hero card without blank lines that break Markdown HTML blocks."""
lines = [
@@ -480,13 +601,19 @@ def generate_page(term_data):
publications = term_data.get("Publications", [])
technique = term_data.get("Technique", [])
images = term_data.get("Images", {})
+ examples = term_data.get("Examples", {})
+ xrefs = term_data.get("Xrefs", [])
+ related_tools = term_data.get("RelatedTools", [])
# Description for front matter
description = meta.get("Description", "")
if not description:
- # Fallback to Types text (stripped of markdown links)
- types_text = meta.get("Types", "")
- description = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', types_text)
+ # Fallback to Types text
+ description = meta.get("Types", "")
+ # Strip markdown link syntax to plain labels: the front-matter description
+ # becomes the tag and page summary, where raw
+ # `[label](id)` syntax would show literally.
+ description = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', description)
# Clean for YAML
description = description.replace('"', '\\"').replace("\n", " ").replace("\r", " ").strip()
@@ -532,11 +659,11 @@ def generate_page(term_data):
desc_html = ""
if meta.get("Description"):
- desc_html = f'{convert_internal_links(meta["Description"])}
'
+ desc_html = f'{linkify_html(meta["Description"])}
'
comment_html = ""
if comment:
- comment_html = f'{convert_internal_links(comment)}
'
+ comment_html = f'{linkify_html(comment)}
'
sections.append(build_hero_card(name, term_id, tags_badges, desc_html, comment_html, thumbnails))
@@ -557,6 +684,12 @@ def generate_page(term_data):
syn_md = format_synonyms_table(synonyms)
sections.append(f'## Alternative Names\n\n{syn_md}\n')
+ # ── Cross references (external databases) ──
+ if xrefs:
+ xref_md = format_xrefs(xrefs, name)
+ if xref_md.strip():
+ sections.append(f'## Cross References\n\n{xref_md}\n')
+
# ── Technique ──
if technique:
tech_lines = "\n".join(f'- {t}' for t in technique)
@@ -567,9 +700,17 @@ def generate_page(term_data):
lic_md = format_licenses(licenses)
sections.append(f'## License\n\n{lic_md}\n')
- # ── Downloads ──
- if images:
- dl_md = format_downloads(images)
+ # ── Downloads (individual Images and class Examples) ──
+ # Class terms carry their downloadable volumes under Examples (one list
+ # per template), individuals under Images. Merge both so class pages get
+ # download links too, matching the browser's Available Images downloads.
+ download_sources = {}
+ for tpl, img_list in images.items():
+ download_sources.setdefault(tpl, []).extend(img_list)
+ for tpl, ex_list in examples.items():
+ download_sources.setdefault(tpl, []).extend(ex_list)
+ if download_sources:
+ dl_md = format_downloads(download_sources)
if dl_md.strip():
sections.append(f'## Downloads\n\n{dl_md}\n')
@@ -581,12 +722,22 @@ def generate_page(term_data):
q_md = format_query_preview(q, term_id)
sections.append(q_md)
+ # ── Graphs For (related graph/hierarchy tools) ──
+ if related_tools:
+ tools_md = format_related_tools(related_tools, term_id)
+ if tools_md.strip():
+ sections.append(f'## Graphs For\n\n{tools_md}\n')
+
# ── Publications ──
if publications:
pub_md = format_publications(publications)
sections.append(f'## References\n\n{pub_md}\n')
- return "\n".join(sections)
+ # Join with a blank line between every section. Goldmark keeps a raw HTML
+ # block (e.g. the hero card) open until a blank line, so without this the
+ # first heading after the hero ("## Classification") gets swallowed into
+ # the HTML block and printed literally instead of rendered as a heading.
+ return "\n\n".join(sections)
# ─── Term Saving ─────────────────────────────────────────────────────────────
@@ -613,46 +764,79 @@ def process_group(base_path, relative_dir, label, query):
chdir(target_dir)
save_terms(fetch_ids(label, query))
-def save_terms(ids):
- """Fetch and save term pages for a list of IDs."""
- total = len(ids)
- success_count = 0
- skip_count = 0
- fail_count = 0
- for i, term_id in enumerate(ids):
- try:
- filename = term_id + "_v" + str(version) + ".md"
- if os.path.isfile(filename):
- skip_count += 1
- continue
+def process_term(term_id):
+ """Fetch, render and write one term page. Returns a status string.
- print(f"Processing {term_id} ({i+1}/{total})...")
- term_data = fetch_term_info(term_id)
- if term_data is None:
- fail_count += 1
- continue
+ Runs inside a worker thread. Writes to a temp file then atomically renames
+ so a killed build never leaves a half-written page. Throttling against the
+ server's capacity happens inside fetch_term_info via throttle().
+ """
+ filename = term_id + "_v" + str(version) + ".md"
+ if os.path.isfile(filename):
+ return "skip"
- page_content = generate_page(term_data)
+ term_data = fetch_term_info(term_id)
+ if term_data is None:
+ return "fail"
- with open(filename, "w", encoding="utf-8") as f:
- f.write(page_content)
+ page_content = generate_page(term_data)
- success_count += 1
+ tmp = f"{filename}.{os.getpid()}.{threading.get_ident()}.tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ f.write(page_content)
+ os.replace(tmp, filename)
- # Clean up previous version
- old_filename = term_id + "_v" + str(version - 1) + ".md"
- if os.path.isfile(old_filename):
- os.remove(old_filename)
- print(f'Removed: {old_filename}')
+ # Clean up previous version
+ old_filename = term_id + "_v" + str(version - 1) + ".md"
+ if os.path.isfile(old_filename):
+ try:
+ os.remove(old_filename)
+ except OSError:
+ pass
+ return "ok"
- # Throttling is handled by wait_for_server_capacity() in fetch_term_info
+def save_terms(ids):
+ """Fetch and save term pages for a list of IDs, concurrently.
+
+ A bounded ThreadPoolExecutor keeps up to MAX_WORKERS requests in flight so
+ the server's ~20 query slots stay busy, instead of the old one-at-a-time
+ loop that left them idle. The in-flight window is capped so building the
+ future set for a 600k-id group doesn't balloon memory.
+ """
+ total = len(ids)
+ counts = {"ok": 0, "skip": 0, "fail": 0}
+ done = 0
+ window = MAX_WORKERS * 4
+ progress_every = max(100, window)
+ def record(fut, tid):
+ nonlocal done
+ try:
+ status = fut.result()
except Exception as e:
- fail_count += 1
- print(f"ERROR processing {term_id}: {str(e)}")
+ status = "fail"
+ print(f"ERROR processing {tid}: {e}")
print(traceback.format_exc())
-
- print(f"\nBatch complete: {success_count} created, {skip_count} skipped (existing), {fail_count} failed out of {total} total")
+ counts[status] = counts.get(status, 0) + 1
+ done += 1
+ if status == "fail" or done % progress_every == 0:
+ print(f" {done}/{total} processed "
+ f"(created={counts['ok']} skipped={counts['skip']} failed={counts['fail']}) "
+ f"last={tid}:{status}")
+
+ with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
+ inflight = {}
+ for term_id in ids:
+ inflight[ex.submit(process_term, term_id)] = term_id
+ if len(inflight) >= window:
+ finished, _ = wait(set(inflight), return_when=FIRST_COMPLETED)
+ for fut in finished:
+ record(fut, inflight.pop(fut))
+ for fut in as_completed(set(inflight)):
+ record(fut, inflight[fut])
+
+ print(f"\nBatch complete: {counts['ok']} created, {counts['skip']} skipped (existing), "
+ f"{counts['fail']} failed out of {total} total")
# ─── Testing ─────────────────────────────────────────────────────────────────
@@ -698,12 +882,16 @@ def test_term_page(term_id, term_type="class"):
checks["Relationships"] = "## Relationships" in page_content
if term_data.get("Synonyms"):
checks["Synonyms"] = "## Alternative Names" in page_content
+ if term_data.get("Xrefs"):
+ checks["Cross References"] = "## Cross References" in page_content
if term_data.get("Licenses"):
checks["Licenses"] = "## License" in page_content
- if term_data.get("Images"):
+ if term_data.get("Images") or term_data.get("Examples"):
checks["Downloads"] = "## Downloads" in page_content
if any(q.get("preview_results", {}).get("rows") for q in term_data.get("Queries", [])):
checks["Query previews"] = "table-responsive" in page_content
+ if term_data.get("RelatedTools"):
+ checks["Graphs For"] = "## Graphs For" in page_content
all_pass = True
for check_name, result in checks.items():
@@ -791,8 +979,8 @@ def test_report_link_regression():
checks = {
"Markdown links use report path": converted == "[DNb08](/reports/FBbt_20011340)",
- "Query preview preserves report link": "[DNb08](/reports/FBbt_20011340)" in query_preview,
- "Plain row labels get report link": "[DNp45](/reports/FBbt_20011346)" in query_preview,
+ "Query preview preserves report link": 'DNb08' in query_preview,
+ "Plain row labels get report link": 'DNp45' in query_preview,
}
all_pass = True