From e39746e17df32ea7fea8b9ff29ddca8a606db557 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 02:54:26 -0400 Subject: [PATCH 1/7] Add a nodenorm skill so coding agents can use this API correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #403 fixed the reference problem — every endpoint now carries a real description and the response schema is documented. What an agent still lacks is judgement: when to reach for NodeNorm at all, which conflation flags to set, how to batch a whole column of identifiers, and the handful of behaviours that silently produce wrong answers rather than errors. This is that document. Every factual claim in it was measured against the live RENCI instance rather than written from memory, including the equivalent- identifier counts used to illustrate what conflation actually does. Three things it exists to prevent, all of which fail silently: - GET and POST disagree on the conflation defaults, so the same query returns 206 equivalent identifiers one way and 30 the other (#398). The rule taught is to set both flags on every request. - /get_setid takes `conflation` (singular) on GET and `conflations` (plural) in the POST body, defaults to no conflation unlike GET /get_normalized_nodes, and silently ignores the wrong name — returning HTTP 200 and an unconflated hash. Anyone comparing a gene against its protein this way concludes they are different concepts. - A conflated clique routinely holds several identifiers sharing one prefix (the DMD clique has three ENSEMBL entries: one gene, two proteins), so matching on prefix alone is a coin flip. The recipe uses individual_types and filters on type. Format is skills/nodenorm/SKILL.md with YAML frontmatter, which is what Claude Code actually discovers; the body is plain Markdown usable by any agent. The `description` names concrete CURIE prefixes, since that is what the model matches on when deciding whether the skill is relevant. Co-Authored-By: Claude Opus 5 --- skills/nodenorm/SKILL.md | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 skills/nodenorm/SKILL.md diff --git a/skills/nodenorm/SKILL.md b/skills/nodenorm/SKILL.md new file mode 100644 index 0000000..d7db87c --- /dev/null +++ b/skills/nodenorm/SKILL.md @@ -0,0 +1,241 @@ +--- +name: nodenorm +description: Normalize biomedical identifiers (CURIEs) using the Translator NodeNorm API — find the preferred identifier for a concept, list its equivalent identifiers in other databases, and get its Biolink type. Use when working with CURIEs such as MESH:D014867, NCBIGene:1756, CHEBI:15377, UniProtKB:P11532 or MONDO:0005148, to connect an identifier to another data source, to normalize a whole column of identifiers, or to check whether two identifiers refer to the same concept. +--- + +# Normalizing biomedical identifiers with NodeNorm + +NodeNorm answers the question **"what is this identifier?"** Given a CURIE, it returns the preferred +identifier for that concept, every equivalent identifier it knows about, and the concept's Biolink +types. + +- **Base URL:** `https://nodenormalization-sri.renci.org/` +- **Machine-readable spec:** `GET /openapi.json` — the authority on parameter names and defaults. + Prefer it over `/docs`, which is a JavaScript-rendered Swagger UI and near-useless to read. +- The one endpoint that matters is `/get_normalized_nodes`. Everything else is supporting. + +## Is this the right service? + +| You have | Use | +|---|---| +| An identifier (`CHEBI:15377`, `NCBIGene:1756`) | **NodeNorm** — this skill | +| A name or string (`"aspirin"`, `"type 2 diabetes"`) | **NameRes** (`https://name-resolution-sri.renci.org/`), then normalize the CURIE it returns | + +If you have free text, resolve it to a CURIE with NameRes first. NodeNorm will not look up names. + +## One identifier + +``` +GET https://nodenormalization-sri.renci.org/get_normalized_nodes?curie=MESH:D014867 +``` + +Repeat `curie=` for a handful of identifiers. Response: + +```json +{ + "MESH:D014867": { + "id": { "identifier": "CHEBI:15377", "label": "Water" }, + "equivalent_identifiers": [ + { "identifier": "CHEBI:15377" }, + { "identifier": "PUBCHEM.COMPOUND:962" }, + { "identifier": "UNII:059QF0KO0R" } + ], + "type": ["biolink:SmallMolecule", "biolink:MolecularEntity", "biolink:ChemicalEntity"], + "information_content": 47.5 + } +} +``` + +### Reading the response + +- **`id.identifier`** — the preferred identifier. Two CURIEs that normalize to the same + `id.identifier` refer to the same concept. This is what you store, join on, and send downstream. +- **`id.label`** — the preferred name. **Not necessarily the label of `id.identifier`**; for + chemicals especially, the name may come from a different member of the clique. +- **`equivalent_identifiers`** — every identifier for this concept, in the Biolink Model's preferred + prefix order. **This is the field that answers "connect this to another database."** Note that + **`label` is often absent** on individual entries, so read it with `.get("label")` rather than + `["label"]`. +- **`type`** — Biolink classes, most specific first. +- **`information_content`** — 0.0 (broad concept) to 100.0 (very specific). Absent when unknown. + +Optional flags: `description=true` adds descriptions (many identifiers have none), +`individual_types=true` adds a `type` to each equivalent identifier, `include_taxa=true` (the +default) adds a `taxa` array of NCBITaxon CURIEs both at the top level and on the individual +entries that have one. + +Use GET for one or two identifiers you are inspecting by hand. For anything programmatic use POST — +a long `curie=` list will eventually exceed the server's URL length limit, and POST has no such +ceiling. + +## Many identifiers + +Use POST. It is fast — 1000 identifiers in roughly 2 seconds — so **never loop over GET requests**. + +``` +POST https://nodenormalization-sri.renci.org/get_normalized_nodes +Content-Type: application/json + +{ + "curies": ["MESH:D014867", "NCBIGene:1756", "RUBBISH:1234"], + "conflate": true, + "drug_chemical_conflate": false +} +``` + +The response is keyed by input CURIE, with the same value shape as the GET method. + +- **Deduplicate before sending.** The response is a dictionary, so duplicates buy you nothing. +- **Chunk at around 1000 per request.** Larger batches work but tie up a shared public service. +- **An identifier that cannot be normalized comes back as `null`** — the key is present with a null + value, it is *not* omitted. Check for null values, not for missing keys. +- **Response keys echo your input exactly**, including any surrounding whitespace. Look results up + by the exact string you sent, not a cleaned-up version. + +## Conflation — decide this deliberately + +Conflation merges concepts that are not strictly identical but are often treated as one. It changes +both the preferred identifier and the size of `equivalent_identifiers`, sometimes dramatically. + +| Flag | Merges | Preferred identifier becomes | +|---|---|---| +| `conflate` | A gene with the protein it encodes | the **gene** | +| `drug_chemical_conflate` | A drug with its active ingredient | the **active ingredient** | + +Real numbers from the live service: + +- `NCBIGene:1756` — 5 equivalent identifiers unconflated, **22** with `conflate=true` + (5 `biolink:Gene` + 17 `biolink:Protein`). +- `MESH:D014867` (water) — 30 equivalent identifiers unconflated, **206** with + `drug_chemical_conflate=true`. + +### Always set both flags explicitly + +**The defaults differ between GET and POST.** The same query returns different results: + +``` +GET /get_normalized_nodes?curie=MESH:D014867 → 206 equivalent identifiers +POST /get_normalized_nodes {"curies":["MESH:D014867"]} → 30 equivalent identifiers +``` + +GET defaults both flags to true; the POST body defaults `drug_chemical_conflate` to false. This is a +known bug ([NodeNormalization#398](https://github.com/NCATSTranslator/NodeNormalization/issues/398)). +Until it is fixed, **set `conflate` and `drug_chemical_conflate` explicitly on every request** and +you will not be caught by it. + +### Which setting do you want? + +**Conflation changes which question you are asking, not just how many rows come back.** +`conflate=false` asks *"what is this protein?"*; `conflate=true` asks *"what gene is this protein a +product of?"* Both are valid; picking the wrong one gives you a confident, error-free, useless +answer. + +- **Matching an existing knowledge graph** (Translator, ROBOKOP) — use the conflation that graph was + built with. If you do not know, try both and see which one's identifiers appear in the target. +- **Crossing between a gene and a protein** — you need `conflate=true`. A UniProtKB accession + unconflated has no gene identifiers in its clique at all, so a gene lookup will silently come back + empty rather than failing. +- **Asking about one specific molecule or one specific protein as itself** — turn conflation off, so + you get that concept rather than a merged one. +- **Linking loosely across sources** — turn it on for the widest set of equivalents. + +Conflated results are a **single flat list** — all the members of the first clique, then the second, +with no marker between them. Use `individual_types=true` to tell gene from protein. + +## Recipes + +**Connect an identifier to another database.** Normalize it with `individual_types=true`, then look +in `equivalent_identifiers` for the prefix you want — **and filter on `type`, never on prefix +alone.** + +``` +GET /get_normalized_nodes?curie=UniProtKB:P11532&conflate=true&drug_chemical_conflate=false&individual_types=true +→ id.identifier = NCBIGene:1756 ("DMD"), plus HGNC:2928, OMIM:300377, and three ENSEMBL entries: + ENSEMBL:ENSG00000198947 biolink:Gene ← the Ensembl *gene* + ENSEMBL:ENSP00000288447 biolink:Protein + ENSEMBL:ENSP00000288447.4 biolink:Protein +``` + +A conflated clique routinely contains **several identifiers sharing one prefix**, because it holds +both halves of a gene/protein or drug/chemical pair — and they may be versioned duplicates too. +Taking the first `ENSEMBL:` you find gets the right answer here only by luck of ordering. Decide +which one you want by its `type`. + +**Normalize a column in a file.** Read the column, deduplicate, POST in chunks of 1000, build a +`{input → id.identifier}` map, and report the nulls separately — they are usually retired +identifiers, typos, or prefixes NodeNorm does not cover. + +**Are these two identifiers the same concept?** Normalize both with identical conflation settings +and compare `id.identifier`. This is the reliable way; prefer it. See `/get_setid` below if you want +a single comparable hash for a whole set. + +**What kind of thing is this?** Read `type[0]` — the most specific Biolink class. + +## `/get_setid` — a stable hash for a set of identifiers + +Normalizes a set of CURIEs, deduplicates, sorts, and hashes the result. Two sets containing the same +concepts produce the same hash, whatever order or spelling of identifiers you started from. Useful +for comparing or caching sets of concepts without storing the members. + +``` +GET /get_setid?curie=MESH:D014867&curie=NCBIGene:1756&conflation=GeneProtein&conflation=DrugChemical +``` + +**This endpoint does not take the same conflation parameters as `/get_normalized_nodes`, and it will +not tell you when you get them wrong.** + +- The parameter is **`conflation`** (singular), repeated once per conflation, with the values + `GeneProtein` and `DrugChemical` — not the `conflate` / `drug_chemical_conflate` booleans used + everywhere else. +- **It defaults to no conflation at all**, the opposite of GET `/get_normalized_nodes`. +- **Wrong parameter names are silently ignored.** `conflations=GeneProtein` (plural) and + `conflate=true` both return HTTP 200 with an unconflated hash and no warning: + + ``` + /get_setid?curie=UniProtKB:P11532 → uuid:e9a5e65f… (UniProtKB:P11532) + /get_setid?curie=UniProtKB:P11532&conflation=GeneProtein → uuid:e020b733… (NCBIGene:1756) + /get_setid?curie=UniProtKB:P11532&conflations=GeneProtein → uuid:e9a5e65f… silently unconflated + ``` + + Always check the `conflations` field echoed back in the response — if it is `[]` when you asked for + a conflation, your parameter name was wrong. + +One request is **one set**, not a batch. To hash several sets in one call, POST a list of +`{"curies": [...], "conflations": [...]}` objects — note that the POST body field *is* `conflations` +(plural), differing from the GET query parameter. + +The response also carries `normalized_curies` (what the hash was built from — check this if a result +surprises you), `normalized_string`, and `error`. + +## Gotchas + +- **Lookups are case-insensitive and whitespace-trimmed.** `" mesh:d014867 "` resolves fine. But + the response key is still the string you sent. +- **A missing identifier may be missing by design.** Babel keeps only identifiers whose prefix is + valid for the concept's Biolink type and drops the rest, so a CURIE you expected can be + legitimately absent from a clique. +- **The data is a fixed snapshot**, not a live database. `GET /status` reports `babel_version` (a + build name like `2025sep1`). Record it if you need reproducible results — a later build may + normalize the same identifier differently. +- **Information content from builds before `2025sep1` is unreliable** (it was compared as a string, + so some values are wrongly 100). Check `/status` first. +- **This is a shared public service.** Batch with POST; do not hammer it with parallel requests. + +## Other endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /status` | Which Babel build and Biolink Model version this instance serves | +| `GET /get_allowed_conflations` | The conflation names this instance supports | +| `GET/POST /get_setid` | A stable hash for a set of CURIEs — see above, its parameters differ | +| `GET /get_semantic_types` | Every Biolink type present in this instance | +| `GET /get_curie_prefixes` | CURIE prefix counts per Biolink type (approximate) | +| `POST /query`, `/asyncquery` | Normalize a whole TRAPI message — **deprecated**, do not use | + +## Why cliques look the way they do + +The identifiers, preferred names, types and information content all come from +[Babel](https://github.com/NCATSTranslator/Babel), which is what decides that two identifiers are +equivalent. If a clique looks wrong — two concepts merged, or one concept split in two — that is a +Babel issue, not a NodeNorm one. See +[Where NodeNorm's data comes from](https://github.com/NCATSTranslator/NodeNormalization/blob/master/documentation/Babel.md). From 29175d8c5c763696020989548baebd1358301eae Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 02:54:37 -0400 Subject: [PATCH 2/7] Serve the agent instructions from the running service at /llms.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent handed a NodeNorm URL and nothing else has no way to find the skill file, which lives in a GitHub repository it has no reason to know about. This serves the same document from the instance itself, so the instructions are always reachable from the thing being described and always match the deployed version. It is linked from the OpenAPI description too, so an agent reading /openapi.json will find it. The YAML frontmatter is stripped on the way out — it is Claude Code packaging metadata and noise in an llms.txt. There is still only one copy of the text: the route reads skills/nodenorm/SKILL.md directly. Note the Dockerfile change. The image copies only node_normalizer/, config.json, redis_config.yaml and load.py, so without `COPY ./skills skills` this route works locally and 404s in every deployment — the kind of failure that shows up long after the PR is merged. A missing file returns a 404 naming where to read the instructions instead, rather than a 500. Co-Authored-By: Claude Opus 5 --- Dockerfile | 2 ++ node_normalizer/config.py | 4 +++ node_normalizer/resources/openapi.yml | 3 +- node_normalizer/server.py | 38 ++++++++++++++++++++- tests/frontend/test_llms_txt.py | 48 +++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/frontend/test_llms_txt.py diff --git a/Dockerfile b/Dockerfile index 2cb6c12..522c01d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ COPY ./node_normalizer node_normalizer COPY ./config.json config.json COPY ./redis_config.yaml redis_config.yaml COPY ./load.py load.py +# Agent instructions served at /llms.txt (node_normalizer/config.py: SKILL_PATH). +COPY ./skills skills # install requirements (frontend + loader; the loader Helm chart runs load.py # from this image) diff --git a/node_normalizer/config.py b/node_normalizer/config.py index b9bcdab..3cecea7 100644 --- a/node_normalizer/config.py +++ b/node_normalizer/config.py @@ -16,6 +16,10 @@ REDIS_CONFIG_PATH = REPO_ROOT / "redis_config.yaml" RESOURCES_DIR = Path(__file__).parent / "resources" +# Agent-facing instructions for using this API, served at /llms.txt. Note that the +# Dockerfile has to COPY ./skills for this to exist in a deployed image. +SKILL_PATH = REPO_ROOT / "skills" / "nodenorm" / "SKILL.md" + def get_config() -> dict: """Load and return the parsed config.json.""" diff --git a/node_normalizer/resources/openapi.yml b/node_normalizer/resources/openapi.yml index de72d28..66d6552 100644 --- a/node_normalizer/resources/openapi.yml +++ b/node_normalizer/resources/openapi.yml @@ -21,7 +21,8 @@ info: to be useful, check /get_semantic_types, which lists the BioLink semantic types for which normalization has been attempted, and /get_curie_prefixes, which lists the number of times each prefix is used for a semantic type. You can find out more about these API methods at the - Node Normalization API documentation.' + Node Normalization API documentation. + If you are an AI agent, GET /llms.txt for instructions on using this service.' license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/node_normalizer/server.py b/node_normalizer/server.py index 6875d66..d0079f0 100644 --- a/node_normalizer/server.py +++ b/node_normalizer/server.py @@ -1,6 +1,7 @@ """FastAPI server.""" import asyncio import os +import re import logging, warnings from pathlib import Path @@ -15,9 +16,10 @@ import yaml from pydantic import BaseModel from bmt import Toolkit -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, PlainTextResponse from .apidocs import get_app_info, construct_open_api_schema +from .config import SKILL_PATH from .model import ( SemanticTypes, CuriePivot, @@ -186,6 +188,40 @@ async def status() -> Dict: } +#: Matches the YAML frontmatter block at the very top of a SKILL.md. +_FRONTMATTER = re.compile(r"\A---\n.*?\n---\n", re.DOTALL) + + +@app.get( + "/llms.txt", + summary="Instructions for using this API from an AI agent or LLM", + description="Returns a Markdown document explaining how to use this service to normalize " + "identifiers: which endpoint to call, how to read the response, how to batch, and " + "which conflation settings to choose. Intended to be fetched by an agent that has " + "been pointed at this instance and needs to work out how to use it. The same " + "document is maintained as a " + "" + "skill in the NodeNorm repository.", + response_class=PlainTextResponse, + response_description="Markdown instructions for using this API.", +) +async def llms_txt() -> PlainTextResponse: + """Serve the agent instructions from skills/nodenorm/SKILL.md.""" + try: + skill = SKILL_PATH.read_text(encoding="utf-8") + except OSError: + # Most likely an image built without `COPY ./skills`; say so rather than 500ing. + logger.warning(f"Could not read agent instructions from {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/NodeNormalization/tree/master/skills/nodenorm", + ) + + # The frontmatter is Claude Code packaging metadata; it is noise in an llms.txt. + return PlainTextResponse(_FRONTMATTER.sub("", skill, count=1).lstrip("\n")) + + @app.post( "/query", summary="Normalizes every identifier in a TRAPI message", diff --git a/tests/frontend/test_llms_txt.py b/tests/frontend/test_llms_txt.py new file mode 100644 index 0000000..c7a8c1b --- /dev/null +++ b/tests/frontend/test_llms_txt.py @@ -0,0 +1,48 @@ +"""Tests for the /llms.txt endpoint, which serves skills/nodenorm/SKILL.md to AI agents. + +These only issue GETs against the shared app singleton and never touch `app.state`, so +they are safe to run alongside the other frontend tests (see tests/CLAUDE.md). A +TestClient that is not used as a context manager does not run the lifespan, so no Redis +connection is needed. +""" + +from starlette.testclient import TestClient + +from node_normalizer.config import SKILL_PATH +from node_normalizer.server import app + +client = TestClient(app) + + +def test_skill_file_exists(): + """SKILL_PATH must resolve; if this fails, /llms.txt 404s everywhere.""" + assert SKILL_PATH.is_file(), f"No skill file at {SKILL_PATH}" + + +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_yaml_frontmatter(): + """The frontmatter is Claude Code packaging metadata and should not be served.""" + assert SKILL_PATH.read_text(encoding="utf-8").startswith("---"), ( + "Expected SKILL.md to start with YAML frontmatter; if that changed on purpose, " + "this test and the stripping in server.llms_txt() are both now pointless." + ) + + body = client.get("/llms.txt").text + assert not body.startswith("---") + assert "name: nodenorm" not in body + assert body.startswith("# ") + + +def test_llms_txt_documents_the_main_endpoint(): + """A guide that never names the endpoint it is a guide to is not useful.""" + body = client.get("/llms.txt").text + assert "get_normalized_nodes" in body + # The GET/POST conflation-default divergence (#398) is the trap most likely to + # silently give an agent wrong results, so it must survive any rewrite. + assert "drug_chemical_conflate" in body From a1265f8645186a7ad53f119525ff76903166503c Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 02:54:45 -0400 Subject: [PATCH 3/7] Document how to install and use the skill Mirrors the structure of NameResolution#259 so the two services read the same way, but with install instructions that work: Claude Code discovers skills as /SKILL.md inside a skills directory, so the whole skills/nodenorm/ directory has to be copied, not just the file. Filed as NameResolution#290 so the sibling repo can converge on the same format. Also documents fetching the instructions straight from a running instance with /llms.txt, which is the shortest path for an agent that has a URL and nothing else. Co-Authored-By: Claude Opus 5 --- README.md | 3 ++ documentation/LLMs.md | 92 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 documentation/LLMs.md diff --git a/README.md b/README.md index f2e3d4b..87c674b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ which lists the number of times each prefix is used for a semantic type. For examples of service usage, see the example [notebook](documentation/NodeNormalization.ipynb). +To use NodeNorm from an AI coding agent, see [Using with AI Agents / LLMs](documentation/LLMs.md); +any running instance also serves agent instructions at `/llms.txt`. + The Node normalization website leverages the [R3 (Redis-REST with referencing)](https://github.com/TranslatorSRI/r3) Redis data design and configuration. Users can find the publicly available website at [service](https://nodenormalization-sri.renci.org/docs). diff --git a/documentation/LLMs.md b/documentation/LLMs.md new file mode 100644 index 0000000..9626a83 --- /dev/null +++ b/documentation/LLMs.md @@ -0,0 +1,92 @@ +# Using NodeNorm with AI Agents and LLMs + +NodeNorm can be used directly from an AI coding agent (such as Claude Code) or any LLM-based tool +that can make HTTP requests. A skill file provides the instructions an agent needs to call NodeNorm +correctly — not just which endpoints exist, but which conflation settings to choose, how to batch a +large set of identifiers, and which behaviours will otherwise catch it out. + +## The skill file + +The skill lives at [`skills/nodenorm/SKILL.md`](../skills/nodenorm/SKILL.md). It covers: + +- When NodeNorm is the right service and when you want [NameRes](https://github.com/NCATSTranslator/NameResolution) instead +- Normalizing a single identifier with `GET /get_normalized_nodes`, and how to read the response +- Normalizing many identifiers at once with `POST /get_normalized_nodes` +- Choosing GeneProtein and DrugChemical conflation deliberately, including the fact that the + defaults differ between GET and POST ([#398](https://github.com/NCATSTranslator/NodeNormalization/issues/398)) +- Recipes for the common tasks: connecting an identifier to another database, normalizing a column + in a file, and testing whether two identifiers mean the same thing +- The non-obvious behaviours: unnormalizable CURIEs come back as `null` rather than being omitted, + the preferred label is not necessarily the preferred identifier's label, and the data is a fixed + Babel snapshot rather than a live database + +Apart from the YAML frontmatter, which is packaging metadata for Claude Code, the file is plain +Markdown with no agent-specific syntax. + +## Installing it in Claude Code + +Claude Code discovers skills as `/SKILL.md` inside a `skills` directory — copy the whole +`skills/nodenorm/` directory, not just the file. + +**For one project:** + +```bash +mkdir -p .claude/skills +git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/NCATSTranslator/NodeNormalization.git /tmp/nn \ + && (cd /tmp/nn && git sparse-checkout set skills/nodenorm) \ + && cp -r /tmp/nn/skills/nodenorm .claude/skills/ +``` + +**For every session**, use `~/.claude/skills` instead of `.claude/skills`. + +Either way you can also just create the directory and save the file into it by hand: + +```bash +mkdir -p ~/.claude/skills/nodenorm +curl -o ~/.claude/skills/nodenorm/SKILL.md \ + https://raw.githubusercontent.com/NCATSTranslator/NodeNormalization/master/skills/nodenorm/SKILL.md +``` + +Claude Code will then offer it as `/nodenorm`, and will also load it automatically when a task +involves normalizing identifiers. + +## Fetching it from a running instance + +Every NodeNorm instance serves the same instructions at `/llms.txt`, with the frontmatter stripped: + +```bash +curl https://nodenormalization-sri.renci.org/llms.txt +``` + +This is the quickest route for an agent that has been given a NodeNorm URL and nothing else, and it +guarantees the instructions match the deployed version. It is also linked from the API description, +so an agent reading `/openapi.json` will find it. + +## Using it with other agents + +For any agent that accepts a system prompt or context document, paste the contents of +[`skills/nodenorm/SKILL.md`](../skills/nodenorm/SKILL.md) (or the output of `/llms.txt`) into its +instructions. The skill is self-contained and depends on no external tooling. + +## Example tasks + +Once installed, these should work without further explanation: + +``` +Normalize MESH:D014867 and tell me what it is. + +I have a UniProtKB accession, UniProtKB:P11532. What is the corresponding NCBI Gene ID? + +Here's a CSV with an "identifier" column of mixed CURIEs. Normalize them all, +add a "preferred_id" column, and tell me which ones failed. + +Are CHEBI:15377 and PUBCHEM.COMPOUND:962 the same thing? +``` + +## Keeping it accurate + +The skill states specific facts about the API — parameter defaults, response shape, and worked +examples with real identifier counts. If you change any of those, update +[`skills/nodenorm/SKILL.md`](../skills/nodenorm/SKILL.md) in the same PR. `/llms.txt` is served +directly from that file, so there is only one copy to maintain. From 7f29c1b6b3b0da3883e54286b1e67a700bb92698 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 02:58:59 -0400 Subject: [PATCH 4/7] Teach the conflation rule instead of three worked examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cold-read validations, each given only the skill file and network access, turned up real gaps. The first hit two traps the document caused; both are fixed in the preceding commits. This addresses what the second found. The main change is generalization. The document stated "30 vs 206" three times but never gave the rule those numbers illustrate, so a reader handed any CURIE outside the three worked examples had to re-derive the flag matrix by trial and error — exactly the work the skill claims to have already done for them. The rule, measured across all four flag combinations for seven CURIEs spanning genes, proteins, chemicals, diseases and phenotypes: only the conflation matching the concept's kind does anything, and the other flag is a no-op. NCBIGene:1756 and UniProtKB:P11532 move only with conflate (5->22, 4->22); CHEBI:15377, MESH:D014867 and CHEBI:15365 move only with drug_chemical_conflate (30->206, 30->206, 21->436); MONDO:0005148 and HP:0002465 do not move at all. Also stated: POST defaults conflate to true and only drug_chemical_conflate differs, so the GET/POST divergence bites chemicals and not genes; POST /get_setid takes a bare JSON array, with an example, since "a list of objects" left the shape ambiguous; per-entry `type` is a single string where the clique-level `type` is a list; and malformed input, including the empty string, returns null with HTTP 200 rather than an error, so it fails silently. One reported finding was wrong and is not applied: include_taxa was said to add taxa only to individual entries. It populates both a top-level `taxa` array and the individual entries, which is what the document now says. Co-Authored-By: Claude Opus 5 --- skills/nodenorm/SKILL.md | 49 ++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/skills/nodenorm/SKILL.md b/skills/nodenorm/SKILL.md index d7db87c..1e5c12b 100644 --- a/skills/nodenorm/SKILL.md +++ b/skills/nodenorm/SKILL.md @@ -55,7 +55,8 @@ Repeat `curie=` for a handful of identifiers. Response: - **`equivalent_identifiers`** — every identifier for this concept, in the Biolink Model's preferred prefix order. **This is the field that answers "connect this to another database."** Note that **`label` is often absent** on individual entries, so read it with `.get("label")` rather than - `["label"]`. + `["label"]`. With `individual_types=true` each entry also gains a `type`, which is a **single + string**, not a list like the clique-level `type`. - **`type`** — Biolink classes, most specific first. - **`information_content`** — 0.0 (broad concept) to 100.0 (very specific). Absent when unknown. @@ -88,7 +89,9 @@ The response is keyed by input CURIE, with the same value shape as the GET metho - **Deduplicate before sending.** The response is a dictionary, so duplicates buy you nothing. - **Chunk at around 1000 per request.** Larger batches work but tie up a shared public service. - **An identifier that cannot be normalized comes back as `null`** — the key is present with a null - value, it is *not* omitted. Check for null values, not for missing keys. + value, it is *not* omitted. Check for null values, not for missing keys. This covers input that is + not a CURIE at all: `"not a curie"`, and even `""`, return `null` with HTTP 200 rather than an + error, so malformed input fails silently and will not be caught for you. - **Response keys echo your input exactly**, including any surrounding whitespace. Look results up by the exact string you sent, not a cleaned-up version. @@ -102,12 +105,19 @@ both the preferred identifier and the size of `equivalent_identifiers`, sometime | `conflate` | A gene with the protein it encodes | the **gene** | | `drug_chemical_conflate` | A drug with its active ingredient | the **active ingredient** | -Real numbers from the live service: +**Only the conflation matching the concept's kind does anything; the other flag is a no-op.** You do +not have to guess which flag to try: -- `NCBIGene:1756` — 5 equivalent identifiers unconflated, **22** with `conflate=true` - (5 `biolink:Gene` + 17 `biolink:Protein`). -- `MESH:D014867` (water) — 30 equivalent identifiers unconflated, **206** with - `drug_chemical_conflate=true`. +| The concept is | The flag that matters | The other flag | +|---|---|---| +| A gene or a protein | `conflate` | does nothing | +| A chemical or a drug | `drug_chemical_conflate` | does nothing | +| Anything else (disease, phenotype, anatomy…) | neither — conflation has no effect | does nothing | + +Measured across all four flag combinations: `NCBIGene:1756` and `UniProtKB:P11532` move only with +`conflate` (5→22 and 4→22); `CHEBI:15377`, `MESH:D014867` and `CHEBI:15365` move only with +`drug_chemical_conflate` (30→206, 30→206, 21→436); `MONDO:0005148` and `HP:0002465` do not move at +all. Setting both flags is therefore always safe — it just means "conflate whatever is conflatable." ### Always set both flags explicitly @@ -118,10 +128,12 @@ GET /get_normalized_nodes?curie=MESH:D014867 → 206 equivalent identi POST /get_normalized_nodes {"curies":["MESH:D014867"]} → 30 equivalent identifiers ``` -GET defaults both flags to true; the POST body defaults `drug_chemical_conflate` to false. This is a -known bug ([NodeNormalization#398](https://github.com/NCATSTranslator/NodeNormalization/issues/398)). -Until it is fixed, **set `conflate` and `drug_chemical_conflate` explicitly on every request** and -you will not be caught by it. +GET defaults both flags to true. The POST body also defaults `conflate` to true, but defaults +**`drug_chemical_conflate` to false** — that one flag is the entire difference, which is why the +example above changes for a chemical and would not change for a gene. This is a known bug +([NodeNormalization#398](https://github.com/NCATSTranslator/NodeNormalization/issues/398)). Until it +is fixed, **set `conflate` and `drug_chemical_conflate` explicitly on every request** and you will +not be caught by it. ### Which setting do you want? @@ -200,9 +212,18 @@ not tell you when you get them wrong.** Always check the `conflations` field echoed back in the response — if it is `[]` when you asked for a conflation, your parameter name was wrong. -One request is **one set**, not a batch. To hash several sets in one call, POST a list of -`{"curies": [...], "conflations": [...]}` objects — note that the POST body field *is* `conflations` -(plural), differing from the GET query parameter. +One GET request is **one set**, not a batch. To hash several sets in one call, POST a bare JSON +array (not an object wrapping one) and get a parallel array back. Note that the POST body field +*is* `conflations` — plural, the opposite of the GET query parameter, and the singular form is +silently ignored here too: + +```json +POST /get_setid +[ + {"curies": ["UniProtKB:P11532"], "conflations": ["GeneProtein"]}, + {"curies": ["NCBIGene:1756"], "conflations": ["GeneProtein"]} +] +``` The response also carries `normalized_curies` (what the hash was built from — check this if a result surprises you), `normalized_string`, and `error`. From 778ecc54d74accc786f514bfce6e7651ed9b6679 Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 03:02:44 -0400 Subject: [PATCH 5/7] Pin the openapi.yml/route-decorator split with tests construct_open_api_schema() regenerates `paths` from the route decorators and merges back only document-level metadata, so both halves fail silently: metadata that stops being copied just vanishes from the served schema, and endpoint documentation written into openapi.yml is never served at all. That is how 235 lines of prose sat unserved until #403. Five tests: the yml metadata (including the license, which was dropped for a long time) reaches the schema; openapi.yml has no `paths` section and the routes supply them; no operation is left on FastAPI's placeholder "Successful Response"; construct_open_api_schema returns the cached schema rather than calling a dict; and get_app_info reads what it claims to. Also records the split in CLAUDE.md, next to the note that the Dockerfile has to COPY ./skills for /llms.txt to work in a deployed image. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 + tests/frontend/test_apidocs.py | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/frontend/test_apidocs.py diff --git a/CLAUDE.md b/CLAUDE.md index 8d7f5cc..e511890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,8 @@ Babel Compendia Files → loader.py → Redis (7 DBs) → FastAPI (server.py) ### Key Modules - **`node_normalizer/server.py`** — FastAPI app with all REST endpoints. Uses lifespan events for Redis connection setup/teardown. Root path is `/`. +- **`node_normalizer/apidocs.py`** — builds the served OpenAPI schema. `resources/openapi.yml` contributes **only** document-level metadata (title, version, description, contact, license, x-translator/x-trapi, servers, tags); `paths` is always regenerated from the route decorators, so per-endpoint documentation belongs on the decorator (`summary`, `description`, `response_description`), never in the YAML. Guarded by `tests/frontend/test_apidocs.py`. +- **`skills/nodenorm/SKILL.md`** — agent-facing usage instructions, also served at `GET /llms.txt`. The Dockerfile must `COPY ./skills`, or that route 404s in deployed images while working locally. - **`node_normalizer/normalizer.py`** — Core logic: `get_normalized_nodes()`, `normalize_message()` (for TRAPI), and equivalent CURIE discovery. Traverses Biolink Model ancestors for semantic type expansion. - **`node_normalizer/loader/`** — the data loader: synchronous module-level functions (`load_all`, `load_compendium`, `load_conflation`, `merge_semantic_meta_data`) that read flat compendia files and populate Redis. Validates input against `resources/valid_data_format.json`. Batch size: 100,000. Invoked via the root `load.py`. `load_all` disables Redis periodic saves (`CONFIG SET save ""`) for the duration of the load — the backend DBs are write-once and persisted by a manual BGSAVE, so snapshotting during the load is wasted work. See `documentation/Loader.md`. - **`node_normalizer/config.py`** — shared repo-relative paths (`config.json`, `redis_config.yaml`, `resources/`) and `get_config()`. Used by both frontend and loader. diff --git a/tests/frontend/test_apidocs.py b/tests/frontend/test_apidocs.py new file mode 100644 index 0000000..df541ea --- /dev/null +++ b/tests/frontend/test_apidocs.py @@ -0,0 +1,82 @@ +"""Tests for OpenAPI schema construction (node_normalizer/apidocs.py). + +`construct_open_api_schema` regenerates `paths` from the route decorators and merges +back only the document-level metadata from resources/openapi.yml. These tests pin that +split, because the failure mode is silent: metadata that stops being copied just +disappears from the served schema, and endpoint documentation written in the wrong +place is never served at all. + +Only reads the app singleton; does not mutate `app.state` (see tests/CLAUDE.md). +""" + +import pytest + +from node_normalizer.apidocs import construct_open_api_schema, get_app_info +from node_normalizer.server import app + +# FastAPI's placeholder when a route declares no response description. +DEFAULT_RESPONSE_DESCRIPTION = "Successful Response" + + +@pytest.fixture(scope="module") +def schema(): + return app.openapi_schema + + +def test_yml_metadata_reaches_the_served_schema(schema): + """Each of these is copied by an explicit branch in construct_open_api_schema. + + `license` in particular was parsed and then silently dropped for a long time. + """ + info = schema["info"] + assert info["license"]["name"] == "MIT" + assert info["contact"]["email"] + assert info["termsOfService"] + assert info["x-translator"]["infores"] == "infores:sri-node-normalizer" + assert info["x-trapi"]["version"] + assert schema["servers"] + + +def test_paths_come_from_the_routes_not_the_yml(schema): + """openapi.yml has no `paths` section; the routes are the only source.""" + assert "/get_normalized_nodes" in schema["paths"] + assert "/llms.txt" in schema["paths"] + + from node_normalizer.config import RESOURCES_DIR + import yaml + + with open(RESOURCES_DIR / "openapi.yml") as f: + api_docs = yaml.safe_load(f) + assert "paths" not in api_docs, ( + "openapi.yml grew a `paths` section again. It is not merged into the served " + "schema, so anything documented there is invisible — put it on the route " + "decorator in server.py instead." + ) + + +def test_every_operation_documents_itself(schema): + """Guards against sliding back to FastAPI's placeholder descriptions.""" + for path, operations in schema["paths"].items(): + for method, operation in operations.items(): + where = f"{method.upper()} {path}" + assert operation.get("description"), f"{where} has no description" + assert operation.get("summary"), f"{where} has no summary" + described = operation["responses"]["200"]["description"] + assert described != DEFAULT_RESPONSE_DESCRIPTION, ( + f"{where} still uses FastAPI's default 200 description" + ) + + +def test_construct_open_api_schema_returns_the_cached_schema(): + """The early-out returned `app.openapi_schema()` — calling a dict, i.e. TypeError. + + Unreachable while server.py assigns the attribute directly, but it would fire the + moment anything called app.openapi(). + """ + assert construct_open_api_schema(app) is app.openapi_schema + + +def test_get_app_info_reads_title_version_description(): + info = get_app_info() + assert set(info) == {"title", "version", "description"} + assert info["version"] == app.openapi_schema["info"]["version"] From 4f7778de46b631167e4026bdd5f8c9911ba4a56f Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 05:25:58 -0400 Subject: [PATCH 6/7] Point the skill's own links at main The two remaining master URLs, both in this PR's files. The install curl in LLMs.md matters most: it is the one line a reader actually executes. Both targets 404 right now, because skills/nodenorm/SKILL.md and documentation/Babel.md only exist on these two branches. main is still the right target -- an install instruction should hand people the merged file, and pinning it to a branch would rot as soon as the branch is deleted. They resolve once #403 and this PR land. Co-Authored-By: Claude Opus 5 --- documentation/LLMs.md | 2 +- skills/nodenorm/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/LLMs.md b/documentation/LLMs.md index 9626a83..4e34969 100644 --- a/documentation/LLMs.md +++ b/documentation/LLMs.md @@ -45,7 +45,7 @@ Either way you can also just create the directory and save the file into it by h ```bash mkdir -p ~/.claude/skills/nodenorm curl -o ~/.claude/skills/nodenorm/SKILL.md \ - https://raw.githubusercontent.com/NCATSTranslator/NodeNormalization/master/skills/nodenorm/SKILL.md + https://raw.githubusercontent.com/NCATSTranslator/NodeNormalization/main/skills/nodenorm/SKILL.md ``` Claude Code will then offer it as `/nodenorm`, and will also load it automatically when a task diff --git a/skills/nodenorm/SKILL.md b/skills/nodenorm/SKILL.md index 1e5c12b..becddf2 100644 --- a/skills/nodenorm/SKILL.md +++ b/skills/nodenorm/SKILL.md @@ -259,4 +259,4 @@ The identifiers, preferred names, types and information content all come from [Babel](https://github.com/NCATSTranslator/Babel), which is what decides that two identifiers are equivalent. If a clique looks wrong — two concepts merged, or one concept split in two — that is a Babel issue, not a NodeNorm one. See -[Where NodeNorm's data comes from](https://github.com/NCATSTranslator/NodeNormalization/blob/master/documentation/Babel.md). +[Where NodeNorm's data comes from](https://github.com/NCATSTranslator/NodeNormalization/blob/main/documentation/Babel.md). From 1231a4977bc77b8f95e7e6691230c253ff3100dd Mon Sep 17 00:00:00 2001 From: Gaurav Vaidya Date: Mon, 27 Jul 2026 06:04:39 -0400 Subject: [PATCH 7/7] Scan every link-bearing file, not just Markdown The link tests only looked at Markdown plus two hand-listed source files. A hand-written list of "where links live" is exactly what lets a link rot unnoticed, and it silently stops covering anything added later, so the file set is now globbed by extension. It immediately found one the Markdown-only scan could not see: a captured /status sample in documentation/NodeNormalization.ipynb still showed a blob/master babel_version_url. Normalized to main, matching the equivalent sample in NameRes's API.md. Worth knowing that this one is aspirational rather than descriptive: the value comes from BABEL_VERSION_URL, which the deployment chart sets to a master URL, so a live instance really does return master today. The fix for that belongs in translator-devops. Co-Authored-By: Claude Opus 5 --- documentation/NodeNormalization.ipynb | 2 +- tests/test_docs_links.py | 30 ++++++++++++++++++++------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/documentation/NodeNormalization.ipynb b/documentation/NodeNormalization.ipynb index 872a1e8..edef515 100644 --- a/documentation/NodeNormalization.ipynb +++ b/documentation/NodeNormalization.ipynb @@ -69,7 +69,7 @@ "{\n", " \"status\": \"running\",\n", " \"babel_version\": \"2025sep1\",\n", - " \"babel_version_url\": \"https://github.com/ncatstranslator/Babel/blob/master/releases/2025sep1.md\",\n", + " \"babel_version_url\": \"https://github.com/ncatstranslator/Babel/blob/main/releases/2025sep1.md\",\n", " \"databases\": {\n", " \"eq_id_to_id_db\": {\n", " \"dbname\": \"id-id\",\n", diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py index 86d1fb8..f2577da 100644 --- a/tests/test_docs_links.py +++ b/tests/test_docs_links.py @@ -13,15 +13,29 @@ 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", "data"} - for part in p.parts) -) +#: `data/` holds untracked working copies of helm charts, which are not ours to police. +EXCLUDED = {"venv", ".venv", "node_modules", ".git", ".pytest_cache", "data"} + + +def _ours(path): + return not any(part in EXCLUDED for part in path.parts) + -# Files that carry links out to GitHub in code rather than in prose. -SOURCE_WITH_LINKS = [REPO_ROOT / "node_normalizer" / "server.py", REPO_ROOT / "node_normalizer" / "resources" / "openapi.yml"] +# Markdown we own. Anything vendored is deliberately excluded. +MARKDOWN_FILES = sorted(p for p in REPO_ROOT.rglob("*.md") if _ours(p)) + +#: Everything else that can carry a GitHub link. 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. 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", + ".snakefile"} + +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)