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/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/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..4e34969 --- /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/main/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. 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/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/skills/nodenorm/SKILL.md b/skills/nodenorm/SKILL.md new file mode 100644 index 0000000..becddf2 --- /dev/null +++ b/skills/nodenorm/SKILL.md @@ -0,0 +1,262 @@ +--- +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"]`. 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. + +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. 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. + +## 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** | + +**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: + +| 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 + +**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 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? + +**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 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`. + +## 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/main/documentation/Babel.md). 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"] 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 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)