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.
#
diff --git a/CLAUDE.md b/CLAUDE.md
index 960b4201..5884a5e6 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
@@ -111,4 +127,24 @@ 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`.
+
+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:
+`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/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/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..79693412 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,17 @@
# 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. 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())
logger = logging.getLogger(__name__)
logging.basicConfig(level=os.getenv("LOGLEVEL", logging.INFO))
@@ -48,6 +60,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 +309,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/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
new file mode 100644
index 00000000..f9515966
--- /dev/null
+++ b/documentation/LLMs.md
@@ -0,0 +1,80 @@
+# Using NameRes from an AI agent
+
+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 lives at [`skills/nameres/SKILL.md`](../skills/nameres/SKILL.md) and covers:
+
+- 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
+
+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.
+
+## Installing it in Claude Code
+
+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.
+
+For one project, from the repository root:
+
+```shell
+mkdir -p .claude/skills/nameres
+curl -o .claude/skills/nameres/SKILL.md \
+ https://raw.githubusercontent.com/NCATSTranslator/NameResolution/main/skills/nameres/SKILL.md
+```
+
+For every session on your machine, use `~/.claude/skills/nameres/` instead.
+
+Claude Code will then offer it as `/nameres`, and will also load it automatically when a task
+involves resolving names to identifiers.
+
+## Fetching it from a running instance
+
+Any NameRes instance serves the same document at `/llms.txt`:
+
+```shell
+curl https://name-resolution-sri.renci.org/llms.txt
+```
+
+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.
+
+## Using it with other agents
+
+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.
+
+## Example tasks
+
+Once installed, an agent can be asked things like:
+
+- "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?"
+
+## Keeping it accurate
+
+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.
+
+Two things make that enforceable rather than aspirational:
+
+- `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.
+
+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.
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.
diff --git a/tests/test_apidocs.py b/tests/test_apidocs.py
index 5af9bcd6..8618ba89 100644
--- a/tests/test_apidocs.py
+++ b/tests/test_apidocs.py
@@ -14,4 +14,25 @@ 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"
+ ]
+
+
+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_docs_links.py b/tests/test_docs_links.py
index b8a950bb..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)
+
+
+# Markdown we own. Anything vendored is deliberately excluded.
+MARKDOWN_FILES = sorted(p for p in REPO_ROOT.rglob("*.md") if _ours(p))
-# 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"]
+# 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)
@@ -79,16 +96,29 @@ 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:
+ if not path.exists():
+ continue
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
@@ -104,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}")
diff --git a/tests/test_llms_txt.py b/tests/test_llms_txt.py
new file mode 100644
index 00000000..afe4356d
--- /dev/null
+++ b/tests/test_llms_txt.py
@@ -0,0 +1,84 @@
+"""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}"
+