# papers-extraction API

> Read-only HTTP access to a curated *Vibrio fischeri* gene-extraction
> database. Each paper has metadata (title, authors, DOI, PMC ID, abstract,
> …) and a list of genes with structured attributes mined by an LLM pipeline.
> Queries always return the latest extraction run for each (paper, gene).

This file is for LLM agents that want to call the API directly. Humans should
read `GET /api` instead — same information, prose form.

## Base URL

Use the origin you fetched this file from. Examples below write it as
`$BASE_URL`, e.g. `export BASE_URL=https://mandel.jasonlo.dev` (production)
or `export BASE_URL=http://localhost:8000` (local dev).

All `next` pagination URLs in responses are **relative** to this base.

## Auth

If the server has `API_KEY` configured, every endpoint except `/api`, `/health`,
and `/llms.txt` requires header `X-API-Key: <key>`. Without the header you get
`401 {"title": "unauthorized", ...}`. If `API_KEY` is unset the API is open.

```
curl -H "X-API-Key: $API_KEY" $BASE_URL/genes
```

## Response conventions

- JSON throughout except `/` and `/llms.txt` (markdown).
- Paginated endpoints return an envelope:
  `{"items": [...], "total": N, "next": "<relative url>" | null}`.
  Follow `next` until it is `null` to stream the full result set; it preserves
  every query param you sent.
- Errors follow RFC 9457 problem-details shape:
  `{"title", "status", "detail", "instance", "request_id", "errors"?}`.
  `request_id` is echoed in the `X-Request-ID` response header — include it
  when reporting a failure.
- Gene field enums use a small, fixed vocabulary:
  - `Assessment`: `yes` | `no` | `not_assessed`
  - `DirectionalChange`: `up` | `down` | `no_change` | `not_assessed`
  - `Correlation`: `positive` | `negative` | `no_correlation` | `not_assessed`
  - `AbnormalChange`: `up` | `down` | `abnormal` | `no_change` | `not_assessed`
  Any other string value means the field has not been scored.

## Endpoints

### GET /papers/{pmid}
Fetch one paper plus its latest-run genes (each with aliases) by PubMed ID.
`pmid` is an integer. Returns `404` if the PMID is not in the database.

Use this when you already know the PMID and want the full record in one call.

```
curl $BASE_URL/papers/15901683
```

### GET /papers?doi=… | GET /papers?pmc_id=…
Same response payload as `/papers/{pmid}`, but keyed on DOI or PMC ID.
Provide **exactly one** of the two query params — passing both or neither
returns `400`. Matching is case-insensitive; `pmc_id` accepts either
`PMC1698219` or `1698219`.

Use this when a user gave you a DOI or PMC ID and you need to resolve it to a
paper record.

```
curl "$BASE_URL/papers?doi=10.1128/JB.01214-06"
curl "$BASE_URL/papers?pmc_id=PMC1698219"
```

### GET /genes
Paginated list of gene rows across **all** papers (latest run only). Good for
catalog-level exploration — "find all genes that have biofilm data", "look up
every record for luxR", etc.

Query params:

| Param                 | Type    | Default | Notes                                                       |
|-----------------------|---------|---------|-------------------------------------------------------------|
| `name`                | string  | —       | substring match on `gene_name` OR any alias                 |
| `locus_tag`           | string  | —       | substring match on `locus_tag` (e.g. `VF_A0937`)            |
| `exact`               | bool    | false   | if true, `name` and `locus_tag` are exact case-insensitive  |
| `limit`               | int     | 100     | 1–5000                                                      |
| `offset`              | int     | 0       | ≥ 0                                                         |
| `has_<flag>`          | bool    | —       | filter by a precomputed completeness flag (below)           |
| `<field>`             | enum[]  | —       | filter by a categorical field's value (below); repeatable   |

`has_<flag>` values: `has_function_role`, `has_pathway_regulation`,
`has_localization_context`, `has_phenotype`, `has_biofilm`,
`has_relationships`, `has_aliases`, `has_notes`,
`has_transcriptional_regulators`, `has_post_transcriptional_regulators`,
`has_regulated_targets`, `has_physical_interactions`,
`has_functional_interactions`, `has_operon_members`, `has_chromosomal_locus`.
`has_*` only says that data exists (any value other than `not_assessed`); it
says nothing about the direction of a result.

