import html
import json
import re
import tempfile
from functools import lru_cache
from pathlib import Path
import gradio as gr
import torch
from huggingface_hub import hf_hub_download
from inference import load_lemmatizer, load_registry, MODEL_REPO_ID, MODEL_ROOT
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
REGISTRY = load_registry("models_registry.json")
TARGET_COL_IDX = 2
BATCH_SIZE = 32
def _display_name(item):
return f"{item['language']} - {item['treebank']}"
LANGUAGES = sorted({item["language"] for item in REGISTRY.values()})
DISPLAY_TO_ID = {
_display_name(item): model_id
for model_id, item in REGISTRY.items()
}
def treebank_choices(language):
choices = []
for model_id, item in REGISTRY.items():
if item["language"] == language:
choices.append(_display_name(item))
return sorted(choices)
def default_language():
if "Old Church Slavonic" in LANGUAGES:
return "Old Church Slavonic"
return LANGUAGES[0] if LANGUAGES else None
def default_treebank(language):
choices = treebank_choices(language)
preferred = "Old Church Slavonic - PROIEL"
if preferred in choices:
return preferred
return choices[0] if choices else None
CUSTOM_CSS = """
body {
background: linear-gradient(135deg, #eaf3ff 0%, #ffffff 48%, #dbeafe 100%);
}
.gradio-container {
max-width: 980px !important;
margin: auto !important;
font-family: Arial, Helvetica, sans-serif !important;
}
#main-card {
background: #ffffff;
border: 1px solid #bfdbfe;
border-radius: 26px;
padding: 30px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.16);
}
#title {
text-align: center;
color: #020617;
font-size: 2.5rem;
font-weight: 900;
margin-bottom: 0.25rem;
}
#subtitle {
text-align: center;
color: #1e40af;
font-size: 1.05rem;
line-height: 1.55;
margin-bottom: 1.6rem;
}
#badge-row {
text-align: center;
margin-bottom: 1.2rem;
}
#badge-row span {
display: inline-block;
background: #eff6ff;
color: #1e3a8a;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 7px 13px;
margin: 4px;
font-size: 0.88rem;
font-weight: 700;
}
textarea, input, select {
border-radius: 16px !important;
border: 1.5px solid #2563eb !important;
background: #ffffff !important;
color: #020617 !important;
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.08) !important;
}
textarea:focus, input:focus {
border-color: #1d4ed8 !important;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18) !important;
}
label {
color: #020617 !important;
font-weight: 800 !important;
}
button {
background: linear-gradient(90deg, #020617, #1d4ed8) !important;
color: #ffffff !important;
border: none !important;
border-radius: 16px !important;
padding: 13px 24px !important;
font-weight: 900 !important;
font-size: 1rem !important;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.30) !important;
}
button:hover {
background: linear-gradient(90deg, #000000, #2563eb) !important;
transform: translateY(-1px);
}
#output-box textarea {
background: #f8fbff !important;
border: 1.5px solid #1d4ed8 !important;
color: #020617 !important;
font-family: Consolas, "Courier New", monospace !important;
}
#token-card {
background: #f8fbff;
border: 1.5px solid #1d4ed8;
border-radius: 18px;
padding: 18px;
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.10);
}
.lemma-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
font-size: 1rem;
}
.lemma-table th {
background: linear-gradient(90deg, #020617, #1d4ed8);
color: white;
padding: 12px 14px;
text-align: left;
font-weight: 900;
}
.lemma-table th:first-child {
border-radius: 12px 0 0 12px;
}
.lemma-table th:last-child {
border-radius: 0 12px 12px 0;
}
.lemma-table td {
background: #ffffff;
color: #020617;
padding: 12px 14px;
border-top: 1px solid #bfdbfe;
border-bottom: 1px solid #bfdbfe;
font-weight: 700;
}
.lemma-table td:first-child {
border-left: 1px solid #bfdbfe;
border-radius: 12px 0 0 12px;
}
.lemma-table td:last-child {
border-right: 1px solid #bfdbfe;
border-radius: 0 12px 12px 0;
color: #1d4ed8;
}
#note {
color: #1e3a8a;
font-size: 0.92rem;
text-align: center;
margin-top: 1rem;
font-weight: 600;
}
footer,
.api,
.settings,
a[href*="gradio.app"],
button[aria-label="Settings"] {
display: none !important;
}
"""
def make_html_table(tokens, lemmas):
if not tokens:
return ""
rows = []
for token, lemma in zip(tokens, lemmas):
rows.append(
f"""
| {html.escape(token)} |
{html.escape(lemma)} |
"""
)
return f"""
| Word |
Lemma |
{''.join(rows)}
"""
def update_treebanks(language):
choices = treebank_choices(language)
return gr.Dropdown(
choices=choices,
value=default_treebank(language),
)
def selected_model_id(display_name):
if not display_name or display_name not in DISPLAY_TO_ID:
raise ValueError("Please select a valid language and treebank.")
return DISPLAY_TO_ID[display_name]
@lru_cache(maxsize=128)
def load_vocab_chars_for_model(model_id):
item = REGISTRY[model_id]
vocab_path = hf_hub_download(
repo_id=MODEL_REPO_ID,
repo_type="model",
filename=f"{MODEL_ROOT}/{item['folder']}/{item['vocab_file']}",
)
with open(vocab_path, encoding="utf8") as f:
vocab_data = json.load(f)
return set(vocab_data["char2idx"]) - {"", "", "", ""}
def unsupported_input_for_model(text, allowed_chars, max_bad_ratio=0.60, min_checked_chars=4):
checked = []
bad = []
for ch in text:
if ch.isspace():
continue
if ch.isdigit() or ch in {".", ",", ";", ":", "!", "?", "-", "'", '"', "(", ")", "[", "]", "/"}:
continue
checked.append(ch)
if ch not in allowed_chars:
bad.append(ch)
if len(checked) < min_checked_chars:
return False, []
bad_ratio = len(bad) / len(checked)
return bad_ratio >= max_bad_ratio, sorted(set(bad))
def lemmatize_sentence(sentence, display_name):
sentence = str(sentence).strip()
if not sentence:
return "", ""
try:
model_id = selected_model_id(display_name)
except ValueError as e:
return "", str(e)
allowed_chars = load_vocab_chars_for_model(model_id)
is_bad, bad = unsupported_input_for_model(sentence, allowed_chars)
if is_bad:
return "",f"This input does not seem to match the selected language/treebank. Please select your desired language and treebank, then try again. Unsupported characters: {' '.join(bad[:20])}"
lemmatizer = load_lemmatizer(model_id, DEVICE)
tokens = sentence.split()
lemmas = lemmatizer.lemmatize_sentence(tokens)
lemmatized_sentence = " ".join(lemmas)
token_html = make_html_table(tokens, lemmas)
return lemmatized_sentence, token_html
def parse_conllu_sentences_from_text(text):
text = text.strip()
sents = []
for block in re.split(r"\n\n+", text):
sent = []
for line in block.splitlines():
if not line or line.startswith("#"):
continue
cols = line.split("\t")
if len(cols) != 10:
continue
tok_id = cols[0]
if "-" in tok_id or "." in tok_id:
continue
form = cols[1]
sent.append(form)
if sent:
sents.append(sent)
return sents
def make_source_for_token(tokens, index, k_context, sep_char):
form = tokens[index]
left_context = tokens[max(0, index - k_context):index]
right_context = tokens[index + 1:index + 1 + k_context]
left = " ".join(left_context).strip()
right = " ".join(right_context).strip()
src_left = left + " " if left else ""
src_right = " " + right if right else ""
return f"{src_left}{sep_char}{form}{sep_char}{src_right}"
def make_all_sources_from_conllu(text, lemmatizer):
sents = parse_conllu_sentences_from_text(text)
sources = []
for tokens in sents:
for i in range(len(tokens)):
src_string = make_source_for_token(
tokens=tokens,
index=i,
k_context=lemmatizer.k_context,
sep_char=lemmatizer.sep_char,
)
sources.append(src_string)
return sources
def predict_sources_batched(sources, lemmatizer, batch_size=BATCH_SIZE):
preds_all = []
if not sources:
return preds_all
pad_id = lemmatizer.vocab.char2idx[""]
sos_id = lemmatizer.vocab.char2idx[""]
eos_id = lemmatizer.vocab.char2idx[""]
for start in range(0, len(sources), batch_size):
batch_sources = sources[start:start + batch_size]
src_ids_list = []
src_lens = []
for src_string in batch_sources:
src_ids = (
[sos_id]
+ lemmatizer.vocab.encode(src_string)
+ [eos_id]
)
src_ids_list.append(src_ids)
src_lens.append(len(src_ids))
max_len = max(src_lens)
padded = [
ids + [pad_id] * (max_len - len(ids))
for ids in src_ids_list
]
src = torch.tensor(
padded,
dtype=torch.long,
device=lemmatizer.device,
)
src_lens_tensor = torch.tensor(
src_lens,
dtype=torch.long,
device=lemmatizer.device,
)
batch_preds = lemmatizer.model.generate(
src,
src_lens_tensor,
lemmatizer.vocab,
max_len=lemmatizer.max_gen_len,
)
preds_all.extend(batch_preds)
return preds_all
def predict_conllu_lemmas(text, lemmatizer):
sources = make_all_sources_from_conllu(text, lemmatizer)
return predict_sources_batched(
sources=sources,
lemmatizer=lemmatizer,
batch_size=BATCH_SIZE,
)
def write_back_conllu(input_text, preds_all):
text = input_text.rstrip("\n")
blocks = re.split(r"\n\n+", text)
out_blocks = []
p = 0
for block in blocks:
lines = block.split("\n")
new_lines = []
for line in lines:
if not line or line.startswith("#"):
new_lines.append(line)
continue
cols = line.split("\t")
if len(cols) != 10:
new_lines.append(line)
continue
tok_id = cols[0]
if "-" in tok_id or "." in tok_id:
new_lines.append(line)
continue
pred = preds_all[p] if p < len(preds_all) else "_"
cols[TARGET_COL_IDX] = pred if pred else "_"
new_lines.append("\t".join(cols))
p += 1
out_blocks.append("\n".join(new_lines))
output_text = "\n\n".join(out_blocks).rstrip() + "\n\n"
return output_text, p
def lemmatize_conllu_file(file_obj, display_name):
if file_obj is None:
return gr.update(value=None, visible=False), "Please upload a CoNLL-U file."
try:
model_id = selected_model_id(display_name)
except ValueError as e:
return gr.update(value=None, visible=False), str(e)
input_path = Path(file_obj.name)
with open(input_path, encoding="utf8") as f:
text = f.read()
lemmatizer = load_lemmatizer(model_id, DEVICE)
preds = predict_conllu_lemmas(text, lemmatizer)
output_text, total = write_back_conllu(text, preds)
safe_model_name = display_name.replace(" ", "_").replace("-", "_")
safe_model_name = re.sub(r"[^A-Za-z0-9_]+", "", safe_model_name)
out_path = (
Path(tempfile.gettempdir())
/ f"{input_path.stem}.{safe_model_name}.lemmatized.conllu"
)
with open(out_path, "w", encoding="utf8", newline="\n") as f:
f.write(output_text)
message = (
f"Done. Wrote {total:,} lemma predictions.\n"
f"Input used: FORM column only.\n"
f"Updated column: LEMMA only.\n"
f"All other CoNLL-U columns and comments were preserved."
)
return gr.update(value=str(out_path), visible=True), message
def reset_download_button(file_obj):
return gr.update(value=None, visible=False), ""
DEFAULT_LANGUAGE = default_language()
DEFAULT_TREEBANK = default_treebank(DEFAULT_LANGUAGE)
with gr.Blocks(
title="oldslaviclemma",
css=CUSTOM_CSS,
theme=gr.themes.Soft(
primary_hue="blue",
secondary_hue="sky",
neutral_hue="slate",
),
) as demo:
with gr.Column(elem_id="main-card"):
gr.Markdown("# oldslaviclemma", elem_id="title")
gr.Markdown(
"Select a language and treebank. Paste one sentence or upload a tokenized CoNLL-U file. "
"The system returns lemma predictions while preserving the original tokenization.",
elem_id="subtitle",
)
gr.HTML(
"""
oldslaviclemma
60+ languages
110+ treebanks
UD v2.12
Lemmatization
"""
)
with gr.Row():
language_input = gr.Dropdown(
label="Language",
choices=LANGUAGES,
value=DEFAULT_LANGUAGE,
)
treebank_input = gr.Dropdown(
label="Treebank",
choices=treebank_choices(DEFAULT_LANGUAGE),
value=DEFAULT_TREEBANK,
)
with gr.Tab("Sentence input"):
sentence_input = gr.Textbox(
label="Input sentence",
lines=5,
value="",
placeholder="Paste a sentence with words separated by spaces...",
)
run_button = gr.Button("Lemmatize sentence")
sentence_output = gr.Textbox(
label="Lemmatized sentence",
lines=5,
elem_id="output-box",
)
token_output = gr.HTML(
label="Word-level output",
value="",
)
gr.Markdown(
"Please paste one sentence with whitespace tokenization.",
elem_id="note",
)
with gr.Tab("CoNLL-U file input"):
gr.Markdown(
"Upload an already-tokenized CoNLL-U file. "
"The app reads the FORM column, predicts the LEMMA column, "
"and preserves comments, token IDs, UPOS, XPOS, FEATS, HEAD, DEPREL, DEPS, and MISC."
)
conllu_input = gr.File(
label="Upload CoNLL-U file",
file_types=[".conllu", ".txt"],
)
conllu_button = gr.Button("Lemmatize CoNLL-U file")
conllu_output = gr.DownloadButton(
label="Download lemmatized CoNLL-U file",
value=None,
visible=False,
)
conllu_message = gr.Textbox(
label="Status",
lines=4,
)
language_input.change(
fn=update_treebanks,
inputs=language_input,
outputs=treebank_input,
)
run_button.click(
fn=lemmatize_sentence,
inputs=[sentence_input, treebank_input],
outputs=[sentence_output, token_output],
)
conllu_input.change(
fn=reset_download_button,
inputs=conllu_input,
outputs=[conllu_output, conllu_message],
)
conllu_button.click(
fn=lemmatize_conllu_file,
inputs=[conllu_input, treebank_input],
outputs=[conllu_output, conllu_message],
)
demo.queue()
demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)