POST /ingest
Uploads one or more PDFs (multipart/form-data, field files) and drives
each through the full straat: ingest → OCR recovery (if scanned) → anonymize →
store → index. See /docs (Swagger) for the live schema; this page is the
parameters and what they cause to be recorded.
Parameters
| name | required | what |
|---|---|---|
files |
yes | one or more PDFs |
dossier |
yes | the dossier these documents belong to; a document in none is invisible to every scoped search |
domain |
no | pseudonymisation domain for the batch; default WORDSWORTH_DEFAULT_DOMAIN |
source_url |
no | where the bytes were fetched from. https:// with a host, at most 2048 characters. A malformed value is refused: 400 with the reason, nothing ingested |
fetched_at |
no | when the bytes were fetched, ISO-8601 with a timezone (e.g. 2026-10-20T09:30:00+00:00). A malformed value is refused the same way |
source_url and fetched_at apply to every file in one request — one upload
is one delivery from one place. Neither is required: an unpublished document
has no URL, and omitting both leaves ingest exactly as it was before these
parameters existed.
What is recorded
For every ingested document, wordsworth computes source_sha256 itself — the
sha256 of the uploaded bytes, the same digest the content-addressed object key
already carries. It is never taken from the request: a client-supplied
hash would only repeat what the client claims, and would prove nothing.
source_url, fetched_at (when given) and source_sha256 (always) are
written to:
- the
documentsrow (source_url,fetched_at,source_sha256columns, all nullable), and - the
registerstep of that document's append-only audit record — so the hash-chained trail proves them too, not only the current row.
GET /documents/{id} returns all three top-level (source_url, fetched_at,
source_sha256), and the full register payload under steps.
A page with no text layer
A document whose text extraction and OCR recovery together yield no text ends
in a visible error state (unprocessable_ocr, shown by the API and the
console) and is never indexed — never an empty text pushed into the search
index. See the document-lifecycle spec for the state machine.
wordsworth-ingest --manifest — manifest-driven corpus ingest
wordsworth-ingest (src/wordsworth/ingest_corpus.py) accepts an optional
--manifest <file> alongside --corpus-dir and --dossier. Without it,
ingest behaves exactly as before. With it, the stricter rules below apply —
one entry point for both a scripted fetch and a person fetching by hand, so a
pilot-only script never drifts from the one ingest path.
Manifest format
One JSONL record per file, the same format every fetcher writes
(wordsworth.manifest.manifest_line/read_manifest):
| field | what |
|---|---|
file |
the file name, as it sits in --corpus-dir |
url |
where the bytes were fetched from |
fetched_at |
when they were fetched, ISO-8601 with a timezone |
sha256 |
the fetcher's own digest of the bytes |
bytes |
their size |
status |
the fetch's HTTP status |
ok |
whether the fetch succeeded |
Before anything is ingested:
- a
*.pdfin--corpus-dirwith no manifest entry aborts the whole run, naming the file; - every file's sha256 is recomputed from the bytes on disk and checked against its manifest entry — the manifest's sha256 is never trusted over the bytes — and a mismatch aborts the whole run, naming the file and both digests;
check.json(below) must pass.
Each document is then ingested with the entry's url as source_url and the
entry's fetched_at as fetched_at. fetched_at always comes from the
manifest — it is never replaced by the time ingest itself ran; that would
make an incomplete provenance record look complete.
Read-back: the proof, not a re-run
After each document is ingested (and committed), the script reads back that
document's register audit record and compares source_url and fetched_at
with the manifest entry. A difference, or either field missing, fails that
document with the reason, and the run's exit status is non-zero.
This is the check that catches the pitfall ingest() doesn't close on its
own: the object key is documents/<sha256 of the bytes>, and a byte-identical
file already ingested (in this dossier or in another one) makes
register_live() return the existing document — silently keeping its old
(possibly empty) provenance rather than recording the new delivery. A
duplicate delivery therefore keeps its first recorded provenance and fails
the manifest run; it does not overwrite it. (Recording provenance on
re-delivery would change what a document's provenance means once it exists —
that needs its own change, and is out of scope here.)
Homelab only
A manifest run requires the environment variable WORDSWORTH_BACKEND_ENV to
be exactly homelab. Missing, empty, alma, or any other value refuses the
run before any file is touched. The backend is never inferred from a backend
URL or host (both clusters resolve under *.svc.cluster.local, so a host
allow-list or DNS resolution cannot tell them apart) and is never defaulted —
a default would be a silent fallback.
A layer that did not run fails the run
In a manifest run, a document that does not reach indexed because some
layer did not run — OpenAnonymiser being unreachable included — is reported
as failed, never as a success with a note, and the run's exit status is
non-zero.
check.json — the pilot's completed check
A manifest run refuses to start unless check.json, a JSON object in the
same directory as manifest.jsonl, passes. Required fields:
checked_by, checked_at (ISO-8601 with a timezone), robots_txt_url,
robots_txt (verbatim), paths_used, paths_disallowed_used, terms,
anubis, user_agent, delay_seconds, decision.
The gate passes only when every field above is present, paths_disallowed_used
is empty, and decision is "proceed". Otherwise the manifest is refused and
the refusal names the failed condition:
check.jsonmissing next to the manifest;- a required field missing;
paths_disallowed_usednot empty (a disallowed path was fetched);decisionis not"proceed"(e.g."stop").
There is no flag or config path for check.json — it always sits next to the
manifest it gates, so there is nothing to mis-point. See
docs/reference/evaluation.md ("The published-dossier pilot") for the gate
order (check, then manifest, then ingest) in context.