Platform for Evaluation and Reviewing of Multilingual Tasks: Evaluate model outputs for translation and NLP tasks with support for multimodal data (text, video, audio, images) and multiple annotation protocols (DA, ESA, ESAAI, MQM, XSTS/XSTS+R+P, cESA, and more!).
Install and run locally without cloning:
pip install pearmut
# Download example campaigns
wget https://raw.githubusercontent.com/zouharvi/pearmut/refs/heads/main/examples/cesa.json
wget https://raw.githubusercontent.com/zouharvi/pearmut/refs/heads/main/examples/da.json
# Load and start
pearmut add cesa.json da.json
pearmut run
Campaigns are defined in JSON files (see examples/). The simplest configuration uses task-based assignment where each user has pre-defined tasks:
{
"info": {
"assignment": "task-based",
# DA: scores
# ESA: error spans and scores
# cESA: error spans and scores, comparison of multiple translations
# MQM: error spans, categories, and scores
"protocol": "ESA",
},
"campaign_id": "wmt25_#_en-cs_CZ",
"data": [
# data for first task/user
[
[
# each evaluation item is a document
{
"instructions": "Evaluate translation from en to cs_CZ", # message to show to users above the first item
"src": "This will be the year that Guinness loses its cool. Cheers to that!",
"tgt": {"modelA": "Nevím přesně, kdy jsem to poprvé zaznamenal. Možná to bylo ve chvíli, ..."},
"item_id": "first item in first document"
},
{
"src": "I'm not sure I can remember exactly when I sensed it. Maybe it was when some...",
"tgt": {"modelA": "Tohle bude rok, kdy Guinness přijde o svůj „cool“ faktor. Na zdraví!"},
"item_id": "second item in first document"
}
...
],
# more document
...
],
# data for second task/user
[
...
],
# arbitrary number of users (each corresponds to a single URL to be shared)
]
}
Each item has to have tgt (dictionary from model names to strings, even for a single model evaluation).
Optionally, you can also include src (source string) and/or ref (reference string).
If neither src nor ref is provided, only the model outputs will be displayed.
For full Pearmut functionality (e.g. automatic statistical analysis), add item_id as well.
Any other keys that you add will simply be stored in the logs.
Load campaigns and start the server:
pearmut add my_campaign.json # Use -o/--overwrite to replace existing
pearmut run
task-based: Each user has predefined itemssingle-stream: All users draw from a shared pool (random assignment)dynamic: Items are dynamically assigned based on current model performance (see Dynamic Assignment)By default, Pearmut randomly shuffles the order in which models are shown per each item in order to avoid positional bias.
The shuffle parameter in campaign info controls this behavior:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"seed": 123, # For full reproducibility of shuffled models
"shuffle": true # Default: true. Set to false to disable shuffling.
},
"campaign_id": "my_campaign",
"data": [...]
}
Documents in data_welcome and data_goodbye are not shuffled and so don't require to have the same models in all documents.
By default, model names are hidden to avoid biasing annotators. To display model names on top of each output block, set show_model_names to true:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"show_model_names": true # Default: false.
},
"campaign_id": "my_campaign",
"data": [...]
}
For ESA/cESA campaigns, you can set default values for the annotator settings panel directly in campaign info:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"show_alignment": true, # Default: true
"word_level": false # Default: false
},
"campaign_id": "my_campaign",
"data": [...]
}
These act as defaults. If an annotator already has a saved preference in their browser, that saved preference is used.
For multi-dimensional evaluation tasks (e.g., assessing fluency on a Likert scale), you can define custom sliders with specific ranges and steps:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"sliders": [
{"name": "Fluency", "min": 0, "max": 5, "step": 1},
{"name": "Adequacy", "min": 0, "max": 100, "step": 1}
]
},
"campaign_id": "my_campaign",
"data": [...]
}
When sliders is specified, only the custom sliders are shown. Each slider must have name, min, max, and step properties. All sliders must be answered before proceeding.
By default, cESA uses a single score slider which is colored relative to other translations of the same segment. This can be turned on or off using slider_colors parameter in info.
To enable a textfield for commenting, post-editing or translation tasks, use the textfield parameter in info. The textfield content is stored in annotations alongside scores and error spans.
{
"info": {
"protocol": "DA",
"textfield": "prefilled" # Options: null, "hidden", "visible", "prefilled"
}
}
Textfield modes:
null or omitted: No textfield (default)"hidden": Textfield hidden by default, shown by clicking a button"visible": Textfield always visible"prefilled": Textfield visible and pre-filled with model output for post-editingFor MQM protocol campaigns, you can define a custom error taxonomy instead of using the default MQM categories. Specify mqm_categories in the campaign info section as a dictionary mapping main categories to lists of subcategories:
{
"info": {
"assignment": "task-based",
"protocol": "MQM",
"mqm_categories": {
"General": ["Accuracy", "Fluency"],
"Audio-specific": ["Inaudible", "Background noise", "Speaker overlap", "Misinterpretation"],
"Style": ["Awkward", "Embarassing"],
"Unknown": [] # Category with no subcategories
}
},
"campaign_id": "custom_mqm_example",
"data": [...]
}
If mqm_categories is not provided, the default MQM taxonomy will be used. The empty string key "" provides an unselected state in the dropdown. Categories with empty subcategory lists (e.g., "Style": []) do not require a subcategory selection.
The severity levels can also be customized via mqm_severities (default: ["Minor", "Major"]):
"mqm_severities": ["Neutral", "Minor", "Major", "Critical"]
See examples/custom_mqm.json for a complete example.
You can also set "mqm_categories": "input" so that the annotators fill in the category from scratch. Useful for explorative analyses.
By default, ESA, cESA, and MQM protocols allow annotating missing content with a [missing] token.
You can customize this by providing a list of strings in the special_tokens array inside campaign info.
For example: ["[missing]", "[untranslated]"].
Set campaign-level instructions using the instructions field in info (supports HTML).
Instructions default to protocol-specific ones (DA: scoring, ESA: error spans + scoring, MQM: error spans + categories + scoring).
{
"info": {
"protocol": "DA",
"instructions": "Rate translation quality on a 0-100 scale.<br>Pay special attention to document-level phenomena."
}
}
Include error_spans to pre-fill annotations that users can review, modify, or delete:
{
"src": "The quick brown fox jumps over the lazy dog.",
"tgt": {"modelA": "Rychlá hnědá liška skáče přes líného psa."},
"error_spans": {
"modelA": [
{
"start_i": 0, # character index start (inclusive)
"end_i": 5, # character index end (inclusive)
"severity": "minor", # "minor", "major", "neutral", or null
"category": null # MQM category string or null
},
{
"start_i": 27,
"end_i": 32,
"severity": "major",
"category": null
}
]
}
}
The error_spans field is a 2D array (one per candidate). See examples/esaai_prefilled.json.
Add validation rules for tutorials or attention checks. For tutorial items that users should be able to skip if they've seen them before, add skippable: true at the item level:
{
"src": "The quick brown fox jumps.",
"tgt": {"modelA": "Rychlá hnědá liška skáče."},
"skippable": true, # Show skip button for incomplete items
"validation": {
"modelA": {
"warning": "Please set score between 70-80.", # shown on failure (omit for silent logging)
"score": [70, 80], # required score range [min, max]
"error_spans": [{"start_i": [0, 2], "end_i": [4, 8], "severity": "minor"}] # expected spans
}
}
}
Types:
skippable: true and warning to let users skip after feedbackwarning without skippable to force retrywarning to log failures without notification (quality control)The validation field is an array (one per candidate). Dashboard shows ✅/❌ based on validation_threshold in info (integer for max failed count, float [0,1) for max proportion, default 0).
Score comparison: Use score_greaterthan to ensure one candidate scores higher than another:
{
"src": "AI transforms industries.",
"tgt": {"A": "UI transformuje průmysly.", "B": "Umělá inteligence mění obory."},
"validation": {
"A": [
{"warning": "A has error, score 20-40.", "score": [20, 40]}
],
"B": [
{"warning": "B is correct and must score higher than A.", "score": [70, 90], "score_greaterthan": "A"}
]
}
}
The score_greaterthan field specifies the index of the candidate that must have a lower score than the current candidate.
See examples/tutorial/esa_deen.json for a mock campaign with a fully prepared ESA tutorial.
To use it, simply extract the data attribute and prefix it to each task in your campaign.
data_welcomeUse data_welcome to add tutorial items that users must complete before starting regular tasks. The structure is a list of documents (same as data). Welcome items have IDs welcome_0, welcome_1, etc. and are tracked separately via progress_welcome.
This works similarly with data_goodbye.
data_randomUse data_random to add items that will be shown to users randomly during their annotation process (with a probability data_random_prob set in info).
This is useful for attention checks or quality control. Random items are drawn from this list such that a user will not see the same random item twice.
They are internally indexed as random_0, random_1, etc., but do not appear in the progress bar.
Collect user information (demographics, expertise) before annotation tasks using form items in data_welcome.
Form items have text (label/question) and form (field type: null, "string", "number", "choices", and "script").
Documents must be homogeneous: all form items or all evaluation items.
{
"data_welcome": [
[
{"text": "What is your native language?", "form": "string"},
{"text": "Rate your expertise (1-10)", "form": "number"}
]
]
}
It is possible to automatically collect additional information from the host system using "script" field type.
Typically such a form document (or their sequence) would be stored in "data_welcome" such that it is both mandatory and show to all users.
See examples/user_info_form.json.
All annotators draw from a shared pool with random assignment:
{
"campaign_id": "my campaign 6",
"info": {
"assignment": "single-stream",
# DA: scores
# MQM: error spans and categories
# ESA: error spans and scores
"protocol": "ESA",
"users": 50, # number of annotators (can also be a list, see below)
"docs_per_user": 10, # optional: show goodbye after N documents per user
},
"data": [...], # list of all items (shared among all annotators)
}
Set docs_per_user to limit how many documents each user annotates before seeing the goodbye message (for single-stream, this is the number of documents).
The dynamic assignment type intelligently selects items based on current model performance to focus annotation effort on top-performing models using contrastive comparisons.
All items must contain outputs from all models for this assignment type to work properly.
{
"campaign_id": "my dynamic campaign",
"info": {
"assignment": "dynamic",
"protocol": "ESA",
"users": 10, # number of annotators
"dynamic_models": 2, # how many models to compare per item (optional, default: 1)
"dynamic_coldstart": 5, # annotations per model before dynamic kicks in (optional, default: 5)
"dynamic_coldstart_pool": 5, # optional: from how many of the first N items the coldstart should sample from.
# this is useful if you want to ensure that coldstarted models are evaluated on the same set
"docs_per_user": 20, # optional: show goodbye after N documents per user
},
"data": [...], # list of all items (shared among all annotators)
}
Set docs_per_user to limit how many documents each user annotates before seeing the goodbye message (for dynamic, this is roughly the number of documents × models).
How it works:
dynamic_coldstart annotations with fully random contrastive evaluationdynamic_models models are randomly selected for each itemThis approach efficiently focuses annotation resources on distinguishing between the best-performing models while ensuring all models get adequate baseline coverage. The contrastive evaluation allows for direct comparison of multiple models simultaneously. For an example, see examples/dynamic.json.
The users field accepts:
50): Generate random user IDs["alice", "bob"]): Use specific user IDs{
"info": {
...
"users": [
{"user_id": "alice", "token_pass": "alice_done", "token_fail": "alice_fail"},
{"user_id": "bob", "token_pass": "bob_done"} # missing tokens are auto-generated
],
},
...
}
Support for HTML-compatible elements (YouTube embeds, <video> tags, images). Ensure elements are pre-styled. See examples/multimodal.json.
Some basic HTML tags are supported: b, strong, em, i, br, h1, h2, h3, p. Newlines \n are converted to <br>.
Host local assets (audio, images, videos) by manually copying them into the data/assets/ directory.
This directory is automatically created by the server upon startup and becomes accessible at the /assets/ URL path.
For example, if you place a video at data/assets/video.mp4, it will be available at localhost:8001/assets/video.mp4.
You can then reference this URL in your campaign definitions by data/assets/video.mp4.
pearmut add <file(s)>: Add campaign JSON files (supports wildcards). Campaign JSON file can also be a list of multiple campaign dictionaries.
-o/--overwrite: Replace existing campaigns with same ID--url <url>: Server URL prefix (default: http://localhost:8001)pearmut run: Start server
--port <port>: Server port (default: 8001)--url <url>: Server URL prefixpearmut add-existing --campaigns <file> --progress <file> --annotations <file>: Import campaigns with existing progress and annotations. Use downloaded files.
-o/--overwrite: Replace existing campaigns with same IDpearmut bake-existing --campaigns <file> --progress <file> --annotations <file>: Bake an existing campaign into a static standalone web frontend. Use downloaded files.
-o/--output: Specify the output directory (default: baked_output)pearmut purge [campaign]: Remove campaign data
PEARMUT_ROOT=<path> pearmut : User pearmut with custom root directoryPEARMUT_TOKEN_MAIN=<token> pearmut run: Main admin tokenManagement link (shown when adding campaigns or running server) provides:
Completion tokens are shown at annotation end for verification (download correct tokens from dashboard). Incorrect tokens can be shown if quality control fails.
When tokens are supplied, the dashboard will try to show model rankings based on the names in the dictionaries.
Customize the goodbye message shown to users when they complete all annotations using the instructions_goodbye field in campaign info. Supports arbitrary HTML for styling and formatting with variable replacement: ${TOKEN} (completion token) and ${USER_ID} (user ID). Default: "If someone asks you for a token of completion, show them: ${TOKEN}".
Use task-based assignment with Prolific. For each task, Pearmut generates a unique URL which can be uploaded to Prolific's interface. Add redirect (on completion) to instructions_goodbye:
"instructions_goodbye": "<a href='https://app.prolific.com/submissions/complete?cc=${TOKEN}'>Click here to return to Prolific</a>"
The ${TOKEN} is automatically replaced based on passing attention checks (see Attention checks and Pre-defined tokens).
Add "show_progress": false to the campaign json to hide the progress bar from the annotator dashboard.
"wmt25_#_en-cs_CZ"). Used to reference and manage specific campaigns. Typically a campaign is created for a specific language and domain.token_pass): Shown when user meets validation thresholdstoken_fail): Shown when user fails to meet validation requirementsskippable: true can be skipped if users have already completed the tutorial."GPT-4", "Claude"). Used for tracking and ranking model performance.minor, major)annotate template supports comparing multiple outputs simultaneously.Server responds to data-only requests from frontend (no template coupling). Frontend served from pre-built static/ on install.
cd pearmut
# Frontend (separate terminal, recompiles on change)
npm install web/ --prefix web/
npm run build --prefix web/
# optionally keep running indefinitely to auto-rebuild
npm run watch --prefix web/
# Install as editable
pip3 install -e .
# Load examples
pearmut add examples/wmt25_#_en-cs_CZ.json examples/wmt25_#_cs-de_DE.json
pearmut run
web/srcwebpack.config.jsinfo->template in campaign JSONSee web/src/annotate.ts for example.
Pearmut allows you to export existing campaigns and their annotations into a standalone static site that can be hosted anywhere (e.g., GitHub Pages, FTP) without needing a Python backend server. This is especially useful for creating a publicly browsable, read-only archive of completed annotation campaigns to share alongside a publication.
How to bake a frontend:
progress.json and annotations.jsonl files. You will also need the original campaign source data (e.g., campaign.json).bake-existing command, providing the campaign data, progress, and exported annotations:
pearmut bake-existing campaign.json --progress path/to/progress.json --annotations path/to/annotations.jsonl -o my_baked_site/
my_baked_site/ directory to any static file hosting service.?baked flag and the campaign ID. For example: your-domain.com?baked&campaign_id=my_campaign. The interface will load the annotations and allow users to page through all annotated items and interact with the interface without it being saved.Run on public server or tunnel local port to public IP/domain and run locally.
If you use this work in your paper, please cite as following.
@misc{zouhar2026pearmut,
title={Pearmut: Human Evaluation of Translation Made Trivial},
author={Vilém Zouhar and Tom Kocmi},
year={2026},
eprint={2601.02933},
archivePrefix={arXiv},
primaryClass={cs.CL},
url={https://arxiv.org/abs/2601.02933},
}
Contributions are welcome! Please reach out to Vilém Zouhar. See changes in CHANGELOG.md.
Python
59.6%
TypeScript
34.8%
HTML
3.0%
CSS
1.6%
Platform for Evaluation and Reviewing of Multilingual Tasks: Evaluate model outputs for translation and NLP tasks with support for multimodal data (text, video, audio, images) and multiple annotation protocols (DA, ESA, ESAAI, MQM, XSTS/XSTS+R+P, cESA, and more!).
Install and run locally without cloning:
pip install pearmut
# Download example campaigns
wget https://raw.githubusercontent.com/zouharvi/pearmut/refs/heads/main/examples/cesa.json
wget https://raw.githubusercontent.com/zouharvi/pearmut/refs/heads/main/examples/da.json
# Load and start
pearmut add cesa.json da.json
pearmut run
Campaigns are defined in JSON files (see examples/). The simplest configuration uses task-based assignment where each user has pre-defined tasks:
{
"info": {
"assignment": "task-based",
# DA: scores
# ESA: error spans and scores
# cESA: error spans and scores, comparison of multiple translations
# MQM: error spans, categories, and scores
"protocol": "ESA",
},
"campaign_id": "wmt25_#_en-cs_CZ",
"data": [
# data for first task/user
[
[
# each evaluation item is a document
{
"instructions": "Evaluate translation from en to cs_CZ", # message to show to users above the first item
"src": "This will be the year that Guinness loses its cool. Cheers to that!",
"tgt": {"modelA": "Nevím přesně, kdy jsem to poprvé zaznamenal. Možná to bylo ve chvíli, ..."},
"item_id": "first item in first document"
},
{
"src": "I'm not sure I can remember exactly when I sensed it. Maybe it was when some...",
"tgt": {"modelA": "Tohle bude rok, kdy Guinness přijde o svůj „cool“ faktor. Na zdraví!"},
"item_id": "second item in first document"
}
...
],
# more document
...
],
# data for second task/user
[
...
],
# arbitrary number of users (each corresponds to a single URL to be shared)
]
}
Each item has to have tgt (dictionary from model names to strings, even for a single model evaluation).
Optionally, you can also include src (source string) and/or ref (reference string).
If neither src nor ref is provided, only the model outputs will be displayed.
For full Pearmut functionality (e.g. automatic statistical analysis), add item_id as well.
Any other keys that you add will simply be stored in the logs.
Load campaigns and start the server:
pearmut add my_campaign.json # Use -o/--overwrite to replace existing
pearmut run
task-based: Each user has predefined itemssingle-stream: All users draw from a shared pool (random assignment)dynamic: Items are dynamically assigned based on current model performance (see Dynamic Assignment)By default, Pearmut randomly shuffles the order in which models are shown per each item in order to avoid positional bias.
The shuffle parameter in campaign info controls this behavior:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"seed": 123, # For full reproducibility of shuffled models
"shuffle": true # Default: true. Set to false to disable shuffling.
},
"campaign_id": "my_campaign",
"data": [...]
}
Documents in data_welcome and data_goodbye are not shuffled and so don't require to have the same models in all documents.
By default, model names are hidden to avoid biasing annotators. To display model names on top of each output block, set show_model_names to true:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"show_model_names": true # Default: false.
},
"campaign_id": "my_campaign",
"data": [...]
}
For ESA/cESA campaigns, you can set default values for the annotator settings panel directly in campaign info:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"show_alignment": true, # Default: true
"word_level": false # Default: false
},
"campaign_id": "my_campaign",
"data": [...]
}
These act as defaults. If an annotator already has a saved preference in their browser, that saved preference is used.
For multi-dimensional evaluation tasks (e.g., assessing fluency on a Likert scale), you can define custom sliders with specific ranges and steps:
{
"info": {
"assignment": "task-based",
"protocol": "ESA",
"sliders": [
{"name": "Fluency", "min": 0, "max": 5, "step": 1},
{"name": "Adequacy", "min": 0, "max": 100, "step": 1}
]
},
"campaign_id": "my_campaign",
"data": [...]
}
When sliders is specified, only the custom sliders are shown. Each slider must have name, min, max, and step properties. All sliders must be answered before proceeding.
By default, cESA uses a single score slider which is colored relative to other translations of the same segment. This can be turned on or off using slider_colors parameter in info.
To enable a textfield for commenting, post-editing or translation tasks, use the textfield parameter in info. The textfield content is stored in annotations alongside scores and error spans.
{
"info": {
"protocol": "DA",
"textfield": "prefilled" # Options: null, "hidden", "visible", "prefilled"
}
}
Textfield modes:
null or omitted: No textfield (default)"hidden": Textfield hidden by default, shown by clicking a button"visible": Textfield always visible"prefilled": Textfield visible and pre-filled with model output for post-editingFor MQM protocol campaigns, you can define a custom error taxonomy instead of using the default MQM categories. Specify mqm_categories in the campaign info section as a dictionary mapping main categories to lists of subcategories:
{
"info": {
"assignment": "task-based",
"protocol": "MQM",
"mqm_categories": {
"General": ["Accuracy", "Fluency"],
"Audio-specific": ["Inaudible", "Background noise", "Speaker overlap", "Misinterpretation"],
"Style": ["Awkward", "Embarassing"],
"Unknown": [] # Category with no subcategories
}
},
"campaign_id": "custom_mqm_example",
"data": [...]
}
If mqm_categories is not provided, the default MQM taxonomy will be used. The empty string key "" provides an unselected state in the dropdown. Categories with empty subcategory lists (e.g., "Style": []) do not require a subcategory selection.
The severity levels can also be customized via mqm_severities (default: ["Minor", "Major"]):
"mqm_severities": ["Neutral", "Minor", "Major", "Critical"]
See examples/custom_mqm.json for a complete example.
You can also set "mqm_categories": "input" so that the annotators fill in the category from scratch. Useful for explorative analyses.
By default, ESA, cESA, and MQM protocols allow annotating missing content with a [missing] token.
You can customize this by providing a list of strings in the special_tokens array inside campaign info.
For example: ["[missing]", "[untranslated]"].
Set campaign-level instructions using the instructions field in info (supports HTML).
Instructions default to protocol-specific ones (DA: scoring, ESA: error spans + scoring, MQM: error spans + categories + scoring).
{
"info": {
"protocol": "DA",
"instructions": "Rate translation quality on a 0-100 scale.<br>Pay special attention to document-level phenomena."
}
}
Include error_spans to pre-fill annotations that users can review, modify, or delete:
{
"src": "The quick brown fox jumps over the lazy dog.",
"tgt": {"modelA": "Rychlá hnědá liška skáče přes líného psa."},
"error_spans": {
"modelA": [
{
"start_i": 0, # character index start (inclusive)
"end_i": 5, # character index end (inclusive)
"severity": "minor", # "minor", "major", "neutral", or null
"category": null # MQM category string or null
},
{
"start_i": 27,
"end_i": 32,
"severity": "major",
"category": null
}
]
}
}
The error_spans field is a 2D array (one per candidate). See examples/esaai_prefilled.json.
Add validation rules for tutorials or attention checks. For tutorial items that users should be able to skip if they've seen them before, add skippable: true at the item level:
{
"src": "The quick brown fox jumps.",
"tgt": {"modelA": "Rychlá hnědá liška skáče."},
"skippable": true, # Show skip button for incomplete items
"validation": {
"modelA": {
"warning": "Please set score between 70-80.", # shown on failure (omit for silent logging)
"score": [70, 80], # required score range [min, max]
"error_spans": [{"start_i": [0, 2], "end_i": [4, 8], "severity": "minor"}] # expected spans
}
}
}
Types:
skippable: true and warning to let users skip after feedbackwarning without skippable to force retrywarning to log failures without notification (quality control)The validation field is an array (one per candidate). Dashboard shows ✅/❌ based on validation_threshold in info (integer for max failed count, float [0,1) for max proportion, default 0).
Score comparison: Use score_greaterthan to ensure one candidate scores higher than another:
{
"src": "AI transforms industries.",
"tgt": {"A": "UI transformuje průmysly.", "B": "Umělá inteligence mění obory."},
"validation": {
"A": [
{"warning": "A has error, score 20-40.", "score": [20, 40]}
],
"B": [
{"warning": "B is correct and must score higher than A.", "score": [70, 90], "score_greaterthan": "A"}
]
}
}
The score_greaterthan field specifies the index of the candidate that must have a lower score than the current candidate.
See examples/tutorial/esa_deen.json for a mock campaign with a fully prepared ESA tutorial.
To use it, simply extract the data attribute and prefix it to each task in your campaign.
data_welcomeUse data_welcome to add tutorial items that users must complete before starting regular tasks. The structure is a list of documents (same as data). Welcome items have IDs welcome_0, welcome_1, etc. and are tracked separately via progress_welcome.
This works similarly with data_goodbye.
data_randomUse data_random to add items that will be shown to users randomly during their annotation process (with a probability data_random_prob set in info).
This is useful for attention checks or quality control. Random items are drawn from this list such that a user will not see the same random item twice.
They are internally indexed as random_0, random_1, etc., but do not appear in the progress bar.
Collect user information (demographics, expertise) before annotation tasks using form items in data_welcome.
Form items have text (label/question) and form (field type: null, "string", "number", "choices", and "script").
Documents must be homogeneous: all form items or all evaluation items.
{
"data_welcome": [
[
{"text": "What is your native language?", "form": "string"},
{"text": "Rate your expertise (1-10)", "form": "number"}
]
]
}
It is possible to automatically collect additional information from the host system using "script" field type.
Typically such a form document (or their sequence) would be stored in "data_welcome" such that it is both mandatory and show to all users.
See examples/user_info_form.json.
All annotators draw from a shared pool with random assignment:
{
"campaign_id": "my campaign 6",
"info": {
"assignment": "single-stream",
# DA: scores
# MQM: error spans and categories
# ESA: error spans and scores
"protocol": "ESA",
"users": 50, # number of annotators (can also be a list, see below)
"docs_per_user": 10, # optional: show goodbye after N documents per user
},
"data": [...], # list of all items (shared among all annotators)
}
Set docs_per_user to limit how many documents each user annotates before seeing the goodbye message (for single-stream, this is the number of documents).
The dynamic assignment type intelligently selects items based on current model performance to focus annotation effort on top-performing models using contrastive comparisons.
All items must contain outputs from all models for this assignment type to work properly.
{
"campaign_id": "my dynamic campaign",
"info": {
"assignment": "dynamic",
"protocol": "ESA",
"users": 10, # number of annotators
"dynamic_models": 2, # how many models to compare per item (optional, default: 1)
"dynamic_coldstart": 5, # annotations per model before dynamic kicks in (optional, default: 5)
"dynamic_coldstart_pool": 5, # optional: from how many of the first N items the coldstart should sample from.
# this is useful if you want to ensure that coldstarted models are evaluated on the same set
"docs_per_user": 20, # optional: show goodbye after N documents per user
},
"data": [...], # list of all items (shared among all annotators)
}
Set docs_per_user to limit how many documents each user annotates before seeing the goodbye message (for dynamic, this is roughly the number of documents × models).
How it works:
dynamic_coldstart annotations with fully random contrastive evaluationdynamic_models models are randomly selected for each itemThis approach efficiently focuses annotation resources on distinguishing between the best-performing models while ensuring all models get adequate baseline coverage. The contrastive evaluation allows for direct comparison of multiple models simultaneously. For an example, see examples/dynamic.json.
The users field accepts:
50): Generate random user IDs["alice", "bob"]): Use specific user IDs{
"info": {
...
"users": [
{"user_id": "alice", "token_pass": "alice_done", "token_fail": "alice_fail"},
{"user_id": "bob", "token_pass": "bob_done"} # missing tokens are auto-generated
],
},
...
}
Support for HTML-compatible elements (YouTube embeds, <video> tags, images). Ensure elements are pre-styled. See examples/multimodal.json.
Some basic HTML tags are supported: b, strong, em, i, br, h1, h2, h3, p. Newlines \n are converted to <br>.
Host local assets (audio, images, videos) by manually copying them into the data/assets/ directory.
This directory is automatically created by the server upon startup and becomes accessible at the /assets/ URL path.
For example, if you place a video at data/assets/video.mp4, it will be available at localhost:8001/assets/video.mp4.
You can then reference this URL in your campaign definitions by data/assets/video.mp4.
pearmut add <file(s)>: Add campaign JSON files (supports wildcards). Campaign JSON file can also be a list of multiple campaign dictionaries.
-o/--overwrite: Replace existing campaigns with same ID--url <url>: Server URL prefix (default: http://localhost:8001)pearmut run: Start server
--port <port>: Server port (default: 8001)--url <url>: Server URL prefixpearmut add-existing --campaigns <file> --progress <file> --annotations <file>: Import campaigns with existing progress and annotations. Use downloaded files.
-o/--overwrite: Replace existing campaigns with same IDpearmut bake-existing --campaigns <file> --progress <file> --annotations <file>: Bake an existing campaign into a static standalone web frontend. Use downloaded files.
-o/--output: Specify the output directory (default: baked_output)pearmut purge [campaign]: Remove campaign data
PEARMUT_ROOT=<path> pearmut : User pearmut with custom root directoryPEARMUT_TOKEN_MAIN=<token> pearmut run: Main admin tokenManagement link (shown when adding campaigns or running server) provides:
Completion tokens are shown at annotation end for verification (download correct tokens from dashboard). Incorrect tokens can be shown if quality control fails.
When tokens are supplied, the dashboard will try to show model rankings based on the names in the dictionaries.
Customize the goodbye message shown to users when they complete all annotations using the instructions_goodbye field in campaign info. Supports arbitrary HTML for styling and formatting with variable replacement: ${TOKEN} (completion token) and ${USER_ID} (user ID). Default: "If someone asks you for a token of completion, show them: ${TOKEN}".
Use task-based assignment with Prolific. For each task, Pearmut generates a unique URL which can be uploaded to Prolific's interface. Add redirect (on completion) to instructions_goodbye:
"instructions_goodbye": "<a href='https://app.prolific.com/submissions/complete?cc=${TOKEN}'>Click here to return to Prolific</a>"
The ${TOKEN} is automatically replaced based on passing attention checks (see Attention checks and Pre-defined tokens).
Add "show_progress": false to the campaign json to hide the progress bar from the annotator dashboard.
"wmt25_#_en-cs_CZ"). Used to reference and manage specific campaigns. Typically a campaign is created for a specific language and domain.token_pass): Shown when user meets validation thresholdstoken_fail): Shown when user fails to meet validation requirementsskippable: true can be skipped if users have already completed the tutorial."GPT-4", "Claude"). Used for tracking and ranking model performance.minor, major)annotate template supports comparing multiple outputs simultaneously.Server responds to data-only requests from frontend (no template coupling). Frontend served from pre-built static/ on install.
cd pearmut
# Frontend (separate terminal, recompiles on change)
npm install web/ --prefix web/
npm run build --prefix web/
# optionally keep running indefinitely to auto-rebuild
npm run watch --prefix web/
# Install as editable
pip3 install -e .
# Load examples
pearmut add examples/wmt25_#_en-cs_CZ.json examples/wmt25_#_cs-de_DE.json
pearmut run
web/srcwebpack.config.jsinfo->template in campaign JSONSee web/src/annotate.ts for example.
Pearmut allows you to export existing campaigns and their annotations into a standalone static site that can be hosted anywhere (e.g., GitHub Pages, FTP) without needing a Python backend server. This is especially useful for creating a publicly browsable, read-only archive of completed annotation campaigns to share alongside a publication.
How to bake a frontend:
progress.json and annotations.jsonl files. You will also need the original campaign source data (e.g., campaign.json).bake-existing command, providing the campaign data, progress, and exported annotations:
pearmut bake-existing campaign.json --progress path/to/progress.json --annotations path/to/annotations.jsonl -o my_baked_site/
my_baked_site/ directory to any static file hosting service.?baked flag and the campaign ID. For example: your-domain.com?baked&campaign_id=my_campaign. The interface will load the annotations and allow users to page through all annotated items and interact with the interface without it being saved.Run on public server or tunnel local port to public IP/domain and run locally.
If you use this work in your paper, please cite as following.
@misc{zouhar2026pearmut,
title={Pearmut: Human Evaluation of Translation Made Trivial},
author={Vilém Zouhar and Tom Kocmi},
year={2026},
eprint={2601.02933},
archivePrefix={arXiv},
primaryClass={cs.CL},
url={https://arxiv.org/abs/2601.02933},
}
Contributions are welcome! Please reach out to Vilém Zouhar. See changes in CHANGELOG.md.
Python
59.6%
TypeScript
34.8%
HTML
3.0%
CSS
1.6%