# Ontology Term Labels from OBO Files and OLS4

Source: https://www.potatoannotator.com/docs/features/ontology-labels

*New in v2.13.0*

A `label_source` block on a `radio`, `multiselect`, `span`, or `image_annotation` scheme adds a search box over an ontology. Annotators type a name, a synonym, or an id and pick a term instead of scrolling a fixed label list. Each pick is stored with the term's id, name, IRI, and the ontology release the term came from. Biocurators use the feature for cell type annotation with the [Cell Ontology](https://obofoundry.org/ontology/cl.html), phenotype annotation with the Human Phenotype Ontology (HPO), and disease normalization with MONDO, and the tagging of chemical mentions with ChEBI terms, tissue regions on a slide with UBERON terms, and gene products with GO terms. Potato's agreement metrics and the export read the term id. An annotator who searched "B cell" and an annotator who typed the id `CL:0000236` agree because both answers store the same id.

The [OBO flat file format](https://owlcollab.github.io/oboformat/doc/obo-syntax.html) is the plain-text release format of the [OBO Foundry](https://obofoundry.org/) ontologies. Potato reads OBO 1.4. The [Ontology Lookup Service](https://www.ebi.ac.uk/ols4/) (OLS4) is EMBL-EBI's search service and API for biomedical ontologies, and an organization can also run an OLS4 server of its own.

- **Local file:** with `service: file`, Potato searches an OBO 1.4 file inside the task directory. A file source works offline.
- **OLS4:** with `service: ols4`, Potato searches an OLS4 server. Every search sends the typed text to that server, and Potato refuses to start unless the scheme sets `allow_network: true`. The Potato server makes the request, and the annotator's browser only talks to Potato.
- **Stored record:** each pick is saved as `{obo_id, label, iri, ontology_version}`. Potato's agreement metrics compare the `obo_id`.
- **Export:** `python -m potato.export` with `--format ontology_terms` writes one row per picked term.
- **No extra install:** Potato parses OBO with its own reader, and OLS4 calls use the `requests` package Potato already depends on.

## Configuration

The config below sets up three schemes over the synthetic sample ontology from Potato's example. The first scheme asks for one main cell type through the search box alone, the second scheme offers a fixed `None` option above the search box, and the third scheme tags text spans with immune cell terms only:

```yaml
annotation_task_name: "Cell types from an ontology"
task_dir: .
output_annotation_dir: annotation_output
data_files: [data/cell-descriptions.jsonl]
item_properties: {id_key: id, text_key: text}

user_config:
  allow_all_users: true

annotation_schemes:
  - annotation_type: radio
    name: main_cell_type
    description: "Which cell type does the description mainly talk about?"
    labels: []                  # search box only
    label_requirement: {required: true}
    label_source:
      ontology: scl
      service: file
      file: ontologies/scl-sample.obo

  - annotation_type: multiselect
    name: other_cell_types
    description: "Which other cell types are mentioned?"
    labels: [None]              # a fixed option above the search box
    label_source:
      ontology: scl
      service: file
      file: ontologies/scl-sample.obo

  - annotation_type: span
    name: immune_cell_mentions
    description: "Highlight each immune cell mention and tag its type"
    labels: []
    label_source:
      ontology: scl
      service: file
      file: ontologies/scl-sample.obo
      root: SCL:0000001         # immune cell and its is_a descendants
```

With `labels: []`, or with no `labels` key, the search box is the only way to answer. With a list, the fixed labels show as usual and the search box sits below the fixed labels. A fixed label is stored as a plain label name, and only a term picked from the search is stored as an ontology term. Because the first scheme sets `required`, Potato keeps Next blocked until the annotator picks a term. For the third scheme, Potato limits the search to the `root` immune cell term and every term below that term by `is_a`. A neuron term never appears in the span search.

Potato ships no ontology files. Download the `.obo` release you need into the task directory and keep the file with the project. The HPO release is `http://purl.obolibrary.org/obo/hp.obo`, and the Cell Ontology release is `http://purl.obolibrary.org/obo/cl.obo`. The example's `scl-sample.obo` is a made-up ontology with 19 current terms, one obsolete term, and the prefix `SCL`. The `SCL` ids are not Cell Ontology ids.

For an [image annotation](/docs/annotation-types/image-annotation) scheme, the same block goes beside the drawing tools. The partial block below adds a fourth scheme to the config above:

```yaml
  - annotation_type: image_annotation
    name: cells_in_image
    description: "Box each cell and name its type"
    tools: [bbox, polygon]
    labels: []
    label_source:
      ontology: scl
      service: file
      file: ontologies/scl-sample.obo
```

### Searching OLS4 instead of a file

The partial block below replaces `annotation_schemes` in the config above with one scheme that searches the Cell Ontology on EBI's OLS4 server. The `ontology` value is the OLS4 ontology id, such as `cl`, `hp`, `go`, `mondo`, `chebi`, or `uberon`:

```yaml
annotation_schemes:
  - annotation_type: radio
    name: cell_type
    description: "Which cell type is described?"
    labels: []
    label_source:
      ontology: cl
      service: ols4
      allow_network: true       # required for ols4
      root: CL:0000000          # optional, sent to OLS4 as childrenOf
      timeout_s: 5
```

Set `base_url` to search a self-hosted OLS4 server. The `allow_network` key is a deliberate opt-in. A study whose search text must stay on your network uses `service: file`. Potato refuses to start when an OLS4 block lacks `allow_network: true`.

## Options

Every key `label_source` accepts:

| Key | Service | Default | Meaning |
|---|---|---|---|
| `ontology` | both | required | A short ontology name (`cl`, `hp`). The name is shown in the search box label, and for OLS4 the lowercased name is the ontology id sent to the server |
| `service` | both | required | `file` or `ols4` |
| `file` | file | required | Path to an OBO 1.4 file, relative to `task_dir` and inside `task_dir`. A URL is refused because Potato does not download ontology files |
| `max_file_mb` | file | `300` | A file larger than the given size in megabytes is refused at startup |
| `root` | both | none | A term id such as `CL:0000000`. Only that term and the terms below the term by `is_a` are offered |
| `max_results` | both | `20` | Matches shown per search, an integer from 1 to 100 |
| `allow_network` | ols4 | `false` | Must be `true` for `service: ols4` |
| `base_url` | ols4 | `https://www.ebi.ac.uk/ols4/api` | The OLS4 API root |
| `timeout_s` | ols4 | `5` | Seconds to wait for OLS4, from 0.5 to 30 |

The server and `python -m potato.validate_cli` refuse a config and name the offending key when any of the following conditions holds:

- `label_source` sits on a scheme type other than `radio`, `multiselect`, `span`, or `image_annotation`;
- `service` is not `file` or `ols4`, or a key belongs to the other service (`base_url` with `file`);
- a key is unknown;
- the file is missing, outside `task_dir`, larger than `max_file_mb`, an OWL file, or has no terms;
- `root` is not a term in the file, or is marked obsolete; or
- `service: ols4` is set without `allow_network: true`.

## OBO file parsing

Potato reads the `data-version`, `ontology`, and `default-namespace` header tags. In each `[Term]` stanza, the reader takes `id`, `name`, `namespace`, `synonym`, `def`, `is_a`, `alt_id`, and `is_obsolete`, plus the OBO 1.0 synonym tags such as `exact_synonym` found in older files. `[Typedef]` stanzas are skipped, and `relationship:` and `intersection_of:` lines are not edges. Potato therefore builds a `root` subtree from `is_a` alone. For example, a UBERON subtree has the subclasses of the root and none of the structures linked to the root only by `part_of`.

The stored `ontology_version` is the file's `data-version` header, such as `scl/releases/2026-10-01` in the sample. A file without the header gets `sha256:` plus the first 12 hex digits of the file's hash. The file is parsed once and kept in memory. Potato parses the file again when the file's size or modification time changes. A new release copied over the old file takes effect on the next search without a restart.

Obsolete terms are left out of search and out of every subtree, and Potato does not follow a `replaced_by` tag to the newer term. A search for an `alt_id` finds the primary term, and Potato stores the primary id. OWL files are refused with a message. Most OBO Foundry ontologies publish an `.obo` release beside the `.owl` file. Download the `.obo` release, or convert an OWL file with [ROBOT](http://robot.obolibrary.org/convert):

```bash
robot convert --input cl.owl --output cl.obo
```

For a large ontology such as NCBITaxon, use a slim or subset file, raise `max_file_mb`, or search the ontology through OLS4.

## Searching and picking terms

Annotators type at least two characters of a name, a synonym, or a term id. A file search ranks a term id first (`CL:0000236`, `CL_0000236`, an `alt_id`, or the full PURL), then an exact name, a name that starts with the typed text, and a name whose words start with each typed word. Synonyms are matched the same three ways, followed by text found anywhere in a name and then anywhere in a synonym. Every synonym scope counts (`EXACT`, `BROAD`, `NARROW`, and `RELATED`), and a match found through a synonym shows the synonym under the term name. A result also shows the term id and the first 240 characters of the definition. In the sample ontology, typing `white blood` finds leukocyte through the synonym "white blood cell", and typing `SCL:0000099` finds T cell through the term's `alt_id`.

When more terms match than `max_results`, a status line gives the number shown and the total, and asks the annotator to type more. A line under the box names the source. The line shows the ontology and the file's version for a file source, and the ontology name followed by "via OLS4" for an OLS4 source.

- **Keyboard:** Up and Down move through the matches, Enter picks the highlighted match, Escape closes the list, and a second Escape clears the box. Clicking or tapping a match also picks the match, and each match is a tap target at least 44 pixels tall.
- **`radio`:** the picked term appears as a selected option, and picking another term replaces the earlier term.
- **`multiselect`:** each picked term appears as a ticked checkbox, and unticking the checkbox removes the term.
- **`span`:** the picked term becomes the active span label, and the annotator then highlights the text. The chip above the highlight shows the term name.
- **`image_annotation`:** the picked term becomes a label button and the active label, and the annotator then draws. Terms already used on an image appear as buttons when the page loads.

[Pocket Mode](/docs/features/pocket-mode) shows no search box. A task with a `label_source` scheme therefore always uses the regular annotation page. The regular page also works on a phone.

## Network access and OLS4 caching

Potato makes no network request for a `service: file` scheme at any point. Once the `.obo` file is in the task directory, a file source runs in an [air-gapped deployment](/docs/measurement/air-gap). Searches run against the parsed file on the Potato server. An OLS4 scheme without `allow_network: true` cannot reach the network because the server refuses to start with that config and suggests a local file instead.

With `service: ols4` and `allow_network: true`, the Potato server sends each search to OLS4 with `obsoletes=false`. OLS4 then leaves obsolete terms out of the results. Search answers are cached in the server's memory for six hours, up to 512 distinct searches, and the ontology's release string is cached for six hours as well. The browser may reuse an answer for five minutes. Every term OLS4 returns is also written to `<output_annotation_dir>/ontology_cache/<ontology>-ols4-terms.json`. After a reload, Potato reads the term names on span chips and image label buttons from that file, and the export reads the same file.

When OLS4 is slow or unreachable, the search box shows "Search failed: OLS4 did not answer within 5 s." or a message starting "Search failed: OLS4 request failed", and annotators can still answer the fixed labels. Searches answered in the previous six hours are served from the cache. Terms already picked stay saved because a radio or multiselect answer holds the whole record and span and image answers resolve through the term file on disk. If Potato cannot read the ontology's release from OLS4, picks are stored with an empty `ontology_version` until the next release lookup, up to six hours later. For a study that must run through network outages, download the `.obo` file and switch to `service: file`.

## Stored answer

A picked term is stored as the record below:

```json
{"obo_id": "SCL:0000005", "label": "CD4-positive T cell",
 "iri": "http://purl.obolibrary.org/obo/SCL_0000005",
 "ontology_version": "scl/releases/2026-10-01"}
```

For a file source, the `iri` is the OBO PURL built from the id, and for OLS4 the `iri` is the IRI that OLS4 returns. A radio or multiselect answer keeps the `ontology_version` from the moment of the pick. After the `.obo` file is replaced, the answer still names the release the annotator searched.

| Scheme | Stored as |
|---|---|
| `radio`, `multiselect` | One label per term. The label name is the `obo_id`, and the value is the record above as a JSON string |
| `span` | The span's label (`name`) is the `obo_id` |
| `image_annotation` | Each shape's `label` is the `obo_id`, inside the usual `_data` JSON |

Spans and shapes store the id alone. The export looks up the name, IRI, and version in the `.obo` file for `service: file`, and in the OLS4 term file under `ontology_cache` for `service: ols4`. For spans and shapes in file mode, the export writes the version of the `.obo` file at export time.

## Export

The `ontology_terms` format writes `ontology_terms.csv` and `ontology_terms.jsonl`, one row per picked term:

```bash
python -m potato.export --config config.yaml --format ontology_terms --output out/
```

For one annotator on the first item of the config above, `ontology_terms.csv` reads:

```text
user_id,instance_id,schema,annotation_type,ontology,obo_id,label,iri,ontology_version,start,end,text,shape_index,shape_type,known
ana,cells_01,main_cell_type,radio,scl,SCL:0000005,CD4-positive T cell,http://purl.obolibrary.org/obo/SCL_0000005,scl/releases/2026-10-01,,,,,,true
ana,cells_01,other_cell_types,multiselect,scl,SCL:0000011,macrophage,http://purl.obolibrary.org/obo/SCL_0000011,scl/releases/2026-10-01,,,,,,true
ana,cells_01,immune_cell_mentions,span,scl,SCL:0000011,macrophage,http://purl.obolibrary.org/obo/SCL_0000011,scl/releases/2026-10-01,128,150,large phagocytic cells,,,true
```

The `start`, `end`, and `text` columns are filled for spans, and `shape_index` and `shape_type` for image shapes. The `known` column is `false` when the id is not in the current file or the OLS4 term file. A term that a newer release removed or made obsolete gets `false`. The export prints a warning with the count of such rows. Fixed labels on the same scheme are not rows in `ontology_terms`. The general `csv` export writes a radio or multiselect term as a column named `<schema>.<obo_id>` holding the JSON record, and spans and shapes keep the `obo_id` as their label. See [Export Formats](/docs/features/export-formats) for the other formats.

## Agreement

Potato's [agreement](/docs/guides/inter-annotator-agreement) metrics compare the `obo_id`. A `radio` scheme is scored as a nominal label (percent agreement, Cohen's kappa, Fleiss' kappa, and Krippendorff's alpha), a `multiselect` scheme by Jaccard overlap and MASI-weighted alpha over the sets of term ids, a `span` scheme by span position and `obo_id`, and an `image_annotation` scheme by each shape's geometry and term id. Two annotators who pick the same term agree even when their records hold different `ontology_version` values. A parent and a child term count as different labels, and the agreement code gives no partial credit along the ontology hierarchy. For example, T cell and CD4-positive T cell are scored as a disagreement. For a hierarchy-aware comparison, export `ontology_terms` and compute the measure from the `obo_id` column against the same `.obo` release.

## Example

```bash
python potato/flask_server.py start examples/classification/ontology-labels/config.yaml -p 8000
```

The example has four short cell descriptions, the synthetic `scl-sample.obo` ontology, and the three schemes from the config above.

## Further reading

- [Life Sciences](/use-cases/life-sciences) - Potato for biocuration and life-science annotation
- [Healthcare](/use-cases/healthcare) - Potato for clinical and biomedical annotation
- [Entity Linking](/docs/guides/entity-linking) - Linking a mention to a knowledge-base entry, where the span label stays a fixed category
- [Radio and Multiselect](/docs/annotation-types/radio-multiselect) - The single-choice and multiple-choice schemes that take `label_source`
- [Export Formats](/docs/features/export-formats) - Potato's export formats and the export command

For implementation details, see the [source documentation](https://github.com/davidjurgens/potato/blob/master/docs/annotation-types/science/ontology_labels.md).
