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}" +