From 89bc159fe99e4e212d9b70dcc0f92a58b725b28f Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Fri, 17 Apr 2026 13:15:13 -0400 Subject: [PATCH 1/7] Added LLM agent skill and documentation for using NameRes from AI agents. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + documentation/LLMs.md | 100 ++++++++++++++++++ skills/nameres.md | 235 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 documentation/LLMs.md create mode 100644 skills/nameres.md diff --git a/README.md b/README.md index 2eaa89a9..77727d29 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The best place to start is the Jupyter Notebook, which walks through the most co ## Documentation * [Translator Guide](documentation/TranslatorGuide.md) — what to do when results are unexpected, when to use `/synonyms` vs. NodeNorm, and performance tips +* [Using NameRes from an AI agent](documentation/LLMs.md) — the agent skill, how to install it, and how to fetch it from a running instance at `/llms.txt` * [API documentation](documentation/API.md) — full reference for all NameRes endpoints * [Where NameRes data comes from](documentation/Babel.md) — how Babel builds the concepts NameRes searches, and the gotchas that follow from that * [Scoring](documentation/Scoring.md) — how NameRes scores and ranks results diff --git a/documentation/LLMs.md b/documentation/LLMs.md new file mode 100644 index 00000000..a86e6bb2 --- /dev/null +++ b/documentation/LLMs.md @@ -0,0 +1,100 @@ +# Using NameRes with AI Agents and LLMs + +The Name Resolver can be used directly from an AI coding agent (such as Claude Code) or any LLM-based tool that can make HTTP requests. A skill file is provided that gives the agent the instructions it needs to call NameRes correctly. + +## The Skill File + +The skill file is at [`skills/nameres.md`](../skills/nameres.md) in this repository. + +It covers: +- How to call `/lookup` to find CURIEs for a single biomedical name +- How to interpret and disambiguate scored results — including when to ask the user for help +- When to use `/bulk-lookup` for resolving multiple names in one request +- When to use `/synonyms` to retrieve all known names for a CURIE you already have +- Which ontology prefixes and Biolink types to use for common concept types + +The skill file is model-agnostic — it contains no Claude-specific syntax and can be used with any agent that accepts markdown context. + +**Raw file URL (for direct download or linking):** +``` +https://raw.githubusercontent.com/NCATSTranslator/NameResolution/master/skills/nameres.md +``` + +## Adding the Skill to Claude Code + +### Option 1: Project-level (applies when working in a specific project) + +Save the skill file to `.claude/skills/nameres.md` inside your project directory: + +```bash +mkdir -p .claude/skills +curl -o .claude/skills/nameres.md \ + https://raw.githubusercontent.com/NCATSTranslator/NameResolution/master/skills/nameres.md +``` + +Claude Code will make it available as `/nameres` when you are working in that project. + +### Option 2: Global (applies to all your Claude Code sessions) + +```bash +mkdir -p ~/.claude/skills +curl -o ~/.claude/skills/nameres.md \ + https://raw.githubusercontent.com/NCATSTranslator/NameResolution/master/skills/nameres.md +``` + +### Using the skill + +Once installed, invoke the skill with: + +``` +/nameres +``` + +For example: +``` +/nameres find the CURIE for aspirin +/nameres resolve these disease names to CURIEs: diabetes, hypertension, asthma +/nameres what are all the synonyms for MONDO:0005148? +``` + +## Adding the Skill to Other Agents + +For any agent that accepts a system prompt or context document, copy the content of [`skills/nameres.md`](../skills/nameres.md) and include it in the agent's system prompt or instructions. The skill file is self-contained and does not depend on any external tooling. + +## Example Workflows + +### Resolving a single chemical name + +**Goal:** Find the normalized CURIE for "acetaminophen" to use with a downstream Translator service. + +1. Call `/lookup`: + ``` + GET https://name-resolution-sri.renci.org/lookup?string=acetaminophen&limit=5 + ``` +2. The top result will likely be `CHEBI:46195` with label "paracetamol" — this is correct due to DrugChemical conflation (acetaminophen and paracetamol are the same compound). +3. Use `CHEBI:46195` as the normalized identifier for downstream calls. + +### Batch-resolving entities from text + +**Goal:** A paragraph mentions "type 2 diabetes", "BRCA1", and "metformin". Resolve all three. + +1. Call `/bulk-lookup`: + ```json + POST https://name-resolution-sri.renci.org/bulk-lookup + { + "strings": ["type 2 diabetes", "BRCA1", "metformin"], + "limit": 5 + } + ``` +2. Inspect each result list. "type 2 diabetes" and "metformin" are likely unambiguous. "BRCA1" may return both the gene and related concepts — check `types` to confirm you have `biolink:Gene`. +3. If any result is ambiguous, re-query with `biolink_type` or `only_prefixes` as appropriate, or present the top options to the user. + +### Looking up synonyms for a known CURIE + +**Goal:** You have `MONDO:0005148` and want to know all the names it is known by (e.g., to search a corpus for mentions). + +``` +GET https://name-resolution-sri.renci.org/synonyms?preferred_curies=MONDO:0005148 +``` + +The `names` field in the response contains the full synonym list. diff --git a/skills/nameres.md b/skills/nameres.md new file mode 100644 index 00000000..81e0bfce --- /dev/null +++ b/skills/nameres.md @@ -0,0 +1,235 @@ +# Skill: Resolve Biomedical Names to CURIEs using NameRes + +## Overview + +The Name Resolver (NameRes) maps lexical strings (names, synonyms, abbreviations) to normalized CURIEs from biomedical ontologies. Use it whenever you need a stable, normalized identifier for a biomedical concept before calling a downstream service. + +- **Base URL:** `https://name-resolution-sri.renci.org/` +- **Interactive docs:** `https://name-resolution-sri.renci.org/docs` +- **Three endpoints:** `/lookup`, `/bulk-lookup`, `/synonyms` + +All CURIEs returned are normalized using the Node Normalization service and are subject to GeneProtein and DrugChemical conflation (see [Conflation](#conflation)). + +--- + +## Endpoint: `/lookup` — Find CURIEs for a single name + +**When to use:** You have one biomedical term and need candidate CURIEs. + +``` +GET https://name-resolution-sri.renci.org/lookup?string=&limit=10 +``` + +POST is also supported (send `string` as a query parameter). + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `string` | string | required | The term to search | +| `limit` | integer | 10 | Number of results (max 1000). Use 10–25 when disambiguating. | +| `autocomplete` | boolean | false | `true` = prefix/partial matching (for search-as-you-type). `false` = exact entity linking (preferred for most uses). | +| `highlighting` | boolean | false | If `true`, response includes which labels/synonyms matched. Useful for debugging unexpected results. | +| `biolink_type` | string[] | [] | Filter to specific Biolink types (e.g., `Disease`, `Gene`, `ChemicalEntity`). Multiple values are OR'd. | +| `only_prefixes` | string | | Pipe-separated list of CURIE prefixes to include (e.g., `MONDO\|EFO`). Case-sensitive. | +| `exclude_prefixes` | string | | Pipe-separated list of CURIE prefixes to exclude (e.g., `UMLS\|EFO`). Case-sensitive. | +| `only_taxa` | string | | Pipe-separated NCBI taxon CURIEs (e.g., `NCBITaxon:9606` for human). Results without any taxon annotation are always included. | + +### Response + +A ranked list of `LookupResult` objects: + +```json +[ + { + "curie": "MONDO:0005148", + "label": "diabetes mellitus", + "score": 42.5, + "types": ["biolink:Disease", "biolink:DiseaseOrPhenotypicFeature"], + "taxa": [], + "clique_identifier_count": 125, + "synonyms": ["diabetes", "DM", "diabetes mellitus", "sugar diabetes"], + "highlighting": {} + } +] +``` + +### Scoring + +Results are ranked by a TF*IDF score with field boosts: +- Exact preferred-name match: **250×** boost +- Exact synonym match: **100×** boost +- Partial preferred-name match: **25×** boost +- Partial synonym match: **10×** boost + +The score is also multiplied by `log10(clique_identifier_count + 1)`, so widely-used, well-populated concepts rank higher when scores are otherwise similar. Results are sorted by score descending, then by clique size descending, then by CURIE suffix ascending. + +--- + +## Choosing the Right Match — Disambiguation + +**The top result is often correct, but do not blindly use it.** Scoring is strong for unambiguous terms and weaker for abbreviations, common words, and homonyms. + +### When the top result is likely correct + +- The `label` closely matches the input string (e.g., searching "diabetes mellitus" and getting back `label: "diabetes mellitus"`) +- The `score` of the top result is substantially higher than the second result +- The `types` are consistent with what you expect + +### When to ask the user to choose + +- The term is an **abbreviation** (e.g., "DMD" could be the gene *dystrophin* or the disease *Duchenne muscular dystrophy*; "MS" could be *multiple sclerosis* or *mass spectrometry*) +- **Multiple top results have similar scores** with different labels +- The term is a **common word** that appears in many contexts (e.g., "cold", "positive", "marker") +- The expected type is **ambiguous** (e.g., a term that could be a disease or a phenotypic feature) + +When asking the user, present the top 10–25 results with `label`, `curie`, and the first `types` entry so they can identify the intended concept. + +### Disambiguation strategies + +1. **Add a `biolink_type` filter** when context makes the type clear. For example, if resolving names from a disease list, add `biolink_type=Disease`. + +2. **Add `only_prefixes`** to prefer a canonical ontology for the domain: + + | Concept type | Preferred prefixes | + |---|---| + | Disease | `MONDO` | + | Phenotype / clinical finding | `HP` | + | Gene | `NCBIGene`, `HGNC` | + | Chemical / drug | `CHEBI`, `CHEMBL`, `PUBCHEM.COMPOUND` | + | Protein | `UniProtKB` | + | Anatomical entity | `UBERON` | + | Cell type | `CL` | + | Taxon | `NCBITaxon` | + +3. **Add `only_taxa=NCBITaxon:9606`** when you need human-specific results (genes, proteins). + +4. **Use `highlighting=true`** to see which synonym triggered the match — useful for explaining unexpected results to the user. + +--- + +## Endpoint: `/bulk-lookup` — Resolve multiple names in one request + +**When to use:** You have 3 or more terms to resolve. Prefer this over serial `/lookup` calls. + +``` +POST https://name-resolution-sri.renci.org/bulk-lookup +Content-Type: application/json +``` + +Request body: + +```json +{ + "strings": ["diabetes", "hypertension", "aspirin"], + "limit": 10, + "biolink_types": ["Disease"], + "only_prefixes": "", + "exclude_prefixes": "", + "only_taxa": "", + "autocomplete": false, + "highlighting": false +} +``` + +Note: the body field is `biolink_types` (plural), unlike the query parameter `biolink_type` in `/lookup`. + +All filter parameters apply uniformly to every string in the batch. If different strings need different filters, make separate requests. + +### Response + +A dictionary keyed by input string, each value is a ranked list of `LookupResult` objects (same structure as `/lookup`): + +```json +{ + "diabetes": [ + { "curie": "MONDO:0005148", "label": "diabetes mellitus", "score": 42.5, ... } + ], + "hypertension": [ + { "curie": "MONDO:0005044", "label": "hypertension", "score": 38.1, ... } + ], + "aspirin": [ + { "curie": "CHEBI:15365", "label": "aspirin", "score": 51.2, ... } + ] +} +``` + +--- + +## Endpoint: `/synonyms` — Retrieve all names for a known CURIE + +**When to use:** +- You have a CURIE and need its full synonym list (e.g., to search text for mentions of a concept) +- You want to confirm what concept a CURIE refers to and what Biolink types it has +- You need the taxa or clique size for a set of CURIEs + +``` +GET https://name-resolution-sri.renci.org/synonyms?preferred_curies=MONDO:0005148 +``` + +For multiple CURIEs, repeat the parameter: + +``` +GET https://name-resolution-sri.renci.org/synonyms?preferred_curies=MONDO:0005148&preferred_curies=NCBIGene:1756 +``` + +POST is also supported: +```json +{ "preferred_curies": ["MONDO:0005148", "NCBIGene:1756"] } +``` + +### Response + +A dictionary keyed by CURIE: + +```json +{ + "MONDO:0005148": { + "curie": "MONDO:0005148", + "preferred_name": "diabetes mellitus", + "names": ["diabetes mellitus", "diabetes", "DM", "sugar diabetes", "T2DM", ...], + "types": ["Disease", "DiseaseOrPhenotypicFeature", ...], + "taxa": [], + "clique_identifier_count": 125 + }, + "NCBIGene:1756": { + "curie": "NCBIGene:1756", + "preferred_name": "DMD", + "names": ["DMD", "dystrophin", "DYSTROPHIN", "BMD", ...], + "types": ["Gene", "GeneOrGeneProduct", ...], + "taxa": ["NCBITaxon:9606"], + "clique_identifier_count": 22 + } +} +``` + +If a CURIE is not found, its value will be an empty object `{}`. + +--- + +## Decision Guide + +``` +Need a CURIE for a name? + One term → GET /lookup?string=&limit=10 + Many terms → POST /bulk-lookup {"strings": [...]} + +Result ambiguous? + Context implies a type → add biolink_type=Disease (or Gene, ChemicalEntity, etc.) + Context implies a source → add only_prefixes=MONDO (or NCBIGene, CHEBI, etc.) + Still ambiguous → fetch limit=25, show label+curie+types to user, ask them to pick + +Have a CURIE, need its names/metadata? + → GET /synonyms?preferred_curies= +``` + +--- + +## Conflation + +NameRes applies two conflations at index-build time (not on-the-fly): + +- **GeneProtein conflation:** Protein-encoding genes are conflated with the protein(s) they encode. The gene identifier is used; searching for a protein name will return the gene CURIE. +- **DrugChemical conflation:** Drugs are conflated with their active ingredient. The active ingredient's identifier is used. + +This means you may not find a separate entry for a specific protein or brand-name drug — look for the gene or active ingredient instead. Once you have a CURIE, you can use [Node Normalization](https://nodenormalization-sri.renci.org/) to retrieve equivalent identifiers with and without conflation applied. From 3228f9e0f7f7e3e9e218cdd72fa080b2fe88bd21 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 05:03:18 -0400 Subject: [PATCH 2/7] Restructure the skill to the shared convention and correct it Claude Code discovers skills as /SKILL.md with YAML frontmatter supplying `name` and `description`. A flat skills/nameres.md is never loaded, so /nameres did not exist and the skill never triggered. Move it and add the frontmatter; the `description` names trigger situations rather than describing the service, because that is what the model matches on to decide whether the skill is relevant. Closes #290. Corrections, all checked against a live instance rather than recalled: - The flagship example was wrong. /lookup?string=diabetes does not return MONDO:0005148 "diabetes mellitus" -- that CURIE is "type 2 diabetes mellitus". The real top hit is UMLS:C0011847 "Diabetes", a leftover-UMLS singleton with clique_identifier_count 1, ahead of MONDO:0005015 "diabetes mellitus". That is a better example than the invented one, so it now leads the disambiguation section: it shows concretely why the top hit is the best textual match rather than the concept you want. - Added `offset` and `debug`, which were missing from the parameter table. Without `offset` an agent cannot page past the 1000-result `limit` cap. - `highlighting` was shown as `{}` with no explanation; that is what you get with the flag off. Documented the real shape. - Added the always-present `explain` and `debug` response keys. Content that was a second copy of something else is now a link: the scoring boost table (Scoring.md owns it, and it was already a revision out of date), the exhaustive parameter reference (API.md), and why cliques look the way they do (Babel.md). Invented scores of 42.5/38.1/51.2 are gone -- real scores run into the thousands, and quoting any of them invites thresholding on a value that has no fixed scale. The base URL was hardcoded eight times; it is stated once, with every example using a bare path, so the document is correct when served from /llms.txt by an instance that is not RENCI dev. Adds what was missing entirely: /status and data provenance, a routing table sending CURIE-to-CURIE work to NodeNorm instead, that autocomplete=false is entity-linker mode, how to tell whether two names are the same concept, and the /lookup-vs-/synonyms field divergences tracked in #291. Co-Authored-By: Claude Opus 5 --- skills/nameres.md | 235 ---------------------------------------- skills/nameres/SKILL.md | 233 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 235 deletions(-) delete mode 100644 skills/nameres.md create mode 100644 skills/nameres/SKILL.md diff --git a/skills/nameres.md b/skills/nameres.md deleted file mode 100644 index 81e0bfce..00000000 --- a/skills/nameres.md +++ /dev/null @@ -1,235 +0,0 @@ -# Skill: Resolve Biomedical Names to CURIEs using NameRes - -## Overview - -The Name Resolver (NameRes) maps lexical strings (names, synonyms, abbreviations) to normalized CURIEs from biomedical ontologies. Use it whenever you need a stable, normalized identifier for a biomedical concept before calling a downstream service. - -- **Base URL:** `https://name-resolution-sri.renci.org/` -- **Interactive docs:** `https://name-resolution-sri.renci.org/docs` -- **Three endpoints:** `/lookup`, `/bulk-lookup`, `/synonyms` - -All CURIEs returned are normalized using the Node Normalization service and are subject to GeneProtein and DrugChemical conflation (see [Conflation](#conflation)). - ---- - -## Endpoint: `/lookup` — Find CURIEs for a single name - -**When to use:** You have one biomedical term and need candidate CURIEs. - -``` -GET https://name-resolution-sri.renci.org/lookup?string=&limit=10 -``` - -POST is also supported (send `string` as a query parameter). - -### Parameters - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `string` | string | required | The term to search | -| `limit` | integer | 10 | Number of results (max 1000). Use 10–25 when disambiguating. | -| `autocomplete` | boolean | false | `true` = prefix/partial matching (for search-as-you-type). `false` = exact entity linking (preferred for most uses). | -| `highlighting` | boolean | false | If `true`, response includes which labels/synonyms matched. Useful for debugging unexpected results. | -| `biolink_type` | string[] | [] | Filter to specific Biolink types (e.g., `Disease`, `Gene`, `ChemicalEntity`). Multiple values are OR'd. | -| `only_prefixes` | string | | Pipe-separated list of CURIE prefixes to include (e.g., `MONDO\|EFO`). Case-sensitive. | -| `exclude_prefixes` | string | | Pipe-separated list of CURIE prefixes to exclude (e.g., `UMLS\|EFO`). Case-sensitive. | -| `only_taxa` | string | | Pipe-separated NCBI taxon CURIEs (e.g., `NCBITaxon:9606` for human). Results without any taxon annotation are always included. | - -### Response - -A ranked list of `LookupResult` objects: - -```json -[ - { - "curie": "MONDO:0005148", - "label": "diabetes mellitus", - "score": 42.5, - "types": ["biolink:Disease", "biolink:DiseaseOrPhenotypicFeature"], - "taxa": [], - "clique_identifier_count": 125, - "synonyms": ["diabetes", "DM", "diabetes mellitus", "sugar diabetes"], - "highlighting": {} - } -] -``` - -### Scoring - -Results are ranked by a TF*IDF score with field boosts: -- Exact preferred-name match: **250×** boost -- Exact synonym match: **100×** boost -- Partial preferred-name match: **25×** boost -- Partial synonym match: **10×** boost - -The score is also multiplied by `log10(clique_identifier_count + 1)`, so widely-used, well-populated concepts rank higher when scores are otherwise similar. Results are sorted by score descending, then by clique size descending, then by CURIE suffix ascending. - ---- - -## Choosing the Right Match — Disambiguation - -**The top result is often correct, but do not blindly use it.** Scoring is strong for unambiguous terms and weaker for abbreviations, common words, and homonyms. - -### When the top result is likely correct - -- The `label` closely matches the input string (e.g., searching "diabetes mellitus" and getting back `label: "diabetes mellitus"`) -- The `score` of the top result is substantially higher than the second result -- The `types` are consistent with what you expect - -### When to ask the user to choose - -- The term is an **abbreviation** (e.g., "DMD" could be the gene *dystrophin* or the disease *Duchenne muscular dystrophy*; "MS" could be *multiple sclerosis* or *mass spectrometry*) -- **Multiple top results have similar scores** with different labels -- The term is a **common word** that appears in many contexts (e.g., "cold", "positive", "marker") -- The expected type is **ambiguous** (e.g., a term that could be a disease or a phenotypic feature) - -When asking the user, present the top 10–25 results with `label`, `curie`, and the first `types` entry so they can identify the intended concept. - -### Disambiguation strategies - -1. **Add a `biolink_type` filter** when context makes the type clear. For example, if resolving names from a disease list, add `biolink_type=Disease`. - -2. **Add `only_prefixes`** to prefer a canonical ontology for the domain: - - | Concept type | Preferred prefixes | - |---|---| - | Disease | `MONDO` | - | Phenotype / clinical finding | `HP` | - | Gene | `NCBIGene`, `HGNC` | - | Chemical / drug | `CHEBI`, `CHEMBL`, `PUBCHEM.COMPOUND` | - | Protein | `UniProtKB` | - | Anatomical entity | `UBERON` | - | Cell type | `CL` | - | Taxon | `NCBITaxon` | - -3. **Add `only_taxa=NCBITaxon:9606`** when you need human-specific results (genes, proteins). - -4. **Use `highlighting=true`** to see which synonym triggered the match — useful for explaining unexpected results to the user. - ---- - -## Endpoint: `/bulk-lookup` — Resolve multiple names in one request - -**When to use:** You have 3 or more terms to resolve. Prefer this over serial `/lookup` calls. - -``` -POST https://name-resolution-sri.renci.org/bulk-lookup -Content-Type: application/json -``` - -Request body: - -```json -{ - "strings": ["diabetes", "hypertension", "aspirin"], - "limit": 10, - "biolink_types": ["Disease"], - "only_prefixes": "", - "exclude_prefixes": "", - "only_taxa": "", - "autocomplete": false, - "highlighting": false -} -``` - -Note: the body field is `biolink_types` (plural), unlike the query parameter `biolink_type` in `/lookup`. - -All filter parameters apply uniformly to every string in the batch. If different strings need different filters, make separate requests. - -### Response - -A dictionary keyed by input string, each value is a ranked list of `LookupResult` objects (same structure as `/lookup`): - -```json -{ - "diabetes": [ - { "curie": "MONDO:0005148", "label": "diabetes mellitus", "score": 42.5, ... } - ], - "hypertension": [ - { "curie": "MONDO:0005044", "label": "hypertension", "score": 38.1, ... } - ], - "aspirin": [ - { "curie": "CHEBI:15365", "label": "aspirin", "score": 51.2, ... } - ] -} -``` - ---- - -## Endpoint: `/synonyms` — Retrieve all names for a known CURIE - -**When to use:** -- You have a CURIE and need its full synonym list (e.g., to search text for mentions of a concept) -- You want to confirm what concept a CURIE refers to and what Biolink types it has -- You need the taxa or clique size for a set of CURIEs - -``` -GET https://name-resolution-sri.renci.org/synonyms?preferred_curies=MONDO:0005148 -``` - -For multiple CURIEs, repeat the parameter: - -``` -GET https://name-resolution-sri.renci.org/synonyms?preferred_curies=MONDO:0005148&preferred_curies=NCBIGene:1756 -``` - -POST is also supported: -```json -{ "preferred_curies": ["MONDO:0005148", "NCBIGene:1756"] } -``` - -### Response - -A dictionary keyed by CURIE: - -```json -{ - "MONDO:0005148": { - "curie": "MONDO:0005148", - "preferred_name": "diabetes mellitus", - "names": ["diabetes mellitus", "diabetes", "DM", "sugar diabetes", "T2DM", ...], - "types": ["Disease", "DiseaseOrPhenotypicFeature", ...], - "taxa": [], - "clique_identifier_count": 125 - }, - "NCBIGene:1756": { - "curie": "NCBIGene:1756", - "preferred_name": "DMD", - "names": ["DMD", "dystrophin", "DYSTROPHIN", "BMD", ...], - "types": ["Gene", "GeneOrGeneProduct", ...], - "taxa": ["NCBITaxon:9606"], - "clique_identifier_count": 22 - } -} -``` - -If a CURIE is not found, its value will be an empty object `{}`. - ---- - -## Decision Guide - -``` -Need a CURIE for a name? - One term → GET /lookup?string=&limit=10 - Many terms → POST /bulk-lookup {"strings": [...]} - -Result ambiguous? - Context implies a type → add biolink_type=Disease (or Gene, ChemicalEntity, etc.) - Context implies a source → add only_prefixes=MONDO (or NCBIGene, CHEBI, etc.) - Still ambiguous → fetch limit=25, show label+curie+types to user, ask them to pick - -Have a CURIE, need its names/metadata? - → GET /synonyms?preferred_curies= -``` - ---- - -## Conflation - -NameRes applies two conflations at index-build time (not on-the-fly): - -- **GeneProtein conflation:** Protein-encoding genes are conflated with the protein(s) they encode. The gene identifier is used; searching for a protein name will return the gene CURIE. -- **DrugChemical conflation:** Drugs are conflated with their active ingredient. The active ingredient's identifier is used. - -This means you may not find a separate entry for a specific protein or brand-name drug — look for the gene or active ingredient instead. Once you have a CURIE, you can use [Node Normalization](https://nodenormalization-sri.renci.org/) to retrieve equivalent identifiers with and without conflation applied. diff --git a/skills/nameres/SKILL.md b/skills/nameres/SKILL.md new file mode 100644 index 00000000..9b8ac298 --- /dev/null +++ b/skills/nameres/SKILL.md @@ -0,0 +1,233 @@ +--- +name: nameres +description: Resolve biomedical names to CURIEs using the Translator Name Resolver (NameRes) — turn a term like "type 2 diabetes", "BRCA1", "aspirin" or "Duchenne muscular dystrophy" into a normalized identifier, entity-link a whole list of terms at once, or list every known synonym for a CURIE. Use when text names a disease, gene, protein, chemical, drug, phenotype, cell type or anatomical structure and you need an identifier for it, when normalizing a column of names in a spreadsheet, or when checking whether two names refer to the same concept. +--- + +# Resolving biomedical names to CURIEs with NameRes + +NameRes searches every name and synonym Translator knows for a biomedical concept and returns +ranked, normalized identifiers (CURIEs). + +**Base URL:** `https://name-resolution-sri.renci.org/` — if you were given a different NameRes URL, +use that instead. Every instance serves the same API and its own `/llms.txt` and `/status`. +Examples below use bare paths so they work against any of them. + +`GET /openapi.json` is the machine-readable authority on parameters and defaults. Prefer it over +`/docs`, which is a JavaScript-rendered Swagger UI and near-useless to read. + +## Is this the right service? + +| You have | You want | Use | +|---|---|---| +| A name or string (`"aspirin"`) | An identifier | **NameRes** — this service | +| An identifier (`CHEBI:15365`) | Its preferred identifier, equivalent identifiers in other databases, or a description | [NodeNorm](https://nodenormalization-sri.renci.org/docs) — not this service | +| An identifier | Its synonyms | **NameRes** `/synonyms` | + +NameRes will not normalize a CURIE you already have. Do not pass a CURIE to `/lookup`. + +## Endpoints + +| Endpoint | Use | +|---|---| +| `GET`/`POST` `/lookup` | One term → ranked candidate CURIEs | +| `POST /bulk-lookup` | Many terms at once (entity linking / NER) | +| `GET`/`POST` `/synonyms` | Known CURIE → all its names | +| `GET /status` | Which data this instance is serving | +| `/reverse_lookup` | **Deprecated** — use `/synonyms` | + +## One term: `/lookup` + +``` +GET /lookup?string=hypertension&limit=10 +``` + +`POST /lookup` takes the same **query parameters**, not a JSON body. + +| Parameter | Default | Notes | +|---|---|---| +| `string` | required | The term to search for | +| `limit` | 10 | Max 1000 | +| `offset` | 0 | Pagination; the only way past the first 1000 results | +| `autocomplete` | false | See below — the most consequential parameter here | +| `highlighting` | false | Adds which label/synonym matched | +| `biolink_type` | none | Repeatable. With or without the `biolink:` prefix. Multiple values are OR'd | +| `only_prefixes` | none | Pipe-separated, **case-sensitive**, e.g. `MONDO\|EFO` | +| `exclude_prefixes` | none | Pipe-separated, case-sensitive | +| `only_taxa` | none | Pipe-separated NCBITaxon CURIEs. Also keeps results that have *no* taxon | +| `debug` | none | `none\|query\|timing\|results\|all`; `results` adds per-result `explain` | + +**`autocomplete=false` (the default) is entity-linker mode** — the whole string is treated as a +complete phrase. Set `autocomplete=true` only for search-as-you-type, where the user is still typing +and the last word is a prefix. Using `true` for entity linking produces confident nonsense. + +### Reading the response + +A ranked list, best first. This is the response *shape* — labels, counts and scores come from the +Babel build the instance is serving and change between releases: + +```json +[ + { + "curie": "MONDO:0005044", + "label": "hypertension", + "synonyms": ["hypertension", "HTN", "high blood pressure"], + "types": ["biolink:Disease", "biolink:DiseaseOrPhenotypicFeature"], + "taxa": [], + "score": 3770.1, + "clique_identifier_count": 33, + "highlighting": {}, + "explain": null, + "debug": null + } +] +``` + +- **`label`** is the *clique's* preferred name. It is not necessarily the label of `curie` in its own + source database, and it may differ in case or wording from what you searched for. +- **`types`** carry the `biolink:` prefix here. On `/synonyms` they do **not**. +- **`synonyms`** is not ordered by quality — do not take `synonyms[0]` as the best name. Use `label`. +- **`score`** is unbounded and has no fixed scale; real scores run into the thousands. + **Never threshold on an absolute score.** Compare the top score to the second one instead. +- **`highlighting`** is `{}` unless you passed `highlighting=true`, when it becomes + `{"labels": [...], "synonyms": [...]}`. +- **`explain`** and `debug` are `null` unless you passed `debug=results` or `debug=all`. + +Ranking, in short: an exact match on the preferred name outranks an exact match on a synonym, which +outranks a partial match; the score is then scaled by the log of `clique_identifier_count`, so a +widely cross-referenced concept beats an obscure one on an otherwise equal match. Details: +[Scoring](https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/Scoring.md). + +## Do not blindly take the top result + +The top hit is the best *textual* match, which is not always the concept meant. A real example — +searching `diabetes` returns, in order: + +| curie | label | clique_identifier_count | +|---|---|---| +| `UMLS:C0011847` | Diabetes | 1 | +| `MONDO:0005015` | diabetes mellitus | 15 | + +The winner is a **single-identifier UMLS concept**, because it matches the string exactly. The +concept a caller almost always wants is the second one. A `UMLS:` CURIE with +`clique_identifier_count: 1` is a leftover-UMLS singleton — real, deliberately included for +coverage, and usually not what you want. See +[Where NameRes data comes from](https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/Babel.md). + +**Ask the user to choose** when the term is an abbreviation (`DMD` is both a gene and a disease; +`MS` could be multiple sclerosis or mass spectrometry), when the top few scores are close, or when +the term is a common word (`cold`, `positive`, `marker`). Show `label`, `curie` and the first `types` +entry for the top 10–25 and let them pick. + +**Narrow the query instead of guessing:** + +1. `biolink_type=Disease` when context makes the type clear. +2. `only_prefixes` to prefer a canonical vocabulary: + + | Concept | Prefixes | + |---|---| + | Disease | `MONDO` | + | Phenotype | `HP` | + | Gene | `NCBIGene`, `HGNC` | + | Chemical / drug | `CHEBI`, `CHEMBL`, `PUBCHEM.COMPOUND` | + | Protein | `UniProtKB` | + | Anatomy | `UBERON` | + | Cell type | `CL` | + | Taxon | `NCBITaxon` | + + `exclude_prefixes=UMLS` is a blunt but effective way to drop the singletons above. +3. `only_taxa=NCBITaxon:9606` for human genes and proteins. +4. `highlighting=true` to see *which* synonym matched, or `debug=results` for the score breakdown. + +**Deciding whether two names are the same concept:** resolve both and compare the returned `curie`. +Equal CURIEs mean Babel considers them the same concept. Different CURIEs are not proof they differ — +check with [NodeNorm](https://nodenormalization-sri.renci.org/docs), since conflation may relate them. + +## Many terms: `/bulk-lookup` + +For three or more terms, and for any entity-linking or NER pass, use this instead of looping over +`/lookup`. + +``` +POST /bulk-lookup +Content-Type: application/json + +{ + "strings": ["diabetes", "hypertension", "aspirin"], + "limit": 10, + "autocomplete": false, + "biolink_types": ["Disease"] +} +``` + +**The body field is `biolink_types` (plural).** The `/lookup` query parameter is `biolink_type` +(singular). Using the wrong one is silently ignored rather than rejected. + +Every filter applies to every string in the batch. If different terms need different filters, send +separate requests. The response is a dictionary keyed by your input strings, each value a ranked list +of the same objects `/lookup` returns. + +## A known CURIE: `/synonyms` + +``` +GET /synonyms?preferred_curies=NCBIGene:1756&preferred_curies=MONDO:0005015 +``` + +`POST /synonyms` takes `{"preferred_curies": [...]}`. This endpoint does **not** normalize — pass a +preferred CURIE, which you can get from `/lookup` or from NodeNorm. + +It returns the raw underlying document, keyed by CURIE, and so differs from `/lookup` in ways that +will trip you up: + +- Field names are `preferred_name` and `names`, not `label` and `synonyms`. +- **`types` have no `biolink:` prefix** here (`"Gene"`, not `"biolink:Gene"`). +- **`taxa` is omitted entirely** when the concept has none, where `/lookup` returns `[]`. +- An unknown CURIE comes back as an **empty object** `{}` — the key is present, not missing, not null. +- There are extra index fields (`id`, `_version_`, `curie_suffix`, `shortest_name_length`); ignore them. + +These divergences are known and tracked in +[issue #291](https://github.com/NCATSTranslator/NameResolution/issues/291). + +## Which data am I querying? + +``` +GET /status +``` + +Reports `babel_version` (the Babel build this index was made from), `nameres_version`, the Biolink +model tag, and the conflations baked into the index. Older deployments may omit some of these; if +`babel_version` is absent, the instance is running an older NameRes build. + +An instance serves a **fixed snapshot**, not live data. A concept Babel has since fixed stays wrong +here until the instance is rebuilt. + +## Conflation, and why results look the way they do + +Conflation is **baked into the index when it is built** and cannot be turned off per query — unlike +NodeNorm, which takes conflation flags per request. + +- **GeneProtein:** a protein is searchable, but the result is identified by the *gene* that encodes + it, and carries both sets of synonyms. There is no separate document for the protein. +- **DrugChemical:** a drug is identified by its active ingredient. + +So searching a protein name and getting `NCBIGene:…` back is correct behaviour, not a bug. Once you +have a CURIE, use [NodeNorm](https://nodenormalization-sri.renci.org/docs) to see the clique with and +without each conflation. + +Other Babel-derived behaviour worth knowing, all covered in +[Where NameRes data comes from](https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/Babel.md): + +- Empty `taxa` means no source asserted a taxon — not that the concept is taxon-agnostic. That is why + `only_taxa` keeps untaxoned results. +- `clique_identifier_count` counts how many source vocabularies cover the concept. It is a decent + proxy for "well known", which is why it boosts the score, but it is not importance. +- There are **no descriptions** in NameRes. Use NodeNorm's `description` flag. +- A concept that is missing, wrongly merged, or wrongly labelled is a + [Babel](https://github.com/NCATSTranslator/Babel/issues) issue. Bad *ranking* or an API error is a + [NameRes](https://github.com/NCATSTranslator/NameResolution/issues) issue. + +## Full reference + +[API documentation](https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/API.md) +for every parameter and field; +[Translator Guide](https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/TranslatorGuide.md) +for worked troubleshooting and performance advice. From e1fdd8c9673e37e6cbea506465f958ede090d10a Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 05:03:47 -0400 Subject: [PATCH 3/7] Serve the skill at GET /llms.txt, and describe the response in OpenAPI /llms.txt returns skills/nameres/SKILL.md with its YAML frontmatter stripped, read from disk per request. Serving the file rather than a second copy means the instructions an instance hands out always match the API answering the queries. The OpenAPI info.description points at it, so an agent given nothing but a base URL can find it. No Dockerfile change is needed -- the Dockerfile is `COPY . /repo/NameResolution` and .dockerignore does not exclude skills/. Verified rather than assumed: `docker build` then asserting SKILL_PATH.is_file() inside the image, and curling /llms.txt from the running container. That is the one failure mode that passes locally and 404s in every deployment, so the path constant carries a comment saying so and a test asserts it resolves. The OpenAPI gap this closes: server.py had 40 `description=` strings, every one on a request parameter, while LookupResult's ten fields had none -- so an agent reading /openapi.json got a well-documented request and an unexplained response. The field descriptions carry the traps, because that schema is what an agent reads without being told to: that `label` belongs to the clique rather than to `curie`, that `types` is biolink:-prefixed here but not on /synonyms, that `synonyms` is not quality-ordered, and that `score` is unbounded and only comparable within one query. Giving /synonyms a real response model is deliberately left to #291, which has to settle the field names first. test_apidocs.py asserts the exact path list, so it changes here rather than breaking. test_docs_links.py's master-branch ban now also catches the raw.githubusercontent.com form, which is what the install instructions use; it is scoped to NCATSTranslator because server.py legitimately builds biolink-model raw URLs and biolink-model really does default to master. Co-Authored-By: Claude Opus 5 --- api/resources/openapi.yml | 3 +- api/server.py | 99 ++++++++++++++++++++++++++++++++++----- tests/test_apidocs.py | 4 +- tests/test_docs_links.py | 21 +++++++-- tests/test_llms_txt.py | 83 ++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 tests/test_llms_txt.py diff --git a/api/resources/openapi.yml b/api/resources/openapi.yml index 3a2e0d2a..2461b30f 100644 --- a/api/resources/openapi.yml +++ b/api/resources/openapi.yml @@ -17,7 +17,8 @@ info: conflation; the active conflations for any deployment can be discovered via the /status endpoint. You can read more about this, and about the other Babel behaviour visible through this API, at - Where NameRes data comes from.

