A small, Python-native workbench for turning unstructured text into reviewable span annotations.
Alpha: spanselector is a local, single-user prototype. Drafts live in process memory until you export them. It is not yet a hosted labeling platform or a persistence layer.
Human-in-the-loop experiments often need a focused review surface long before they need a full annotation platform. spanselector keeps that first loop deliberately small:
- Load text records from a common local format.
- Define labels in a readable YAML schema.
- Select a span and assign a label.
- Review annotations in context.
- Export the labeled dataset as JSONL.
The useful part to showcase is the seam between Python data models and a human review workflow, not infrastructure around accounts, queues, consensus, or model training.
- CSV, TSV, JSONL, NDJSON, and plain-text input
- Schema-driven labels with validated, deterministic colors
- Read-only schema preview with label colors and number-key mappings
- Best-effort and strict annotation policies with explicit event outcomes
- Offset-based span annotations with overlap protection
- Automatically retained in-session drafts and multiple-event navigation
- UTF-8 JSONL export containing source text, metadata, schema, and annotations
- Environment-based configuration through Pydantic Settings
- A responsive NiceGUI interface with reduced-motion support
- Contextual label actions with focus-scoped keyboard shortcuts and Undo
The project uses uv, but a regular editable install also works.
git clone https://github.com/wiperpaul/span-annotator.git
cd span-annotator
uv sync --extra dev
uv run spanselectorOpen http://localhost:8080. To use pip instead:
python -m venv .venv
python -m pip install -e ".[dev]"
spanselectordocker compose up --buildThe container runs as a non-root user and exposes the app on port 8080.
Copy .env.example to .env, then point DATA_PATH at a local input file. CSV, TSV, JSONL,
NDJSON, and TXT are supported. The legacy SAMPLE_CSV name is still accepted.
SCHEMA_NAME=Customer Support Ticket
SCHEMA_DIR=src/spanselector/schemas
DATA_PATH=src/spanselector/data/sample.csv
HOST=127.0.0.1
PORT=8080Structured files need a text column named text, message, log, content, body, raw,
event, or payload. IDs are read from event_id, eventid, or id; otherwise stable row-based
IDs are generated. Duplicate IDs are rejected because they would make annotation ownership
ambiguous.
Place YAML files in SCHEMA_DIR:
name: Customer Support Ticket
annotation_mode: strict
fields:
- CustomerName
- Product
- IssueSummary
- Priority
required_fields:
- CustomerName
- IssueSummary
colors:
Priority: "#f59e0b"annotation_mode can be best_effort or strict and defaults to best_effort. Best-effort
schemas cannot declare required fields; their unlabelled fields remain not_reviewed. Strict
schemas must declare at least one required field, and every required field must also appear in
fields. Colors are optional and assigned deterministically when missing. Field names must be
non-empty and unique, and colors must be six-digit hex values.
The bundled Customer Support schema demonstrates strict review; Project Meeting Notes demonstrates best-effort annotation.
Drag across the source text to open the contextual label tray, then click a label to apply it. For
schemas with up to nine labels, press 1–9 while the source text has focus to apply the matching
visible label directly. Escape clears a pending selection and Ctrl+Z (or Cmd+Z) undoes the
most recent assignment on the current event. Draft changes are retained automatically in process
memory.
In best-effort mode, Done & next accepts the useful spans collected so far without interpreting
unlabelled fields as negative evidence. In strict mode, each required field needs an explicit
conclusion: either assign at least one matching span or Confirm absent. Complete & next
validates those conclusions. Ctrl+Enter (or Cmd+Enter) performs the mode's primary action.
Skip for now defers the draft until the remaining events have been reviewed. Unable to label
records a terminal outcome and reason for unusable or inconclusive source text.
The compact helper at the bottom of the page opens a complete shortcut reference. Workspace
shortcuts can be disabled there; label number keys are focus-scoped and shortcuts are ignored in
editable controls and dialogs. Native text navigation always wins in the source: combinations such
as Ctrl+Left/Right and Ctrl+Shift+Left/Right continue to move or extend the selection by word.
Export JSONL includes every input event, including drafts, deferred events, and unlabelable
events. Each record includes the annotation mode, outcome, and a three-valued conclusion for every
schema field: labelled, confirmed_absent, or not_reviewed. Offsets use Python slice semantics:
start is inclusive and end is exclusive.
{"id":"ticket-17","text":"Alex cannot sign in","metadata":{"source":"email"},"schema":"Customer Support Ticket","annotation_mode":"strict","status":"completed","deferred":false,"field_conclusions":{"CustomerName":"labelled","Product":"not_reviewed","IssueSummary":"confirmed_absent","Priority":"not_reviewed"},"annotations":[{"start":0,"end":4,"text":"Alex","field":"CustomerName"}]}Including the selected text alongside offsets makes downstream integrity checks straightforward.
Included now: a trustworthy local annotation loop, clear import/export contracts, and extension points for schemas and data sources.
Intentionally deferred: authentication, multi-user isolation, server-side persistence, annotation queues, reviewer consensus, model inference, active learning, and deployment hardening. Those are valuable only after the local workflow proves useful.
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy src/spanselector
uv buildSelection is also covered by a real pointer-drag test in Chromium and Firefox. Browser binaries are an explicit extra so the regular unit-test setup stays lightweight:
uv sync --extra dev --extra e2e
uv run playwright install chromium firefox
uv run pytest tests/e2e --run-e2e --browser chromium --browser firefox --no-covWarning
Text selection is currently unreliable in VS Code's Integrated Browser even though the same app works in external Firefox and Chromium. Treat the integrated viewer as unsupported for annotation testing for now.
For a manual pass in VS Code, choose NiceGUI: Python + External Browser in Run and Debug and
press F5. The launch configuration opens the app in your system browser. Drag across the source
text and confirm that both the range and selected text update; repeat with Ctrl+A to cover keyboard
selection.
See CONTRIBUTING.md for the contribution workflow and ROADMAP.md for the next scoped increments.
spanselector is built with NiceGUI, Pydantic, Pydantic Settings, and PyYAML. Direct dependencies and their licenses are listed in THIRD_PARTY.md; the lockfile records the complete resolved dependency graph.
The interface takes principle-level inspiration from Motion, Revolut's published design principles, and Kokonut UI: purposeful motion, strong hierarchy, quiet surfaces, and clear action states. No source components, brand assets, or proprietary typefaces are copied, and this project is not affiliated with those projects or companies.
Released under the MIT License.