Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/tester.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
38 changes: 37 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <owner>/<repo> --json defaultBranchRef`.
`gh repo view <owner>/<repo> --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/<owner>/<repo>/compare/<base>...<head> --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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion api/resources/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ info:
conflation; the active conflations for any deployment can be discovered via the <code>/status</code> endpoint.
You can read more about this, and about the other <a href="https://github.com/NCATSTranslator/Babel">Babel</a>
behaviour visible through this API, at
<a href="https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/Babel.md">Where NameRes data comes from</a>.</p>'
<a href="https://github.com/NCATSTranslator/NameResolution/blob/main/documentation/Babel.md">Where NameRes data comes from</a>.</p>
<p>If you are an AI agent, <code>GET /llms.txt</code> for instructions on using this API.</p>'
license:
name: MIT
url: https://opensource.org/licenses/MIT
Expand Down
100 changes: 88 additions & 12 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -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="<p>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.</p>"
"<p>The same document is maintained as a "
"<a href=\"https://github.com/NCATSTranslator/NameResolution/tree/main/skills/nameres\">skill</a> "
"in the NameResolution repository, so these instructions always match the version of "
"the API serving them.</p>",
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="<p>This endpoint will return status information and a list of counts from the underlying Solr database instance for this NameRes instance.</p>"
Expand Down Expand Up @@ -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",
Expand Down
34 changes: 25 additions & 9 deletions documentation/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
...
],
Expand Down Expand Up @@ -276,27 +276,26 @@ 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"]
}
```

**Example response:**

```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": {
Expand Down Expand Up @@ -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.
80 changes: 80 additions & 0 deletions documentation/LLMs.md
Original file line number Diff line number Diff line change
@@ -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-name>/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.
Loading
Loading