' + Where NameRes data comes from.

+

If you are an AI agent, GET /llms.txt for instructions on using this API.

' license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/api/server.py b/api/server.py index 4cf31c95..20aff0bd 100755 --- a/api/server.py +++ b/api/server.py @@ -10,10 +10,11 @@ import os import re from enum import Enum +from pathlib import Path from typing import Dict, List, Union, Annotated, Optional -from fastapi import Body, FastAPI, Query -from fastapi.responses import RedirectResponse +from fastapi import Body, FastAPI, HTTPException, Query +from fastapi.responses import PlainTextResponse, RedirectResponse import httpx from pydantic import BaseModel, Field from starlette.middleware.cors import CORSMiddleware @@ -26,6 +27,16 @@ # backups we used to ship called it name_lookup_shard1_replica_n1 instead (see status()). SOLR_CORE = os.getenv("SOLR_CORE", "name_lookup") +# The agent instructions served at /llms.txt. api/ sits one level below the repo root. +# The Dockerfile's `COPY .` is what puts this in the image; narrowing that copy, or adding +# skills/ to .dockerignore, makes this route 404 in every deployment while still passing +# locally and in CI. tests/test_llms_txt.py asserts the path resolves. +SKILL_PATH = Path(__file__).parents[1] / "skills" / "nameres" / "SKILL.md" + +# The YAML frontmatter block at the top of SKILL.md. \r?\n rather than \n so that a CRLF +# checkout doesn't silently serve the frontmatter as part of the document. +FRONTMATTER = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n", re.DOTALL) + app = FastAPI(**get_app_info()) logger = logging.getLogger(__name__) logging.basicConfig(level=os.getenv("LOGLEVEL", logging.INFO)) @@ -48,6 +59,36 @@ async def docs_redirect(): return RedirectResponse(url='/docs') +# ENDPOINT /llms.txt +@app.get( + "/llms.txt", + summary="Instructions for using this API from an AI agent or LLM.", + description="

