This project provides a workflow for processing text stored in CSV (XLSX) with NLP services. It takes ordered text and extracts high-level linguistic features like Named Entities (NER) with tags and CONLL-U files with lemmas & part-of-sentence tags, and keywords (KER) per page/document.
[!CAUTION] This repository is a follow-up to main ALTO XML postprocessing GitHub repository, a part of ATRIUM project dedicated to ALTO-2-TXT workflow and collection of statistics and from text content of the documents (text and bounding boxes ordered by LayoutReader) recorder in CSV (XLSX) tables as a
textcolumn 1.
TEITOK XML (.teitok.xml) is the primary enriched output format of this pipeline. It is a
TEI-compliant XML format used by the TEITOK
corpus platform, extended to carry spatially-grounded linguistic and NER annotations produced by
UDPipe and NameTag.
Each document in the collection is serialised as a single .teitok.xml file that integrates four
layers of information in a consistent, machine-readable structure:
| Layer | Content |
|---|---|
| Layout | Page, text-block, and line boundaries with pixel-accurate bounding boxes from the source ALTO XML, scaled to match the stored PNG images |
| Morphology & Syntax | Per-token lemma, UPOS/XPOS tags, morphological features, and dependency relations produced by UDPipe 2 |
| Named Entities | BIO-tagged entity spans with both a CoNLL-style category (PER, ORG, LOC, MISC) and a fine-grained CNEC 2.0 code (e.g. pf = first name, gu = city) produced by NameTag 3 |
| Facsimile links | <surface> elements in <facsimile> that tie each page to its companion image, enabling TEITOK's side-by-side text/image view |
Storing all enrichment layers in a single interoperable format offers several practical advantages over keeping CoNLL-U, TSV, and image files in separate silos:
<name type="PER" cnec="pf">) are first-class XML
elements: queryable, stylable, and exportable independently of the surrounding tokens.<tok>,
<lb>, and <div> are used by TEITOK's facsimile viewer to overlay text highlights directly
onto the scanned page image, making OCR quality immediately visible.<div type="MarginTextZone-P">), lines (<lb>),
and graphical elements (<figure>) preserve the physical layout of the original document.<TEI xmlns="http://www.tei-c.org/ns/1.0" xml:lang="cs">
<teiHeader> ... </teiHeader>
<facsimile>
<surface id="doc1.surface1" lrx="1240" lry="1754">
<graphic url="doc1-1.png"/>
</surface>
</facsimile>
<text><body>
<pb n="1" id="doc1.pb1" facs="doc1-1.png"/>
<div type="MarginTextZone-P" id="doc1.TB_1" bbox="142 210 1098 880">
<s id="doc1.s1" text="VΓ½roΔnΓ zprΓ‘va 2012 .">
<lb id="doc1.TL_1" bbox="142 210 680 255"/>
<tok id="doc1.s1.w1" type="w" lemma="vΓ½roΔnΓ" upos="ADJ"
feats="Case=Nom|..." deprel="amod"
bbox="142 210 310 255">VΓ½roΔnΓ</tok>
<name type="ORG" cnec="if">
<tok id="doc1.s1.w3" type="w" lemma="ministerstvo" upos="NOUN"
bbox="320 210 580 255">Ministerstvo</tok>
<tok id="doc1.s1.w4" type="w" lemma="finance" upos="NOUN"
bbox="585 210 680 255">financΓ</tok>
</n>
</s>
</div>
</body></text>
</TEI>
[!NOTE] TEITOK XML is generated by Step 4 of this pipeline (
api_4_stats.sh) whenSAVE_TEITOK=true. The source ALTO XML files must be present inINPUT_ALTO_DIRfor spatial coordinates to be included. IfINPUT_ALTO_DIRis not set, TEITOK XML is still produced but without bounding box attributes. If your documents are not in ALTO format, see EXTRA: Converting Other Input Formats with flexiconv.
Before you begin, set up your environment.
pip install -r requirements.txt
For keyword extraction, install the backend(s) you intend to use:
# YAKE β unsupervised statistical extraction, CPU-only
pip install yake
# KeyBERT β embedding-based extraction, GPU-accelerated when available
pip install keybert sentence-transformers
pip install torch # optional β enables CUDA GPU acceleration
The original legacy KER backend requires no additional packages. For the LLM Semantic Enrichment pipeline, install the inference backend you intend to use:
# Transformers backend β single GPU, models β€ 31 B (BnB 4-bit / AWQ / GGUF)
pip install -r requirements_llm.txt
# vLLM backend β multi-GPU, large models (β₯ 70 B), Automatic Prefix Caching
# Replaces lmformatenforcer; uses xgrammar for native guided JSON decoding
pip install vllm
(Optional) To run the REST API service, install additional requirements:
pip install -r service/requirements.txt
The process is divided into sequential steps, each responsible for a specific part of the NLP enrichment pipeline.
[!IMPORTANT] If you already have a directory of CSV (XLSX) tables with
textcolumn containing extracted text files from ALTO XMLs, you can skip Step 1 and proceed directly to Step 2.
The ../CSVS_with_TEXT/ directory mentioned later is the result of ALTO XML postprocessing pipeline described
in the separate repository 1. It contains document-specific CSV (XLSX) files with the text column containing
extracted textual content from the ALTO XML files. Each CSV (XLSX) file corresponds to a document and contains rows
for each page with a line number column for the proper ordering (page_num and line_num).
CSVS_with_TEXT/
βββ document1.csv
βββ document2.csv
βββ ...
with the structure of each CSV (XLSX) file like:
file,page_num,line_num,text,split_ws,split_we,lang,lang_score,perplex,categ
CTX201504033,1,8,2012,,,N/A,0,0,Non-text
CTX201504033,2,2,1,,,N/A,0,0,Non-text
CTX201504033,3,2,2,,,N/A,0,0,Non-text
...
Where split_ws and split_we are the start and end character offsets of the words split in the original ALTO XML.
The lang and lang_score columns indicate the detected language and its confidence score,
while perplex and categ provide additional metadata about the text classification.
If the script detects an .xlsx file, it will iterate over all sheet names, verify if a text column exists
in each sheet, and extract the content safely for Excel tables with multiple sheets.
This stage performs advanced NLP analysis using external APIs (Lindat/CLARIAH-CZ) to generate Universal Dependencies (CoNLL-U) and Named Entity Recognition (NER) data.
Unlike previous steps, this process is split into modular shell scripts to handle large-scale processing, text chunking, and API rate limiting.
Before running the pipeline, review the api_config.txt π file. This file controls directory paths, API endpoints, and model selection.
# config_api.txt
OUTPUT_DIR="../../ARUB" # Destination for results
INPUT_TABLES_DIR="$OUTPUT_DIR/DOC_LINE_LR_CLS" # Input tables from Step 1
WORK_DIR="./TEMP" # Working directory for intermediate files
LOG_FILE="$OUTPUT_DIR/processing.log"
CONLLU_INPUT_DIR="$OUTPUT_DIR/UDP"
TEMP_TXT_DIR="./TEMP/TXT_EXTRACT"
CHUNK_DIR="./TEMP/CHUNKS"
TSV_INPUT_DIR="$OUTPUT_DIR/NE"
SUMMARY_OUTPUT_DIR="$OUTPUT_DIR/UDP_NE"
TEITOK_OUTPUT_DIR="$OUTPUT_DIR/TEITOK"
INPUT_ALTO_DIR="$OUTPUT_DIR/altos" # Source ALTO XML files - for TEITOK conversion
# ββ Image Options βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# OPTIONAL: Only required if your companion PNG/JPEG display images have been resized
# to a different target resolution relative to ABBYY's baseline dimensions.
# If left empty, the pipeline calibrates layout shifts natively using ALTO PrintSpace.
INPUT_PAGES_DIR=""
UDPIPE_URL="https://lindat.mff.cuni.cz/services/udpipe/api/process"
NAMETAG_URL="https://lindat.mff.cuni.cz/services/nametag/api/recognize"
MODEL_UDPIPE="czech-pdt-ud-2.15-241121"
MODEL_NAMETAG="nametag3-czech-cnec2.0-240830"
TIMEOUT=60 # API call timeout in seconds
MAX_RETRIES=5 # Number of retries for failed API calls
BACKOFF_FACTOR=1.5
WORD_CHUNK_LIMIT=900 # Word limit per API call
SAVE_CSV=true # write token-level summary CSV
SAVE_CONLLU_NE=true # keep merged CoNLL-U with NER in MISC
SAVE_TEITOK=true # write TEITOK-style TEI XML (flexiconv-compatible)
Run the following scripts in sequence. Each script sources config_api.txt π
directly for configuration. Retry logic and per-attempt error handling are implemented inside
the Python helper scripts (call_udpipe.py,
call_nametag.py) using exponential back-off controlled by the
MAX_RETRIES and BACKOFF_FACTOR variables. api_util/api_common.sh π
is a standalone utility module that exposes a log() helper and an api_call_with_retry()
shell function for any custom scripts that choose to source it; the four main pipeline scripts
(api_1_manifest.sh β¦ api_4_stats.sh) do not source it. Additionally, api_util/ π
contains helper Python scripts for chunking and analysis
Maps input text files to document IDs and page numbers to ensure correct processing order.
./api_1_manifest.sh
../CSVS_with_TEXT/ (raw text files in subdirectories from Step 1).OUTPUT_DIR/manifest.tsv.Example output file manifest.tsv π with file, page number, and path columns. It lists all text files to be processed in the next steps. Run the following command to see how many documents will be processed:
tail -n +2 OUTPUT_DIR/manifest.tsv | wc -l
which returns the total number of document rows in the manifest, excluding the header line.
Sends text to the UDPipe API 2. Large documents are automatically split into chunks (default 900 words) using chunk.py π to respect API limits, then merged back into valid CoNLL-U files.
./api_2_udp.sh
OUTPUT_DIR/manifest.tsv (mapping of text files to document IDs and page numbers).../CSVS_with_TEXT/ (raw text files in subdirectories from Step 1).OUTPUT_DIR/UDP/*.conllu (Intermediate per-document CoNLL-U files).Run the following command to see how many documents have been processed into CoNLL-U files:
ls -l <OUTPUT_DIR>/UDP/ | wc -l
which returns the total number of CoNLL-U files created (each file corresponds to a document).
Example output directory UDP π contains per-document CoNLL-U files.
[!NOTE] Chunking and page boundaries. chunk.pyπ splits text on OCR line boundaries (not raw whitespace), preserving the newline-separated structure of the source CSV so that UDPipe receives proper sentence-boundary hints between lines. When a document spans multiple chunks, call_udpipe.pyπ merges them into a single CoNLL-U and injects a
# page_break = truecomment immediately before every sentence that began a new page in its source chunk. All downstream scripts (call_nametag.pyπ, summarize_nt_udp.pyπ, teitok_alto.pyπ) recognise this marker alongside the legacy# sent_id = 1page-reset convention, so both single-chunk and multi-chunk files are handled transparently.
[!TIP] You can launch the next step when a portion of CoNLL-U files are ready, without waiting for the entire input collection to finish. You will have to relaunch the next step after all CoNLL-U files are ready to process the files created after the previous run began.
Takes the valid CoNLL-U files and passes them through the NameTag API 3 to annotate Named Entities (NE) directly into the syntax trees.
./api_3_nt.sh
OUTPUT_DIR/UDP/*.conllu (Intermediate per-document CoNLL-U files).OUTPUT_DIR/NE/*/*.tsv (NE annotated per-page files)Run the following command to see how many documents have been processed into TSV files:
ls -l OUTPUT_DIR/NE | wc -l
which returns the total number of directories created (each subfolder corresponds to a document).
Example output directory NE π contains per-page TSV files with NE annotations, where the NE tags follow the CNEC 2.0 standard 4 which is used in the Czech Nametag model.
This stage consolidates the linguistic data from UDPipe (CoNLL-U) and the NER data from NameTag (TSV) into final per-document formats. It also generates a master summary of entity counts across the entire collection and can optionally produce TEITOK-compatible XML files that merge linguistic tokens with original ALTO layout coordinates.
The process utilizes summarize_nt_udp.py π to merge these
layers, map complex CNEC 2.0 tags (e.g., g, pf, if) into human-readable categories
(e.g., "Geographical name", "First name", "Company/Firm"), and write all output formats.
Optionally, TEITOK-related functionality is implemented in
teitok_alto.py π.
./api_4_stats.sh
OUTPUT_DIR/UDP/*.conllu β Per-document CoNLL-U files containing morphology and syntax.OUTPUT_DIR/NE/*/*.tsv β Per-page TSV files containing Named Entity annotations.INPUT_ALTO_DIR/*.alto.xml β Source ALTO XML files used during TEITOK conversion to provide spatial bounding box coordinates for each token.INPUT_PAGES_DIR/<doc_id>-N.png β Per-page facsimile images. When specified, the pipeline dynamically
extracts pixel boundaries from the headers to compute scaling transformations (sx, sy). If omitted, coordinates are safely
translated and aligned at a native 1.0 scale factor.OUTPUT_DIR/summary_ne_counts.csv β Global table of aggregated Named Entity statistics across all documents.OUTPUT_DIR/UDP_NE/<doc_id>/<doc_id>.csv β Per-document CSV tables with tokens, lemmas, and human-readable NE explanations.OUTPUT_DIR/UDP_NE/<doc_id>/<doc_id>.conllu β Final CoNLL-U files with NER tags enriched in the MISC column.OUTPUT_DIR/TEITOK/<doc_id>.teitok.xml β TEITOK-style TEI XML files ready for the flexiconv converter and facsimile viewing (see below).The behavior of this step is controlled by boolean flags in your config_api.txt:
| Variable | Description | Default |
|---|---|---|
SAVE_CONLLU_NE | Keep the enriched CoNLL-U with NER in the MISC field. | true |
SAVE_CSV | Write the token-level summary CSV per document. | true |
SAVE_TEITOK | Write TEITOK-style TEI XML with bounding boxes and NER spans. When INPUT_ALTO_DIR is not set a warning is emitted and TEITOK XML is still produced without bboxes. If INPUT_ALTO_DIR is set but the path does not exist, the step exits with an error. | true |
INPUT_PAGES_DIR | Directory of per-page images (<doc_id>-N.png). When set, bbox coordinates are scaled to match the actual PNG resolution. Leave empty to write raw ALTO pixel values. | (empty) |
When SAVE_TEITOK=true, teitok_alto.py π reads and processes the internal spatial
hierarchy of your ALTO source specifications.
Offset Alignment (Resolving Layout Shifting):
ABBYY FineReader naturally indexes element positions from the absolute physical boundary of the scanner bed (0,0).
However, companion web images cropped for public view or optimized to strip away raw scanner artifacts introduce
a uniform positional drift (causing text layers to display too far left or too high up on screen).
To neutralize this error without modifying binary assets or re-cropping, the script automatically parses page-level
<PrintSpace> properties from the ALTO structure:
<PrintSpace HEIGHT="3263" WIDTH="2027" VPOS="80" HPOS="297">
The horizontal boundary (HPOS) and vertical boundary (VPOS) values are captured as active translation variables
(dx, dy). Prior to rendering bounding boxes into the TEITOK XML stream, these values are subtracted from the
coordinate targets, recalculating alignment automatically:
$$\text{Scaled Coordinate} = \text{round}((\text{Absolute Coordinate} - \text{Offset}) \times \text{Scale Factor})$$
Dynamic Scale Calculations:
INPUT_PAGES_DIR is set and matching images exist, the tool safely reads
binary file headers without invoking bloated third-party imaging dependencies. Ratios are resolved by evaluating layout
sizes against image shapes (sx = img_width / alto_width).<MeasurementUnit>
(inch1200, mm10, or pixel) mapped against the environment variables IMAGE_DPI and ALTO_DPI.1.0 scale factor.Future direction: Relative / resolution-independent coordinates are the preferred long-term direction (pending TEITOK-team confirmation).
If you are a new user approaching this pipelineβperhaps a researcher who just digitized a batch of archival documentsβyour primary goal might be making sure the semantic annotations actually line up with your page images in a web viewer.
Let's say your original document was processed at a massive archival resolution, but the image you are serving to your web frontend is exactly 1200 pixels wide and 1800 pixels high. Currently, your TEITOK XML bounding boxes are completely misaligned.
Here is exactly how you would use the integrated tools to solve this problem.
Method 1: The Quick API Fix (Best for single files or web integrations)
Since the pipeline now includes a dedicated FastAPI service, you don't even need to write a script. You can just send
your misaligned XML to the /rescale endpoint.
Open your terminal and run a simple curl command, explicitly telling the API the exact dimensions of your target image and requesting the output as an XML file instead of the default JSON metadata:
curl -X POST "http://localhost:8000/rescale" \
-F "file=@CTX000000001.teitok.xml" \
-F "width=1200" \
-F "height=1800" \
-F "format=xml" \
-o CTX000000001.rescaled.teitok.xml
What happens behind the scenes: The API automatically detects the original coordinate space from the <surface> tag
in your XML. It calculates the exact scaling factors needed to stretch or shrink the bounding boxes (bbox) to fit the
new 1200x1800 dimensions. As a bonus, it also silently repairs any malformed named-entity tags (like <name>...</n>)
in the document.
Method 2: The Command-Line Batch Process (Best for whole directories)
If you have hundreds of XML files in a folder and you know exactly what scale ratio or DPI conversion you need, using
the REST API file-by-file would be tedious. Instead, use the dedicated CLI tool, fix_teitok_bboxes.py.
If you know your web images are exactly 50% the size of your original scans (a scale factor of 0.5), you can process the entire directory at once:
python3 fix_teitok_bboxes.py -i /path/to/my/teitok_folder/ --sx 0.5 --sy 0.5
Alternatively, if your original ALTO OCR data was in millimeters (mm10) and you need to target a standard 72 DPI
screen resolution, the script can handle that math directly:
python3 fix_teitok_bboxes.py -i my_document.teitok.xml --unit mm10 --dpi 72
If the original scans included a scanner bed margin (e.g., 50 pixels on the left and 20 on the top) that was cropped out of the final web image, you can strip that out by shifting everything left and up:
python3 fix_teitok_bboxes.py -i my_document.teitok.xml --dx -50 --dy -20
Both methods directly address the historical pain point of facsimile alignment, allowing you to flawlessly overlay the NLP enrichments onto the visual documents without needing to re-run the entire pipeline.
[!NOTE] When a token's matched ALTO strings span more than one page (a rare OCR edge case near page boundaries), a warning is printed to stderr identifying the token and the conflicting page indices. The first matched page is used for the bbox assignment in that case.
The structural and spatial hierarchy from the ALTO file is strictly preserved in the generated TEITOK XML:
<tok> element as @bbox="x1 y1 x2 y2" (absolute
pixel coordinates in TEITOK's hOCR-derived format). Each token also carries @type="w" (word) or
@type="pc" (punctuation character) derived from UDPipe's UPOS tag.<TextLine> elements are preserved via <lb> (line break) tags, which also include
their own @bbox spatial coordinates.<div type="MarginTextZone-P"> containers, satisfying
the core ATRIUM guidelines for classified text zones.Illustration and GraphicalElement blocks are parsed and
appended to their respective pages as <figure> tags with strict bounding boxes.<pb n="N" id="..." facs="..."/> elements pointing to
the specific document surface.Named entity spans are wrapped in <n> elements grouping their constituent <tok> nodes.
Two attributes encode the entity type at different levels of granularity: @type holds the CoNLL-style
category (PER, ORG, LOC, or MISC) intended for querying and interoperability, while @cnec carries
the raw CNEC 2.0 code (e.g., pf, gu, if) for use in visualisation. For example, a span tagged as a
first name is written as <name type="PER" cnec="pf">.
[!NOTE] Thanks to the sequence matching approach, the script achieves near-perfect spatial alignment between NLP tokens and OCR coordinates, drastically improving upon older greedy matching methods that would break on minor character variations. Alignment statistics (matched vs. total tokens) are printed to the console per document.
ls OUTPUT_DIR/UDP_NE | wc -l
which returns the total number of created files, both .csv and .conllu corresponding
to specific documents.
ls OUTPUT_DIR/UDP_NE/*/*.csv | wc -l
returns number of documents processed into tables
ls OUTPUT_DIR/TEITOK/*.xml | wc -l
returns number of recorded .teitok.xml documents.
Example summary table: summary_ne_counts.csv (produced by a real run; not committed β
see the note below).
Example output directory UDP_NE π contains per-document CSV tables with NE tags and UDPipe feature columns, plus CoNLL-U files with NE annotations in per-document manner.
Example output directory TEITOK π contains per-document TEITOK XML files combining UD linguistic annotations and NER spans with bounding boxes aligned from the source ALTO XML.
After completing the pipeline, your working and output directories will be organized as follows:
TEMP/
βββ CHUNKS/
β βββ ...
βββ nametag_response_docname1.conllu.json
βββ ...
AND
<OUTPUT_DIR>
βββ UDP_NE/
β βββ <doc_id>
β β βββ <doc_id>.csv
β β βββ <doc_id>.conllu
β βββ <doc_id>
β β βββ <doc_id>.csv
β β βββ <doc_id>.conllu
β βββ ...
βββ UDP/
β βββ <doc_id>.conllu
β βββ <doc_id>.conllu
β βββ ...
βββ TEITOK/
β βββ <doc_id>.teitok.xml
β βββ <doc_id>.teitok.xml
β βββ ...
βββ NE/
β βββ <doc_id>
β β βββ <doc_id>-<page_num>.tsv
β β βββ ...
β βββ <doc_id>
β β βββ <doc_id>-<page_num>.tsv
β β βββ ...
β βββ ...
βββ altos/
β βββ <doc_id>.alto.xml
β βββ ...
βββ pages/
β βββ <doc_id>-1.png
β βββ <doc_id>-2.png
β βββ ...
βββ processing.log
βββ summary_ne_counts.csv
βββ manifest.tsv
The combined output summary_ne_counts.csv contains aggregated Named Entity
statistics across all processed pages. This repository's data_samples/ only ships the three
synthetic demo documents (CTX00000000{1,2,3}), so no summary_ne_counts.csv is
committed β the file is real output of a real api_5_summary_ne.sh run, not a sample bundled here.
[!NOTE] Now you can delete
UDP/from<OUTPUT_DIR>/if you no longer need the raw CoNLL-U files. The final CoNLL-U files with NER features are in<OUTPUT_DIR>/UDP_NE/.
If you do not plan to rerun any part of the pipeline, you can also delete
the entire TEMP/ directory including manifest.tsv π.
[!NOTE] This is an optional step in NLP enrichment of your data. It can give a fast thematic overview of the whole collection and works best when UDPipe lemmas (output of Step 2) are available. Three extraction backends are provided; choose the one that best fits your environment and quality requirements.
Extract keywords π from your documents by running keywords.py on a directory of CoNLL-U files produced by Step 2.
The keyword extraction script uses a three-tier configuration hierarchy (from highest to lowest priority):
-m yake, -w 3) always override everything else.kw_config.txt (the [DEFAULTS] section) is read automatically if placed next to the script.This means if you configure your settings in kw_config.txt, you can simply run:
python3 keywords.py
| Flag value | Method | Dependencies | Score semantics | Best for |
|---|---|---|---|---|
legacy | Original KER β NOUN/PROPN/ADJ lemma frequency | none (stdlib only) | raw occurrence count | reproducing original ATRIUM results |
yake (default) | YAKE β unsupervised statistical, CPU-only | pip install yake | normalised inverse YAKE score, [0, 1] | fast CPU runs, no model download |
keybert | KeyBERT β embedding-based, GPU-accelerated | pip install keybert sentence-transformers | cosine similarity, [0, 1] | highest semantic quality, GPU recommended |
You can override any kw_config.txt setting via the command line:
python3 keywords.py -i <input_dir> -m <method> -l <lang> -w <integer> \
-n <integer> -d <output_dir> -o <output_file>.csv
All available flags:
| Flag | Long form | Default in kw_config.txt | Description |
|---|---|---|---|
-i | --input_dir | data_samples/UDP | CoNLL-U directory to process |
-m | --method | yake | Backend: legacy, yake, or keybert |
-l | --lang | cs | Language code for YAKE stopwords (cs, en, de, β¦). Ignored by legacy and keybert |
-w | --max_words | 3 | Maximum words per keyword phrase (n-gram upper bound) |
-n | --num_keywords | 20 | Number of keywords to extract per document |
-d | --per_doc_out_dir | data_samples/KW_PER_DOC | Output directory for per-document CSV files |
-o | --output_file | keywords_summary.csv | Master keywords CSV |
--keybert-model | paraphrase-multilingual-MiniLM-L12-v2 | Sentence-Transformer model name (KeyBERT only) | |
--no-mmr | (False) | Disable Maximal Marginal Relevance diversification (KeyBERT only) | |
--diversity | 0.5 | MMR diversity parameter, 0 = max relevance β 1 = max diversity (KeyBERT only) | |
--workers | 0 (Auto / CPU count) | Parallel worker processes. Auto-forced to 1 for KeyBERT + GPU |
Examples:
YAKE β Czech, up to 3-word phrases, 20 keywords per document (default)
python3 keywords.py -i OUTPUT_DIR/UDP -m yake -l cs -w 3 -n 20 \
-o keywords_summary.csv -d KW_PER_DOC
KeyBERT β multilingual model, GPU-accelerated
python3 keywords.py -i OUTPUT_DIR/UDP -m keybert -w 3 -n 20 \
--keybert-model paraphrase-multilingual-MiniLM-L12-v2 \
-o keywords_summary.csv -d KW_PER_DOC
Legacy KER β (English/Czech) original ATRIUM lemma-frequency approach, no extra dependencies
python3 keywords.py -i OUTPUT_DIR/UDP -m legacy -n 20 \
-o keywords_summary.csv -d KW_PER_DOC
[!WARNING] For KeyBERT with a GPU, the script automatically forces
--workers 1to prevent competing CUDA context initialisation across subprocesses. On CPU, any worker count is safe.
keywords_summary.csv).KW_PER_DOC/).KW_PER_DOC/
βββ <docname1>_keywords.csv
βββ <docname2>_keywords.csv
βββ ...
Each per-document file contains two columns β keyword and score β sorted
by score in descending order. The master summary uses the same column structure
as the original pipeline (document_id, kw-1, score-1, kw-2, score-2, β¦).
legacy β raw lemma count; higher = more frequent in the document. Examples in directory: KW_PER_DOC_L π and summary file
kw_summary_l.csv π.
| Score range | Interpretation |
|---|---|
| 1β5 | Common functional nouns, low informativeness |
| 5β20 | Topic-representative vocabulary |
| > 20 | Dominant terms, likely named entities or domain headings |
yake β normalised inverse YAKE score, [0, 1] per document. Examples in directory: KW_PER_DOC_Y π and summary file
kw_summary_y.csv π.
| Score range | Semantic category | Interpretation |
|---|---|---|
| 0.0β0.2 | Noise floor | Common words, low local relevance |
| 0.2β0.6 | Context layer | General vocabulary defining the broad topic |
| 0.6β0.9 | Topic layer | Specific nouns and verbs central to the text |
| 0.9β1.0 | Entity layer | Rare terms, neologisms, named entities |
keybert β cosine similarity to document centroid, [0, 1]. Examples in directory: KW_PER_DOC_KB π and summary file
kw_summary_kb.csv π.
| Score range | Interpretation |
|---|---|
| < 0.3 | Weakly related phrases |
| 0.3β0.6 | Contextually relevant terms |
| > 0.6 | Highly representative keyphrases |
[!NOTE] This section is relevant when your documents originate from an OCR or digitisation pipeline that does not produce ALTO XML β for example, PAGE XML, hOCR, plain-text exports, or proprietary formats. If you already have ALTO XML, the pipeline generates TEITOK XML natively via
api_4_stats.sh(see above).
flexiconv 5(https://github.com/ufal/flexiconv) is a flexible format-conversion tool developed at UFAL that translates a variety of OCR and document layout formats into TEITOK XML β the unified output format used by this project. It acts as a universal adapter: once your documents are in TEITOK XML, they can be ingested directly into the TEITOK corpus platform and will benefit from all the same search, visualisation, and NER capabilities described above.
Your input format flexiconv Unified output
βββββββββββββββββ βββββββββββββββββββββββββ βββββββββββββββββ
PAGE XML ββ
hOCR ββ€βββΊ flexiconv βββββββββββββββΊ .teitok.xml βββΊ TEITOK platform
plain text + CSV ββ€ βββΊ this pipeline
other OCR output ββ (NER, KWs, ...)
Use flexiconv before running this pipeline when:
teitok_alto.py.git clone [https://github.com/ufal/flexiconv.git](https://github.com/ufal/flexiconv.git)
cd flexiconv
pip install -r requirements.txt
python flexiconv.py \
--input-dir /path/to/your/source/documents \
--input-fmt page-xml \ # or: hocr, plain, ...
--output-dir /path/to/teitok_out \
--output-fmt teitok
Refer to the flexiconv documentation for the full list
of supported --input-fmt values and format-specific options.
[!TIP] If your format is not yet supported by flexiconv, please open an issue on the flexiconv GitHub repository. The tool is actively developed within the ATRIUM project and new format adapters are added regularly.
[!NOTE] This is an advanced, optional step. It runs a local Large Language Model to semantically analyse each text line and map it to the controlled TEATER/AMCR archaeological vocabulary. Two inference backends are supported:
transformers(HuggingFace + BnB 4-bit, single GPU, models β€ 31 B) andvllm(multi-GPU, Automatic Prefix Caching, native guided JSON decoding, models β₯ 70 B or any multi-GPU node).
This pipeline goes beyond traditional keyword extraction by using Constrained Decoding.
For the transformers backend this is implemented via Pydantic schemas and lmformatenforcer.
For the vllm backend, guided decoding is handled natively by xgrammar inside vLLM β
no additional library is required. In both cases the model is mathematically prevented from
producing any token that would violate the predefined JSON structure or select a vocabulary
term outside the thematic dictionary, entirely eliminating hallucinated formatting.
The pipeline reads all runtime parameters from llm_config.txt in the repository root.
The minimum required change is MODEL_KEY; every other key has a sensible default.
# Single-GPU (BACKEND=transformers): qwen-3.6-27b-it | gemma-4-31b-it | qwen3-14b |
# qwen-3.5-9b-it | qwen3-8b | qwen2.5-14b-awq |
# qwen2.5-7b | gemma-3-12b-it
# MoE / GGUF (single GPU): gemma-4-26b-moe-gguf | qwen-3.6-35b-moe
# Multi-GPU (BACKEND=vllm): qwen3-235b-a22b-fp8 | deepseek-v3 | llama4-maverick | llama3.1-70b
MODEL_KEY=qwen-3.6-27b-it
# Only needed for gated models: gemma-4-*, llama4-maverick, llama3.1-70b
# HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx
INPUT_DIR=data_samples/DOC_LINE_CATEG
OUTPUT_DIR=data_samples/KW_PER_DOC_LLM
VOCAB_PATH=data_samples/vocab/union_nested.json
PARADATA_DIR=paradata
# Attach the surviving vocabulary term's source record id(s) to each enrichment as
# teater_category_ids (issue #6, M7). Kept behind a switch since it was agreed to be
# reversible: "list them now and drop it if it will create some issues."
EMIT_CATEGORY_IDS=true
INCLUDE_NON_TEXT=true
MIN_CHAR_COUNT=3
MIN_CHAR_NON_TEXT=8
MIN_ALPHA_RATIO_NON_TEXT=0.4
# ββ System prompt βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# The instruction text lives in prompts/system_prompt.txt as [[named blocks]]; these
# flags choose which of them render. The run banner prints the resulting on/off list,
# so a log always says what the model was told. See prompts/RUNBOOK.md.
PROMPT_TEMPLATE=prompts/system_prompt.txt
PROMPT_TASK_EXTRACT=true
PROMPT_TASK_SELECT=true
PROMPT_METATEXT_RULE=true
PROMPT_OCR_NORMALISATION=true
PROMPT_EXACT_TERM=true
PROMPT_EXAMPLES=true
# The one three-way switch: strict | preference | off. Paired with
# taxonomy_config.json's geo_guardrail.active β vocab_build.py refuses a build where
# the two disagree.
PROMPT_GEO_GUARDRAIL=preference
# Term-list layout: facet_sub | facet | flat. Same terms, same truncation; only the
# headers move.
PROMPT_VOCAB_GROUPING=facet_sub
# ββ Inference parameter overrides βββββββββββββββββββββββββββββββββββββββββββββ
# ALL of these are COMMENTED OUT in the shipped file. Backend, GPU count, memory
# utilisation, batch size and context cap are resolved automatically from the model
# registry in llm_utils.py; the startup log prints every effective value next to
# where it came from (β llm_config.txt / model default / global default). Uncomment
# a line only to deviate from the model's recommended configuration.
# BACKEND=vllm # transformers (single GPU, β€ 31 B) | vllm (multi-GPU)
# TENSOR_PARALLEL_SIZE=8 # GPUs to shard across (vLLM only)
# GPU_MEMORY_UTILIZATION=0.88 # Fraction of each GPU's VRAM for the KV cache
# VLLM_BATCH_SIZE=8 # Lines per generate() call
# MAX_MODEL_LEN=16384 # Cap the context window to reduce KV-cache pressure
# CPU_OFFLOAD_GB=0 # Weights to keep in CPU RAM when VRAM is short
# GUIDED_DECODING_BACKEND=xgrammar
# ENABLE_PREFIX_CACHING=false # Not recommended; reduces throughput
The nine PROMPT_* keys are the prompt's whole configuration surface;
prompts/RUNBOOK.md π documents each block, what it costs, and
which pairings are unsafe to change alone.
1. Vocabulary Harvesting (vocab_build.py π)
The vocabulary is built in two stages, and only the first needs the internet:
harvest (network) β FLAT artifacts β nest (pure) β NESTED artifacts
vocab_sources.py *_flat.{json,csv} vocab_manager *_nested.json
vocab_sources.py π harvests two controlled vocabularies:
| Source | How | What comes back |
|---|---|---|
| AMCR heslΓ‘Ε | OAI-PMH, api.aiscr.cz/2.2/oai?set=heslo | CzechβEnglish pairs plus ident_cely, nazev_heslare (which of the ~50 controlled lists the term belongs to), popis, zkratka, razeni, broader terms and SKOS mappings |
| TEATER thesaurus | the 12 pinned import_*.json files in ARUP-CAS/aiscr-teater, or live teater.aiscr.cz/api/export | 4 134 concepts in 12 branches, trilingual labels, scope notes, and the real broader/narrower hierarchy |
vocab_manager.py π then groups the flat terms into the thematic
taxonomy defined by taxonomy_config.json π. Placement
is tried in precedence order β a per-term correction in
taxonomy_overrides.json π, AMCR list membership
(heslar_map), TEATER branch (teater_branch_map, resolved most-specific-first so a
depth-2 sub-branch like muzeum can be moved without moving its whole parent branch),
the legacy keyword match, a cross-source rescue, an opt-in LLM fallback, then Other β
and every placement records the rule that made it in *_placement_audit.csv, so the
grouping can be reviewed rather than taken on trust.
Two labels can collide (AMCR and TEATER both use zΓ‘mek for "lock" and "chΓ’teau").
vocab_sources.to_term_pairs() treats a same-label group as one concept by default β
the winning record's id survives, every other one is listed on it as discarded_ids
(issue #6, M7) β and only pulls a record into its own bracketed entry
("zΓ‘mek (sΓdlo elity)") when taxonomy_overrides.json explicitly flags it as a
genuine homonym (M8). Guessing that from a differing English gloss alone would mistake
ordinary translation variance for a real split far more often than it would catch one.
Every vocabulary decision is a config edit, not a code change. The two JSON files are the whole surface a domain reviewer needs; nothing below requires touching Python:
In taxonomy_config.json β _settings | Decides |
|---|---|
heslar_map, teater_branch_map | which facet a whole AMCR list or TEATER branch lands in, or __exclude__ |
_exclusions | why each exclusion stands, and whether it is settled or still open (open_geo_ethnic / open_other) |
geo_guardrail | whether the prompt's "never select a country/language/region name" clause is in force, and which rules it reaches |
nested_keep | which harvested keys reach the prompt payload |
admin_stop_words | what sorts to the back of a facet, and so what survives prompt truncation |
composite_separators | what splits a composite X/Y label |
tie_break, per-facet priority | facet order β load-bearing, since the prompt truncates a prefix |
In taxonomy_overrides.json, per (source, id) | Decides |
|---|---|
facet | one term's facet, including "__exclude__" to drop a single term from a list worth keeping |
sub | one term's sub-header β otherwise a moved term keeps the header of the list it left |
qualifier_cs | pull a confirmed homonym out of its dedup group as "<cs> (<qualifier>)" |
same_as / same_as_suppress | add or drop a composite/component equivalence link; neither changes what the prompt offers |
The system prompt is a config surface too. Its instruction text lives in
prompts/system_prompt.txt π as [[named blocks]] in
render order; llm_config.txt's PROMPT_* flags choose which of them reach the model,
and the run banner prints the resulting on/off list so a log always says what the model
was told. PROMPT_GEO_GUARDRAIL is the one three-way switch (strict / preference /
off) because the geographic rule has three states, and it is paired with
taxonomy_config.json's geo_guardrail.active:
prompts/output_template.json π documents the resulting
per-document output file.
Reading the prompt needs neither a GPU nor the model stack β prompt_template.py imports
nothing outside the standard library, so these run in a bare checkout:
python3 prompt_template.py --blocks # which rules are on, and what each costs
python3 prompt_template.py --preview # the instruction text, term list elided
python3 prompt_template.py --full > prompt.txt # the whole prompt, all 4 718 terms
python3 prompt_template.py --diff PROMPT_GEO_GUARDRAIL=strict \
PROMPT_GEO_GUARDRAIL=preference
python3 prompt_template.py --write # regenerate the committed sheets
python3 prompt_template.py --check # exit 1 if a sheet is out of date
--full renders the untruncated prompt β instructions under the current flags, then every
term VOCAB_PATH offers, grouped exactly as build_system_prompt() groups them (both call
the same two functions, and a test asserts they agree byte for byte). It is what a model
with room for the whole vocabulary sees; at a tighter window the run drops a tail of terms,
which context_budget.csv π sizes per window.
The four sheets under prompts/ π β prompt_blocks.txt, prompt_preview.txt,
prompt_full.txt, prompt_guardrail_diff.txt β are the output of the first four commands,
committed so a reviewer can read the prompt in a diff without running Python. They are
generated, never hand-edited: --write rewrites them, --check fails when the
vocabulary, a flag or the template has moved without them, and
.github/workflows/vocab-drift.yml runs that check on
every PR touching either half.
PROMPT_VOCAB_GROUPING controls the layout of the term list, not its contents:
facet_sub (shipped β --- Facet / Subgroup ---, both curated levels), facet (facet
headers only) or flat (no headers). It exists to answer @motyc's question in
issue #6 β
whether the facet grouping affects results at all, now that the whole vocabulary fits a
128k window. All three offer the same terms and truncate identically; facet and flat
also preserve term order, while facet_sub makes each facet's sub-groups contiguous. So
facet vs flat isolates the ~125 header lines and facet_sub vs facet measures the
source's second level. The headers are not free: at an 8 192-token window they cost 26
terms, at 32 768 they cost 126, and at 128k nothing, since everything fits either way.
validate_settings() refuses an edit that would not do what it says β an undeclared
facet, a relabel for a list no map places, a reason for something nobody excludes, an
unknown override key, a stale (source, id), a pair both linked and suppressed, two
overrides that would build the same bracketed key β and reports every problem at once
rather than one per rebuild. vocab_build.py additionally renders the prompt the config
selects and refuses to build a vocabulary that contradicts its geographic guardrail.
Two runbooks carry the operational detail, and they are the pages to read before
touching either half:
data_samples/vocab/RUNBOOK.md π β every vocabulary
script, the eight review sheets, and the full "where a decision gets recorded" table;
prompts/RUNBOOK.md π β the eleven prompt blocks, the nine flags,
the guardrail's two halves, and the output contract.
# stage 1 + 2, needs network access to aiscr.cz
python3 vocab_build.py --source both --stats
# stage 2 only: re-nest from the committed flat files after editing the taxonomy.
# Pure, offline, sub-second β this is the loop for tuning the taxonomy.
python3 vocab_build.py --from-flat --stats
python3 vocab_build.py --from-flat --check # exit 1 if the artifacts would change
If this machine cannot reach aiscr.cz, run the Vocabulary Refresh workflow
(.github/workflows/vocab-refresh.yml) β a hosted
runner harvests and uploads the artifacts.
[!NOTE] The nested files are deliberately not written with
sort_keys=True. Theme order is priority-descending and load-bearing:build_system_prompt()iterates the file in insertion order and truncates a prefix of the resulting term list, so alphabetising the keys would silently change which themes survive a tight context budget. Determinism comes from the explicit priority ordering plus a(boilerplate, razeni, label)sort within each theme. Provenance lives in a sidecar*.meta.json, not inline β every consumer reads the nested file as{theme: terms}, so an inline_metakey would be rendered to the model as a phantom theme.
python3 vocab_manager.py still works and still performs the legacy AMCR-only sync.
Two read-only tools turn the built vocabulary into sheets a domain reviewer can rule on. Neither writes to the vocabulary, and neither guesses a semantic verdict β they rank and surface candidates; a human decides, and the decision goes back as a config edit.
vocab_review.py π β eight sheets, offline and pure, built from
the committed *_flat.json plus the taxonomy config:
python3 vocab_review.py --all # all eight, into data_samples/vocab/
python3 vocab_review.py --collisions # collision_review.csv same-label groups (M8/M13)
python3 vocab_review.py --composites # composite_pairs.csv "X/Y" vs standalone X, Y
python3 vocab_review.py --exclusions # exclusion_impact.csv what each exclusion costs
python3 vocab_review.py --subbranches # teater_subbranch_impact.csv the same, one level finer
python3 vocab_review.py --reinstate # reinstatement_preview.csv usable count + token delta
python3 vocab_review.py --specificity # specificity_pairs.csv term offered under its own parent
python3 vocab_review.py --budget # context_budget.csv what survives each window
python3 vocab_review.py --census # facet_census.csv what is in each facet, and its cost
corpus_review.py π β three evidence sheets matching the
vocabulary against real report text by UDPipe lemma, which is the evidence standard for
keeping or dropping a branch:
python3 corpus_review.py --all # corpus_term_evidence.csv, corpus_branch_evidence.csv,
# gold_workbook.csv (+ corpus_review.meta.json)
β οΈ corpus_review.py needs the document corpus, and the repository tracks only three
synthetic demo documents β the real reports arrive as the issue
#19 attachment and are untracked.
Every run prints its corpus size before writing, and the tool refuses to overwrite
sheets built from a larger corpus rather than silently replacing real evidence with
placeholder numbers. On a clean checkout these three are a smoke test, not evidence.
Both sets are covered by a drift test: a taxonomy edit that is not followed by a
regeneration fails tests/test_vocab_review.py, for the same reason the artifacts have
their own gate. See data_samples/vocab/RUNBOOK.md π
for what each sheet answers and how to read it.
2. LLM Inference Pipeline (llm_run.py π)
Reads the CSV files, filters lines by quality, injects the nested vocabulary and a
sliding context window into the system prompt, and executes constrained generation.
Output files are named <stem>_enriched.json and written to
KW_PER_DOC_LLM_<model_suffix>/.
[!TIP] All model-loading logic, constrained-decoding helpers, and prompt templates live in llm_utils.py π and are shared between both backends.
# Transformers backend (default)
python3 llm_run.py
# Custom config file
python3 llm_run.py my_config.txt
For multi-GPU runs (vLLM backend):
# 1. Edit llm_config.txt:
# BACKEND=vllm
# MODEL_KEY=qwen3-235b-a22b-fp8
# TENSOR_PARALLEL_SIZE=2
# ENABLE_PREFIX_CACHING=true
python3 llm_run.py
The built-in registry in llm_utils.py covers the full range of supported models.
All VRAM figures assume BnB 4-bit for the transformers backend and FP8/BF16 for vLLM.
BACKEND=transformers (or BACKEND=vllm)| Registry key | Model | Size | Context | Est. VRAM | Notes |
|---|---|---|---|---|---|
qwen-3.6-27b-it | Qwen/Qwen3.6-27B 6 | 27 B dense | 262 k | ~18 GB | Default. Best accuracy/VRAM ratio on a single GPU. |
gemma-4-31b-it | google/gemma-4-31B-it 7 | 31 B dense | 256 k | ~21 GB | Highest single-GPU accuracy. Gated β HF_TOKEN required. |
qwen3-14b | OpenPipe/Qwen3-14B-Instruct 8 | 14 B dense | 128 k | ~9 GB | Good baseline; thinking mode suppressed automatically. |
qwen-3.5-9b-it | Qwen/Qwen3.5-9B 9 | 9 B dense | 262 k | ~6 GB | Entry-level (8 GB VRAM). |
qwen3-8b | Qwen/Qwen3-8B 10 | 8 B dense | 128 k | ~16 GB | BF16 (no 4-bit); straightforward baseline. |
qwen2.5-14b-awq | Qwen/Qwen2.5-14B-Instruct-AWQ 11 | 14 B AWQ | 128 k | ~9 GB | Pre-quantized; fast on NVIDIA GPUs. |
qwen2.5-7b | Qwen/Qwen2.5-7B-Instruct 12 | 7 B dense | 32 k | ~14 GB | BF16; short context window. |
gemma-3-12b-it | google/gemma-3-12b-it 13 | 12 B dense | 128 k | ~8 GB | Good bilingual extraction. Gated. |
| Registry key | Model | Active params | Context | Notes |
|---|---|---|---|---|
gemma-4-26b-moe-gguf | bartowski/google_gemma-4-26B-A4B-it-GGUF | 4 B | 8 k | BnB 4-bit unsupported (fused experts). Q4_K_M quantization via llama.cpp. |
BACKEND=vllm (single GPU or multi-GPU)| Registry key | Model | Active params | Context | Notes |
|---|---|---|---|---|
qwen-3.6-35b-moe | Qwen/Qwen3.6-35B-A3B 14 | 3 B | 262 k | 35 B total / 3 B active. Single GPU usually fits. |
gemma-4-26b-moe | google/gemma-4-26B-A4B-it 15 | 4 B | 256 k | 26 B total / 4 B active. Gated. |
gemma-4-26b-moe-awq | google/gemma-4-26B-A4B-it 15 | 4 B | 256 k | AWQ-quantised variant of gemma-4-26b-moe. Gated. |
BACKEND=vllm, TENSOR_PARALLEL_SIZE β₯ 2| Registry key | Model | Total / Active | Context | Rec. TP | Notes |
|---|---|---|---|---|---|
qwen3-235b-a22b-fp8 | Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 16 | 235 B / 22 B | 128 k | 2 | Recommended for 144 GB / 200 GB nodes. Native FP8 (~117 GB loaded). |
qwen3-235b-a22b | Qwen/Qwen3-235B-A22B-Instruct-2507 16 | 235 B / 22 B | 128 k | 2 | BF16 variant β heavier than FP8. |
deepseek-v3 | deepseek-ai/DeepSeek-V3 17 | 671 B MoE / β | 128 k | 4 | FP8 official checkpoint available. 4Γ80 GB minimum. |
llama4-maverick | meta-llama/Llama-4-Maverick-17B-128E-Instruct 18 | 128 experts / 17 B active | 1 M | 2 | Multimodal. 1 M token context. Gated β HF_TOKEN required. |
llama3.1-70b | meta-llama/Meta-Llama-3.1-70B-Instruct 19 | 70 B dense / β | 128 k | 2 | Also works with transformers + 4-bit on 2Γ40 GB. Gated. |
[!TIP] Automatic Prefix Caching (APC) β enabled by default for the vLLM backend (
ENABLE_PREFIX_CACHING=true). The system prompt (which embeds the full TEATER vocabulary) is computed once per run; its KV-cache is reused across every line in every document. This is the primary throughput multiplier: on a 500-line document the vocabulary forward pass happens once instead of 500 times. APC also removes the need to truncate the vocabulary to fit the token budget β the full thematic dictionary is injected when APC is active.
DOC_LINE_CATEG/*.csv (contains file_id, page_num, line_num,
categ, quality_score, and raw text).KW_PER_DOC_LLM_<model_suffix>/*_enriched.json β one file per document,
containing an array of JSON objects that merge CSV metadata with the LLM's semantic
extraction.KW_PER_DOC_LLM_<model_suffix>/*_enriched.abort.json β written
alongside the main output only when a document is abandoned after 10 consecutive
inference errors. Its presence is the canonical signal that the corresponding JSON
file contains partial results.Example output record:
{
"file_id": "CTX195603828",
"page": 1,
"line": 14,
"categ": "Text",
"quality_score": 0.98,
"original_text": "VΓ½zkum odhalil zΓ‘klady gotickΓ©ho kostela ze 14. stoletΓ.",
"enrichment": {
"extracted_keywords_cs": ["zΓ‘klady", "gotickΓ½ kostel"],
"extracted_keywords_en": ["foundations", "gothic church"],
"teater_category": "kostel",
"teater_category_ids": [
{ "source": "amcr", "id": "HES-000021" },
{ "source": "amcr", "id": "HES-000465" },
{ "source": "teater", "id": "1333" }
],
"confidence_score": 0.95
}
}
teater_category_ids (present when EMIT_CATEGORY_IDS=true, the default) lists every
source record the selected vocabulary term absorbed during dedup β issue #6, M7. It is
attached after inference, from the vocabulary's own discarded_ids; the prompt and
schema are unaffected. When the selected term was a bracketed disambiguation (B3, e.g.
"zΓ‘mek (sΓdlo elity)"), teater_category is stripped back to the bare label
("zΓ‘mek") before it is written, and teater_category_ids carries the id that
disambiguates which sense was meant β a term that legitimately contains parentheses in
its own source label (e.g. "GPS (navigaΔnΓ systΓ©m)") is never touched.
Abort sidecar format (*_enriched.abort.json):
{
"aborted": true,
"abort_reason": "10 consecutive inference errors",
"processed_before_abort": 42,
"errors_before_abort": 10,
"timestamp_utc": "2026-05-20T09:14:33"
}
[!NOTE] None of the per-model output sets below is committed to this repository β each is the real output of a real run against the full report corpus, not a sample bundled with the code (same reason
data_samples/DOC_LINE_CATEG/itself holds only three synthetic demo documents). The directory names are theOUTPUT_DIRa local run with thatMODEL_KEYproduces; the footnote on each is the model card.
Output examples per model (directory names, not links β see the note above):
KW_PER_DOC_LLM_qwen3_14b by Qwen 3-14B 8KW_PER_DOC_LLM_qwen25_14b_awq by Qwen 2.5-14B AWQ 11KW_PER_DOC_LLM_gemma_3_12b_it by Gemma 3-12B-IT 13KW_PER_DOC_LLM_qwen_36_27b_it by Qwen 3.6-27B-IT 6KW_PER_DOC_LLM_gemma_4_31b_it by Gemma 4-31B-IT 7KW_PER_DOC_LLM_qwen_35_9b_it by Qwen 3.5-9B-IT 9KW_PER_DOC_LLM_llama31_70b by LLaMA 3.1-70B 19KW_PER_DOC_LLM_qwen3_8b by Qwen 3-8B 10Pending (sample runs in progress):
KW_PER_DOC_LLM_qwen_36_35b_moe by Qwen 3.6-35B-MoE 14KW_PER_DOC_LLM_gemma_4_26b_a4b_it by Gemma 4-26B-A4B-IT 15Archived (unsuccessful β evaluation notes in issue #6; would have been under
archived_KW_PER_DOC_LLM/):
KW_PER_DOC_LLM_mistral_nemo_12b by Mistral Nemo 12B 20KW_PER_DOC_LLM_aya_expanse_8b by Aya Expanse 8B 21KW_PER_DOC_LLM_bielik_11b_v30 by Bielik 11B v3.0 22KW_PER_DOC_LLM_llama31_8b by LLaMA 3.1-8B 23KW_PER_DOC_LLM_ministral_3_14b by Ministral 3-14B 24KW_PER_DOC_LLM_qwen3_8b (early run) by Qwen 3-8B 10KW_PER_DOC_LLM_qwen25_7b by Qwen 2.5-7B 12Just like the main shell-script pipelines, LLM enrichment natively hooks into
atrium_paradata.py and automatically logs:
*.meta.json beside the artifact:
tool version, term count, the sha256 of both taxonomy files, and each source's record
count and pinned ref (TEATER's harvest commit). A run is reproducible only if the
vocabulary it saw is identifiable, and every placement decision is a function of those
two sha256s.log_component() names them.
Logged per source actually present in the build β an AMCR-only artifact does not claim
it used TEATER data.json success events).skipped_filter), inference faults
(skipped_error), and already-completed files (already_exists).*.abort.json file is also written next to the
(partial) output JSON for easy programmatic detection.
The resulting logs are dropped into the specified PARADATA_DIR alongside the other pipeline execution records.The pipeline now includes a fully-featured FastAPI REST service that exposes the core NLP enrichment and rescaling functionalities over HTTP.
/enrich endpoint and receive a combined JSON envelope (or ZIP workspace) with TEITOK XML, keywords, paradata, and NER summaries./rescale endpoint to align XML spatial coordinates to specific target image resolutions directly over the network./jobs queue.For complete setup instructions, payload examples, and endpoint documentation, refer to the Service README.
Every pipeline script records structured provenance metadata through atrium_paradata.py π. Two complementary log surfaces are produced after a run:
<OUTPUT_DIR>/paradata/ β structured run logs πEach of the four pipeline scripts produces one JSON file here, named with the pattern:
YYMMDD-HHmmss_nlp-enrich.json
where the timestamp prefix is the UTC wall-clock time at which the script started. Because every script is an independent invocation, a complete four-step run will create four separate files, making it straightforward to audit individual stages in isolation.
The paradata logs (samples in directory paradata π) capture key details about each pipeline stage, including the program name, run ID, execution duration, configuration parameters, input and output statistics, and performance metrics. They also document skipped files with reasons and provide a breakdown of output types and processing rates for benchmarking. This structured metadata ensures traceability and facilitates auditing of the pipeline's execution.
The declared output types per stage are:
| Script | Types recorded |
|---|---|
api_1_manifest.sh | tsv (one entry per input CSV/XLSX processed into the manifest) |
api_2_udp.sh | conllu (one per document) |
api_3_nt.sh | tsv (one per page β count reflects individual page TSV files) |
api_4_stats.sh | csv always; conllu when SAVE_CONLLU_NE=true; xml when SAVE_TEITOK=true |
keywords.py | csv_per_doc (one per document keyword CSV) and csv_summary_row (one summary row per document) |
[!NOTE] When resuming an interrupted run (steps 2β4 skip already-finished documents via
[ -f "$out" ] && continue), the resumed documents are not re-counted in the paradata JSON. Theinput_files_totalfield still reflects the full manifest, soskipped_files + successfully_processedwill be less thaninput_files_totalfor partial runs. This is expected behaviour; the difference represents the documents carried over from a previous invocation.
[!NOTE] Paradata state files. While a pipeline script is running, atrium_paradata.py stores intermediate state in a plain-text JSON file inside
<OUTPUT_DIR>/paradata/(named.state_<runid>_<program>.json). This file is automatically removed when the script completes. Because it is plain JSON it can be inspected with any text editor if a run is interrupted unexpectedly.
<OUTPUT_DIR>/processing.log β human-readable runtime log πapi_common.sh π exposes a log() helper that timestamps and
tee-appends warnings and errors to this flat file. The four main pipeline scripts
(api_1_manifest.sh β¦ api_4_stats.sh) write to processing.log indirectly through the
Python helpers, which print timestamped messages to stderr; any script that sources
api_common.sh can also write here via the log() function directly.
[2026-01-15 09:42:11] [WARN] UDPipe failed (HTTP 503). Retrying in 2sβ¦
[2026-01-15 09:42:14] [ERR] UDPipe failed permanently after 5 attempts.
This file accumulates across reruns;
it is the first place to check when a document appears in
skipped_files_detail but the reason is terse.
TEMP/ β intermediate working files πTEMP/ (set by WORK_DIR in config_api.txt π) holds
transient artefacts that are only needed during processing and can be deleted
once the full pipeline has completed successfully:
TEMP/
βββ CHUNKS/
β βββ <doc_id>/
β β βββ chunk_0.txt # OCR-line-preserving text fragment sent to UDPipe
β β βββ chunk_1.txt
β β βββ β¦
β βββ β¦
βββ nametag_response_<doc_id>.conllu.json # raw JSON reply from the NameTag API
CHUNKS/ is produced by api_util/chunk.py π which splits
documents that exceed WORD_CHUNK_LIMIT (default 900 words) into
sentence-boundary-aware fragments before each UDPipe API call. Each chunk file
preserves the original OCR line structure (one line per row) so that UDPipe
receives correct sentence-boundary signals between text lines. The per-chunk
plain-text files and the raw NameTag JSON responses carry no provenance value
after the CoNLL-U files have been merged and validated; they are not tracked by
the paradata logger.
[!TIP] If disk space is a concern you can safely delete
TEMP/once<OUTPUT_DIR>/UDP/and<OUTPUT_DIR>/NE/have been fully populated and step 4 has completed without errors. The paradata JSONs in<OUTPUT_DIR>/paradata/and theprocessing.logare the only runtime records worth keeping long-term.
run_pipeline.py)While each stage can be launched manually (see Workflow Stages),
run_pipeline.py π chains them end-to-end and merges every per-stage paradata JSON
produced during the run into a single pipeline-run-merged record.
# Full core run: api_1 β api_2 β api_3 β api_4
python3 run_pipeline.py
# Core run plus keyword extraction (CPU-only YAKE backend, default)
python3 run_pipeline.py --kw
# Keyword extraction with the GPU KeyBERT backend
python3 run_pipeline.py --kw --kw-method keybert
# Add the optional LLM semantic-enrichment stage (needs requirements_llm.txt)
python3 run_pipeline.py --kw --llm
# Run only a subset of the core stages (canonical order is always enforced)
python3 run_pipeline.py --stages udp nt
# Resume after an interruption: start from a chosen stage, skip every earlier one
python3 run_pipeline.py --start-from nt
# Skip individual stages (re-run only NER + stats, leave manifest/UDPipe as-is)
python3 run_pipeline.py --skip-manifest --skip-udp
# Clear stale .state_* checkpoint sidecars from PARADATA_DIR before running
python3 run_pipeline.py --clean-state
# Force execution: bypass missing dependency checks and ignore individual stage failures
python3 run_pipeline.py --kw --kw-method keybert --force
# Validate configuration and resolve the plan without running anything
python3 run_pipeline.py --dry-run
# Print the resolved config + stage plan as JSON (for wrappers / healthchecks)
python3 run_pipeline.py --print-config json
The runner reads the same config_api.txt π that the shell stages source, so Python and Bash always agree on OUTPUT_DIR, PARADATA_DIR, and the input/output paths.
$VAR / ${VAR} expansion).YYMMDD-HHmmss_nlp-enrich.json)
never collide.<PARADATA_DIR>/<runid>_nlp-enrich_pipeline-run.json via
atrium_paradata.merge_run_paradata. The merged record accurately tracks document-level statistics across the sequential pipeline (recording true throughput without inflating input counts). The effective license of the merged record is re-derived from the union of every component used across the stages, so the most-restrictive rule holds end-to-end (a core run is CC BY-NC-SA 4.0; adding the YAKE backend escalates the share-alike/AGPL constraint, etc.).Long batches on constrained hardware are expensive to restart from scratch, so the runner lets you re-enter the pipeline at any stage instead of redoing completed work. Recovery operates at two complementary levels.
Document-level (automatic). Every stage already skips inputs whose output exists
(steps 2β4 via [ -f "$out" ] && continue; the LLM stage logs already_exists and
moves on), so simply re-running the same command picks up where the previous run
stopped. When the LLM stage abandons a document after 10 consecutive inference
errors it writes a *_enriched.abort.json sidecar next to the partial output (see
LLM Inputs and Outputs); that marker is the canonical signal
that a document holds partial results and should be re-run.
Pipeline-level starting points. To skip whole stages β not just completed documents β the runner accepts explicit entry points over the full stage order:
| Flag | Effect |
|---|---|
--start-from <stage> | Run from <stage> onward; every earlier stage is skipped. |
--skip-<stage> | Skip one named stage, run the rest. |
--clean-state | Sweep stale .state_*.json sidecars from PARADATA_DIR before running. |
<stage> is one of manifest, udp, nt, stats, keywords, llm (the canonical
order; keywords/llm require their --kw/--llm flags to be part of the run).
Each skip flag also has an equivalent SKIP_<STAGE>=true knob that can live in
config_api.txt π (e.g. SKIP_MANIFEST=true), so a habitual resume
profile can be persisted without retyping flags.
# UDPipe + NameTag already finished β resume at statistics, then keywords
python3 run_pipeline.py --kw --start-from stats
# Re-run only NER and statistics; keep the existing manifest and CoNLL-U
python3 run_pipeline.py --skip-manifest --skip-udp
Skipped stages are recorded under skipped_stages in the merged
<runid>_nlp-enrich_pipeline-run.json record, so a resumed run remains fully
auditable. An all-skipped run is treated as a successful resume, not an empty
failure (see Exit codes below).
When the runner (or its Docker entrypoint) is started with the
ATRIUM_RUNNER_IMAGE, ATRIUM_RUNNER_REPO, and ATRIUM_RUNNER_REF environment
variables set, those values are forwarded to every stage subprocess and end up
in each stage's paradata record (and therefore the merged record). This ties a
run back to the exact image/commit that produced it.
ATRIUM_RUNNER_IMAGE="ghcr.io/ufal/atrium-nlp-enrich:v0.11.0" \
ATRIUM_RUNNER_REF="$(git rev-parse --short HEAD)" \
python3 run_pipeline.py --kw
| Code | Meaning |
|---|---|
0 | All requested stages completed; nothing flagged. |
1 | A stage processed nothing despite having input and no resume, and FAIL_ON_EMPTY=true (the default). |
2 | A required stage script was not found. |
3 | A dependency preflight failed (e.g. --kw-method keybert without keybert/sentence-transformers, or --llm without the requirements_llm.txt π stack). |
β 0 | A stage script itself exited non-zero (its code is propagated). |
[!TIP] Using
--force(-f) overrides exit codes1,3, andβ 0. It bypasses preflight dependency crashes and forcesFAIL_ON_EMPTY=False, allowing the pipeline to continue attempting subsequent stages even if one stage crashes or processes zero files.
The empty-run guard is governed by FAIL_ON_EMPTY in config_api.txt π. A
resumed run β where every document was already complete and thus skipped, or
where a stage was skipped outright via --start-from / --skip-<stage> (see
Resume / checkpoint recovery) β is treated as
success, not an empty failure. Set FAIL_ON_EMPTY=false to permit genuinely empty
stages.
[!NOTE] The runner never re-implements stage logic: it shells out to the exact same api_1_manifest.sh β¦ api_4_stats.sh, config_api.txt, and llm_run.py π you can run by hand. Anything documented for those stages (resume behaviour, output flags, model registry, β¦) applies unchanged under the runner.
For support write to: lutsai.k@gmail.com responsible for this GitHub repository 25 π
Β©οΈ 2026 UFAL & ATRIUM
https://lindat.mff.cuni.cz/services/udpipe/api-reference.php β© β©2
https://lindat.mff.cuni.cz/services/nametag/api-reference.php β© β©2
https://ufal.mff.cuni.cz/~strakova/cnec2.0/ne-type-hierarchy.pdf β©
https://huggingface.co/Qwen/Qwen2.5-14B-Instruct-AWQ β© β©2
https://huggingface.co/google/gemma-4-26B-A4B-it β© β©2 β©3
https://huggingface.co/Qwen/Qwen3-235B-A22B-Instruct-2507 β© β©2
https://huggingface.co/meta-llama/Llama-4-Maverick-17B-128E-Instruct β©
https://huggingface.co/meta-llama/Meta-Llama-3.1-70B-Instruct β© β©2
https://huggingface.co/mistralai/Mistral-Nemo-Instruct-2407 β©
https://huggingface.co/speakleash/Bielik-11B-v3.0-Instruct β©
https://huggingface.co/meta-llama/Meta-Llama-3.1-8B-Instruct β©
https://huggingface.co/Aratako/Ministral-3-14B-Instruct-2512-BF16-TextOnly β©
364 commits
17 commits
Python
97.5%
Shell
1.5%
This project provides a workflow for processing text stored in CSV (XLSX) with NLP services. It takes ordered text and extracts high-level linguistic features like Named Entities (NER) with tags and CONLL-U files with lemmas & part-of-sentence tags, and keywords (KER) per page/document.
[!CAUTION] This repository is a follow-up to main ALTO XML postprocessing GitHub repository, a part of ATRIUM project dedicated to ALTO-2-TXT workflow and collection of statistics and from text content of the documents (text and bounding boxes ordered by LayoutReader) recorder in CSV (XLSX) tables as a
textcolumn 1.
TEITOK XML (.teitok.xml) is the primary enriched output format of this pipeline. It is a
TEI-compliant XML format used by the TEITOK
corpus platform, extended to carry spatially-grounded linguistic and NER annotations produced by
UDPipe and NameTag.
Each document in the collection is serialised as a single .teitok.xml file that integrates four
layers of information in a consistent, machine-readable structure:
| Layer | Content |
|---|---|
| Layout | Page, text-block, and line boundaries with pixel-accurate bounding boxes from the source ALTO XML, scaled to match the stored PNG images |
| Morphology & Syntax | Per-token lemma, UPOS/XPOS tags, morphological features, and dependency relations produced by UDPipe 2 |
| Named Entities | BIO-tagged entity spans with both a CoNLL-style category (PER, ORG, LOC, MISC) and a fine-grained CNEC 2.0 code (e.g. pf = first name, gu = city) produced by NameTag 3 |
| Facsimile links | <surface> elements in <facsimile> that tie each page to its companion image, enabling TEITOK's side-by-side text/image view |
Storing all enrichment layers in a single interoperable format offers several practical advantages over keeping CoNLL-U, TSV, and image files in separate silos:
<name type="PER" cnec="pf">) are first-class XML
elements: queryable, stylable, and exportable independently of the surrounding tokens.<tok>,
<lb>, and <div> are used by TEITOK's facsimile viewer to overlay text highlights directly
onto the scanned page image, making OCR quality immediately visible.<div type="MarginTextZone-P">), lines (<lb>),
and graphical elements (<figure>) preserve the physical layout of the original document.<TEI xmlns="http://www.tei-c.org/ns/1.0" xml:lang="cs">
<teiHeader> ... </teiHeader>
<facsimile>
<surface id="doc1.surface1" lrx="1240" lry="1754">
<graphic url="doc1-1.png"/>
</surface>
</facsimile>
<text><body>
<pb n="1" id="doc1.pb1" facs="doc1-1.png"/>
<div type="MarginTextZone-P" id="doc1.TB_1" bbox="142 210 1098 880">
<s id="doc1.s1" text="VΓ½roΔnΓ zprΓ‘va 2012 .">
<lb id="doc1.TL_1" bbox="142 210 680 255"/>
<tok id="doc1.s1.w1" type="w" lemma="vΓ½roΔnΓ" upos="ADJ"
feats="Case=Nom|..." deprel="amod"
bbox="142 210 310 255">VΓ½roΔnΓ</tok>
<name type="ORG" cnec="if">
<tok id="doc1.s1.w3" type="w" lemma="ministerstvo" upos="NOUN"
bbox="320 210 580 255">Ministerstvo</tok>
<tok id="doc1.s1.w4" type="w" lemma="finance" upos="NOUN"
bbox="585 210 680 255">financΓ</tok>
</n>
</s>
</div>
</body></text>
</TEI>
[!NOTE] TEITOK XML is generated by Step 4 of this pipeline (
api_4_stats.sh) whenSAVE_TEITOK=true. The source ALTO XML files must be present inINPUT_ALTO_DIRfor spatial coordinates to be included. IfINPUT_ALTO_DIRis not set, TEITOK XML is still produced but without bounding box attributes. If your documents are not in ALTO format, see EXTRA: Converting Other Input Formats with flexiconv.
Before you begin, set up your environment.
pip install -r requirements.txt
For keyword extraction, install the backend(s) you intend to use:
# YAKE β unsupervised statistical extraction, CPU-only
pip install yake
# KeyBERT β embedding-based extraction, GPU-accelerated when available
pip install keybert sentence-transformers
pip install torch # optional β enables CUDA GPU acceleration
The original legacy KER backend requires no additional packages. For the LLM Semantic Enrichment pipeline, install the inference backend you intend to use:
# Transformers backend β single GPU, models β€ 31 B (BnB 4-bit / AWQ / GGUF)
pip install -r requirements_llm.txt
# vLLM backend β multi-GPU, large models (β₯ 70 B), Automatic Prefix Caching
# Replaces lmformatenforcer; uses xgrammar for native guided JSON decoding
pip install vllm
(Optional) To run the REST API service, install additional requirements:
pip install -r service/requirements.txt
The process is divided into sequential steps, each responsible for a specific part of the NLP enrichment pipeline.
[!IMPORTANT] If you already have a directory of CSV (XLSX) tables with
textcolumn containing extracted text files from ALTO XMLs, you can skip Step 1 and proceed directly to Step 2.
The ../CSVS_with_TEXT/ directory mentioned later is the result of ALTO XML postprocessing pipeline described
in the separate repository 1. It contains document-specific CSV (XLSX) files with the text column containing
extracted textual content from the ALTO XML files. Each CSV (XLSX) file corresponds to a document and contains rows
for each page with a line number column for the proper ordering (page_num and line_num).
CSVS_with_TEXT/
βββ document1.csv
βββ document2.csv
βββ ...
with the structure of each CSV (XLSX) file like:
file,page_num,line_num,text,split_ws,split_we,lang,lang_score,perplex,categ
CTX201504033,1,8,2012,,,N/A,0,0,Non-text
CTX201504033,2,2,1,,,N/A,0,0,Non-text
CTX201504033,3,2,2,,,N/A,0,0,Non-text
...
Where split_ws and split_we are the start and end character offsets of the words split in the original ALTO XML.
The lang and lang_score columns indicate the detected language and its confidence score,
while perplex and categ provide additional metadata about the text classification.
If the script detects an .xlsx file, it will iterate over all sheet names, verify if a text column exists
in each sheet, and extract the content safely for Excel tables with multiple sheets.
This stage performs advanced NLP analysis using external APIs (Lindat/CLARIAH-CZ) to generate Universal Dependencies (CoNLL-U) and Named Entity Recognition (NER) data.
Unlike previous steps, this process is split into modular shell scripts to handle large-scale processing, text chunking, and API rate limiting.
Before running the pipeline, review the api_config.txt π file. This file controls directory paths, API endpoints, and model selection.
# config_api.txt
OUTPUT_DIR="../../ARUB" # Destination for results
INPUT_TABLES_DIR="$OUTPUT_DIR/DOC_LINE_LR_CLS" # Input tables from Step 1
WORK_DIR="./TEMP" # Working directory for intermediate files
LOG_FILE="$OUTPUT_DIR/processing.log"
CONLLU_INPUT_DIR="$OUTPUT_DIR/UDP"
TEMP_TXT_DIR="./TEMP/TXT_EXTRACT"
CHUNK_DIR="./TEMP/CHUNKS"
TSV_INPUT_DIR="$OUTPUT_DIR/NE"
SUMMARY_OUTPUT_DIR="$OUTPUT_DIR/UDP_NE"
TEITOK_OUTPUT_DIR="$OUTPUT_DIR/TEITOK"
INPUT_ALTO_DIR="$OUTPUT_DIR/altos" # Source ALTO XML files - for TEITOK conversion
# ββ Image Options βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# OPTIONAL: Only required if your companion PNG/JPEG display images have been resized
# to a different target resolution relative to ABBYY's baseline dimensions.
# If left empty, the pipeline calibrates layout shifts natively using ALTO PrintSpace.
INPUT_PAGES_DIR=""
UDPIPE_URL="https://lindat.mff.cuni.cz/services/udpipe/api/process"
NAMETAG_URL="https://lindat.mff.cuni.cz/services/nametag/api/recognize"
MODEL_UDPIPE="czech-pdt-ud-2.15-241121"
MODEL_NAMETAG="nametag3-czech-cnec2.0-240830"
TIMEOUT=60 # API call timeout in seconds
MAX_RETRIES=5 # Number of retries for failed API calls
BACKOFF_FACTOR=1.5
WORD_CHUNK_LIMIT=900 # Word limit per API call
SAVE_CSV=true # write token-level summary CSV
SAVE_CONLLU_NE=true # keep merged CoNLL-U with NER in MISC
SAVE_TEITOK=true # write TEITOK-style TEI XML (flexiconv-compatible)
Run the following scripts in sequence. Each script sources config_api.txt π
directly for configuration. Retry logic and per-attempt error handling are implemented inside
the Python helper scripts (call_udpipe.py,
call_nametag.py) using exponential back-off controlled by the
MAX_RETRIES and BACKOFF_FACTOR variables. api_util/api_common.sh π
is a standalone utility module that exposes a log() helper and an api_call_with_retry()
shell function for any custom scripts that choose to source it; the four main pipeline scripts
(api_1_manifest.sh β¦ api_4_stats.sh) do not source it. Additionally, api_util/ π
contains helper Python scripts for chunking and analysis
Maps input text files to document IDs and page numbers to ensure correct processing order.
./api_1_manifest.sh
../CSVS_with_TEXT/ (raw text files in subdirectories from Step 1).OUTPUT_DIR/manifest.tsv.Example output file manifest.tsv π with file, page number, and path columns. It lists all text files to be processed in the next steps. Run the following command to see how many documents will be processed:
tail -n +2 OUTPUT_DIR/manifest.tsv | wc -l
which returns the total number of document rows in the manifest, excluding the header line.
Sends text to the UDPipe API 2. Large documents are automatically split into chunks (default 900 words) using chunk.py π to respect API limits, then merged back into valid CoNLL-U files.
./api_2_udp.sh
OUTPUT_DIR/manifest.tsv (mapping of text files to document IDs and page numbers).../CSVS_with_TEXT/ (raw text files in subdirectories from Step 1).OUTPUT_DIR/UDP/*.conllu (Intermediate per-document CoNLL-U files).Run the following command to see how many documents have been processed into CoNLL-U files:
ls -l <OUTPUT_DIR>/UDP/ | wc -l
which returns the total number of CoNLL-U files created (each file corresponds to a document).
Example output directory UDP π contains per-document CoNLL-U files.
[!NOTE] Chunking and page boundaries. chunk.pyπ splits text on OCR line boundaries (not raw whitespace), preserving the newline-separated structure of the source CSV so that UDPipe receives proper sentence-boundary hints between lines. When a document spans multiple chunks, call_udpipe.pyπ merges them into a single CoNLL-U and injects a
# page_break = truecomment immediately before every sentence that began a new page in its source chunk. All downstream scripts (call_nametag.pyπ, summarize_nt_udp.pyπ, teitok_alto.pyπ) recognise this marker alongside the legacy# sent_id = 1page-reset convention, so both single-chunk and multi-chunk files are handled transparently.
[!TIP] You can launch the next step when a portion of CoNLL-U files are ready, without waiting for the entire input collection to finish. You will have to relaunch the next step after all CoNLL-U files are ready to process the files created after the previous run began.
Takes the valid CoNLL-U files and passes them through the NameTag API 3 to annotate Named Entities (NE) directly into the syntax trees.
./api_3_nt.sh
OUTPUT_DIR/UDP/*.conllu (Intermediate per-document CoNLL-U files).OUTPUT_DIR/NE/*/*.tsv (NE annotated per-page files)Run the following command to see how many documents have been processed into TSV files:
ls -l OUTPUT_DIR/NE | wc -l
which returns the total number of directories created (each subfolder corresponds to a document).
Example output directory NE π contains per-page TSV files with NE annotations, where the NE tags follow the CNEC 2.0 standard 4 which is used in the Czech Nametag model.
This stage consolidates the linguistic data from UDPipe (CoNLL-U) and the NER data from NameTag (TSV) into final per-document formats. It also generates a master summary of entity counts across the entire collection and can optionally produce TEITOK-compatible XML files that merge linguistic tokens with original ALTO layout coordinates.
The process utilizes summarize_nt_udp.py π to merge these
layers, map complex CNEC 2.0 tags (e.g., g, pf, if) into human-readable categories
(e.g., "Geographical name", "First name", "Company/Firm"), and write all output formats.
Optionally, TEITOK-related functionality is implemented in
teitok_alto.py π.
./api_4_stats.sh
OUTPUT_DIR/UDP/*.conllu β Per-document CoNLL-U files containing morphology and syntax.OUTPUT_DIR/NE/*/*.tsv β Per-page TSV files containing Named Entity annotations.INPUT_ALTO_DIR/*.alto.xml β Source ALTO XML files used during TEITOK conversion to provide spatial bounding box coordinates for each token.INPUT_PAGES_DIR/<doc_id>-N.png β Per-page facsimile images. When specified, the pipeline dynamically
extracts pixel boundaries from the headers to compute scaling transformations (sx, sy). If omitted, coordinates are safely
translated and aligned at a native 1.0 scale factor.OUTPUT_DIR/summary_ne_counts.csv β Global table of aggregated Named Entity statistics across all documents.OUTPUT_DIR/UDP_NE/<doc_id>/<doc_id>.csv β Per-document CSV tables with tokens, lemmas, and human-readable NE explanations.OUTPUT_DIR/UDP_NE/<doc_id>/<doc_id>.conllu β Final CoNLL-U files with NER tags enriched in the MISC column.OUTPUT_DIR/TEITOK/<doc_id>.teitok.xml β TEITOK-style TEI XML files ready for the flexiconv converter and facsimile viewing (see below).The behavior of this step is controlled by boolean flags in your config_api.txt:
| Variable | Description | Default |
|---|---|---|
SAVE_CONLLU_NE | Keep the enriched CoNLL-U with NER in the MISC field. | true |
SAVE_CSV | Write the token-level summary CSV per document. | true |
SAVE_TEITOK | Write TEITOK-style TEI XML with bounding boxes and NER spans. When INPUT_ALTO_DIR is not set a warning is emitted and TEITOK XML is still produced without bboxes. If INPUT_ALTO_DIR is set but the path does not exist, the step exits with an error. | true |
INPUT_PAGES_DIR | Directory of per-page images (<doc_id>-N.png). When set, bbox coordinates are scaled to match the actual PNG resolution. Leave empty to write raw ALTO pixel values. | (empty) |
When SAVE_TEITOK=true, teitok_alto.py π reads and processes the internal spatial
hierarchy of your ALTO source specifications.
Offset Alignment (Resolving Layout Shifting):
ABBYY FineReader naturally indexes element positions from the absolute physical boundary of the scanner bed (0,0).
However, companion web images cropped for public view or optimized to strip away raw scanner artifacts introduce
a uniform positional drift (causing text layers to display too far left or too high up on screen).
To neutralize this error without modifying binary assets or re-cropping, the script automatically parses page-level
<PrintSpace> properties from the ALTO structure:
<PrintSpace HEIGHT="3263" WIDTH="2027" VPOS="80" HPOS="297">
The horizontal boundary (HPOS) and vertical boundary (VPOS) values are captured as active translation variables
(dx, dy). Prior to rendering bounding boxes into the TEITOK XML stream, these values are subtracted from the
coordinate targets, recalculating alignment automatically:
$$\text{Scaled Coordinate} = \text{round}((\text{Absolute Coordinate} - \text{Offset}) \times \text{Scale Factor})$$
Dynamic Scale Calculations:
INPUT_PAGES_DIR is set and matching images exist, the tool safely reads
binary file headers without invoking bloated third-party imaging dependencies. Ratios are resolved by evaluating layout
sizes against image shapes (sx = img_width / alto_width).<MeasurementUnit>
(inch1200, mm10, or pixel) mapped against the environment variables IMAGE_DPI and ALTO_DPI.1.0 scale factor.Future direction: Relative / resolution-independent coordinates are the preferred long-term direction (pending TEITOK-team confirmation).
If you are a new user approaching this pipelineβperhaps a researcher who just digitized a batch of archival documentsβyour primary goal might be making sure the semantic annotations actually line up with your page images in a web viewer.
Let's say your original document was processed at a massive archival resolution, but the image you are serving to your web frontend is exactly 1200 pixels wide and 1800 pixels high. Currently, your TEITOK XML bounding boxes are completely misaligned.
Here is exactly how you would use the integrated tools to solve this problem.
Method 1: The Quick API Fix (Best for single files or web integrations)
Since the pipeline now includes a dedicated FastAPI service, you don't even need to write a script. You can just send
your misaligned XML to the /rescale endpoint.
Open your terminal and run a simple curl command, explicitly telling the API the exact dimensions of your target image and requesting the output as an XML file instead of the default JSON metadata:
curl -X POST "http://localhost:8000/rescale" \
-F "file=@CTX000000001.teitok.xml" \
-F "width=1200" \
-F "height=1800" \
-F "format=xml" \
-o CTX000000001.rescaled.teitok.xml
What happens behind the scenes: The API automatically detects the original coordinate space from the <surface> tag
in your XML. It calculates the exact scaling factors needed to stretch or shrink the bounding boxes (bbox) to fit the
new 1200x1800 dimensions. As a bonus, it also silently repairs any malformed named-entity tags (like <name>...</n>)
in the document.
Method 2: The Command-Line Batch Process (Best for whole directories)
If you have hundreds of XML files in a folder and you know exactly what scale ratio or DPI conversion you need, using
the REST API file-by-file would be tedious. Instead, use the dedicated CLI tool, fix_teitok_bboxes.py.
If you know your web images are exactly 50% the size of your original scans (a scale factor of 0.5), you can process the entire directory at once:
python3 fix_teitok_bboxes.py -i /path/to/my/teitok_folder/ --sx 0.5 --sy 0.5
Alternatively, if your original ALTO OCR data was in millimeters (mm10) and you need to target a standard 72 DPI
screen resolution, the script can handle that math directly:
python3 fix_teitok_bboxes.py -i my_document.teitok.xml --unit mm10 --dpi 72
If the original scans included a scanner bed margin (e.g., 50 pixels on the left and 20 on the top) that was cropped out of the final web image, you can strip that out by shifting everything left and up:
python3 fix_teitok_bboxes.py -i my_document.teitok.xml --dx -50 --dy -20
Both methods directly address the historical pain point of facsimile alignment, allowing you to flawlessly overlay the NLP enrichments onto the visual documents without needing to re-run the entire pipeline.
[!NOTE] When a token's matched ALTO strings span more than one page (a rare OCR edge case near page boundaries), a warning is printed to stderr identifying the token and the conflicting page indices. The first matched page is used for the bbox assignment in that case.
The structural and spatial hierarchy from the ALTO file is strictly preserved in the generated TEITOK XML:
<tok> element as @bbox="x1 y1 x2 y2" (absolute
pixel coordinates in TEITOK's hOCR-derived format). Each token also carries @type="w" (word) or
@type="pc" (punctuation character) derived from UDPipe's UPOS tag.<TextLine> elements are preserved via <lb> (line break) tags, which also include
their own @bbox spatial coordinates.<div type="MarginTextZone-P"> containers, satisfying
the core ATRIUM guidelines for classified text zones.Illustration and GraphicalElement blocks are parsed and
appended to their respective pages as <figure> tags with strict bounding boxes.<pb n="N" id="..." facs="..."/> elements pointing to
the specific document surface.Named entity spans are wrapped in <n> elements grouping their constituent <tok> nodes.
Two attributes encode the entity type at different levels of granularity: @type holds the CoNLL-style
category (PER, ORG, LOC, or MISC) intended for querying and interoperability, while @cnec carries
the raw CNEC 2.0 code (e.g., pf, gu, if) for use in visualisation. For example, a span tagged as a
first name is written as <name type="PER" cnec="pf">.
[!NOTE] Thanks to the sequence matching approach, the script achieves near-perfect spatial alignment between NLP tokens and OCR coordinates, drastically improving upon older greedy matching methods that would break on minor character variations. Alignment statistics (matched vs. total tokens) are printed to the console per document.
ls OUTPUT_DIR/UDP_NE | wc -l
which returns the total number of created files, both .csv and .conllu corresponding
to specific documents.
ls OUTPUT_DIR/UDP_NE/*/*.csv | wc -l
returns number of documents processed into tables
ls OUTPUT_DIR/TEITOK/*.xml | wc -l
returns number of recorded .teitok.xml documents.
Example summary table: summary_ne_counts.csv (produced by a real run; not committed β
see the note below).
Example output directory UDP_NE π contains per-document CSV tables with NE tags and UDPipe feature columns, plus CoNLL-U files with NE annotations in per-document manner.
Example output directory TEITOK π contains per-document TEITOK XML files combining UD linguistic annotations and NER spans with bounding boxes aligned from the source ALTO XML.
After completing the pipeline, your working and output directories will be organized as follows:
TEMP/
βββ CHUNKS/
β βββ ...
βββ nametag_response_docname1.conllu.json
βββ ...
AND
<OUTPUT_DIR>
βββ UDP_NE/
β βββ <doc_id>
β β βββ <doc_id>.csv
β β βββ <doc_id>.conllu
β βββ <doc_id>
β β βββ <doc_id>.csv
β β βββ <doc_id>.conllu
β βββ ...
βββ UDP/
β βββ <doc_id>.conllu
β βββ <doc_id>.conllu
β βββ ...
βββ TEITOK/
β βββ <doc_id>.teitok.xml
β βββ <doc_id>.teitok.xml
β βββ ...
βββ NE/
β βββ <doc_id>
β β βββ <doc_id>-<page_num>.tsv
β β βββ ...
β βββ <doc_id>
β β βββ <doc_id>-<page_num>.tsv
β β βββ ...
β βββ ...
βββ altos/
β βββ <doc_id>.alto.xml
β βββ ...
βββ pages/
β βββ <doc_id>-1.png
β βββ <doc_id>-2.png
β βββ ...
βββ processing.log
βββ summary_ne_counts.csv
βββ manifest.tsv
The combined output summary_ne_counts.csv contains aggregated Named Entity
statistics across all processed pages. This repository's data_samples/ only ships the three
synthetic demo documents (CTX00000000{1,2,3}), so no summary_ne_counts.csv is
committed β the file is real output of a real api_5_summary_ne.sh run, not a sample bundled here.
[!NOTE] Now you can delete
UDP/from<OUTPUT_DIR>/if you no longer need the raw CoNLL-U files. The final CoNLL-U files with NER features are in<OUTPUT_DIR>/UDP_NE/.
If you do not plan to rerun any part of the pipeline, you can also delete
the entire TEMP/ directory including manifest.tsv π.
[!NOTE] This is an optional step in NLP enrichment of your data. It can give a fast thematic overview of the whole collection and works best when UDPipe lemmas (output of Step 2) are available. Three extraction backends are provided; choose the one that best fits your environment and quality requirements.
Extract keywords π from your documents by running keywords.py on a directory of CoNLL-U files produced by Step 2.
The keyword extraction script uses a three-tier configuration hierarchy (from highest to lowest priority):
-m yake, -w 3) always override everything else.kw_config.txt (the [DEFAULTS] section) is read automatically if placed next to the script.This means if you configure your settings in kw_config.txt, you can simply run:
python3 keywords.py
| Flag value | Method | Dependencies | Score semantics | Best for |
|---|---|---|---|---|
legacy | Original KER β NOUN/PROPN/ADJ lemma frequency | none (stdlib only) | raw occurrence count | reproducing original ATRIUM results |
yake (default) | YAKE β unsupervised statistical, CPU-only | pip install yake | normalised inverse YAKE score, [0, 1] | fast CPU runs, no model download |
keybert | KeyBERT β embedding-based, GPU-accelerated | pip install keybert sentence-transformers | cosine similarity, [0, 1] | highest semantic quality, GPU recommended |
You can override any kw_config.txt setting via the command line:
python3 keywords.py -i <input_dir> -m <method> -l <lang> -w <integer> \
-n <integer> -d <output_dir> -o <output_file>.csv
All available flags:
| Flag | Long form | Default in kw_config.txt | Description |
|---|---|---|---|
-i | --input_dir | data_samples/UDP | CoNLL-U directory to process |
-m | --method | yake | Backend: legacy, yake, or keybert |
-l | --lang | cs | Language code for YAKE stopwords (cs, en, de, β¦). Ignored by legacy and keybert |
-w | --max_words | 3 | Maximum words per keyword phrase (n-gram upper bound) |
-n | --num_keywords | 20 | Number of keywords to extract per document |
-d | --per_doc_out_dir | data_samples/KW_PER_DOC | Output directory for per-document CSV files |
-o | --output_file | keywords_summary.csv | Master keywords CSV |
--keybert-model | paraphrase-multilingual-MiniLM-L12-v2 | Sentence-Transformer model name (KeyBERT only) | |
--no-mmr | (False) | Disable Maximal Marginal Relevance diversification (KeyBERT only) | |
--diversity | 0.5 | MMR diversity parameter, 0 = max relevance β 1 = max diversity (KeyBERT only) | |
--workers | 0 (Auto / CPU count) | Parallel worker processes. Auto-forced to 1 for KeyBERT + GPU |
Examples:
YAKE β Czech, up to 3-word phrases, 20 keywords per document (default)
python3 keywords.py -i OUTPUT_DIR/UDP -m yake -l cs -w 3 -n 20 \
-o keywords_summary.csv -d KW_PER_DOC
KeyBERT β multilingual model, GPU-accelerated
python3 keywords.py -i OUTPUT_DIR/UDP -m keybert -w 3 -n 20 \
--keybert-model paraphrase-multilingual-MiniLM-L12-v2 \
-o keywords_summary.csv -d KW_PER_DOC
Legacy KER β (English/Czech) original ATRIUM lemma-frequency approach, no extra dependencies
python3 keywords.py -i OUTPUT_DIR/UDP -m legacy -n 20 \
-o keywords_summary.csv -d KW_PER_DOC
[!WARNING] For KeyBERT with a GPU, the script automatically forces
--workers 1to prevent competing CUDA context initialisation across subprocesses. On CPU, any worker count is safe.
keywords_summary.csv).KW_PER_DOC/).KW_PER_DOC/
βββ <docname1>_keywords.csv
βββ <docname2>_keywords.csv
βββ ...
Each per-document file contains two columns β keyword and score β sorted
by score in descending order. The master summary uses the same column structure
as the original pipeline (document_id, kw-1, score-1, kw-2, score-2, β¦).
legacy β raw lemma count; higher = more frequent in the document. Examples in directory: KW_PER_DOC_L π and summary file
kw_summary_l.csv π.
| Score range | Interpretation |
|---|---|
| 1β5 | Common functional nouns, low informativeness |
| 5β20 | Topic-representative vocabulary |
| > 20 | Dominant terms, likely named entities or domain headings |
yake β normalised inverse YAKE score, [0, 1] per document. Examples in directory: KW_PER_DOC_Y π and summary file
kw_summary_y.csv π.
| Score range | Semantic category | Interpretation |
|---|---|---|
| 0.0β0.2 | Noise floor | Common words, low local relevance |
| 0.2β0.6 | Context layer | General vocabulary defining the broad topic |
| 0.6β0.9 | Topic layer | Specific nouns and verbs central to the text |
| 0.9β1.0 | Entity layer | Rare terms, neologisms, named entities |
keybert β cosine similarity to document centroid, [0, 1]. Examples in directory: KW_PER_DOC_KB π and summary file
kw_summary_kb.csv π.
| Score range | Interpretation |
|---|---|
| < 0.3 | Weakly related phrases |
| 0.3β0.6 | Contextually relevant terms |
| > 0.6 | Highly representative keyphrases |
[!NOTE] This section is relevant when your documents originate from an OCR or digitisation pipeline that does not produce ALTO XML β for example, PAGE XML, hOCR, plain-text exports, or proprietary formats. If you already have ALTO XML, the pipeline generates TEITOK XML natively via
api_4_stats.sh(see above).
flexiconv 5(https://github.com/ufal/flexiconv) is a flexible format-conversion tool developed at UFAL that translates a variety of OCR and document layout formats into TEITOK XML β the unified output format used by this project. It acts as a universal adapter: once your documents are in TEITOK XML, they can be ingested directly into the TEITOK corpus platform and will benefit from all the same search, visualisation, and NER capabilities described above.
Your input format flexiconv Unified output
βββββββββββββββββ βββββββββββββββββββββββββ βββββββββββββββββ
PAGE XML ββ
hOCR ββ€βββΊ flexiconv βββββββββββββββΊ .teitok.xml βββΊ TEITOK platform
plain text + CSV ββ€ βββΊ this pipeline
other OCR output ββ (NER, KWs, ...)
Use flexiconv before running this pipeline when:
teitok_alto.py.git clone [https://github.com/ufal/flexiconv.git](https://github.com/ufal/flexiconv.git)
cd flexiconv
pip install -r requirements.txt
python flexiconv.py \
--input-dir /path/to/your/source/documents \
--input-fmt page-xml \ # or: hocr, plain, ...
--output-dir /path/to/teitok_out \
--output-fmt teitok
Refer to the flexiconv documentation for the full list
of supported --input-fmt values and format-specific options.
[!TIP] If your format is not yet supported by flexiconv, please open an issue on the flexiconv GitHub repository. The tool is actively developed within the ATRIUM project and new format adapters are added regularly.
[!NOTE] This is an advanced, optional step. It runs a local Large Language Model to semantically analyse each text line and map it to the controlled TEATER/AMCR archaeological vocabulary. Two inference backends are supported:
transformers(HuggingFace + BnB 4-bit, single GPU, models β€ 31 B) andvllm(multi-GPU, Automatic Prefix Caching, native guided JSON decoding, models β₯ 70 B or any multi-GPU node).
This pipeline goes beyond traditional keyword extraction by using Constrained Decoding.
For the transformers backend this is implemented via Pydantic schemas and lmformatenforcer.
For the vllm backend, guided decoding is handled natively by xgrammar inside vLLM β
no additional library is required. In both cases the model is mathematically prevented from
producing any token that would violate the predefined JSON structure or select a vocabulary
term outside the thematic dictionary, entirely eliminating hallucinated formatting.
The pipeline reads all runtime parameters from llm_config.txt in the repository root.
The minimum required change is MODEL_KEY; every other key has a sensible default.
# Single-GPU (BACKEND=transformers): qwen-3.6-27b-it | gemma-4-31b-it | qwen3-14b |
# qwen-3.5-9b-it | qwen3-8b | qwen2.5-14b-awq |
# qwen2.5-7b | gemma-3-12b-it
# MoE / GGUF (single GPU): gemma-4-26b-moe-gguf | qwen-3.6-35b-moe
# Multi-GPU (BACKEND=vllm): qwen3-235b-a22b-fp8 | deepseek-v3 | llama4-maverick | llama3.1-70b
MODEL_KEY=qwen-3.6-27b-it
# Only needed for gated models: gemma-4-*, llama4-maverick, llama3.1-70b
# HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx
INPUT_DIR=data_samples/DOC_LINE_CATEG
OUTPUT_DIR=data_samples/KW_PER_DOC_LLM
VOCAB_PATH=data_samples/vocab/union_nested.json
PARADATA_DIR=paradata
# Attach the surviving vocabulary term's source record id(s) to each enrichment as
# teater_category_ids (issue #6, M7). Kept behind a switch since it was agreed to be
# reversible: "list them now and drop it if it will create some issues."
EMIT_CATEGORY_IDS=true
INCLUDE_NON_TEXT=true
MIN_CHAR_COUNT=3
MIN_CHAR_NON_TEXT=8
MIN_ALPHA_RATIO_NON_TEXT=0.4
# ββ System prompt βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# The instruction text lives in prompts/system_prompt.txt as [[named blocks]]; these
# flags choose which of them render. The run banner prints the resulting on/off list,
# so a log always says what the model was told. See prompts/RUNBOOK.md.
PROMPT_TEMPLATE=prompts/system_prompt.txt
PROMPT_TASK_EXTRACT=true
PROMPT_TASK_SELECT=true
PROMPT_METATEXT_RULE=true
PROMPT_OCR_NORMALISATION=true
PROMPT_EXACT_TERM=true
PROMPT_EXAMPLES=true
# The one three-way switch: strict | preference | off. Paired with
# taxonomy_config.json's geo_guardrail.active β vocab_build.py refuses a build where
# the two disagree.
PROMPT_GEO_GUARDRAIL=preference
# Term-list layout: facet_sub | facet | flat. Same terms, same truncation; only the
# headers move.
PROMPT_VOCAB_GROUPING=facet_sub
# ββ Inference parameter overrides βββββββββββββββββββββββββββββββββββββββββββββ
# ALL of these are COMMENTED OUT in the shipped file. Backend, GPU count, memory
# utilisation, batch size and context cap are resolved automatically from the model
# registry in llm_utils.py; the startup log prints every effective value next to
# where it came from (β llm_config.txt / model default / global default). Uncomment
# a line only to deviate from the model's recommended configuration.
# BACKEND=vllm # transformers (single GPU, β€ 31 B) | vllm (multi-GPU)
# TENSOR_PARALLEL_SIZE=8 # GPUs to shard across (vLLM only)
# GPU_MEMORY_UTILIZATION=0.88 # Fraction of each GPU's VRAM for the KV cache
# VLLM_BATCH_SIZE=8 # Lines per generate() call
# MAX_MODEL_LEN=16384 # Cap the context window to reduce KV-cache pressure
# CPU_OFFLOAD_GB=0 # Weights to keep in CPU RAM when VRAM is short
# GUIDED_DECODING_BACKEND=xgrammar
# ENABLE_PREFIX_CACHING=false # Not recommended; reduces throughput
The nine PROMPT_* keys are the prompt's whole configuration surface;
prompts/RUNBOOK.md π documents each block, what it costs, and
which pairings are unsafe to change alone.
1. Vocabulary Harvesting (vocab_build.py π)
The vocabulary is built in two stages, and only the first needs the internet:
harvest (network) β FLAT artifacts β nest (pure) β NESTED artifacts
vocab_sources.py *_flat.{json,csv} vocab_manager *_nested.json
vocab_sources.py π harvests two controlled vocabularies:
| Source | How | What comes back |
|---|---|---|
| AMCR heslΓ‘Ε | OAI-PMH, api.aiscr.cz/2.2/oai?set=heslo | CzechβEnglish pairs plus ident_cely, nazev_heslare (which of the ~50 controlled lists the term belongs to), popis, zkratka, razeni, broader terms and SKOS mappings |
| TEATER thesaurus | the 12 pinned import_*.json files in ARUP-CAS/aiscr-teater, or live teater.aiscr.cz/api/export | 4 134 concepts in 12 branches, trilingual labels, scope notes, and the real broader/narrower hierarchy |
vocab_manager.py π then groups the flat terms into the thematic
taxonomy defined by taxonomy_config.json π. Placement
is tried in precedence order β a per-term correction in
taxonomy_overrides.json π, AMCR list membership
(heslar_map), TEATER branch (teater_branch_map, resolved most-specific-first so a
depth-2 sub-branch like muzeum can be moved without moving its whole parent branch),
the legacy keyword match, a cross-source rescue, an opt-in LLM fallback, then Other β
and every placement records the rule that made it in *_placement_audit.csv, so the
grouping can be reviewed rather than taken on trust.
Two labels can collide (AMCR and TEATER both use zΓ‘mek for "lock" and "chΓ’teau").
vocab_sources.to_term_pairs() treats a same-label group as one concept by default β
the winning record's id survives, every other one is listed on it as discarded_ids
(issue #6, M7) β and only pulls a record into its own bracketed entry
("zΓ‘mek (sΓdlo elity)") when taxonomy_overrides.json explicitly flags it as a
genuine homonym (M8). Guessing that from a differing English gloss alone would mistake
ordinary translation variance for a real split far more often than it would catch one.
Every vocabulary decision is a config edit, not a code change. The two JSON files are the whole surface a domain reviewer needs; nothing below requires touching Python:
In taxonomy_config.json β _settings | Decides |
|---|---|
heslar_map, teater_branch_map | which facet a whole AMCR list or TEATER branch lands in, or __exclude__ |
_exclusions | why each exclusion stands, and whether it is settled or still open (open_geo_ethnic / open_other) |
geo_guardrail | whether the prompt's "never select a country/language/region name" clause is in force, and which rules it reaches |
nested_keep | which harvested keys reach the prompt payload |
admin_stop_words | what sorts to the back of a facet, and so what survives prompt truncation |
composite_separators | what splits a composite X/Y label |
tie_break, per-facet priority | facet order β load-bearing, since the prompt truncates a prefix |
In taxonomy_overrides.json, per (source, id) | Decides |
|---|---|
facet | one term's facet, including "__exclude__" to drop a single term from a list worth keeping |
sub | one term's sub-header β otherwise a moved term keeps the header of the list it left |
qualifier_cs | pull a confirmed homonym out of its dedup group as "<cs> (<qualifier>)" |
same_as / same_as_suppress | add or drop a composite/component equivalence link; neither changes what the prompt offers |
The system prompt is a config surface too. Its instruction text lives in
prompts/system_prompt.txt π as [[named blocks]] in
render order; llm_config.txt's PROMPT_* flags choose which of them reach the model,
and the run banner prints the resulting on/off list so a log always says what the model
was told. PROMPT_GEO_GUARDRAIL is the one three-way switch (strict / preference /
off) because the geographic rule has three states, and it is paired with
taxonomy_config.json's geo_guardrail.active:
prompts/output_template.json π documents the resulting
per-document output file.
Reading the prompt needs neither a GPU nor the model stack β prompt_template.py imports
nothing outside the standard library, so these run in a bare checkout:
python3 prompt_template.py --blocks # which rules are on, and what each costs
python3 prompt_template.py --preview # the instruction text, term list elided
python3 prompt_template.py --full > prompt.txt # the whole prompt, all 4 718 terms
python3 prompt_template.py --diff PROMPT_GEO_GUARDRAIL=strict \
PROMPT_GEO_GUARDRAIL=preference
python3 prompt_template.py --write # regenerate the committed sheets
python3 prompt_template.py --check # exit 1 if a sheet is out of date
--full renders the untruncated prompt β instructions under the current flags, then every
term VOCAB_PATH offers, grouped exactly as build_system_prompt() groups them (both call
the same two functions, and a test asserts they agree byte for byte). It is what a model
with room for the whole vocabulary sees; at a tighter window the run drops a tail of terms,
which context_budget.csv π sizes per window.
The four sheets under prompts/ π β prompt_blocks.txt, prompt_preview.txt,
prompt_full.txt, prompt_guardrail_diff.txt β are the output of the first four commands,
committed so a reviewer can read the prompt in a diff without running Python. They are
generated, never hand-edited: --write rewrites them, --check fails when the
vocabulary, a flag or the template has moved without them, and
.github/workflows/vocab-drift.yml runs that check on
every PR touching either half.
PROMPT_VOCAB_GROUPING controls the layout of the term list, not its contents:
facet_sub (shipped β --- Facet / Subgroup ---, both curated levels), facet (facet
headers only) or flat (no headers). It exists to answer @motyc's question in
issue #6 β
whether the facet grouping affects results at all, now that the whole vocabulary fits a
128k window. All three offer the same terms and truncate identically; facet and flat
also preserve term order, while facet_sub makes each facet's sub-groups contiguous. So
facet vs flat isolates the ~125 header lines and facet_sub vs facet measures the
source's second level. The headers are not free: at an 8 192-token window they cost 26
terms, at 32 768 they cost 126, and at 128k nothing, since everything fits either way.
validate_settings() refuses an edit that would not do what it says β an undeclared
facet, a relabel for a list no map places, a reason for something nobody excludes, an
unknown override key, a stale (source, id), a pair both linked and suppressed, two
overrides that would build the same bracketed key β and reports every problem at once
rather than one per rebuild. vocab_build.py additionally renders the prompt the config
selects and refuses to build a vocabulary that contradicts its geographic guardrail.
Two runbooks carry the operational detail, and they are the pages to read before
touching either half:
data_samples/vocab/RUNBOOK.md π β every vocabulary
script, the eight review sheets, and the full "where a decision gets recorded" table;
prompts/RUNBOOK.md π β the eleven prompt blocks, the nine flags,
the guardrail's two halves, and the output contract.
# stage 1 + 2, needs network access to aiscr.cz
python3 vocab_build.py --source both --stats
# stage 2 only: re-nest from the committed flat files after editing the taxonomy.
# Pure, offline, sub-second β this is the loop for tuning the taxonomy.
python3 vocab_build.py --from-flat --stats
python3 vocab_build.py --from-flat --check # exit 1 if the artifacts would change
If this machine cannot reach aiscr.cz, run the Vocabulary Refresh workflow
(.github/workflows/vocab-refresh.yml) β a hosted
runner harvests and uploads the artifacts.
[!NOTE] The nested files are deliberately not written with
sort_keys=True. Theme order is priority-descending and load-bearing:build_system_prompt()iterates the file in insertion order and truncates a prefix of the resulting term list, so alphabetising the keys would silently change which themes survive a tight context budget. Determinism comes from the explicit priority ordering plus a(boilerplate, razeni, label)sort within each theme. Provenance lives in a sidecar*.meta.json, not inline β every consumer reads the nested file as{theme: terms}, so an inline_metakey would be rendered to the model as a phantom theme.
python3 vocab_manager.py still works and still performs the legacy AMCR-only sync.
Two read-only tools turn the built vocabulary into sheets a domain reviewer can rule on. Neither writes to the vocabulary, and neither guesses a semantic verdict β they rank and surface candidates; a human decides, and the decision goes back as a config edit.
vocab_review.py π β eight sheets, offline and pure, built from
the committed *_flat.json plus the taxonomy config:
python3 vocab_review.py --all # all eight, into data_samples/vocab/
python3 vocab_review.py --collisions # collision_review.csv same-label groups (M8/M13)
python3 vocab_review.py --composites # composite_pairs.csv "X/Y" vs standalone X, Y
python3 vocab_review.py --exclusions # exclusion_impact.csv what each exclusion costs
python3 vocab_review.py --subbranches # teater_subbranch_impact.csv the same, one level finer
python3 vocab_review.py --reinstate # reinstatement_preview.csv usable count + token delta
python3 vocab_review.py --specificity # specificity_pairs.csv term offered under its own parent
python3 vocab_review.py --budget # context_budget.csv what survives each window
python3 vocab_review.py --census # facet_census.csv what is in each facet, and its cost
corpus_review.py π β three evidence sheets matching the
vocabulary against real report text by UDPipe lemma, which is the evidence standard for
keeping or dropping a branch:
python3 corpus_review.py --all # corpus_term_evidence.csv, corpus_branch_evidence.csv,
# gold_workbook.csv (+ corpus_review.meta.json)
β οΈ corpus_review.py needs the document corpus, and the repository tracks only three
synthetic demo documents β the real reports arrive as the issue
#19 attachment and are untracked.
Every run prints its corpus size before writing, and the tool refuses to overwrite
sheets built from a larger corpus rather than silently replacing real evidence with
placeholder numbers. On a clean checkout these three are a smoke test, not evidence.
Both sets are covered by a drift test: a taxonomy edit that is not followed by a
regeneration fails tests/test_vocab_review.py, for the same reason the artifacts have
their own gate. See data_samples/vocab/RUNBOOK.md π
for what each sheet answers and how to read it.
2. LLM Inference Pipeline (llm_run.py π)
Reads the CSV files, filters lines by quality, injects the nested vocabulary and a
sliding context window into the system prompt, and executes constrained generation.
Output files are named <stem>_enriched.json and written to
KW_PER_DOC_LLM_<model_suffix>/.
[!TIP] All model-loading logic, constrained-decoding helpers, and prompt templates live in llm_utils.py π and are shared between both backends.
# Transformers backend (default)
python3 llm_run.py
# Custom config file
python3 llm_run.py my_config.txt
For multi-GPU runs (vLLM backend):
# 1. Edit llm_config.txt:
# BACKEND=vllm
# MODEL_KEY=qwen3-235b-a22b-fp8
# TENSOR_PARALLEL_SIZE=2
# ENABLE_PREFIX_CACHING=true
python3 llm_run.py
The built-in registry in llm_utils.py covers the full range of supported models.
All VRAM figures assume BnB 4-bit for the transformers backend and FP8/BF16 for vLLM.
BACKEND=transformers (or BACKEND=vllm)| Registry key | Model | Size | Context | Est. VRAM | Notes |
|---|---|---|---|---|---|
qwen-3.6-27b-it | Qwen/Qwen3.6-27B 6 | 27 B dense | 262 k | ~18 GB | Default. Best accuracy/VRAM ratio on a single GPU. |
gemma-4-31b-it | google/gemma-4-31B-it 7 | 31 B dense | 256 k | ~21 GB | Highest single-GPU accuracy. Gated β HF_TOKEN required. |
qwen3-14b | OpenPipe/Qwen3-14B-Instruct 8 | 14 B dense | 128 k | ~9 GB | Good baseline; thinking mode suppressed automatically. |
qwen-3.5-9b-it | Qwen/Qwen3.5-9B 9 | 9 B dense | 262 k | ~6 GB | Entry-level (8 GB VRAM). |
qwen3-8b | Qwen/Qwen3-8B 10 | 8 B dense | 128 k | ~16 GB | BF16 (no 4-bit); straightforward baseline. |
qwen2.5-14b-awq | Qwen/Qwen2.5-14B-Instruct-AWQ 11 | 14 B AWQ | 128 k | ~9 GB | Pre-quantized; fast on NVIDIA GPUs. |
qwen2.5-7b | Qwen/Qwen2.5-7B-Instruct 12 | 7 B dense | 32 k | ~14 GB | BF16; short context window. |
gemma-3-12b-it | google/gemma-3-12b-it 13 | 12 B dense | 128 k | ~8 GB | Good bilingual extraction. Gated. |
| Registry key | Model | Active params | Context | Notes |
|---|---|---|---|---|
gemma-4-26b-moe-gguf | bartowski/google_gemma-4-26B-A4B-it-GGUF | 4 B | 8 k | BnB 4-bit unsupported (fused experts). Q4_K_M quantization via llama.cpp. |
BACKEND=vllm (single GPU or multi-GPU)| Registry key | Model | Active params | Context | Notes |
|---|---|---|---|---|
qwen-3.6-35b-moe | Qwen/Qwen3.6-35B-A3B 14 | 3 B | 262 k | 35 B total / 3 B active. Single GPU usually fits. |
gemma-4-26b-moe | google/gemma-4-26B-A4B-it 15 | 4 B | 256 k | 26 B total / 4 B active. Gated. |
gemma-4-26b-moe-awq | google/gemma-4-26B-A4B-it 15 | 4 B | 256 k | AWQ-quantised variant of gemma-4-26b-moe. Gated. |
BACKEND=vllm, TENSOR_PARALLEL_SIZE β₯ 2| Registry key | Model | Total / Active | Context | Rec. TP | Notes |
|---|---|---|---|---|---|
qwen3-235b-a22b-fp8 | Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 16 | 235 B / 22 B | 128 k | 2 | Recommended for 144 GB / 200 GB nodes. Native FP8 (~117 GB loaded). |
qwen3-235b-a22b | Qwen/Qwen3-235B-A22B-Instruct-2507 16 | 235 B / 22 B | 128 k | 2 | BF16 variant β heavier than FP8. |
deepseek-v3 | deepseek-ai/DeepSeek-V3 17 | 671 B MoE / β | 128 k | 4 | FP8 official checkpoint available. 4Γ80 GB minimum. |
llama4-maverick | meta-llama/Llama-4-Maverick-17B-128E-Instruct 18 | 128 experts / 17 B active | 1 M | 2 | Multimodal. 1 M token context. Gated β HF_TOKEN required. |
llama3.1-70b | meta-llama/Meta-Llama-3.1-70B-Instruct 19 | 70 B dense / β | 128 k | 2 | Also works with transformers + 4-bit on 2Γ40 GB. Gated. |
[!TIP] Automatic Prefix Caching (APC) β enabled by default for the vLLM backend (
ENABLE_PREFIX_CACHING=true). The system prompt (which embeds the full TEATER vocabulary) is computed once per run; its KV-cache is reused across every line in every document. This is the primary throughput multiplier: on a 500-line document the vocabulary forward pass happens once instead of 500 times. APC also removes the need to truncate the vocabulary to fit the token budget β the full thematic dictionary is injected when APC is active.
DOC_LINE_CATEG/*.csv (contains file_id, page_num, line_num,
categ, quality_score, and raw text).KW_PER_DOC_LLM_<model_suffix>/*_enriched.json β one file per document,
containing an array of JSON objects that merge CSV metadata with the LLM's semantic
extraction.KW_PER_DOC_LLM_<model_suffix>/*_enriched.abort.json β written
alongside the main output only when a document is abandoned after 10 consecutive
inference errors. Its presence is the canonical signal that the corresponding JSON
file contains partial results.Example output record:
{
"file_id": "CTX195603828",
"page": 1,
"line": 14,
"categ": "Text",
"quality_score": 0.98,
"original_text": "VΓ½zkum odhalil zΓ‘klady gotickΓ©ho kostela ze 14. stoletΓ.",
"enrichment": {
"extracted_keywords_cs": ["zΓ‘klady", "gotickΓ½ kostel"],
"extracted_keywords_en": ["foundations", "gothic church"],
"teater_category": "kostel",
"teater_category_ids": [
{ "source": "amcr", "id": "HES-000021" },
{ "source": "amcr", "id": "HES-000465" },
{ "source": "teater", "id": "1333" }
],
"confidence_score": 0.95
}
}
teater_category_ids (present when EMIT_CATEGORY_IDS=true, the default) lists every
source record the selected vocabulary term absorbed during dedup β issue #6, M7. It is
attached after inference, from the vocabulary's own discarded_ids; the prompt and
schema are unaffected. When the selected term was a bracketed disambiguation (B3, e.g.
"zΓ‘mek (sΓdlo elity)"), teater_category is stripped back to the bare label
("zΓ‘mek") before it is written, and teater_category_ids carries the id that
disambiguates which sense was meant β a term that legitimately contains parentheses in
its own source label (e.g. "GPS (navigaΔnΓ systΓ©m)") is never touched.
Abort sidecar format (*_enriched.abort.json):
{
"aborted": true,
"abort_reason": "10 consecutive inference errors",
"processed_before_abort": 42,
"errors_before_abort": 10,
"timestamp_utc": "2026-05-20T09:14:33"
}
[!NOTE] None of the per-model output sets below is committed to this repository β each is the real output of a real run against the full report corpus, not a sample bundled with the code (same reason
data_samples/DOC_LINE_CATEG/itself holds only three synthetic demo documents). The directory names are theOUTPUT_DIRa local run with thatMODEL_KEYproduces; the footnote on each is the model card.
Output examples per model (directory names, not links β see the note above):
KW_PER_DOC_LLM_qwen3_14b by Qwen 3-14B 8KW_PER_DOC_LLM_qwen25_14b_awq by Qwen 2.5-14B AWQ 11KW_PER_DOC_LLM_gemma_3_12b_it by Gemma 3-12B-IT 13KW_PER_DOC_LLM_qwen_36_27b_it by Qwen 3.6-27B-IT 6KW_PER_DOC_LLM_gemma_4_31b_it by Gemma 4-31B-IT 7KW_PER_DOC_LLM_qwen_35_9b_it by Qwen 3.5-9B-IT 9KW_PER_DOC_LLM_llama31_70b by LLaMA 3.1-70B 19KW_PER_DOC_LLM_qwen3_8b by Qwen 3-8B 10Pending (sample runs in progress):
KW_PER_DOC_LLM_qwen_36_35b_moe by Qwen 3.6-35B-MoE 14KW_PER_DOC_LLM_gemma_4_26b_a4b_it by Gemma 4-26B-A4B-IT 15Archived (unsuccessful β evaluation notes in issue #6; would have been under
archived_KW_PER_DOC_LLM/):
KW_PER_DOC_LLM_mistral_nemo_12b by Mistral Nemo 12B 20KW_PER_DOC_LLM_aya_expanse_8b by Aya Expanse 8B 21KW_PER_DOC_LLM_bielik_11b_v30 by Bielik 11B v3.0 22KW_PER_DOC_LLM_llama31_8b by LLaMA 3.1-8B 23KW_PER_DOC_LLM_ministral_3_14b by Ministral 3-14B 24KW_PER_DOC_LLM_qwen3_8b (early run) by Qwen 3-8B 10KW_PER_DOC_LLM_qwen25_7b by Qwen 2.5-7B 12Just like the main shell-script pipelines, LLM enrichment natively hooks into
atrium_paradata.py and automatically logs:
*.meta.json beside the artifact:
tool version, term count, the sha256 of both taxonomy files, and each source's record
count and pinned ref (TEATER's harvest commit). A run is reproducible only if the
vocabulary it saw is identifiable, and every placement decision is a function of those
two sha256s.log_component() names them.
Logged per source actually present in the build β an AMCR-only artifact does not claim
it used TEATER data.json success events).skipped_filter), inference faults
(skipped_error), and already-completed files (already_exists).*.abort.json file is also written next to the
(partial) output JSON for easy programmatic detection.
The resulting logs are dropped into the specified PARADATA_DIR alongside the other pipeline execution records.The pipeline now includes a fully-featured FastAPI REST service that exposes the core NLP enrichment and rescaling functionalities over HTTP.
/enrich endpoint and receive a combined JSON envelope (or ZIP workspace) with TEITOK XML, keywords, paradata, and NER summaries./rescale endpoint to align XML spatial coordinates to specific target image resolutions directly over the network./jobs queue.For complete setup instructions, payload examples, and endpoint documentation, refer to the Service README.
Every pipeline script records structured provenance metadata through atrium_paradata.py π. Two complementary log surfaces are produced after a run:
<OUTPUT_DIR>/paradata/ β structured run logs πEach of the four pipeline scripts produces one JSON file here, named with the pattern:
YYMMDD-HHmmss_nlp-enrich.json
where the timestamp prefix is the UTC wall-clock time at which the script started. Because every script is an independent invocation, a complete four-step run will create four separate files, making it straightforward to audit individual stages in isolation.
The paradata logs (samples in directory paradata π) capture key details about each pipeline stage, including the program name, run ID, execution duration, configuration parameters, input and output statistics, and performance metrics. They also document skipped files with reasons and provide a breakdown of output types and processing rates for benchmarking. This structured metadata ensures traceability and facilitates auditing of the pipeline's execution.
The declared output types per stage are:
| Script | Types recorded |
|---|---|
api_1_manifest.sh | tsv (one entry per input CSV/XLSX processed into the manifest) |
api_2_udp.sh | conllu (one per document) |
api_3_nt.sh | tsv (one per page β count reflects individual page TSV files) |
api_4_stats.sh | csv always; conllu when SAVE_CONLLU_NE=true; xml when SAVE_TEITOK=true |
keywords.py | csv_per_doc (one per document keyword CSV) and csv_summary_row (one summary row per document) |
[!NOTE] When resuming an interrupted run (steps 2β4 skip already-finished documents via
[ -f "$out" ] && continue), the resumed documents are not re-counted in the paradata JSON. Theinput_files_totalfield still reflects the full manifest, soskipped_files + successfully_processedwill be less thaninput_files_totalfor partial runs. This is expected behaviour; the difference represents the documents carried over from a previous invocation.
[!NOTE] Paradata state files. While a pipeline script is running, atrium_paradata.py stores intermediate state in a plain-text JSON file inside
<OUTPUT_DIR>/paradata/(named.state_<runid>_<program>.json). This file is automatically removed when the script completes. Because it is plain JSON it can be inspected with any text editor if a run is interrupted unexpectedly.
<OUTPUT_DIR>/processing.log β human-readable runtime log πapi_common.sh π exposes a log() helper that timestamps and
tee-appends warnings and errors to this flat file. The four main pipeline scripts
(api_1_manifest.sh β¦ api_4_stats.sh) write to processing.log indirectly through the
Python helpers, which print timestamped messages to stderr; any script that sources
api_common.sh can also write here via the log() function directly.
[2026-01-15 09:42:11] [WARN] UDPipe failed (HTTP 503). Retrying in 2sβ¦
[2026-01-15 09:42:14] [ERR] UDPipe failed permanently after 5 attempts.
This file accumulates across reruns;
it is the first place to check when a document appears in
skipped_files_detail but the reason is terse.
TEMP/ β intermediate working files πTEMP/ (set by WORK_DIR in config_api.txt π) holds
transient artefacts that are only needed during processing and can be deleted
once the full pipeline has completed successfully:
TEMP/
βββ CHUNKS/
β βββ <doc_id>/
β β βββ chunk_0.txt # OCR-line-preserving text fragment sent to UDPipe
β β βββ chunk_1.txt
β β βββ β¦
β βββ β¦
βββ nametag_response_<doc_id>.conllu.json # raw JSON reply from the NameTag API
CHUNKS/ is produced by api_util/chunk.py π which splits
documents that exceed WORD_CHUNK_LIMIT (default 900 words) into
sentence-boundary-aware fragments before each UDPipe API call. Each chunk file
preserves the original OCR line structure (one line per row) so that UDPipe
receives correct sentence-boundary signals between text lines. The per-chunk
plain-text files and the raw NameTag JSON responses carry no provenance value
after the CoNLL-U files have been merged and validated; they are not tracked by
the paradata logger.
[!TIP] If disk space is a concern you can safely delete
TEMP/once<OUTPUT_DIR>/UDP/and<OUTPUT_DIR>/NE/have been fully populated and step 4 has completed without errors. The paradata JSONs in<OUTPUT_DIR>/paradata/and theprocessing.logare the only runtime records worth keeping long-term.
run_pipeline.py)While each stage can be launched manually (see Workflow Stages),
run_pipeline.py π chains them end-to-end and merges every per-stage paradata JSON
produced during the run into a single pipeline-run-merged record.
# Full core run: api_1 β api_2 β api_3 β api_4
python3 run_pipeline.py
# Core run plus keyword extraction (CPU-only YAKE backend, default)
python3 run_pipeline.py --kw
# Keyword extraction with the GPU KeyBERT backend
python3 run_pipeline.py --kw --kw-method keybert
# Add the optional LLM semantic-enrichment stage (needs requirements_llm.txt)
python3 run_pipeline.py --kw --llm
# Run only a subset of the core stages (canonical order is always enforced)
python3 run_pipeline.py --stages udp nt
# Resume after an interruption: start from a chosen stage, skip every earlier one
python3 run_pipeline.py --start-from nt
# Skip individual stages (re-run only NER + stats, leave manifest/UDPipe as-is)
python3 run_pipeline.py --skip-manifest --skip-udp
# Clear stale .state_* checkpoint sidecars from PARADATA_DIR before running
python3 run_pipeline.py --clean-state
# Force execution: bypass missing dependency checks and ignore individual stage failures
python3 run_pipeline.py --kw --kw-method keybert --force
# Validate configuration and resolve the plan without running anything
python3 run_pipeline.py --dry-run
# Print the resolved config + stage plan as JSON (for wrappers / healthchecks)
python3 run_pipeline.py --print-config json
The runner reads the same config_api.txt π that the shell stages source, so Python and Bash always agree on OUTPUT_DIR, PARADATA_DIR, and the input/output paths.
$VAR / ${VAR} expansion).YYMMDD-HHmmss_nlp-enrich.json)
never collide.<PARADATA_DIR>/<runid>_nlp-enrich_pipeline-run.json via
atrium_paradata.merge_run_paradata. The merged record accurately tracks document-level statistics across the sequential pipeline (recording true throughput without inflating input counts). The effective license of the merged record is re-derived from the union of every component used across the stages, so the most-restrictive rule holds end-to-end (a core run is CC BY-NC-SA 4.0; adding the YAKE backend escalates the share-alike/AGPL constraint, etc.).Long batches on constrained hardware are expensive to restart from scratch, so the runner lets you re-enter the pipeline at any stage instead of redoing completed work. Recovery operates at two complementary levels.
Document-level (automatic). Every stage already skips inputs whose output exists
(steps 2β4 via [ -f "$out" ] && continue; the LLM stage logs already_exists and
moves on), so simply re-running the same command picks up where the previous run
stopped. When the LLM stage abandons a document after 10 consecutive inference
errors it writes a *_enriched.abort.json sidecar next to the partial output (see
LLM Inputs and Outputs); that marker is the canonical signal
that a document holds partial results and should be re-run.
Pipeline-level starting points. To skip whole stages β not just completed documents β the runner accepts explicit entry points over the full stage order:
| Flag | Effect |
|---|---|
--start-from <stage> | Run from <stage> onward; every earlier stage is skipped. |
--skip-<stage> | Skip one named stage, run the rest. |
--clean-state | Sweep stale .state_*.json sidecars from PARADATA_DIR before running. |
<stage> is one of manifest, udp, nt, stats, keywords, llm (the canonical
order; keywords/llm require their --kw/--llm flags to be part of the run).
Each skip flag also has an equivalent SKIP_<STAGE>=true knob that can live in
config_api.txt π (e.g. SKIP_MANIFEST=true), so a habitual resume
profile can be persisted without retyping flags.
# UDPipe + NameTag already finished β resume at statistics, then keywords
python3 run_pipeline.py --kw --start-from stats
# Re-run only NER and statistics; keep the existing manifest and CoNLL-U
python3 run_pipeline.py --skip-manifest --skip-udp
Skipped stages are recorded under skipped_stages in the merged
<runid>_nlp-enrich_pipeline-run.json record, so a resumed run remains fully
auditable. An all-skipped run is treated as a successful resume, not an empty
failure (see Exit codes below).
When the runner (or its Docker entrypoint) is started with the
ATRIUM_RUNNER_IMAGE, ATRIUM_RUNNER_REPO, and ATRIUM_RUNNER_REF environment
variables set, those values are forwarded to every stage subprocess and end up
in each stage's paradata record (and therefore the merged record). This ties a
run back to the exact image/commit that produced it.
ATRIUM_RUNNER_IMAGE="ghcr.io/ufal/atrium-nlp-enrich:v0.11.0" \
ATRIUM_RUNNER_REF="$(git rev-parse --short HEAD)" \
python3 run_pipeline.py --kw
| Code | Meaning |
|---|---|
0 | All requested stages completed; nothing flagged. |
1 | A stage processed nothing despite having input and no resume, and FAIL_ON_EMPTY=true (the default). |
2 | A required stage script was not found. |
3 | A dependency preflight failed (e.g. --kw-method keybert without keybert/sentence-transformers, or --llm without the requirements_llm.txt π stack). |
β 0 | A stage script itself exited non-zero (its code is propagated). |
[!TIP] Using
--force(-f) overrides exit codes1,3, andβ 0. It bypasses preflight dependency crashes and forcesFAIL_ON_EMPTY=False, allowing the pipeline to continue attempting subsequent stages even if one stage crashes or processes zero files.
The empty-run guard is governed by FAIL_ON_EMPTY in config_api.txt π. A
resumed run β where every document was already complete and thus skipped, or
where a stage was skipped outright via --start-from / --skip-<stage> (see
Resume / checkpoint recovery) β is treated as
success, not an empty failure. Set FAIL_ON_EMPTY=false to permit genuinely empty
stages.
[!NOTE] The runner never re-implements stage logic: it shells out to the exact same api_1_manifest.sh β¦ api_4_stats.sh, config_api.txt, and llm_run.py π you can run by hand. Anything documented for those stages (resume behaviour, output flags, model registry, β¦) applies unchanged under the runner.
For support write to: lutsai.k@gmail.com responsible for this GitHub repository 25 π
Β©οΈ 2026 UFAL & ATRIUM
https://lindat.mff.cuni.cz/services/udpipe/api-reference.php β© β©2
https://lindat.mff.cuni.cz/services/nametag/api-reference.php β© β©2
https://ufal.mff.cuni.cz/~strakova/cnec2.0/ne-type-hierarchy.pdf β©
https://huggingface.co/Qwen/Qwen2.5-14B-Instruct-AWQ β© β©2
https://huggingface.co/google/gemma-4-26B-A4B-it β© β©2 β©3
https://huggingface.co/Qwen/Qwen3-235B-A22B-Instruct-2507 β© β©2
https://huggingface.co/meta-llama/Llama-4-Maverick-17B-128E-Instruct β©
https://huggingface.co/meta-llama/Meta-Llama-3.1-70B-Instruct β© β©2
https://huggingface.co/mistralai/Mistral-Nemo-Instruct-2407 β©
https://huggingface.co/speakleash/Bielik-11B-v3.0-Instruct β©
https://huggingface.co/meta-llama/Meta-Llama-3.1-8B-Instruct β©
https://huggingface.co/Aratako/Ministral-3-14B-Instruct-2512-BF16-TextOnly β©
364 commits
17 commits
Python
97.5%
Shell
1.5%