Extractor contract
An extractor is an HTTP service the Loom server sends an original file to and gets its extraction artifact back from.
This page is version 1 of the contract between them: what a service, Loom's reference extractor
(extractor/loom_extractor/) or one of an operator's own, must serve. Why the parsing runs outside the server, and how
the server reads a response, is in document extraction.
Configuring the server
| Setting | Meaning |
|---|---|
LOOM_DOCUMENT_EXTRACTOR_URL |
the base URL a format goes to when no route names it |
LOOM_DOCUMENT_EXTRACTORS_JSON |
{format or media type: base URL}; a format's key wins over its media type's (which applies to that media type's format only), either over the URL above; a key that names no format, or a second spelling of one media type, is refused at load |
LOOM_DOCUMENT_EXTRACTOR_TOKEN |
the bearer token sent to a service with none of its own below |
LOOM_DOCUMENT_EXTRACTOR_TOKENS_JSON |
{base URL: token}: a service's own token, so no service holds the token another accepts; each URL must be one the two settings above name |
LOOM_DOCUMENT_EXTRACTOR_TIMEOUT_S |
seconds one call may take as a whole: reading the capabilities, or sending the original and reading the response |
LOOM_DOCUMENT_EXTRACTOR_MAX_ARTIFACT_BYTES |
the largest response the server reads; a longer one is refused as resource_limit |
The format names are markdown, html, text, sql, toml, pdf, docx, pptx, xlsx, csv and parquet. For
example, to send PDFs to an operator's own service and everything else to the reference extractor:
LOOM_DOCUMENT_EXTRACTOR_URL=http://extractor:8790
LOOM_DOCUMENT_EXTRACTORS_JSON='{"pdf": "http://pdf-extractor:8080"}'
Endpoints
Every call carries Authorization: Bearer <token>. A service answers a missing or wrong token with 401.
GET /v1/capabilities
What the service extracts. The server extracts a format only when the service it is routed to lists it here, and reads this again after a minute.
{
"contract_version": 1,
"extractor": { "name": "loom-reference", "version": "1" },
"max_bytes": 67108864,
"formats": [
{ "format": "markdown", "options": ["encoding"] },
{ "format": "sql", "options": ["encoding", "sql_dialect"] }
]
}
| Field | Meaning |
|---|---|
contract_version |
1; the server treats a service of another version as unavailable |
max_bytes |
the largest original the service takes; the server does not send a larger one |
formats |
each format the service extracts and the options it takes |
POST /v1/extract
The original's bytes are the body (Content-Type: application/octet-stream). The query names what the server asks for:
| Parameter | Meaning |
|---|---|
format |
the format the server routed the file as |
name |
the file's name, which may be empty |
encoding |
optional: a codec name for a text file that cannot say its own |
sql_dialect |
optional, SQL only: the dialect to parse with |
A service refuses an option it does not take (invalid_option). It checks the content against format and refuses a
file that is another format (format_mismatch) rather than reading it as the one asked for.
200 carries the artifact JSON. The server sends Accept-Encoding: identity, and a service must not compress an
answer: the server treats a compressed one, whatever its status, as the service being unavailable. A source the service
cannot extract is a 422 with an error body:
{ "error": { "code": "format_mismatch", "message": "the file is pdf, not markdown" } }
| Code | Meaning |
|---|---|
unsupported_format |
the file is a format no extractor supports |
format_unavailable |
this service does not extract the format |
format_mismatch |
the content is another format than the one asked for |
encoding |
the text does not decode |
corrupt |
the file is damaged, or the parser fails on it |
invalid_option |
an option is unknown or has a value the service refuses |
too_large |
the original is over the service's max_bytes |
timeout |
the extraction took longer than the service allows |
resource_limit |
the extraction passed a memory or output limit |
The server keeps a code of up to 40 lowercase letters and _ as the failure's code and reads any other, or an error
body over 64 KiB, as refused. It treats 401, 403, 407, 408, 429, any 5xx, any other answer that is not
200 or 4xx (a redirect, a 204), format_unavailable and a call past its timeout as the service being unavailable,
so the source can be tried again later, and any other 4xx as the source refused. A 200 whose artifact breaks the
rules below is refused as invalid_artifact.
The artifact
{
"schema_version": 1,
"format": "toml",
"parser": "loom-toml",
"parser_version": "1+python3.12.13",
"options": {},
"attrs": {},
"warnings": [],
"coverage": {
"unit": "keys",
"total": 1,
"extracted": 1,
"failed": [],
"lost": [],
"complete": true
},
"blocks": [
{
"id": "b0",
"type": "toml_table",
"text": "[server]",
"section_path": ["server"],
"locator": { "line_start": 1, "line_end": 1, "key_path": "server" },
"keys": ["server"]
},
{
"id": "b1",
"type": "toml_key",
"text": "server.port = 7777",
"parent": "b0",
"section_path": ["server"],
"locator": { "line_start": 2, "line_end": 2, "key_path": "server.port" },
"keys": ["server.port"],
"attrs": { "value": 7777, "value_type": "integer" }
}
]
}
| Field | Type | Rule |
|---|---|---|
schema_version |
integer | 1 |
format |
string | the format asked for |
parser, parser_version |
non-empty strings | what made the artifact; a parser change makes a new version |
options, attrs |
objects | nested at most 16 deep |
warnings |
list of strings | |
coverage.unit |
non-empty string | what the format is made of: lines, statements, keys, chars, pages, paragraphs, slides, rows, ... |
coverage.total, coverage.extracted |
integers | 0 <= extracted <= total |
coverage.failed |
list of {unit, reason} |
each unit not extracted |
coverage.lost |
list of strings | capabilities the artifact cannot serve, such as sql_structure |
coverage.complete |
boolean, optional | if sent, equal to extracted == total with no failures and no losses |
blocks |
list | in reading order |
blocks[].id |
non-empty string | unique in the artifact |
blocks[].type |
string | heading, paragraph, list, code, sql_statement, toml_table or toml_key |
blocks[].text |
string | |
blocks[].parent |
string, optional | the id of an earlier block that encloses it |
blocks[].depth |
integer, optional | 0 or more; a heading's level |
blocks[].section_path |
list of strings | the headings above the block; at most 64, each at most 500 characters |
blocks[].keys |
list of strings | exact names the block can be looked up by; at most 10,000, each at most 1,024 characters |
blocks[].attrs |
object | format facts (table_id on a row block: the id of the table its rows are from; table on a DOCX, PPTX or HTML table block: its rows and columns; orientation on a PDF block of text that runs in another direction than its page's; ocr on a block read from an image) |
blocks[].locator |
object | line_start/line_end (1-based, both 0 or 1 <= start <= end), char_start/char_end (offsets into the decoded text, both -1 or 0 <= start <= end), key_path, page_start/page_end (a PDF's pages or a deck's slides, 1-based, both 0 or 1 <= start <= end), sheet (a workbook's sheet name, at most 500 characters), row_start/row_end (a sheet's or a CSV file's rows, the header counted, a Parquet file's rows from 1, or a PDF table's rows, its header row 1; 1-based, both 0 or 1 <= start <= end) |
A tabular source (a CSV file, a workbook's sheets, a Parquet file) and a PDF that holds tables also carry tables, beside the row blocks they are searched by:
| Field | Type | Rule |
|---|---|---|
tables |
list, optional | each table of the source |
tables[].id, tables[].name |
strings | the id unique and not empty; the name: the sheet's, the file's, or a PDF table's page and place (page 3 table 1) |
tables[].columns |
list of {name, type} |
at most 16,384; a name at most 500 characters; a type string, number, bool, date, datetime, time, duration or mixed (what every value it holds is; dates and date-times together are datetime) |
tables[].rows |
list of lists | each as long as columns; each value a string, a number, a bool, null, or a cell error {"error": "#DIV/0!"}; a date or a time is ISO text, a duration H:MM:SS with hours past a day (36:00:00), a decimal, or an integer past signed 64 bits, in a number column its exact digits ("12.345") |
tables[].header_rows |
integer, optional | 0 or more, 1 when left out: the rows above the values that named the columns; 0 for a source whose names are no row of it (a Parquet schema), whose rows then count from 1 |
tables[].locator |
object | as a block's: the sheet (or a PDF's pages) and the rows the table spans |
tables[].attrs |
object | format facts (hidden for a hidden sheet) |
A server that does not know tables drops it, as any field the contract does not name, and still publishes the row
blocks.
Every string is valid Unicode, and every number is JSON (no NaN or Infinity): an integer is signed 64-bit (-2^63 to
2^63-1, so not a UInt64 past it), any other number within a double (not 1e999). The server refuses an artifact that
breaks any rule, and drops a field the contract does not name, so a service can send one without a version change. A
change an older server cannot read raises schema_version. A block's keys and section path travel with every chunk cut
from it, so they are bounded by count and length as well as by the artifact's size; the reference extractor leaves out a
longer key, keeps the first 10,000 and the first 64 section entries cut to 500 characters, and marks the block
attrs.keys_truncated or attrs.section_truncated and the artifact's coverage.lost (exact_keys, section_path),
so it does not report itself complete. A character, here and in char_start/char_end, is a Unicode code point of the
decoded text (not a UTF-16 unit or a byte). A line or character range lies within the original: the server refuses an
artifact with a char_end past the original's size in bytes or a line_end past that size plus one, bounds that hold
in any encoding.