Returns a Markdown document explaining how to resolve biomedical names to CURIEs " + "with this service: which endpoint to call, how to read and disambiguate the ranked " + "results, and the behaviours that will otherwise silently produce a wrong answer.

" + "

The same document is maintained as a " + "skill " + "in the NameResolution repository, so these instructions always match the version of " + "the API serving them.

", + response_class=PlainTextResponse, + response_description="Markdown instructions for using this API.", +) +async def llms_txt() -> PlainTextResponse: + """ Serve the agent instructions from skills/nameres/SKILL.md. """ + try: + skill = SKILL_PATH.read_text(encoding="utf-8") + except OSError: + logger.warning("Could not read agent instructions from %s.", SKILL_PATH) + raise HTTPException( + status_code=404, + detail="Agent instructions are not available on this instance. They can be read at " + "https://github.com/NCATSTranslator/NameResolution/tree/main/skills/nameres", + ) + + # The frontmatter is Claude Code packaging metadata, which is noise in an llms.txt. + return PlainTextResponse(FRONTMATTER.sub("", skill, count=1).lstrip("\n")) + + @app.get("/status", summary="Get status and counts for this NameRes instance.", description="

This endpoint will return status information and a list of counts from the underlying Solr database instance for this NameRes instance.

" @@ -267,16 +308,50 @@ async def name_lookup(curies) -> Dict[str, Dict]: return output class LookupResult(BaseModel): - curie:str - label: str - highlighting: Dict[str, List[str]] - synonyms: List[str] - taxa: List[str] - types: List[str] - score: float - clique_identifier_count: int - explain: Optional[dict] # Explanation for this specific result - debug: Optional[dict] # The debug information for the entire query + curie: str = Field( + description="The preferred CURIE for this concept. Note that this is the identifier of the " + "conflated clique, so searching for a protein returns the CURIE of the gene that " + "encodes it, and searching for a drug returns its active ingredient." + ) + label: str = Field( + description="The preferred name of this concept, as chosen by Babel. This is a property of the " + "whole clique: it is not necessarily the label that `curie` carries in its own " + "source database." + ) + highlighting: Dict[str, List[str]] = Field( + description="Which labels and synonyms matched the query, as `labels` and `synonyms` lists. " + "Empty unless the request set `highlighting=true`." + ) + synonyms: List[str] = Field( + description="All known synonyms for this concept. Not ordered by quality or length -- use " + "`label` if you want a single name for the concept." + ) + taxa: List[str] = Field( + description="The taxa this concept is associated with, as NCBITaxon CURIEs. An empty list means " + "no source asserted a taxon, not that the concept is taxon-agnostic." + ) + types: List[str] = Field( + description="The Biolink types for this concept, narrowest first, each with the `biolink:` " + "prefix. Note that /synonyms returns these same types *without* the prefix." + ) + score: float = Field( + description="The Solr relevance score. Unbounded and on no fixed scale, so it is only " + "meaningful when comparing results within a single query -- do not threshold on an " + "absolute value." + ) + clique_identifier_count: int = Field( + description="How many identifiers Babel merged into this concept, i.e. how many source " + "vocabularies cover it. Used to boost the score, on the theory that a widely " + "cross-referenced concept is more likely to be the one meant." + ) + explain: Optional[dict] = Field( + description="Solr's explanation of how this result's score was calculated. Null unless the " + "request set `debug` to `results` or `all`." + ) + debug: Optional[dict] = Field( + description="Debugging information for the query as a whole, such as the parsed query and " + "timings. Null unless the request set `debug` to something other than `none`." + ) @app.get("/lookup", diff --git a/tests/test_apidocs.py b/tests/test_apidocs.py index 5af9bcd6..e78e72b2 100644 --- a/tests/test_apidocs.py +++ b/tests/test_apidocs.py @@ -14,4 +14,6 @@ def test_construct_open_api_schema_returns_cached_schema(): second = construct_open_api_schema(app) assert second is first - assert sorted(second["paths"]) == ["/bulk-lookup", "/lookup", "/reverse_lookup", "/status", "/synonyms"] + assert sorted(second["paths"]) == [ + "/bulk-lookup", "/llms.txt", "/lookup", "/reverse_lookup", "/status", "/synonyms" + ] diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py index b8a950bb..7668a3f5 100644 --- a/tests/test_docs_links.py +++ b/tests/test_docs_links.py @@ -79,16 +79,27 @@ def test_in_repo_anchors_in_source_resolve(): assert not broken, "Anchors with no matching heading in API.md:\n " + "\n ".join(broken) +#: A GitHub link pinned to `master`, in either the blob or the raw form. The raw half is scoped to +#: NCATSTranslator on purpose: api/server.py legitimately builds +#: raw.githubusercontent.com/biolink/biolink-model/master/... and biolink-model really does default +#: to master. +MASTER_LINK = re.compile( + r"github\.com/[^/\s]+/[^/\s]+/blob/master/" + r"|raw\.githubusercontent\.com/NCATSTranslator/[^/\s]+/master/", + re.IGNORECASE, +) + + def test_no_master_branch_links(): - """NameResolution, Babel and NodeNormalization all default to `main`. A /blob/master/ - URL resolves only through GitHub's post-rename redirect, so it looks fine right up - until that redirect goes away. See CLAUDE.md.""" + """NameResolution, Babel and NodeNormalization all default to `main`. A master URL resolves + only through GitHub's post-rename redirect, so it looks fine right up until that redirect goes + away. See CLAUDE.md.""" offenders = [] for path in MARKDOWN_FILES + SOURCE_WITH_LINKS: for line_no, line in enumerate(path.read_text().splitlines(), start=1): - if re.search(r"github\.com/[^/\s]+/[^/\s]+/blob/master/", line, re.IGNORECASE): + if MASTER_LINK.search(line): offenders.append(f"{path.relative_to(REPO_ROOT)}:{line_no}") - assert not offenders, "Links using /blob/master/ instead of /blob/main/:\n " + "\n ".join(offenders) + assert not offenders, "Links pinned to `master` instead of `main`:\n " + "\n ".join(offenders) #: The three repositories that moved from the TranslatorSRI org to NCATSTranslator. Scoped to diff --git a/tests/test_llms_txt.py b/tests/test_llms_txt.py new file mode 100644 index 00000000..f83f2fe8 --- /dev/null +++ b/tests/test_llms_txt.py @@ -0,0 +1,83 @@ +"""Tests for the agent instructions served at /llms.txt. + +None of these need Solr: the route only reads a file off disk. +""" + +import re + +import yaml +from fastapi.testclient import TestClient + +from api.server import SKILL_PATH, app + +client = TestClient(app) + + +def test_skill_file_exists(): + """If this fails, /llms.txt 404s everywhere.""" + assert SKILL_PATH.is_file(), f"No skill file at {SKILL_PATH}" + + +def test_skill_frontmatter_is_valid(): + """Claude Code silently ignores a skill whose frontmatter is malformed or whose name does + not match its directory, so that failure mode is invisible without a test.""" + frontmatter = re.match(r"\A---\r?\n(.*?)\r?\n---\r?\n", SKILL_PATH.read_text(), re.DOTALL) + assert frontmatter, "SKILL.md must open with a YAML frontmatter block" + + parsed = yaml.safe_load(frontmatter.group(1)) + assert set(parsed) == {"name", "description"}, f"Unexpected frontmatter keys: {sorted(parsed)}" + assert parsed["name"] == SKILL_PATH.parent.name == "nameres" + # The description is what the model matches on to decide whether to load the skill, so it + # needs to name trigger situations rather than just being a title. + assert len(parsed["description"]) > 100 + + +def test_llms_txt_is_served_as_plain_text(): + response = client.get("/llms.txt") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert len(response.text) > 500 + + +def test_llms_txt_strips_frontmatter(): + # Check the premise first: if SKILL.md stopped having frontmatter on purpose, this test and + # the stripping in server.llms_txt() are both pointless and should go. + assert SKILL_PATH.read_text().startswith("---"), "SKILL.md no longer starts with frontmatter" + + body = client.get("/llms.txt").text + + assert not body.startswith("---") + assert "name: nameres" not in body + assert body.startswith("# ") + + +def test_llms_txt_404s_when_the_skill_is_missing(monkeypatch, tmp_path): + """Only reachable in a mis-built image -- which is exactly why it needs a test, since that is + the one failure mode that passes locally and fails in every deployment.""" + monkeypatch.setattr("api.server.SKILL_PATH", tmp_path / "absent.md") + + response = client.get("/llms.txt") + + assert response.status_code == 404 + assert "github.com/NCATSTranslator/NameResolution" in response.json()["detail"] + + +def test_llms_txt_keeps_the_traps(): + """The specific facts an agent gets wrong without being told. If a rewrite drops one of these, + that is a regression rather than an edit.""" + body = client.get("/llms.txt").text + + for trap in ["bulk-lookup", "biolink_types", "autocomplete", "only_taxa", "conflat"]: + assert trap in body, f"The skill no longer mentions {trap!r}" + + +def test_skill_links_are_absolute(): + """SKILL.md is served raw at /llms.txt and pasted into other agents, where a relative link + resolves against the API host and 404s. test_docs_links.py cannot catch this -- a relative + link that resolves on disk passes there.""" + relative = [ + target for target in re.findall(r"\[[^\]]*\]\(([^)\s]+)\)", SKILL_PATH.read_text()) + if not target.startswith(("https://", "http://", "#")) + ] + assert not relative, f"Links in SKILL.md must be absolute URLs: {relative}" From 4dd144cca1a52602525e83aca05340bf400cd650 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 05:04:28 -0400 Subject: [PATCH 4/7] Document how to install the skill, and how to keep it accurate documentation/LLMs.md's install instructions could not work: they saved a flat .claude/skills/nameres.md, which Claude Code does not discover. Rewritten around copying the directory, and around fetching /llms.txt from a running instance when you want instructions that match the deployment. Its "Example Workflows" section is deleted rather than fixed. It was a second copy of the API surface, written from unverified values -- it is where the claim that CHEBI:46195 is labelled "paracetamol" lived, which a live lookup disproves (it is "Acetaminophen"), attributed to DrugChemical conflation, which is the wrong mechanism: acetaminophen and paracetamol are one compound by ordinary synonymy, while DrugChemical joins drug products to active ingredients. The skill is now the only place that documents API usage. CLAUDE.md gains the rule the skill is written to -- it carries how to call the API, how to read the result, and the traps that silently produce a plausible wrong answer, and links out for anything a rebuild could falsify -- plus the requirement that its links be absolute, since a relative link resolves against the API host once the file is served at /llms.txt. Also corrects an example in API.md that the live checks caught: MONDO:0005148 is "type 2 diabetes mellitus", not "diabetes mellitus" (that is MONDO:0005015), and its clique has 21 identifiers rather than the 125 shown. The /synonyms sample also showed an empty `taxa`, which that endpoint omits entirely. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 16 ++++++ documentation/API.md | 34 ++++++++--- documentation/LLMs.md | 128 ++++++++++++++++++------------------------ 3 files changed, 95 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 960b4201..9d6180ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,22 @@ Solr documents contain: `curie`, `preferred_name`, `names` (synonym list), and b - `documentation/Scoring.md` - Scoring algorithm details - `documentation/NameResolution.ipynb` - Interactive usage examples - `documentation/TranslatorGuide.md` - Translator-specific usage guidance +- `documentation/LLMs.md` - how to install the agent skill and fetch it from a running instance +- `skills/nameres/SKILL.md` - agent-facing usage instructions, **also served at `GET /llms.txt`**. + The Dockerfile's `COPY .` and the absence of `skills/` from `.dockerignore` are what put it in a + built image; narrowing either 404s that route in production while every test still passes locally. + +### Writing for agents + +`skills/nameres/SKILL.md` carries **how to call the API, how to read the result, and the traps that +silently produce a plausible wrong answer**. It does not carry exhaustive parameter tables, scoring +internals, or any number that a Babel rebuild or a tuning change could falsify — those live in +`documentation/` and the skill links to them. If a fact in the skill can go stale without a test +failing, either delete it or add the test. + +Links in `SKILL.md` must be **absolute** `https://github.com/NCATSTranslator/NameResolution/blob/main/...` +URLs. The file is served raw at `/llms.txt` and pasted into other agents, where a relative link +resolves against the API host and 404s. `tests/test_llms_txt.py` enforces this. ### Linking to GitHub diff --git a/documentation/API.md b/documentation/API.md index 2179aa98..77ea22bd 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -221,14 +221,14 @@ POST `/bulk-lookup` with body: { "diabetes": [ { - "curie": "MONDO:0005148", + "curie": "MONDO:0005015", "label": "diabetes mellitus", "highlighting": {}, "synonyms": ["diabetes", ...], - "score": 42.5, + "score": 535.4, "taxa": [], "types": ["biolink:Disease", ...], - "clique_identifier_count": 125 + "clique_identifier_count": 15 }, ... ], @@ -276,13 +276,13 @@ GET /synonyms?preferred_curies=NCBIGene:1756 GET request to look up multiple CURIEs: ``` -GET /synonyms?preferred_curies=MONDO:0005148&preferred_curies=NCBIGene:1756 +GET /synonyms?preferred_curies=MONDO:0005015&preferred_curies=NCBIGene:1756 ``` POST /synonyms with body: ```json { - "preferred_curies": ["MONDO:0005148", "NCBIGene:1756"] + "preferred_curies": ["MONDO:0005015", "NCBIGene:1756"] } ``` @@ -290,13 +290,12 @@ POST /synonyms with body: ```json { - "MONDO:0005148": { - "curie": "MONDO:0005148", + "MONDO:0005015": { + "curie": "MONDO:0005015", "preferred_name": "diabetes mellitus", "names": ["diabetes mellitus", "diabetes", "DM", ...], "types": ["Disease", "DiseaseOrPhenotypicFeature", ...], - "taxa": [], - "clique_identifier_count": 125, + "clique_identifier_count": 15, ... }, "NCBIGene:1756": { @@ -357,3 +356,20 @@ Solr database. "size": "142.17 GB" } ``` + +### `/llms.txt` + +Returns the agent instructions for this service as plain-text Markdown: which endpoint to call for +which job, how to read and disambiguate ranked results, and the behaviours that otherwise produce a +confident wrong answer. + +``` +GET /llms.txt +``` + +The document is served directly from [`skills/nameres/SKILL.md`](../skills/nameres/SKILL.md) in this +repository, with its YAML frontmatter stripped, so the instructions always match the version of the +API serving them. See [Using NameRes from an AI agent](./LLMs.md) for how to install it in a coding +agent instead of fetching it. + +Returns `404` if the instance was built without the `skills/` directory. diff --git a/documentation/LLMs.md b/documentation/LLMs.md index a86e6bb2..f9515966 100644 --- a/documentation/LLMs.md +++ b/documentation/LLMs.md @@ -1,100 +1,80 @@ -# Using NameRes with AI Agents and LLMs +# Using NameRes from an AI agent -The Name Resolver can be used directly from an AI coding agent (such as Claude Code) or any LLM-based tool that can make HTTP requests. A skill file is provided that gives the agent the instructions it needs to call NameRes correctly. +NameRes ships a **skill** — a Markdown document that teaches a coding agent how to use this API +properly: which endpoint to call for which job, how to read and disambiguate ranked results, and the +handful of behaviours that otherwise produce a confident wrong answer. -## The Skill File +The skill lives at [`skills/nameres/SKILL.md`](../skills/nameres/SKILL.md) and covers: -The skill file is at [`skills/nameres.md`](../skills/nameres.md) in this repository. +- resolving a single term to candidate CURIEs, and deciding between them +- entity-linking or NER over a list of terms in one request +- listing every synonym for a known CURIE +- when to use [NodeNorm](https://github.com/NCATSTranslator/NodeNormalization) instead +- the conflation, provenance and field-naming behaviour that surprises people -It covers: -- How to call `/lookup` to find CURIEs for a single biomedical name -- How to interpret and disambiguate scored results — including when to ask the user for help -- When to use `/bulk-lookup` for resolving multiple names in one request -- When to use `/synonyms` to retrieve all known names for a CURIE you already have -- Which ontology prefixes and Biolink types to use for common concept types +Apart from the YAML frontmatter, which is packaging metadata for Claude Code, the file is plain +Markdown with no agent-specific syntax — so it works pasted into any agent, not just Claude Code. -The skill file is model-agnostic — it contains no Claude-specific syntax and can be used with any agent that accepts markdown context. +## Installing it in Claude Code -**Raw file URL (for direct download or linking):** -``` -https://raw.githubusercontent.com/NCATSTranslator/NameResolution/master/skills/nameres.md -``` - -## Adding the Skill to Claude Code - -### Option 1: Project-level (applies when working in a specific project) +Claude Code discovers skills as `/SKILL.md` inside a `skills` directory, so **the +directory matters** — a bare `nameres.md` is not picked up at all. -Save the skill file to `.claude/skills/nameres.md` inside your project directory: +For one project, from the repository root: -```bash -mkdir -p .claude/skills -curl -o .claude/skills/nameres.md \ - https://raw.githubusercontent.com/NCATSTranslator/NameResolution/master/skills/nameres.md +```shell +mkdir -p .claude/skills/nameres +curl -o .claude/skills/nameres/SKILL.md \ + https://raw.githubusercontent.com/NCATSTranslator/NameResolution/main/skills/nameres/SKILL.md ``` -Claude Code will make it available as `/nameres` when you are working in that project. - -### Option 2: Global (applies to all your Claude Code sessions) - -```bash -mkdir -p ~/.claude/skills -curl -o ~/.claude/skills/nameres.md \ - https://raw.githubusercontent.com/NCATSTranslator/NameResolution/master/skills/nameres.md -``` +For every session on your machine, use `~/.claude/skills/nameres/` instead. -### Using the skill +Claude Code will then offer it as `/nameres`, and will also load it automatically when a task +involves resolving names to identifiers. -Once installed, invoke the skill with: +## Fetching it from a running instance -``` -/nameres -``` +Any NameRes instance serves the same document at `/llms.txt`: -For example: +```shell +curl https://name-resolution-sri.renci.org/llms.txt ``` -/nameres find the CURIE for aspirin -/nameres resolve these disease names to CURIEs: diabetes, hypertension, asthma -/nameres what are all the synonyms for MONDO:0005148? -``` - -## Adding the Skill to Other Agents -For any agent that accepts a system prompt or context document, copy the content of [`skills/nameres.md`](../skills/nameres.md) and include it in the agent's system prompt or instructions. The skill file is self-contained and does not depend on any external tooling. +This is the better source when you care about accuracy: it is served from the skill file in that +deployment's own image, so the instructions always match the version of the API answering your +queries. It is also linked from the OpenAPI description, so an agent given nothing but a base URL can +find it. -## Example Workflows +## Using it with other agents -### Resolving a single chemical name +Paste the file — or the output of `curl .../llms.txt` — into the agent's system prompt, project +instructions, or context. There is nothing Claude-specific in the body. -**Goal:** Find the normalized CURIE for "acetaminophen" to use with a downstream Translator service. +## Example tasks -1. Call `/lookup`: - ``` - GET https://name-resolution-sri.renci.org/lookup?string=acetaminophen&limit=5 - ``` -2. The top result will likely be `CHEBI:46195` with label "paracetamol" — this is correct due to DrugChemical conflation (acetaminophen and paracetamol are the same compound). -3. Use `CHEBI:46195` as the normalized identifier for downstream calls. +Once installed, an agent can be asked things like: -### Batch-resolving entities from text +- "Resolve every disease name in `conditions.csv` to a MONDO identifier, and flag the ambiguous ones." +- "What identifier should I use for Duchenne muscular dystrophy?" +- "Find all the synonyms for `NCBIGene:1756` so I can search our corpus for mentions of it." +- "Are 'paracetamol' and 'acetaminophen' the same concept in Translator?" -**Goal:** A paragraph mentions "type 2 diabetes", "BRCA1", and "metformin". Resolve all three. +## Keeping it accurate -1. Call `/bulk-lookup`: - ```json - POST https://name-resolution-sri.renci.org/bulk-lookup - { - "strings": ["type 2 diabetes", "BRCA1", "metformin"], - "limit": 5 - } - ``` -2. Inspect each result list. "type 2 diabetes" and "metformin" are likely unambiguous. "BRCA1" may return both the gene and related concepts — check `types` to confirm you have `biolink:Gene`. -3. If any result is ambiguous, re-query with `biolink_type` or `only_prefixes` as appropriate, or present the top options to the user. +The skill states specific facts about this API — parameter names and defaults, response field names, +and worked examples with real identifiers. **If you change any of those, update +`skills/nameres/SKILL.md` in the same PR.** `/llms.txt` is served directly from that file, so there is +only one copy to maintain. -### Looking up synonyms for a known CURIE +Two things make that enforceable rather than aspirational: -**Goal:** You have `MONDO:0005148` and want to know all the names it is known by (e.g., to search a corpus for mentions). - -``` -GET https://name-resolution-sri.renci.org/synonyms?preferred_curies=MONDO:0005148 -``` +- `tests/test_llms_txt.py` checks that the file exists, that its frontmatter is valid and matches its + directory, that `/llms.txt` serves it with the frontmatter stripped, that its links are absolute, + and that a handful of specific traps are still described. +- `tests/test_docs_links.py` checks its links resolve and that none of them pin a `master` branch. -The `names` field in the response contains the full synonym list. +Examples that quote real identifiers or labels should be re-run against a live instance when they +change. Values that come from Babel — labels, synonym lists, `clique_identifier_count` — move between +Babel releases, so the skill deliberately presents response bodies as *shapes* rather than asserting +values, except where an example is making a specific point that depends on the real result. From 68e5bee33145b6ccde18d2845376748b9f5c80f2 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 05:14:59 -0400 Subject: [PATCH 5/7] Guard the OpenAPI response docs, and record two verification traps Two tests for behaviour this branch added but did not cover: that every LookupResult field carries a description (the gap this branch closed, which a new undescribed field would silently reopen), and that info.description still points at /llms.txt, which is the only way an agent given a bare base URL finds the instructions. No test for the frontmatter regex's \r? branch. I wrote one, then found it passed against a \n-only regex too: read_text() opens in text mode, so a CRLF checkout is already normalised before the regex runs. The comment claiming the \r? was load-bearing is corrected rather than left to mislead; the regex keeps it as belt-and-braces in case the read ever becomes a byte read. CLAUDE.md records two things that cost time this session: `gh pr view`'s commit and file counts are a cached summary that was stale by 37 commits, so PR contents should be checked with the compare API; and a deployed NameRes is not this code -- five instances disagreed about /status alone -- so a live response is not ground truth for repo behaviour without checking nameres_version first. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 15 ++++++++++++++- api/server.py | 5 +++-- tests/test_apidocs.py | 19 +++++++++++++++++++ tests/test_llms_txt.py | 1 + 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d6180ff..d2641ca7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,4 +127,17 @@ that redirect goes away. Always write `/blob/main/`. Do not check this against a local clone's `origin/HEAD`: that ref is cached at clone time and does not follow a remote rename, so it will still say `master` long after the rename. Use -`gh repo view / --json defaultBranchRef`. \ No newline at end of file +`gh repo view / --json defaultBranchRef`. + +`gh pr view --json commits,changedFiles` serves a cached summary that can be badly stale — it has +reported 49 commits / 38 files for a PR that was really 12 and 20. To check what a PR actually +contains, use the compare API: +`gh api repos///compare/... --jq '.ahead_by, .behind_by, (.files|length)'`. + +### Deployed instances are not this code + +Do not verify behaviour against a deployed NameRes and assume it matches the repo. In July 2026 the +ITRB CI, test and prod instances, RENCI dev and this branch all disagreed about `/status` alone — +prod predated `babel_version` entirely, CI nested the Solr fields under `solr`. Check +`/status`'s `nameres_version` before trusting a live response as ground truth for repo behaviour, +and say in the PR which instance an example came from. \ No newline at end of file diff --git a/api/server.py b/api/server.py index 20aff0bd..79693412 100755 --- a/api/server.py +++ b/api/server.py @@ -33,8 +33,9 @@ # locally and in CI. tests/test_llms_txt.py asserts the path resolves. SKILL_PATH = Path(__file__).parents[1] / "skills" / "nameres" / "SKILL.md" -# The YAML frontmatter block at the top of SKILL.md. \r?\n rather than \n so that a CRLF -# checkout doesn't silently serve the frontmatter as part of the document. +# The YAML frontmatter block at the top of SKILL.md. read_text() opens in text mode, so a CRLF +# checkout is already normalised to \n before this runs; the \r? is belt-and-braces in case the +# read ever becomes a byte read, not load-bearing today. FRONTMATTER = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n", re.DOTALL) app = FastAPI(**get_app_info()) diff --git a/tests/test_apidocs.py b/tests/test_apidocs.py index e78e72b2..8618ba89 100644 --- a/tests/test_apidocs.py +++ b/tests/test_apidocs.py @@ -17,3 +17,22 @@ def test_construct_open_api_schema_returns_cached_schema(): assert sorted(second["paths"]) == [ "/bulk-lookup", "/llms.txt", "/lookup", "/reverse_lookup", "/status", "/synonyms" ] + + +def test_lookup_result_fields_are_all_described(): + """/openapi.json is what an agent reads to learn the response shape without being told to. + LookupResult's fields carried no descriptions at all until recently, so a new field added + without one silently reopens that gap.""" + schema = construct_open_api_schema(app) + properties = schema["components"]["schemas"]["LookupResult"]["properties"] + + undescribed = sorted(name for name, spec in properties.items() if not spec.get("description")) + + assert not undescribed, f"LookupResult fields with no description: {undescribed}" + + +def test_openapi_description_points_at_llms_txt(): + """The only way an agent given nothing but a base URL discovers the instructions.""" + description = construct_open_api_schema(app)["info"]["description"] + + assert "/llms.txt" in description diff --git a/tests/test_llms_txt.py b/tests/test_llms_txt.py index f83f2fe8..afe4356d 100644 --- a/tests/test_llms_txt.py +++ b/tests/test_llms_txt.py @@ -81,3 +81,4 @@ def test_skill_links_are_absolute(): if not target.startswith(("https://", "http://", "#")) ] assert not relative, f"Links in SKILL.md must be absolute URLs: {relative}" + From fc8105cfe3bc3d5f6aa63900ccabc2f36fb9706c Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 05:19:03 -0400 Subject: [PATCH 6/7] Check in CI that the skill survives into the built image /llms.txt reads skills/nameres/SKILL.md off disk, and that file reaches the image only via the Dockerfile's blanket `COPY .` plus the absence of skills/ from .dockerignore. Nothing asserts either. Narrowing one of them -- a "slim the image" change is the obvious way -- 404s the route in every deployment while the entire suite still passes, because the tests run against the working tree rather than the image. This step builds the real image and asks it, so that failure mode turns CI red instead of shipping. Verified in both directions: it passes today, and adding /skills/ to .dockerignore makes it fail with the path in the message. Uses TestClient inside the container rather than starting it and curling -- there is no port to bind, no readiness loop, and /llms.txt needs no Solr. Co-Authored-By: Claude Opus 5 --- .github/workflows/tester.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 546e5653..8c6c2300 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -75,6 +75,28 @@ jobs: fi echo "Loader refused the non-empty core as expected." + # /llms.txt reads skills/nameres/SKILL.md off disk, so it depends on that file being in + # the built image. Nothing else does: it gets there via the Dockerfile's blanket + # `COPY .` and the absence of skills/ from .dockerignore. Narrowing either -- a future + # "slim the image" change is the obvious way -- 404s the route in every deployment while + # the whole suite above still passes, because the tests run against the working tree. + # This is the only step that would catch that, so it builds the real image and asks it. + # + # TestClient rather than starting the container and curling: no port to bind, no readiness + # loop, and /llms.txt needs no Solr. + - name: The skill must be present and served in the built image + run: | + docker build -t nameres-image-check . + docker run --rm --entrypoint python nameres-image-check -c ' + from fastapi.testclient import TestClient + from api.server import SKILL_PATH, app + assert SKILL_PATH.is_file(), f"skills/ is missing from the image: {SKILL_PATH}" + response = TestClient(app).get("/llms.txt") + assert response.status_code == 200, response.status_code + assert response.text.startswith("# "), response.text[:80] + print(f"OK: {SKILL_PATH} served as {len(response.text)} chars") + ' + # The backup is the actual product of data-loading/, so check that a core can be # tarred up, dropped into a fresh Solr, and served without any restore-time setup. # From 8aeabdb2c589e9dec9396fdd468ab3e6196505e6 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 06:04:18 -0400 Subject: [PATCH 7/7] Scan every link-bearing file, not a hand-listed two The link tests only ever looked at Markdown plus api/server.py and openapi.yml. Four other files in this repo carry GitHub links and were unchecked -- including documentation/NameResolution.ipynb, whose Colab badge names a branch and which I had to fix by hand earlier this session, and CITATION.cff, whose repository-code field is what Zenodo reads and which was wrong in Babel for exactly that reason. So the file set is now globbed by extension rather than listed. A hand-written list of "where links live" is precisely what let those two rot unnoticed, and it silently stops covering anything added later. Widening it immediately found one more: data/name-lookup/values.yaml sets babelVersionURL to a /blob/master/ URL, which becomes BABEL_VERSION_URL and is what /status hands out -- ITRB CI is serving that master URL right now. That file is a gitignored working copy of the translator-devops chart, so data/ is excluded here and the real fix belongs in that repository. CLAUDE.md records the trap this sweep kept running into: TranslatorSRI is not uniformly stale. RENCI-Python-image, babel-validation and r3 genuinely still live under that org, so the ban names the three moved repositories rather than the org string. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 +++++++ tests/test_docs_links.py | 35 ++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d2641ca7..5884a5e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,13 @@ Do not check this against a local clone's `origin/HEAD`: that ref is cached at c not follow a remote rename, so it will still say `master` long after the rename. Use `gh repo view / --json defaultBranchRef`. +Babel, NodeNormalization and NameResolution moved from the `TranslatorSRI` org to +`NCATSTranslator`, but **not every `TranslatorSRI` reference is stale**: +`TranslatorSRI/RENCI-Python-image` (used by `data-loading/Dockerfile`), +`TranslatorSRI/babel-validation` and `TranslatorSRI/r3` really do still live there, and have no +`NCATSTranslator` equivalent. `tests/test_docs_links.py` bans the three moved repositories by +name for exactly this reason — do not widen it to the bare org string. + `gh pr view --json commits,changedFiles` serves a cached summary that can be badly stale — it has reported 49 commits / 38 files for a PR that was really 12 and 20. To check what a PR actually contains, use the compare API: diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py index 7668a3f5..d6f07f18 100644 --- a/tests/test_docs_links.py +++ b/tests/test_docs_links.py @@ -10,14 +10,31 @@ REPO_ROOT = Path(__file__).parent.parent -# Markdown we own. The notebook and anything vendored is deliberately excluded. -MARKDOWN_FILES = sorted( - p for p in REPO_ROOT.rglob("*.md") - if not any(part in {"venv", ".venv", "node_modules", ".git", ".pytest_cache"} for part in p.parts) -) +#: `data/` is gitignored -- it holds a working copy of the translator-devops name-lookup chart +#: and downloaded Solr data, neither of which is ours to police. +EXCLUDED = {"venv", ".venv", "node_modules", ".git", ".pytest_cache", "data"} + + +def _ours(path): + return not any(part in EXCLUDED for part in path.parts) + -# Files that carry links out to GitHub in code rather than in prose. -SOURCE_WITH_LINKS = [REPO_ROOT / "api" / "server.py", REPO_ROOT / "api" / "resources" / "openapi.yml"] +# Markdown we own. Anything vendored is deliberately excluded. +MARKDOWN_FILES = sorted(p for p in REPO_ROOT.rglob("*.md") if _ours(p)) + +# Everything else that can carry a GitHub link: endpoint descriptions, OpenAPI metadata, the +# notebook (its Colab badge names a branch), CITATION.cff (its repository-code is what Zenodo +# reads), the Solr configset, Dockerfiles and shell scripts. Globbed by extension rather than +# listed, because an explicit list is exactly what let a stale Colab badge and a wrong +# CITATION.cff sit unnoticed in sibling repos. These are scanned only for banned URL forms -- +# relative links and heading anchors are a Markdown concern. +LINK_BEARING_SUFFIXES = {".py", ".yml", ".yaml", ".ipynb", ".cff", ".xml", ".sh", ".toml"} + +SOURCE_WITH_LINKS = sorted( + p for p in REPO_ROOT.rglob("*") + if p.is_file() and _ours(p) + and (p.suffix in LINK_BEARING_SUFFIXES or p.name.startswith("Dockerfile")) +) INLINE_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") HEADING = re.compile(r"^#+\s+(.*)$", re.MULTILINE) @@ -96,6 +113,8 @@ def test_no_master_branch_links(): away. See CLAUDE.md.""" offenders = [] for path in MARKDOWN_FILES + SOURCE_WITH_LINKS: + if not path.exists(): + continue for line_no, line in enumerate(path.read_text().splitlines(), start=1): if MASTER_LINK.search(line): offenders.append(f"{path.relative_to(REPO_ROOT)}:{line_no}") @@ -115,6 +134,8 @@ def test_no_stale_org_links(): still name an org that no longer owns the code.""" offenders = [] for path in MARKDOWN_FILES + SOURCE_WITH_LINKS: + if not path.exists(): + continue for line_no, line in enumerate(path.read_text().splitlines(), start=1): if MOVED_TO_NCATSTRANSLATOR.search(line): offenders.append(f"{path.relative_to(REPO_ROOT)}:{line_no}")