Field-value filters: every categorical extraction field is also a query
param. Repeat a param to OR values; different fields are ANDed. Param names
are case-sensitive (e.g. `LBS_media`, `SWT_media`); unknown params and invalid
values return 422. Value sets:
- yes/no fields (e.g. `squid_monocolonization`, `squid_competitive_colonization`,
  `enzyme`, `quorum_sensing`): `yes`, `no`, `not_assessed`. For colonization,
  `yes` = mutant *altered* vs wild type (not necessarily reduced).
- directional fields (e.g. `motility_mutant_impact`, `LBS_media`,
  `biofilm_mutant_aggregation`, `biofilm_mutant_crystal_violet`,
  `host_phenotype_mutant_impact`): `up`, `down`, `no_change`, `not_assessed`.
- wrinkled-colony / pellicle fields (e.g. `biofilm_mutant_pellicle`):
  `up`, `down`, `abnormal`, `no_change`, `not_assessed`.
- correlation fields (e.g. `motility_expression_correlation`,
  `biofilm_expression_correlation`): `positive`, `negative`,
  `no_correlation`, `not_assessed`.
Full field list with enums: `/openapi.json`.

Each item in `items` is a gene extraction record: `{pmid, gene_name, run_id,
created_at, model, gene_aliases, <all extraction fields>}`.

```
curl "$BASE_URL/genes?name=luxR&exact=true"
curl "$BASE_URL/genes?locus_tag=VF_A0937&exact=true"
curl "$BASE_URL/genes?has_relationships=true&limit=20"
curl "$BASE_URL/genes?squid_monocolonization=yes&motility_mutant_impact=down"
curl "$BASE_URL/genes?biofilm_mutant_pellicle=down&biofilm_mutant_pellicle=abnormal"
```

### GET /search?q=…
Case-insensitive substring search across paper title, abstract, keywords,
authors, journal and every text field on gene records (including aliases).
Latest run only. Returns two independently paged envelopes:

```
{
  "papers": {"items": [...], "total": N, "next": "/search?q=…&paper_offset=50" | null},
  "genes":  {"items": [...], "total": M, "next": "/search?q=…&gene_offset=50"  | null}
}
```

Query params: `q` (required, non-empty), `limit` 1–500 (default 50),
`paper_offset` (default 0), `gene_offset` (default 0). The two offsets advance
independently — you can page papers to completion while genes stays on page 0.

Use this for free-text discovery when you do not yet have a PMID/DOI/gene.

```
curl "$BASE_URL/search?q=quorum&limit=10"
```

### GET /health
Liveness probe. No auth, always `200 {"status": "ok"}`. Use for readiness
checks, not for semantic data.

### POST /mcp/
[Model Context Protocol](https://modelcontextprotocol.io) server (Streamable
HTTP, stateless, JSON responses) exposing the endpoints above as tools —
prefer it over raw HTTP if your client speaks MCP. Same `API_KEY`, sent as
`X-API-Key: <key>` or `Authorization: Bearer <key>`.

| Tool                 | Mirrors                 |
|----------------------|-------------------------|
| `get_paper`          | `GET /papers/{pmid}`    |
| `find_paper`         | `GET /papers?doi|pmc_id`|
| `list_genes`         | `GET /genes` — flags as `has={...}`, field filters as `fields={"<field>": [values]}` |
| `search`             | `GET /search`           |
| `list_filter_fields` | the `has_*` flags and categorical fields with allowed values |
| `health`             | `GET /health`           |

Paged tools return `next_offset` (int or null) instead of a `next` URL.

```
claude mcp add --transport http papers-extraction $BASE_URL/mcp/ \
  --header "X-API-Key: $API_KEY"
```

### GET /
Human-oriented quickstart (markdown). Same endpoints, narrative prose. Prefer
`/llms.txt` (this file) for programmatic consumption.

## Further reference

- [/openapi.json](/openapi.json) — full machine-readable OpenAPI 3.1 schema
- [/docs](/docs) — Swagger UI
- [/redoc](/redoc) — ReDoc
