buntu-morpho / app.py
felerminoali's picture
Fix export: use gradio_api/file= path, update to ID-based keys
4473714
Raw
History Blame Contribute Delete
104 kB
import gradio as gr
import pandas as pd
import json
import os
import time
from pathlib import Path
from itertools import combinations
# ============================================================
# Configuration
# ============================================================
# Use HF Spaces persistent storage if available, else local
PERSISTENT_DIR = "/data" if os.path.exists("/data") else "."
DATA_DIR = os.path.join(PERSISTENT_DIR, "data")
ANNOTATIONS_DIR = os.path.join(PERSISTENT_DIR, "annotations")
OVERLAP_COUNT = 100
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(ANNOTATIONS_DIR, exist_ok=True)
# Copy bundled data to persistent storage on first run
import shutil
_local_data = os.path.join(os.path.dirname(__file__), "data")
if _local_data != DATA_DIR and os.path.exists(_local_data):
for fname in os.listdir(_local_data):
dest = os.path.join(DATA_DIR, fname)
if not os.path.exists(dest):
shutil.copy2(os.path.join(_local_data, fname), dest)
GRAMMATICAL_CLASSES = [
"noun", "verb", "adj", "adv", "pronoun", "demonstrative",
"possessive", "conjunction", "interjection", "ideophone",
"interrogative", "numeral", "copulative", "relative",
"particle", "proper_name", "foreign", "abbreviation", "other"
]
GLOSS_OPTIONS = [
"CL1", "CL2", "CL3", "CL4", "CL5", "CL6", "CL7", "CL8",
"CL9", "CL10", "CL11", "CL14", "CL15", "CL16", "CL17", "CL18",
"SP1sg", "SP2sg", "SP3sg", "SP1pl", "SP2pl", "SP3pl",
"SC.CL1", "SC.CL2", "SC.CL3", "SC.CL4", "SC.CL5",
"SC.CL6", "SC.CL7", "SC.CL8", "SC.CL9", "SC.CL10",
"OM.1SG", "OM.2SG", "OM.1PL", "OM.CL1", "OM.CL2",
"PRES", "PST", "FUT", "REC.PST", "REC.PST.LONG", "PST.CONT",
"PROG", "PERF", "HAB", "COMPL", "TAM", "SP1sg.TAM",
"CAUS", "APPL", "RECIP", "PASS", "STAT", "REV", "INTENS", "ITER",
"INF", "FV", "FV.IMP.SG", "SUBJ.FV", "NEG", "NEG.PL", "HORT", "IMP.PL",
"NMLZ", "DIM", "AUG", "FEM", "AG", "EPENTH", "root.IMBR",
"REL", "DEM", "DEM.PROX", "DEM.DIST", "POSS", "PossConc",
"PRON", "AbsPron", "COP", "CONJ", "IDEO", "INTJ", "INTERROG",
"NUM", "ABBR", "ProperName", "Foreign", "TOP", "FOC", "DET", "AGR",
]
TAG_OPTIONS = ["PREFIX", "ROOT", "SUFFIX", "INFIX"]
# ============================================================
# Data Management
# ============================================================
def get_word_list():
txt_path = os.path.join(DATA_DIR, "words.txt")
xlsx_path = os.path.join(DATA_DIR, "words.xlsx")
csv_path = os.path.join(DATA_DIR, "words.csv")
if os.path.exists(txt_path):
with open(txt_path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
elif os.path.exists(csv_path):
df = pd.read_csv(csv_path, encoding='utf-8')
if 'word' in df.columns:
return df['word'].dropna().astype(str).tolist()
return df.iloc[:, 0].dropna().astype(str).tolist()
elif os.path.exists(xlsx_path):
df = pd.read_excel(xlsx_path)
if 'word' in df.columns:
return df['word'].dropna().astype(str).tolist()
return df.iloc[:, 0].dropna().astype(str).tolist()
else:
return [
"mutthu", "atthu", "kiruma", "kinaaruma", "orumiha",
"anaakhula", "kharumeke", "orumela", "ekwaha", "ikwaha",
"murutthu", "mirutthu", "niruma", "uruma", "mukaya",
"vakaya", "okaya", "ukhuru", "ntoko", "makoho",
"orumana", "orumiwa", "kinaavara", "anavara", "ekumi",
] * 44
def get_word_assignments():
"""Load word assignment metadata (annotator column from CSV).
Returns a list of annotator assignments per word index, or None if not available."""
meta_path = os.path.join(DATA_DIR, "words_metadata.json")
if os.path.exists(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
metadata = json.load(f)
return [item.get("annotator", "all") for item in metadata]
return None
def get_overlap_indices(all_words):
"""Get indices of overlap words (annotator='all' in metadata)."""
assignments = get_word_assignments()
if assignments:
return [i for i, a in enumerate(assignments) if str(a).strip().lower() == "all"]
# Fallback: use first OVERLAP_COUNT words
return list(range(min(OVERLAP_COUNT, len(all_words))))
def get_annotations_path(annotator_id):
return os.path.join(ANNOTATIONS_DIR, f"{annotator_id}.json")
def load_annotations(annotator_id):
path = get_annotations_path(annotator_id)
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def save_annotations(annotator_id, annotations):
path = get_annotations_path(annotator_id)
with open(path, "w", encoding="utf-8") as f:
json.dump(annotations, f, ensure_ascii=False, indent=2)
def get_all_annotators():
path = os.path.join(ANNOTATIONS_DIR, "_annotators.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return []
def save_annotators(annotators):
path = os.path.join(ANNOTATIONS_DIR, "_annotators.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(annotators, f)
def get_annotator_word_list(annotator_id, all_words):
"""Get the list of word indices assigned to this annotator.
- Words with annotator='all' in metadata go to everyone (overlap).
- Words assigned to a specific annotator go only to them.
- Words with no metadata assignment are distributed round-robin.
"""
annotators = get_all_annotators()
if annotator_id not in annotators:
annotators.append(annotator_id)
save_annotators(annotators)
assignments = get_word_assignments()
if assignments:
# Use metadata-based assignment
overlap = []
assigned_to_me = []
unassigned = []
for i, assign in enumerate(assignments):
assign_lower = str(assign).strip().lower()
if assign_lower == "all":
overlap.append(i)
elif assign_lower == annotator_id.lower():
assigned_to_me.append(i)
elif assign_lower in ("", "nan", "none"):
unassigned.append(i)
# else: assigned to another annotator, skip
# Distribute unassigned words round-robin
if unassigned and len(annotators) > 0:
idx = annotators.index(annotator_id)
my_unassigned = [unassigned[i] for i in range(len(unassigned))
if i % len(annotators) == idx]
else:
my_unassigned = []
return overlap + assigned_to_me + my_unassigned
else:
# Fallback: old behavior with OVERLAP_COUNT
shared = list(range(min(OVERLAP_COUNT, len(all_words))))
remaining_count = len(all_words) - OVERLAP_COUNT
if len(annotators) <= 1 or remaining_count <= 0:
return list(range(len(all_words)))
idx = annotators.index(annotator_id)
assigned_remaining = [OVERLAP_COUNT + i for i in range(remaining_count)
if i % len(annotators) == idx]
return shared + assigned_remaining
# ============================================================
# API Functions
# ============================================================
def get_word_metadata():
"""Load word metadata if available."""
meta_path = os.path.join(DATA_DIR, "words_metadata.json")
if os.path.exists(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
return json.load(f)
return None
def get_valid_annotators():
"""Get the list of valid annotator IDs from the CSV metadata."""
metadata = get_word_metadata()
if metadata:
annotators = set()
for item in metadata:
val = str(item.get("annotator", "")).strip().lower()
if val and val not in ("all", "nan", "none", ""):
annotators.add(val)
return sorted(annotators)
# Fallback: all registered annotators
return get_all_annotators()
def _get_word_id(global_idx, metadata=None):
"""Get the word ID from metadata, falling back to the global index."""
if metadata and global_idx < len(metadata):
return str(metadata[global_idx].get("id", global_idx))
return str(global_idx)
def api_get_word(annotator_id, word_idx):
all_words = get_word_list()
# Validate annotator
valid = get_valid_annotators()
if valid and annotator_id.lower() not in [v.lower() for v in valid]:
return json.dumps({"error": f"Anotador '{annotator_id}' não encontrado. Válidos: {', '.join(valid)}"})
assigned = get_annotator_word_list(annotator_id, all_words)
annotations = load_annotations(annotator_id)
word_idx = int(word_idx)
if word_idx >= len(assigned): word_idx = len(assigned) - 1
if word_idx < 0: word_idx = 0
global_idx = assigned[word_idx]
word = all_words[global_idx]
# Get word_id from metadata
metadata = get_word_metadata()
word_id = str(global_idx) # fallback
if metadata and global_idx < len(metadata):
word_id = str(metadata[global_idx].get("id", global_idx))
done = sum(1 for idx in assigned if _get_word_id(idx, metadata) in annotations)
total = len(assigned)
# Overlap based on metadata "all" assignment
overlap_indices = get_overlap_indices(all_words)
overlap_done = sum(1 for idx in overlap_indices if _get_word_id(idx, metadata) in annotations)
overlap_total = len(overlap_indices)
is_overlap = global_idx in overlap_indices
existing = annotations.get(word_id, None)
# Get sample text from metadata
sample_text = ""
if metadata and global_idx < len(metadata):
sample_text = metadata[global_idx].get("sample_text", "")
return json.dumps({
"word": word, "word_idx": word_idx, "global_idx": global_idx,
"word_id": word_id,
"total": total, "done": done,
"overlap_done": overlap_done, "overlap_total": overlap_total,
"is_overlap": is_overlap, "existing": existing,
"sample_text": sample_text,
})
def api_save_annotation(annotator_id, global_idx, morphemes_json, lemma, gram_class, comment, traducao=""):
# Extract duration if appended
duration_seconds = 0
if '|dur:' in traducao:
parts = traducao.rsplit('|dur:', 1)
traducao = parts[0]
try:
duration_seconds = int(parts[1])
except (ValueError, IndexError):
pass
data = json.loads(morphemes_json)
morphemes = [m["text"] for m in data]
tags = [m["tag"] for m in data]
glosses = [m["gloss"] for m in data]
if not morphemes:
return json.dumps({"error": "Segmente a palavra primeiro"})
# Validation: all morphemes must have a tag; gloss required except for ROOT
for i, (t, g) in enumerate(zip(tags, glosses)):
if not t:
return json.dumps({"error": f"Morfema '{morphemes[i]}' sem Tag atribuída"})
if not g and t.upper() != 'ROOT':
return json.dumps({"error": f"Morfema '{morphemes[i]}' sem Glosa atribuída"})
if not lemma.strip():
return json.dumps({"error": "Preencha o campo Lemma"})
if not gram_class:
return json.dumps({"error": "Selecione a Classe Gramatical"})
if not traducao.strip():
return json.dumps({"error": "Preencha o campo Tradução"})
annotation = {
"word_id": str(global_idx),
"morphemes": morphemes, "tags": tags, "glosses": glosses,
"lemma": lemma.strip(), "grammatical_class": gram_class,
"comment": comment.strip(), "traducao": traducao.strip(),
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"gloss_complete": "-".join(glosses),
"duration_seconds": duration_seconds,
}
annotations = load_annotations(annotator_id)
annotations[str(global_idx)] = annotation
save_annotations(annotator_id, annotations)
return json.dumps({"success": True})
def api_next_unannotated(annotator_id, current_idx):
all_words = get_word_list()
assigned = get_annotator_word_list(annotator_id, all_words)
annotations = load_annotations(annotator_id)
metadata = get_word_metadata()
current_idx = int(current_idx)
for i in range(current_idx + 1, len(assigned)):
word_id = _get_word_id(assigned[i], metadata)
if word_id not in annotations:
return json.dumps({"word_idx": i})
return json.dumps({"word_idx": current_idx})
def export_annotations(annotator_id):
if not annotator_id: return None
annotations = load_annotations(annotator_id)
all_words = get_word_list()
metadata = get_word_metadata()
# Build id-to-index map
id_to_idx = {}
if metadata:
for i, m in enumerate(metadata):
id_to_idx[str(m.get("id", i))] = i
rows = []
for word_id, ann in sorted(annotations.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 0):
idx = id_to_idx.get(word_id, int(word_id) if word_id.isdigit() else -1)
word = all_words[idx] if 0 <= idx < len(all_words) else ann.get("word", "?")
row = {"ID_Palavra": word_id, "Palavra": word, "Lemma": ann.get("lemma", "")}
for i, (m, t, g) in enumerate(zip(ann.get("morphemes",[]), ann.get("tags",[]), ann.get("glosses",[])), 1):
row[f"Morfema_{i}"] = m; row[f"Tag_{i}"] = t; row[f"Glosa_{i}"] = g
row["Glosa_Completa"] = ann.get("gloss_complete", "")
row["Classe_Gramatical"] = ann.get("grammatical_class", "")
row["Traducao"] = ann.get("traducao", "")
row["Anotador"] = annotator_id
row["Notas"] = ann.get("comment", "")
row["Duracao_Segundos"] = ann.get("duration_seconds", 0)
rows.append(row)
if not rows: return None
df = pd.DataFrame(rows)
export_path = os.path.join(ANNOTATIONS_DIR, f"export_{annotator_id}.xlsx")
df.to_excel(export_path, index=False)
return export_path
def upload_words(file):
if file is None: return "Nenhum ficheiro selecionado"
fname = file.name if hasattr(file, 'name') else file
if fname.endswith('.csv'):
df = pd.read_csv(fname, encoding='utf-8')
if 'word' in df.columns:
words = df['word'].dropna().astype(str).tolist()
# Save full metadata for reference
df.to_json(os.path.join(DATA_DIR, "words_metadata.json"), orient='records', force_ascii=False)
else:
words = df.iloc[:, 0].dropna().astype(str).tolist()
elif fname.endswith('.xlsx'):
df = pd.read_excel(fname)
if 'word' in df.columns:
words = df['word'].dropna().astype(str).tolist()
df.to_json(os.path.join(DATA_DIR, "words_metadata.json"), orient='records', force_ascii=False)
else:
words = df.iloc[:, 0].dropna().astype(str).tolist()
elif fname.endswith('.txt'):
with open(fname, 'r', encoding='utf-8') as f:
words = [line.strip() for line in f if line.strip()]
else:
return "⚠️ Use .txt, .csv ou .xlsx"
out_path = os.path.join(DATA_DIR, "words.txt")
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(words))
return f"✅ {len(words)} palavras carregadas!"
# ============================================================
# Admin Functions
# ============================================================
def api_admin_login(password):
"""Validate admin password."""
return json.dumps({"success": password == ADMIN_PASSWORD})
def compute_cohens_kappa():
"""Compute Cohen's Kappa for inter-annotator agreement on overlap words (annotator='all')."""
all_words = get_word_list()
annotators = get_all_annotators()
if len(annotators) < 2:
return json.dumps({"error": "Necessário pelo menos 2 anotadores", "annotators": annotators})
overlap_indices = get_overlap_indices(all_words)
overlap_count = len(overlap_indices)
results = {"pairs": [], "annotators": annotators, "overlap_count": overlap_count}
# Load all annotations for overlap words
ann_data = {}
for aid in annotators:
anns = load_annotations(aid)
ann_data[aid] = anns
# For each pair of annotators, compute Kappa
for a1, a2 in combinations(annotators, 2):
labels_a1 = []
labels_a2 = []
common_count = 0
for idx in overlap_indices:
idx_str = str(idx)
if idx_str in ann_data[a1] and idx_str in ann_data[a2]:
common_count += 1
gloss1 = ann_data[a1][idx_str].get("gloss_complete", "")
gloss2 = ann_data[a2][idx_str].get("gloss_complete", "")
labels_a1.append(gloss1)
labels_a2.append(gloss2)
if common_count < 2:
results["pairs"].append({
"annotator1": a1, "annotator2": a2,
"kappa": None, "common": common_count,
"agreement_pct": None,
"message": f"Poucos itens em comum ({common_count})"
})
continue
# Compute Cohen's Kappa
agree = sum(1 for l1, l2 in zip(labels_a1, labels_a2) if l1 == l2)
po = agree / common_count
all_labels = set(labels_a1 + labels_a2)
pe = 0.0
for label in all_labels:
p1 = labels_a1.count(label) / common_count
p2 = labels_a2.count(label) / common_count
pe += p1 * p2
if pe == 1.0:
kappa = 1.0 if po == 1.0 else 0.0
else:
kappa = (po - pe) / (1 - pe)
results["pairs"].append({
"annotator1": a1, "annotator2": a2,
"kappa": round(kappa, 4),
"common": common_count,
"agreement_pct": round(po * 100, 1),
"po": round(po, 4), "pe": round(pe, 4)
})
# Per-level agreement (lemma, root, segmentation, gloss, grammatical class)
for pair in results["pairs"]:
if pair["kappa"] is None:
continue
a1, a2 = pair["annotator1"], pair["annotator2"]
lemma_agree = 0
root_agree = 0
seg_agree = 0
gloss_agree = 0
gram_agree = 0
total = 0
for idx in overlap_indices:
idx_str = str(idx)
if idx_str in ann_data[a1] and idx_str in ann_data[a2]:
total += 1
d1 = ann_data[a1][idx_str]
d2 = ann_data[a2][idx_str]
# Lemma agreement
if d1.get("lemma", "").strip().lower() == d2.get("lemma", "").strip().lower():
lemma_agree += 1
# Root agreement (morpheme tagged ROOT)
root1 = next((m for m, t in zip(d1.get("morphemes", []), d1.get("tags", [])) if t.upper() == "ROOT"), "")
root2 = next((m for m, t in zip(d2.get("morphemes", []), d2.get("tags", [])) if t.upper() == "ROOT"), "")
if root1.lower() == root2.lower():
root_agree += 1
# Segmentation agreement (full morpheme list)
if d1.get("morphemes", []) == d2.get("morphemes", []):
seg_agree += 1
# Gloss complete agreement
if d1.get("gloss_complete", "").strip() == d2.get("gloss_complete", "").strip():
gloss_agree += 1
# Grammatical class agreement
if d1.get("grammatical_class", "").strip().lower() == d2.get("grammatical_class", "").strip().lower():
gram_agree += 1
if total > 0:
pair["lemma_agreement"] = round(lemma_agree / total * 100, 1)
pair["root_agreement"] = round(root_agree / total * 100, 1)
pair["segmentation_agreement"] = round(seg_agree / total * 100, 1)
pair["gloss_agreement"] = round(gloss_agree / total * 100, 1)
pair["gram_class_agreement"] = round(gram_agree / total * 100, 1)
# Compute "all" row — agreement where ALL annotators annotated the same word
valid_pairs = [p for p in results["pairs"] if p["kappa"] is not None]
if len(annotators) >= 2:
# Find overlap words annotated by ALL annotators
all_common = 0
all_lemma = 0
all_root = 0
all_seg = 0
all_gloss = 0
all_gram = 0
for idx in overlap_indices:
idx_str = str(idx)
annotations_for_word = [ann_data[a][idx_str] for a in annotators if idx_str in ann_data[a]]
if len(annotations_for_word) < 2:
continue
all_common += 1
# All agree = all values are the same
lemmas = [d.get("lemma", "").strip().lower() for d in annotations_for_word]
if len(set(lemmas)) == 1:
all_lemma += 1
roots = [next((m for m, t in zip(d.get("morphemes", []), d.get("tags", [])) if t.upper() == "ROOT"), "") for d in annotations_for_word]
if len(set(r.lower() for r in roots)) == 1:
all_root += 1
segs = [tuple(d.get("morphemes", [])) for d in annotations_for_word]
if len(set(segs)) == 1:
all_seg += 1
glosses = [d.get("gloss_complete", "").strip() for d in annotations_for_word]
if len(set(glosses)) == 1:
all_gloss += 1
grams = [d.get("grammatical_class", "").strip().lower() for d in annotations_for_word]
if len(set(grams)) == 1:
all_gram += 1
all_row = {
"annotator1": " / ".join(annotators), "annotator2": "(todos)",
"common": all_common,
"kappa": round(sum(p["kappa"] for p in valid_pairs) / len(valid_pairs), 4) if valid_pairs else None,
"agreement_pct": round(sum(p.get("agreement_pct", 0) for p in valid_pairs) / len(valid_pairs), 1) if valid_pairs else None,
}
if all_common > 0:
all_row["lemma_agreement"] = round(all_lemma / all_common * 100, 1)
all_row["root_agreement"] = round(all_root / all_common * 100, 1)
all_row["segmentation_agreement"] = round(all_seg / all_common * 100, 1)
all_row["gloss_agreement"] = round(all_gloss / all_common * 100, 1)
all_row["gram_class_agreement"] = round(all_gram / all_common * 100, 1)
results["summary"] = all_row
return json.dumps(results)
def api_admin_delete_annotations(annotator_id, indices_json):
"""Delete specific annotations for an annotator. indices_json is a JSON array of word indices."""
try:
indices = json.loads(indices_json)
except:
return json.dumps({"error": "Formato inválido"})
if not indices:
return json.dumps({"error": "Nenhuma anotação selecionada"})
annotations = load_annotations(annotator_id)
deleted = 0
for idx in indices:
idx_str = str(idx)
if idx_str in annotations:
del annotations[idx_str]
deleted += 1
save_annotations(annotator_id, annotations)
return json.dumps({"success": True, "deleted": deleted, "message": f"✅ {deleted} anotação(ões) removida(s) de '{annotator_id}'"})
def save_annotations(annotator_id, annotations):
"""Save full annotations dict for an annotator."""
path = os.path.join(ANNOTATIONS_DIR, f"{annotator_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(annotations, f, ensure_ascii=False, indent=2)
def api_admin_delete_words(indices_json):
"""Delete words from the word list by their global indices."""
try:
indices = json.loads(indices_json)
except:
return json.dumps({"error": "Formato inválido"})
if not indices:
return json.dumps({"error": "Nenhuma palavra selecionada"})
indices_set = set(int(i) for i in indices)
# Load metadata and filter out deleted words
meta_path = os.path.join(DATA_DIR, "words_metadata.json")
words_path = os.path.join(DATA_DIR, "words.txt")
if os.path.exists(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
metadata = json.load(f)
metadata = [m for i, m in enumerate(metadata) if i not in indices_set]
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(metadata, f, ensure_ascii=False)
words = [m["word"] for m in metadata]
else:
words = get_word_list()
words = [w for i, w in enumerate(words) if i not in indices_set]
with open(words_path, "w", encoding="utf-8") as f:
f.write("\n".join(words))
return json.dumps({"success": True, "message": f"✅ {len(indices_set)} palavra(s) removida(s). Total: {len(words)}"})
def api_admin_get_valid_annotators():
"""Return valid annotators from CSV."""
return json.dumps({"annotators": get_valid_annotators()})
def api_admin_export_all():
"""Export all annotators' annotations into a single Excel file."""
annotators = get_all_annotators()
all_words = get_word_list()
metadata = get_word_metadata()
id_to_idx = {}
if metadata:
for i, m in enumerate(metadata):
id_to_idx[str(m.get("id", i))] = i
all_rows = []
for aid in annotators:
annotations = load_annotations(aid)
for word_id, ann in sorted(annotations.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 0):
idx = id_to_idx.get(word_id, int(word_id) if word_id.isdigit() else -1)
word = all_words[idx] if 0 <= idx < len(all_words) else "?"
row = {"ID_Palavra": word_id, "Palavra": word, "Anotador": aid, "Lemma": ann.get("lemma", "")}
for i, (m, t, g) in enumerate(zip(ann.get("morphemes",[]), ann.get("tags",[]), ann.get("glosses",[])), 1):
row[f"Morfema_{i}"] = m; row[f"Tag_{i}"] = t; row[f"Glosa_{i}"] = g
row["Glosa_Completa"] = ann.get("gloss_complete", "")
row["Classe_Gramatical"] = ann.get("grammatical_class", "")
row["Traducao"] = ann.get("traducao", "")
row["Notas"] = ann.get("comment", "")
row["Duracao_Segundos"] = ann.get("duration_seconds", 0)
row["Timestamp"] = ann.get("timestamp", "")
all_rows.append(row)
if not all_rows:
return None
df = pd.DataFrame(all_rows)
export_path = os.path.join(ANNOTATIONS_DIR, "export_all_annotations.xlsx")
df.to_excel(export_path, index=False)
return export_path
def api_admin_stats():
"""Get annotation statistics for admin panel."""
annotators = get_all_annotators()
all_words = get_word_list()
overlap_indices = get_overlap_indices(all_words)
overlap_count = len(overlap_indices)
stats = []
for aid in annotators:
anns = load_annotations(aid)
assigned = get_annotator_word_list(aid, all_words)
done = sum(1 for idx in assigned if str(idx) in anns)
overlap_done = sum(1 for idx in overlap_indices if str(idx) in anns)
stats.append({
"annotator": aid,
"total_assigned": len(assigned),
"done": done,
"overlap_done": overlap_done,
"overlap_total": overlap_count,
"progress_pct": round(done / len(assigned) * 100, 1) if assigned else 0
})
# Build words list with annotator assignment for word management
metadata = []
meta_path = os.path.join(DATA_DIR, "words_metadata.json")
if os.path.exists(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
metadata = json.load(f)
words_info = []
for i, w in enumerate(all_words):
ann_info = metadata[i].get("annotator", "") if i < len(metadata) else ""
words_info.append({"word": w, "annotator": ann_info})
return json.dumps({"stats": stats, "total_words": len(all_words), "total_annotators": len(annotators), "words": words_info})
def api_admin_review(annotator_id):
"""Get all annotations for a specific annotator for review."""
all_words = get_word_list()
anns = load_annotations(annotator_id)
if not anns:
return json.dumps({"error": f"Nenhuma anotação encontrada para '{annotator_id}'", "annotations": []})
rows = []
for idx_str, ann in sorted(anns.items(), key=lambda x: int(x[0])):
idx = int(idx_str)
word = all_words[idx] if idx < len(all_words) else "?"
rows.append({
"idx": idx, "word": word,
"morphemes": ann.get("morphemes", []),
"tags": ann.get("tags", []),
"glosses": ann.get("glosses", []),
"gloss_complete": ann.get("gloss_complete", ""),
"lemma": ann.get("lemma", ""),
"grammatical_class": ann.get("grammatical_class", ""),
"traducao": ann.get("traducao", ""),
"comment": ann.get("comment", ""),
"timestamp": ann.get("timestamp", "")
})
return json.dumps({"annotator": annotator_id, "count": len(rows), "annotations": rows})
# ============================================================
# HTML Frontend
# ============================================================
GUIDELINE_HTML = """
<h2>📖 Guia de Anotação Morfológica — Emakhuwa</h2>
<p style="font-size:11px;color:var(--text-secondary)">Manual do Anotador | Felermino Ali | Versão 3.0 | Junho 2026</p>
<h3>🖱️ Interação com a Ferramenta</h3>
<ul>
<li><strong>Clique</strong> entre letras da palavra para inserir uma divisão (-)</li>
<li><strong>Clique num -</strong> existente para juntar os morfemas</li>
<li><strong>Clique direito</strong> num morfema (chip abaixo) para atribuir Tag e Glosa</li>
<li><strong>Duplo-clique</strong> num morfema para editar o texto</li>
<li><strong>Ctrl+→/←</strong> para navegar | <strong>Ctrl+S</strong> para salvar</li>
</ul>
<h3>⚠️ Campos Obrigatórios</h3>
<ul>
<li><strong>Segmentação:</strong> dividir a palavra em morfemas</li>
<li><strong>Tag + Glosa:</strong> atribuir a cada morfema (use tags específicas, não genéricas)</li>
<li><strong>Lemma:</strong> forma base/citação da palavra (ex: -ruma, mutthu)</li>
<li><strong>Classe Gramatical:</strong> noun, verb, adj, adv, pron, conj, etc.</li>
<li><strong>Tradução:</strong> tradução da palavra em português</li>
</ul>
<h3>🔗 Overlap (Acordo Inter-Anotadores)</h3>
<p style="font-size:12px">Palavras marcadas como <strong>"OVERLAP"</strong> (com <code>annotator=all</code> no ficheiro) são anotadas por <u>todos</u> os anotadores para calcular o acordo (Cohen's Kappa). Anote-as com especial cuidado e precisão ≥ 98%.</p>
<h3>1. Objetivo da Anotação</h3>
<ul style="font-size:12px">
<li>Segmentar palavras do Emakhuwa em morfemas (prefixos, raízes, sufixos, infixos)</li>
<li>Etiquetar cada morfema com a tag apropriada (tags específicas, não genéricas)</li>
<li>Fornecer glosas (traduções/descrições) para cada morfema</li>
<li>Identificar a classe gramatical de cada palavra</li>
<li>Contribuir para um corpus anotado de alta qualidade (precisão ≥ 98%)</li>
</ul>
<h3>2. Conceitos Fundamentais</h3>
<table style="font-size:12px;border-collapse:collapse;width:100%">
<tr><td style="padding:4px;border-bottom:1px solid var(--border-light)"><strong>Morfema</strong></td><td style="padding:4px;border-bottom:1px solid var(--border-light)">Unidade simbólica mínima gramaticalmente significativa. Ex: <i>mu-</i> (CL1), <i>-tthu</i> (pessoa)</td></tr>
<tr><td style="padding:4px;border-bottom:1px solid var(--border-light)"><strong>Raiz (Root)</strong></td><td style="padding:4px;border-bottom:1px solid var(--border-light)">Núcleo semântico sem afixos. Verbal: <i>-rum-</i> (falar), <i>-ven-</i> (vir). Nominal: <i>-tthu</i> (pessoa)</td></tr>
<tr><td style="padding:4px;border-bottom:1px solid var(--border-light)"><strong>Tema/Stem</strong></td><td style="padding:4px;border-bottom:1px solid var(--border-light)">Nominal: raiz sem prefixos de classe. Verbal: raiz + extensões + vogal final (<i>-ruma, -rumela</i>)</td></tr>
<tr><td style="padding:4px"><strong>Prefixo de Classe</strong></td><td style="padding:4px">Prefixo básico (BPre) + pré-prefixo (NPrePre) quando aplicável</td></tr>
</table>
<h3>3. Como Segmentar uma Palavra</h3>
<ol style="font-size:12px">
<li>Identifique a <strong>raiz (ROOT)</strong> — o núcleo semântico</li>
<li>Identifique <strong>prefixos</strong> (à esquerda) — classe, sujeito, tempo, objeto, negação</li>
<li>Identifique <strong>sufixos</strong> (à direita) — extensões verbais, vogais finais</li>
<li>Verifique se há <strong>infixos</strong> dentro da raiz (ex: imbricação)</li>
</ol>
<h3>4. Sistema de Tags</h3>
<h4>Classes Nominais</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Classe</th><th>Prefixo</th><th>Semântica</th><th>Exemplo</th></tr>
<tr><td style="padding:3px">CL1/CL2</td><td>mu-/a-</td><td>Humanos (sg/pl)</td><td>mutthu/atthu</td></tr>
<tr><td style="padding:3px">CL3/CL4</td><td>mu-/mi-</td><td>Árvores/plantas</td><td>murutthu/mirutthu</td></tr>
<tr><td style="padding:3px">CL5/CL6</td><td>e-,ni-/ma-</td><td>Aumentativos, líquidos</td><td>enimu/makoho</td></tr>
<tr><td style="padding:3px">CL7/CL8</td><td>e-/i-</td><td>Coisas/instrumentos</td><td>ekwaha/ikwaha</td></tr>
<tr><td style="padding:3px">CL9/CL10</td><td>N-/N-</td><td>Animais, empréstimos</td><td>ntoko/ntoko</td></tr>
<tr><td style="padding:3px">CL11</td><td>o-</td><td>Objetos longos/finos</td><td>othukha</td></tr>
<tr><td style="padding:3px">CL14</td><td>u-</td><td>Abstratos</td><td>ukhuru</td></tr>
<tr><td style="padding:3px">CL15/INF</td><td>o-</td><td>Infinitivo</td><td>oruma</td></tr>
<tr><td style="padding:3px">CL16/17/18</td><td>va-/o-/mu-</td><td>Locativos</td><td>vakaya/okaya/mukaya</td></tr>
</table>
<h4>Prefixos de Sujeito (SP)</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Pessoa</th><th>Tag</th><th>Forma</th><th>Exemplo</th></tr>
<tr><td style="padding:3px">1ª sg</td><td>[SP1sg]</td><td>ki-</td><td>kiruma (eu falo)</td></tr>
<tr><td style="padding:3px">2ª sg</td><td>[SP2sg]</td><td>u-</td><td>uruma (tu falas)</td></tr>
<tr><td style="padding:3px">3ª sg</td><td>[SP3sg]</td><td>a-/o-</td><td>aruma (ele fala)</td></tr>
<tr><td style="padding:3px">1ª pl</td><td>[SP1pl]</td><td>ni-</td><td>niruma (nós falamos)</td></tr>
<tr><td style="padding:3px">2ª pl</td><td>[SP2pl]</td><td>mu-</td><td>muruma (vós falais)</td></tr>
<tr><td style="padding:3px">3ª pl</td><td>[SP3pl]</td><td>a-</td><td>aruma (eles falam)</td></tr>
</table>
<h4>Marcadores de Objeto (OM)</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Pessoa</th><th>Tag</th><th>Forma</th></tr>
<tr><td style="padding:3px">1ª sg</td><td>[OM.1SG]</td><td>ki-</td></tr>
<tr><td style="padding:3px">2ª sg</td><td>[OM.2SG]</td><td>ku-</td></tr>
<tr><td style="padding:3px">1ª pl</td><td>[OM.1PL]</td><td>ni-</td></tr>
<tr><td style="padding:3px">CL1</td><td>[OM.CL1]</td><td>mu-</td></tr>
<tr><td style="padding:3px">CL2</td><td>[OM.CL2]</td><td>a-</td></tr>
</table>
<h4>Tempo/Aspeto/Modo (TAM)</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Tag</th><th>Descrição</th><th>Forma</th></tr>
<tr><td style="padding:3px">[PRES]</td><td>Presente/Futuro</td><td>-naa-</td></tr>
<tr><td style="padding:3px">[PST]</td><td>Passado</td><td></td></tr>
<tr><td style="padding:3px">[REC.PST]</td><td>Passado recente</td><td>-ho- / -ale</td></tr>
<tr><td style="padding:3px">[REC.PST.LONG]</td><td>Passado recente (vogal longa)</td><td>-hoo- / -aale</td></tr>
<tr><td style="padding:3px">[PST.CONT]</td><td>Passado contínuo</td><td>-aa-</td></tr>
<tr><td style="padding:3px">[PROG]</td><td>Progressivo</td><td>-noo-</td></tr>
<tr><td style="padding:3px">[PERF]</td><td>Perfectivo/Perfeito</td><td></td></tr>
<tr><td style="padding:3px">[HAB]</td><td>Habitual</td><td></td></tr>
<tr><td style="padding:3px">[FUT]</td><td>Futuro</td><td></td></tr>
</table>
<h4>Extensões Verbais (Sufixos Derivacionais)</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Tag</th><th>Descrição</th><th>Forma</th><th>Exemplo</th></tr>
<tr><td style="padding:3px">[CAUS]</td><td>Causativo</td><td>-iha/-eha</td><td>-rumiha (fazer falar)</td></tr>
<tr><td style="padding:3px">[APPL]</td><td>Aplicativo</td><td>-el-/-al-</td><td>-rumela (falar para)</td></tr>
<tr><td style="padding:3px">[RECIP]</td><td>Recíproco</td><td>-an-</td><td>-rumana (falar um com outro)</td></tr>
<tr><td style="padding:3px">[PASS]</td><td>Passivo</td><td>-iw-/-ew-</td><td>-rumiwa (ser falado)</td></tr>
<tr><td style="padding:3px">[STAT]</td><td>Estativo</td><td>-ak-/-ek-</td><td>-rumeka (ser falável)</td></tr>
<tr><td style="padding:3px">[REV]</td><td>Reversivo</td><td>-ul-/-ol-</td><td>(desfazer ação)</td></tr>
<tr><td style="padding:3px">[INTENS]</td><td>Intensivo</td><td>-exa/-axa</td><td>(intensificar)</td></tr>
<tr><td style="padding:3px">[ITER]</td><td>Iterativo</td><td></td><td>(ação repetida)</td></tr>
</table>
<h4>Raiz Verbal e Vogal Final</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Tag</th><th>Descrição</th><th>Exemplo</th></tr>
<tr><td style="padding:3px">[VRoot] / ROOT</td><td>Raiz verbal</td><td>-rum- (falar), -ven- (vir), -khul- (crescer)</td></tr>
<tr><td style="padding:3px">[FV]</td><td>Vogal final (indicativo)</td><td>-a</td></tr>
<tr><td style="padding:3px">[FV.IMP.SG]</td><td>Vogal final imperativo sg</td><td>-a</td></tr>
<tr><td style="padding:3px">[SUBJ.FV]</td><td>Vogal final subjuntivo</td><td>-e</td></tr>
</table>
<h4>Outras Categorias Verbais</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Tag</th><th>Descrição</th><th>Forma</th></tr>
<tr><td style="padding:3px">[INF]</td><td>Prefixo infinitivo</td><td>o-</td></tr>
<tr><td style="padding:3px">[NEG]</td><td>Negação</td><td>kha-</td></tr>
<tr><td style="padding:3px">[NEG.PL]</td><td>Negação plural</td><td>ka-</td></tr>
<tr><td style="padding:3px">[HORT]</td><td>Hortativo (vamos...)</td><td>ka-</td></tr>
<tr><td style="padding:3px">[IMP.PL]</td><td>Imperativo plural</td><td>=ni (enclítico)</td></tr>
</table>
<h4>Tags Nominais Adicionais</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Tag</th><th>Descrição</th></tr>
<tr><td style="padding:3px">[NStem]</td><td>Raiz/tema nominal</td></tr>
<tr><td style="padding:3px">[AUG]</td><td>Sufixo aumentativo</td></tr>
<tr><td style="padding:3px">[DIM]</td><td>Sufixo diminutivo (-ana)</td></tr>
<tr><td style="padding:3px">[FEM]</td><td>Sufixo feminino (-ana)</td></tr>
<tr><td style="padding:3px">[LocSuf]</td><td>Sufixo locativo</td></tr>
<tr><td style="padding:3px">[AG]</td><td>Sufixo agentivo (-i/-u)</td></tr>
</table>
<h4>Outras Categorias Gramaticais</h4>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:3px">Tag</th><th>Descrição</th></tr>
<tr><td style="padding:3px">[AdjStem] / [AdjConc]</td><td>Raiz/concordância adjetival</td></tr>
<tr><td style="padding:3px">[Adv]</td><td>Advérbio</td></tr>
<tr><td style="padding:3px">[DEM] / [DEM.PROX] / [DEM.DIST]</td><td>Demonstrativo (proximal/distal)</td></tr>
<tr><td style="padding:3px">[POSS] / [PossConc]</td><td>Possessivo/concordância</td></tr>
<tr><td style="padding:3px">[PRON] / [AbsPron]</td><td>Pronome/pronome absoluto</td></tr>
<tr><td style="padding:3px">[REL]</td><td>Marcador relativo</td></tr>
<tr><td style="padding:3px">[COP]</td><td>Cópula</td></tr>
<tr><td style="padding:3px">[CONJ]</td><td>Conjunção</td></tr>
<tr><td style="padding:3px">[IDEO]</td><td>Ideofone</td></tr>
<tr><td style="padding:3px">[INTJ]</td><td>Interjeição</td></tr>
<tr><td style="padding:3px">[INTERROG]</td><td>Interrogativo</td></tr>
<tr><td style="padding:3px">[NUM]</td><td>Numeral</td></tr>
<tr><td style="padding:3px">[NMLZ]</td><td>Nominalizador</td></tr>
<tr><td style="padding:3px">[EPENTH]</td><td>Epentético (-ij- em raízes curtas)</td></tr>
<tr><td style="padding:3px">[root.IMBR]</td><td>Raiz imbricada</td></tr>
<tr><td style="padding:3px">[ABBR]</td><td>Abreviatura</td></tr>
<tr><td style="padding:3px">[ProperName]</td><td>Nome próprio</td></tr>
<tr><td style="padding:3px">[Foreign]</td><td>Palavra estrangeira</td></tr>
</table>
<h3>5. Exemplos Anotados</h3>
<table style="font-size:11px;border-collapse:collapse;width:100%">
<tr style="background:var(--bg-tertiary)"><th style="padding:4px">Palavra</th><th>Segmentação</th><th>Tags</th><th>Glosas</th><th>Tradução</th></tr>
<tr><td style="padding:4px"><b>mutthu</b></td><td>mu-tthu</td><td>PREFIX-ROOT</td><td>CL1-pessoa</td><td>pessoa</td></tr>
<tr><td style="padding:4px"><b>atthu</b></td><td>a-tthu</td><td>PREFIX-ROOT</td><td>CL2-pessoa</td><td>pessoas</td></tr>
<tr><td style="padding:4px"><b>kiruma</b></td><td>ki-rum-a</td><td>PREFIX-ROOT-SUFFIX</td><td>SP1sg-falar-FV</td><td>eu falo</td></tr>
<tr><td style="padding:4px"><b>kinaaruma</b></td><td>ki-naa-rum-a</td><td>PREFIX-PREFIX-ROOT-SUFFIX</td><td>SP1sg-PRES-falar-FV</td><td>eu estou a falar</td></tr>
<tr><td style="padding:4px"><b>orumiha</b></td><td>o-rum-iha-a</td><td>PREFIX-ROOT-SUFFIX-SUFFIX</td><td>INF-falar-CAUS-FV</td><td>fazer falar</td></tr>
<tr><td style="padding:4px"><b>anaakhula</b></td><td>a-naa-khul-a</td><td>PREFIX-PREFIX-ROOT-SUFFIX</td><td>SP3pl-PST.CONT-crescer-FV</td><td>eles cresciam</td></tr>
<tr><td style="padding:4px"><b>kharumeke</b></td><td>kha-rum-ek-e</td><td>PREFIX-ROOT-SUFFIX-SUFFIX</td><td>NEG-falar-STAT-SUBJ.FV</td><td>não é falável</td></tr>
<tr><td style="padding:4px"><b>orumela</b></td><td>o-rum-el-a</td><td>PREFIX-ROOT-SUFFIX-SUFFIX</td><td>INF-falar-APPL-FV</td><td>falar para</td></tr>
<tr><td style="padding:4px"><b>ekwaha</b></td><td>e-kwaha</td><td>PREFIX-ROOT</td><td>CL7-coisa</td><td>coisa</td></tr>
<tr><td style="padding:4px"><b>ikwaha</b></td><td>i-kwaha</td><td>PREFIX-ROOT</td><td>CL8-coisa</td><td>coisas</td></tr>
</table>
<h3>6. Glossário de Abreviaturas</h3>
<table style="font-size:10px;border-collapse:collapse;width:100%">
<tr><td style="padding:3px;vertical-align:top"><strong>Derivação:</strong><br>NMLZ (Nominalizador)<br>CAUS (Causativo)<br>APPL (Aplicativo)<br>RECIP (Recíproco)<br>DIM (Diminutivo)<br>AUG (Aumentativo)<br>INTENS (Intensivo)<br>STAT (Estativo)<br>REV (Reversivo)<br>ITER (Iterativo)<br>AG (Agentivo)</td>
<td style="padding:3px;vertical-align:top"><strong>TAM:</strong><br>PRES (Presente)<br>PST (Passado)<br>FUT (Futuro)<br>REC.PST (Passado recente)<br>PST.CONT (Passado contínuo)<br>PROG (Progressivo)<br>PERF (Perfectivo)<br>HAB (Habitual)<br>IND (Indicativo)<br>SUBJ (Subjuntivo)<br>IMP (Imperativo)<br>HORT (Hortativo)</td>
<td style="padding:3px;vertical-align:top"><strong>Pessoa:</strong><br>1SG, 2SG, 3SG<br>1PL, 2PL, 3PL<br><br><strong>Polaridade:</strong><br>NEG (Negação)<br>FV (Vogal final)<br>REL (Relativo)<br>DEM (Demonstrativo)<br>TOP (Tópico)<br>FOC (Foco)<br><br><strong>Voz:</strong><br>PASS (Passiva)<br>ACT (Ativa)</td></tr>
</table>
<h3>7. Erros Comuns a Evitar</h3>
<ol style="font-size:12px">
<li>Não deixar campos vazios: preencha sempre Tag, Glosa, Lemma, Classe e Tradução</li>
<li>Não confundir ROOT com PREFIX: a raiz é o núcleo semântico</li>
<li>Não inventar tags: use apenas as tags definidas neste guia</li>
<li>Não usar tags genéricas (PREFIX, SUFFIX) quando a função é conhecida — prefira CL1, SP1sg, CAUS, etc.</li>
<li>Não confundir prefixos homófonos: <code>mu-</code> pode ser CL1, CL3 ou CL18 — analise o contexto</li>
<li>Não misturar a ordem: preencha morfemas da esquerda para a direita</li>
<li>Não separar extensões fusionadas: se CAUS+APPL estão fusionados, trate como unidade</li>
<li>Não consultar outros anotadores: trabalho deve ser independente</li>
</ol>
<h3>8. Regras Importantes</h3>
<ul style="font-size:12px">
<li>⚠️ <strong>Proibido</strong> consultar outros anotadores (contaminação de dados)</li>
<li>⚠️ <strong>Proibido</strong> usar inteligência artificial na execução da tarefa</li>
<li>Precisão exigida: ≥ 98%</li>
<li>Ritmo recomendado: ~143 palavras/dia (7 dias para 1000 palavras)</li>
<li>Reserve tempo para revisão final</li>
<li>Pode rectificar a ortografia se necessário</li>
<li>Em caso de dúvida, use a coluna "Notas" e contacte o coordenador</li>
</ul>
<h3>9. Referências</h3>
<ul style="font-size:11px">
<li>Katupha, J.M.M. 1983. <i>A preliminary description of sentence structure in the E-Sàaka dialect of E-Makhuwa</i>. SOAS.</li>
<li>Van der Wal, J. 2009. <i>Word order and information structure in Makhuwa-Enahara</i>. LOT, Utrecht.</li>
<li>Pires-Hester, L. 2018. <i>Gramática descritiva do Emakhuwa</i>.</li>
</ul>
"""
def build_html():
result = f"""
<div id="annotation-app">
<style>
/* === Theme Variables === */
#annotation-app {{
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--text-primary: #111827;
--text-secondary: #374151;
--text-muted: #4b5563;
--border-color: #d1d5db;
--border-light: #e5e7eb;
--input-bg: #ffffff;
--card-bg: #ffffff;
--hover-bg: #f3f4f6;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--success: #16a34a;
--success-bg: #dcfce7;
--success-text: #166534;
--error-bg: #fee2e2;
--error-text: #991b1b;
--chip-border: #9ca3af;
--menu-shadow: rgba(0,0,0,0.15);
--overlay-bg: rgba(0,0,0,0.5);
font-family: 'Segoe UI', system-ui, sans-serif;
max-width: 1000px; margin: 0 auto; padding: 10px;
color: #111827 !important;
background: var(--bg-primary);
transition: background 0.3s, color 0.3s;
}}
#annotation-app *, #annotation-app input, #annotation-app select, #annotation-app label {{
color: inherit;
}}
#annotation-app.dark {{
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--text-primary: #e4e4e7;
--text-secondary: #a1a1aa;
--text-muted: #9ca3af;
--border-color: #374151;
--border-light: #4b5563;
--input-bg: #1e293b;
--card-bg: #1e293b;
--hover-bg: #2d3748;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--success: #22c55e;
--success-bg: #064e3b;
--success-text: #6ee7b7;
--error-bg: #450a0a;
--error-text: #fca5a5;
--chip-border: #4b5563;
--menu-shadow: rgba(0,0,0,0.5);
--overlay-bg: rgba(0,0,0,0.7);
color: #e4e4e7 !important;
}}
/* === Theme Toggle === */
.theme-toggle {{
position: fixed; right: 20px; top: 20px; width: 44px; height: 44px;
border-radius: 50%; border: 2px solid var(--border-color);
background: var(--bg-secondary); color: var(--text-primary);
font-size: 20px; cursor: pointer; z-index: 998;
display: flex; align-items: center; justify-content: center;
transition: all 0.3s;
}}
.theme-toggle:hover {{ background: var(--hover-bg); transform: scale(1.1); }}
.top-bar {{
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px; margin-bottom: 10px;
background: var(--bg-secondary); border-radius: 8px; border: 1px solid var(--border-color);
}}
.top-bar .user-info {{ font-size: 14px; color: #111827 !important; font-weight: 500; }}
#annotation-app.dark .top-bar .user-info {{ color: #e4e4e7 !important; }}
.top-bar .logout-btn {{
padding: 6px 14px; font-size: 13px; border-radius: 6px;
border: 1px solid #dc2626; background: transparent; color: #dc2626;
cursor: pointer; transition: all 0.2s;
}}
.top-bar .logout-btn:hover {{ background: #dc2626; color: white; }}
#login-screen {{
text-align: center; padding: 60px 20px;
}}
#login-screen h1 {{ font-size: 2em; margin-bottom: 10px; color: var(--text-primary); }}
#login-screen input {{
font-size: 18px; padding: 12px 20px; border: 2px solid var(--border-light);
border-radius: 8px; width: 300px; margin: 10px;
background: var(--input-bg); color: var(--text-primary);
}}
#login-screen button {{
font-size: 18px; padding: 12px 30px; background: var(--accent); color: white;
border: none; border-radius: 8px; cursor: pointer;
}}
#login-screen button:hover {{ background: var(--accent-hover); }}
#login-screen .admin-link {{
display: block; margin-top: 20px; color: var(--text-secondary);
font-size: 13px; cursor: pointer; text-decoration: underline;
}}
#login-screen .admin-link:hover {{ color: var(--accent); }}
/* Admin login */
#admin-password-row {{
display: none; margin-top: 15px;
}}
#admin-password-row input {{
font-size: 16px; padding: 10px 16px; border: 2px solid var(--border-light);
border-radius: 8px; width: 250px; margin: 5px;
background: var(--input-bg); color: var(--text-primary);
}}
#admin-password-row button {{
font-size: 16px; padding: 10px 20px; background: #dc2626; color: white;
border: none; border-radius: 8px; cursor: pointer;
}}
#main-screen {{ display: none; }}
#admin-screen {{ display: none; }}
/* === Admin Panel === */
.admin-panel {{
padding: 20px;
}}
.admin-panel h2 {{ color: var(--text-primary); margin-bottom: 20px; }}
.admin-card {{
background: var(--card-bg); border: 1px solid var(--border-color);
border-radius: 12px; padding: 20px; margin-bottom: 16px;
}}
.admin-card h3 {{ color: var(--text-primary); margin-top: 0; }}
.admin-btn {{
padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer;
font-size: 14px; margin: 5px; transition: all 0.2s;
}}
.admin-btn.primary {{ background: var(--accent); color: white; }}
.admin-btn.primary:hover {{ background: var(--accent-hover); }}
.admin-btn.success {{ background: var(--success); color: white; }}
.admin-btn.danger {{ background: #dc2626; color: white; }}
.admin-btn.danger:hover {{ background: #b91c1c; }}
.kappa-results {{
margin-top: 15px; font-size: 14px;
}}
.kappa-results table {{
width: 100%; border-collapse: collapse; margin-top: 10px;
}}
.kappa-results th, .kappa-results td {{
padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}}
.kappa-results th {{ background: var(--bg-tertiary); font-weight: 600; }}
.kappa-badge {{
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-weight: 600; font-size: 12px;
}}
.kappa-badge.excellent {{ background: #dcfce7; color: #166534; }}
.kappa-badge.good {{ background: #fef3c7; color: #92400e; }}
.kappa-badge.moderate {{ background: #fee2e2; color: #991b1b; }}
.kappa-badge.poor {{ background: #fecaca; color: #7f1d1d; }}
.stats-grid {{
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px; margin-top: 12px;
}}
.stat-card {{
background: var(--bg-secondary); border-radius: 8px; padding: 12px;
border: 1px solid var(--border-color);
}}
.stat-card .stat-name {{ font-size: 13px; color: var(--text-secondary); }}
.stat-card .stat-value {{ font-size: 22px; font-weight: 700; color: var(--text-primary); }}
.stat-card .stat-bar {{
height: 6px; background: var(--bg-tertiary); border-radius: 3px; margin-top: 6px;
}}
.stat-card .stat-bar-fill {{ height: 100%; background: var(--accent); border-radius: 3px; }}
.progress-bar {{
background: var(--bg-tertiary); border-radius: 20px; padding: 8px 16px;
margin-bottom: 16px; display: flex; align-items: center;
justify-content: space-between; flex-wrap: wrap; gap: 8px;
}}
.progress-bar .stats {{ font-size: 14px; color: #374151 !important; }}
#annotation-app.dark .progress-bar .stats {{ color: #9ca3af !important; }}
.progress-bar .bar-container {{
flex: 1; min-width: 200px; height: 8px; background: var(--border-light);
border-radius: 4px; overflow: hidden;
}}
.progress-bar .bar-fill {{
height: 100%; background: linear-gradient(90deg, #22c55e, #16a34a);
border-radius: 4px; transition: width 0.3s;
}}
.word-area {{
text-align: center; padding: 30px 20px; margin: 20px 0;
background: var(--bg-secondary); border-radius: 12px; border: 2px solid var(--border-color);
min-height: 160px;
}}
.sample-text-area {{
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 10px; padding: 14px 18px; margin: 10px 0;
text-align: left;
}}
.sample-text {{
font-size: 15px; line-height: 1.7; color: #111827 !important;
margin-top: 6px;
}}
#annotation-app.dark .sample-text {{ color: #e4e4e7 !important; }}
.sample-text .highlight {{
font-weight: 800; font-size: 18px; color: var(--accent);
background: rgba(59,130,246,0.1); padding: 1px 4px; border-radius: 4px;
}}
.word-container {{
display: inline-flex; align-items: center; justify-content: center;
flex-wrap: wrap; cursor: pointer; user-select: none;
}}
.word-char {{
font-size: 40px; font-weight: 700; padding: 2px 1px;
position: relative; color: #dc2626; line-height: 1.3;
border-right: 3px solid transparent; transition: border-color 0.15s;
}}
#annotation-app.dark .word-char {{ color: #f87171; }}
.word-char:hover {{
border-right-color: #3b82f6;
}}
.word-char.last-in-morph {{
border-right-color: transparent !important;
}}
.word-separator {{
font-size: 40px; font-weight: 700; color: #ef4444;
padding: 0 3px; cursor: pointer; user-select: none;
transition: transform 0.1s;
}}
.word-separator:hover {{ color: #b91c1c; transform: scale(1.3); }}
.morphemes-display {{
display: flex; flex-wrap: wrap; justify-content: center;
gap: 8px; margin-top: 20px; min-height: 60px;
}}
.morpheme-chip {{
background: var(--card-bg); border: 2px solid var(--chip-border); border-radius: 10px;
padding: 8px 14px; text-align: center; cursor: context-menu;
transition: all 0.2s; min-width: 50px; position: relative;
}}
.morpheme-chip:hover {{ border-color: #3b82f6; box-shadow: 0 2px 8px rgba(59,130,246,0.2); }}
.morpheme-chip .morph-text {{ font-size: 18px; font-weight: 600; color: var(--text-primary); }}
.morpheme-chip .morph-tag {{
font-size: 10px; color: white; background: #6366f1;
border-radius: 4px; padding: 1px 6px; margin-top: 3px; display: inline-block;
}}
.morpheme-chip .morph-gloss {{
font-size: 11px; color: #059669; font-style: italic; margin-top: 2px;
}}
.morpheme-chip .morph-tag:empty, .morpheme-chip .morph-gloss:empty {{ display: none; }}
.context-menu {{
display: none; position: fixed; background: var(--card-bg); border: 1px solid var(--border-light);
border-radius: 8px; box-shadow: 0 4px 20px var(--menu-shadow);
z-index: 10000; min-width: 280px; padding: 0; max-height: 420px;
overflow: hidden; flex-direction: column;
}}
.context-menu.visible {{ display: flex; }}
.context-menu .menu-header {{
padding: 8px 12px; border-bottom: 1px solid var(--border-color); position: sticky; top: 0;
background: var(--card-bg); z-index: 1;
}}
.context-menu .menu-header input {{
width: 100%; padding: 6px 10px; border: 1px solid var(--border-light);
border-radius: 6px; font-size: 13px; outline: none;
background: var(--input-bg); color: var(--text-primary);
}}
.context-menu .menu-header input:focus {{ border-color: #3b82f6; }}
.context-menu .menu-body {{ overflow-y: auto; flex: 1; padding: 4px 0; }}
.context-menu h4 {{
padding: 6px 16px; margin: 0; font-size: 11px; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px; background: var(--bg-secondary);
}}
.context-menu .menu-item {{
padding: 7px 16px; cursor: pointer; font-size: 13px; color: var(--text-primary);
}}
.context-menu .menu-item:hover {{ background: var(--hover-bg); }}
.context-menu .menu-item.selected {{ background: #eff6ff; color: #2563eb; font-weight: 600; }}
.context-menu .divider {{ height: 1px; background: var(--border-color); margin: 4px 0; }}
.nav-row {{
display: flex; gap: 8px; justify-content: center; margin: 16px 0; flex-wrap: wrap;
}}
.nav-row button {{
padding: 10px 18px; border: 1px solid var(--border-light); background: var(--card-bg);
border-radius: 8px; cursor: pointer; font-size: 14px; transition: all 0.2s;
color: #111827 !important;
}}
#annotation-app.dark .nav-row button {{ color: #e4e4e7 !important; }}
.nav-row button:hover {{ background: var(--hover-bg); border-color: #aaa; }}
.nav-row button.primary {{ background: var(--accent); color: white; border-color: var(--accent); }}
.nav-row button.primary:hover {{ background: var(--accent-hover); }}
.nav-row button.success {{ background: var(--success); color: white; border-color: var(--success); }}
.nav-row button.success:hover {{ background: #15803d; }}
.nav-row button:disabled {{ background: #d1d5db; color: #9ca3af; border-color: #d1d5db; cursor: not-allowed; opacity: 0.6; }}
.nav-row button:disabled:hover {{ background: #d1d5db; border-color: #d1d5db; }}
.fields-row {{
display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 12px; margin: 16px 0;
}}
@media (max-width: 600px) {{ .fields-row {{ grid-template-columns: 1fr; }} }}
.fields-row label {{ font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px; font-weight: 500; }}
.fields-row input, .fields-row select {{
width: 100%; padding: 10px 12px; border: 1px solid var(--border-light);
border-radius: 6px; font-size: 14px; box-sizing: border-box;
background: var(--input-bg); color: var(--text-primary);
}}
.fields-row input:focus, .fields-row select:focus {{ border-color: #3b82f6; outline: none; }}
.status-msg {{
text-align: center; padding: 10px; border-radius: 6px; margin: 8px 0;
font-size: 14px; transition: all 0.3s; min-height: 20px;
}}
.status-msg.success {{ background: var(--success-bg); color: var(--success-text); }}
.status-msg.error {{ background: var(--error-bg); color: var(--error-text); }}
.guide-btn {{
position: fixed; right: 20px; top: 80px; width: 50px; height: 50px;
border-radius: 50%; background: #6366f1; color: white; border: none;
font-size: 24px; cursor: pointer; box-shadow: 0 4px 12px rgba(99,102,241,0.4);
z-index: 999; display: flex; align-items: center; justify-content: center;
}}
.guide-btn:hover {{ background: #4f46e5; transform: scale(1.1); }}
.guide-modal {{
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: var(--overlay-bg); z-index: 10001; padding: 20px; overflow-y: auto;
}}
.guide-modal.visible {{ display: flex; align-items: flex-start; justify-content: center; }}
.guide-modal-content {{
background: var(--card-bg); border-radius: 12px; padding: 28px; max-width: 700px;
width: 100%; max-height: 85vh; overflow-y: auto; position: relative; margin-top: 40px;
color: var(--text-primary);
}}
.guide-modal-close {{
position: sticky; top: 0; float: right; font-size: 28px;
cursor: pointer; color: var(--text-secondary); background: var(--card-bg); border: none;
width: 36px; height: 36px; border-radius: 50%; z-index: 1;
}}
.guide-modal-close:hover {{ color: var(--text-primary); background: var(--hover-bg); }}
.section-label {{
display: inline-block; font-size: 11px; padding: 3px 10px;
border-radius: 4px; margin-bottom: 10px; font-weight: 600;
}}
.edit-overlay {{
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: var(--overlay-bg); z-index: 9999;
align-items: center; justify-content: center;
}}
.edit-overlay.visible {{ display: flex; }}
.edit-dialog {{
background: var(--card-bg); border-radius: 12px; padding: 24px; min-width: 300px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}}
.edit-dialog h3 {{ margin-top: 0; color: var(--text-primary); }}
.edit-dialog input {{
font-size: 20px; padding: 10px; border: 2px solid #3b82f6;
border-radius: 8px; width: 100%; margin: 10px 0; box-sizing: border-box;
background: var(--input-bg); color: var(--text-primary);
}}
.edit-dialog .btn-row {{ display: flex; gap: 8px; margin-top: 10px; }}
.edit-dialog button {{
padding: 8px 20px; border: none; border-radius: 6px;
cursor: pointer; font-size: 14px;
}}
</style>
<!-- Theme Toggle -->
<button class="theme-toggle" onclick="toggleTheme()" title="Alternar tema claro/escuro">🌙</button>
<!-- Login Screen -->
<div id="login-screen">
<h1>🏷️ Anotação Morfológica — Emakhuwa</h1>
<p style="color:var(--text-secondary);margin-bottom:30px">Ferramenta de anotação morfológica interativa</p>
<div>
<input type="text" id="annotator-input" placeholder="ID do Anotador (ex: maria)"
onkeydown="if(event.key==='Enter')doLogin()"><br>
<button onclick="doLogin()">Entrar</button>
</div>
<span class="admin-link" onclick="showAdminLogin()">🔐 Acesso Administrador</span>
<div id="admin-password-row">
<input type="password" id="admin-password-input" placeholder="Senha do admin"
onkeydown="if(event.key==='Enter')doAdminLogin()">
<button onclick="doAdminLogin()">Entrar como Admin</button>
</div>
</div>
<!-- Main Annotation Screen -->
<div id="main-screen">
<div class="top-bar">
<span class="user-info">👤 <span id="current-user"></span></span>
<button class="logout-btn" onclick="doLogout()">🚪 Sair</button>
</div>
<div class="progress-bar">
<span class="stats" id="progress-text">Carregando...</span>
<div class="bar-container"><div class="bar-fill" id="progress-fill"></div></div>
</div>
<div class="word-area">
<div class="section-label" id="section-label" style="background:#fef3c7;color:#92400e">🔗 OVERLAP</div>
<div class="word-container" id="word-container"></div>
<div class="morphemes-display" id="morphemes-display"></div>
</div>
<!-- Sample Text Context -->
<div class="sample-text-area" id="sample-text-area" style="display:none">
<label style="font-size:12px;color:var(--text-secondary);font-weight:500">📖 Contexto:</label>
<div class="sample-text" id="sample-text"></div>
</div>
<div class="nav-row">
<button onclick="navPrev()">⬅️ Anterior</button>
<button onclick="navNextUnannotated()">⏭️ Próx. sem anotação</button>
<button id="btn-next" onclick="navNext()" disabled>Seguinte ➡️</button>
<button class="primary" onclick="saveAnnotation()">💾 Salvar</button>
<button class="success" onclick="saveAndNext()">💾 Salvar & Próxima</button>
</div>
<div class="fields-row" style="grid-template-columns: 1fr 1fr 1fr 1fr;">
<div>
<label>Lemma (forma base)</label>
<input type="text" id="lemma-input" placeholder="Ex: -ruma, mutthu">
</div>
<div>
<label>Classe Gramatical</label>
<select id="gram-class-input"><option value="">-- selecionar --</option></select>
</div>
<div>
<label>Tradução (português)</label>
<input type="text" id="traducao-input" placeholder="Ex: pessoa, comer">
</div>
<div>
<label>Comentário (opcional)</label>
<input type="text" id="comment-input" placeholder="Dúvidas ou observações...">
</div>
</div>
<div class="status-msg" id="status-msg"></div>
</div>
<!-- Admin Screen -->
<div id="admin-screen">
<div class="admin-panel">
<h2>⚙️ Painel de Administração</h2>
<button class="admin-btn danger" onclick="adminLogout()">← Voltar ao Login</button>
<!-- Stats -->
<div class="admin-card">
<h3>📊 Estatísticas dos Anotadores</h3>
<button class="admin-btn primary" onclick="loadAdminStats()">🔄 Atualizar</button>
<div id="admin-stats"></div>
</div>
<!-- Inter-Annotator Agreement -->
<div class="admin-card">
<h3>📐 Acordo Inter-Anotadores (Cohen's Kappa)</h3>
<p style="color:var(--text-secondary);font-size:13px">
Calcula o acordo entre pares de anotadores nas palavras de overlap (annotator=all).
</p>
<button class="admin-btn primary" onclick="computeKappa()">Calcular Kappa</button>
<div class="kappa-results" id="kappa-results"></div>
</div>
<!-- Upload Words -->
<div class="admin-card">
<h3>📤 Carregar Palavras</h3>
<input type="file" id="admin-upload-file" accept=".txt,.csv,.xlsx">
<button class="admin-btn primary" onclick="adminUploadWords()">Carregar</button>
<div id="upload-status" style="margin-top:10px;color:var(--text-secondary)"></div>
</div>
<!-- Export All -->
<div class="admin-card">
<h3>📥 Exportar Anotações</h3>
<p style="color:var(--text-secondary);font-size:13px">Exporta todas as anotações de todos os anotadores num ficheiro Excel.</p>
<button class="admin-btn success" onclick="adminExportAll()">Exportar Tudo (.xlsx)</button>
<div id="export-status" style="margin-top:10px"></div>
</div>
<!-- Review Annotations -->
<div class="admin-card">
<h3>🔍 Revisar Anotações</h3>
<p style="color:var(--text-secondary);font-size:13px">Visualizar e gerir anotações de um anotador específico.</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="review-annotator-select" style="padding:6px 10px;border-radius:6px;border:1px solid var(--border-color);background:var(--input-bg);color:var(--text-primary)">
<option value="">Selecionar anotador...</option>
</select>
<button class="admin-btn primary" onclick="loadReviewAnnotations()">Ver Anotações</button>
<button class="admin-btn danger" onclick="deleteSelectedAnnotations()">🗑️ Apagar Selecionadas</button>
</div>
<div id="review-results" style="margin-top:12px;max-height:400px;overflow-y:auto"></div>
</div>
<!-- Manage Words -->
<div class="admin-card">
<h3>📋 Gerir Palavras</h3>
<p style="color:var(--text-secondary);font-size:13px">Pesquisar e apagar palavras da lista de anotação.</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" id="word-search-input" placeholder="Pesquisar palavras..." style="padding:6px 10px;border-radius:6px;border:1px solid var(--border-color);background:var(--input-bg);color:var(--text-primary);flex:1;min-width:150px">
<button class="admin-btn primary" onclick="searchWords()">Pesquisar</button>
<button class="admin-btn danger" onclick="deleteSelectedWords()">🗑️ Apagar Selecionadas</button>
</div>
<div style="margin-top:8px">
<label style="font-size:12px;cursor:pointer"><input type="checkbox" id="word-select-all" onchange="toggleAllWords()"> Selecionar todas</label>
</div>
<div id="word-search-results" style="margin-top:12px;max-height:400px;overflow-y:auto"></div>
</div>
</div>
</div>
<!-- Guideline Button -->
<button class="guide-btn" onclick="toggleGuide()" title="Guia de Anotação">📖</button>
<!-- Guideline Modal -->
<div class="guide-modal" id="guide-modal" onclick="if(event.target===this)toggleGuide()">
<div class="guide-modal-content">
<button class="guide-modal-close" onclick="toggleGuide()">✕</button>
{GUIDELINE_HTML}
</div>
</div>
<!-- Context Menu -->
<div class="context-menu" id="context-menu">
<div class="menu-header"><input id="menu-search" placeholder="Pesquisar..." oninput="filterMenu()"></div>
<div class="menu-body" id="menu-body"></div>
</div>
<!-- Edit Overlay -->
<div class="edit-overlay" id="edit-overlay" onclick="if(event.target===this)closeEdit()">
<div class="edit-dialog">
<h3 id="edit-title">Editar Morfema</h3>
<input type="text" id="edit-input" onkeydown="if(event.key==='Enter')confirmEdit();if(event.key==='Escape')closeEdit()">
<div class="btn-row">
<button style="background:#2563eb;color:white" onclick="confirmEdit()">Confirmar</button>
<button style="background:#e5e7eb" onclick="closeEdit()">Cancelar</button>
</div>
</div>
</div>
<script>
let annotatorId = '';
let currentWordIdx = 0;
let currentGlobalIdx = 0;
let currentWordId = '';
let currentWord = '';
let morphemes = [];
let menuTarget = null;
let editTarget = null;
let wordStartTime = null;
let isDarkTheme = localStorage.getItem('emk-dark-theme') !== 'false';
const TAG_OPTIONS = {json.dumps(TAG_OPTIONS)};
const GLOSS_OPTIONS = {json.dumps(GLOSS_OPTIONS)};
const GRAM_CLASSES = {json.dumps(GRAMMATICAL_CLASSES)};
// === Theme ===
function toggleTheme() {{
isDarkTheme = !isDarkTheme;
localStorage.setItem('emk-dark-theme', isDarkTheme);
applyTheme();
}}
function applyTheme() {{
const app = document.getElementById('annotation-app');
if (!app) return;
if (isDarkTheme) {{
app.classList.add('dark');
document.querySelector('.theme-toggle').textContent = '☀️';
}} else {{
app.classList.remove('dark');
document.querySelector('.theme-toggle').textContent = '🌙';
}}
}}
// Init gram class dropdown (polls until DOM is ready)
function _initGramDropdown() {{
const sel = document.getElementById('gram-class-input');
if (!sel) {{ setTimeout(_initGramDropdown, 100); return; }}
GRAM_CLASSES.forEach(c => {{
const o = document.createElement('option');
o.value = c; o.textContent = c; sel.appendChild(o);
}});
applyTheme();
}}
_initGramDropdown();
// === API ===
async function callAPI(fn, data) {{
let base;
try {{
base = window.location.origin;
}} catch(e) {{
base = '';
}}
try {{
if (window.parent && window.parent.location.origin !== window.location.origin) {{
base = window.parent.location.origin;
}}
}} catch(e) {{}}
base = base.replace(/\\/$/, '');
// Gradio 5 uses /gradio_api/call/<fn> (POST) -> event_id, then GET stream
const prefixes = ['/gradio_api/call/', '/call/', '/api/'];
for (const prefix of prefixes) {{
try {{
const callResp = await fetch(base + prefix + fn, {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{data: data}})
}});
if (!callResp.ok) continue;
const callData = await callResp.json();
if (callData.event_id) {{
// SSE stream to get result
const resultResp = await fetch(base + prefix + fn + '/' + callData.event_id);
const text = await resultResp.text();
const lines = text.split('\\n');
for (const line of lines) {{
if (line.startsWith('data: ')) {{
const payload = JSON.parse(line.slice(6));
if (Array.isArray(payload) && payload.length > 0) {{
return JSON.parse(payload[0]);
}}
return payload;
}}
}}
}} else if (callData.data) {{
// Gradio 4 direct response
return JSON.parse(callData.data[0]);
}}
}} catch(e) {{ continue; }}
}}
throw new Error('API call failed');
}}
// === Login ===
async function doLogin() {{
annotatorId = document.getElementById('annotator-input').value.trim();
if (!annotatorId) return;
document.getElementById('login-screen').style.display = 'none';
document.getElementById('main-screen').style.display = 'block';
document.getElementById('current-user').textContent = annotatorId;
try {{
const d = await callAPI('get_word', [annotatorId, 0]);
if (d.error) {{
alert(d.error);
document.getElementById('login-screen').style.display = 'block';
document.getElementById('main-screen').style.display = 'none';
annotatorId = '';
return;
}}
await loadWordFromData(d);
}} catch(err) {{
alert('Erro ao carregar: ' + err.message);
document.getElementById('login-screen').style.display = 'block';
document.getElementById('main-screen').style.display = 'none';
}}
}}
function doLogout() {{
document.getElementById('main-screen').style.display = 'none';
document.getElementById('login-screen').style.display = 'block';
annotatorId = '';
document.getElementById('annotator-input').value = '';
currentWordSaved = false;
updateNextBtn();
}}
// === Load Word ===
async function loadWord(idx) {{
const d = await callAPI('get_word', [annotatorId, idx]);
if (d.error) {{ showStatus(d.error, 'error'); return; }}
await loadWordFromData(d);
}}
function loadWordFromData(d) {{
currentWord = d.word;
currentWordIdx = d.word_idx;
currentGlobalIdx = d.global_idx;
currentWordId = d.word_id || String(d.global_idx);
wordStartTime = Date.now();
const pct = Math.round(100 * d.done / d.total);
document.getElementById('progress-text').textContent =
`${{d.done}}/${{d.total}} (${{pct}}%) | Overlap: ${{d.overlap_done}}/${{d.overlap_total}} | Palavra ${{d.word_idx+1}}/${{d.total}}`;
document.getElementById('progress-fill').style.width = pct + '%';
const lbl = document.getElementById('section-label');
if (d.is_overlap) {{
lbl.textContent = '🔗 OVERLAP (acordo)';
lbl.style.background = '#fef3c7'; lbl.style.color = '#92400e';
}} else {{
lbl.textContent = '📝 Independente';
lbl.style.background = '#dcfce7'; lbl.style.color = '#166534';
}}
if (d.existing) {{
morphemes = d.existing.morphemes.map((m, i) => ({{
text: m, tag: d.existing.tags[i]||'', gloss: d.existing.glosses[i]||''
}}));
document.getElementById('lemma-input').value = d.existing.lemma || '';
document.getElementById('gram-class-input').value = d.existing.grammatical_class || '';
document.getElementById('traducao-input').value = d.existing.traducao || '';
document.getElementById('comment-input').value = d.existing.comment || '';
currentWordSaved = true;
}} else {{
morphemes = [{{text: currentWord, tag: '', gloss: ''}}];
document.getElementById('lemma-input').value = '';
document.getElementById('gram-class-input').value = '';
document.getElementById('traducao-input').value = '';
document.getElementById('comment-input').value = '';
currentWordSaved = false;
}}
updateNextBtn();
renderWord();
clearStatus();
// Show sample text with highlighted target word
const stArea = document.getElementById('sample-text-area');
const stDiv = document.getElementById('sample-text');
if (d.sample_text) {{
stArea.style.display = 'block';
const regex = new RegExp(`(${{currentWord.replace(/[.*+?^${{}}()|[\\]\\\\]/g, '\\\\$&')}})`, 'gi');
stDiv.innerHTML = d.sample_text.replace(regex, '<span class="highlight">$1</span>');
}} else {{
stArea.style.display = 'none';
stDiv.innerHTML = '';
}}
}}
// === Render ===
function renderWord() {{
const container = document.getElementById('word-container');
container.innerHTML = '';
morphemes.forEach((morph, mIdx) => {{
if (mIdx > 0) {{
const sep = document.createElement('span');
sep.className = 'word-separator';
sep.textContent = '-';
sep.title = 'Clique para juntar';
sep.onclick = (e) => {{ e.stopPropagation(); mergeMorphemes(mIdx); }};
container.appendChild(sep);
}}
const chars = morph.text.split('');
chars.forEach((ch, cIdx) => {{
const span = document.createElement('span');
span.className = 'word-char';
if (cIdx === chars.length - 1) span.classList.add('last-in-morph');
span.textContent = ch;
span.onclick = (e) => {{
e.stopPropagation();
if (cIdx < chars.length - 1) splitAt(mIdx, cIdx + 1);
}};
span.ondblclick = (e) => {{ e.stopPropagation(); openEdit(mIdx); }};
container.appendChild(span);
}});
}});
renderChips();
}}
function renderChips() {{
const display = document.getElementById('morphemes-display');
display.innerHTML = '';
morphemes.forEach((morph, idx) => {{
const chip = document.createElement('div');
chip.className = 'morpheme-chip';
chip.innerHTML = `
<div class="morph-text">${{morph.text}}</div>
<div class="morph-tag">${{morph.tag}}</div>
<div class="morph-gloss">${{morph.gloss}}</div>
`;
chip.oncontextmenu = (e) => {{ e.preventDefault(); openContextMenu(e, idx); }};
chip.ondblclick = () => openEdit(idx);
display.appendChild(chip);
}});
}}
// === Split & Merge ===
function splitAt(morphIdx, charIdx) {{
const morph = morphemes[morphIdx];
const left = morph.text.substring(0, charIdx);
const right = morph.text.substring(charIdx);
if (!left || !right) return;
morphemes.splice(morphIdx, 1,
{{text: left, tag: '', gloss: ''}},
{{text: right, tag: '', gloss: ''}}
);
renderWord();
}}
function mergeMorphemes(idx) {{
if (idx < 1) return;
const merged = morphemes[idx-1].text + morphemes[idx].text;
morphemes.splice(idx-1, 2, {{text: merged, tag: '', gloss: ''}});
renderWord();
}}
// === Context Menu ===
function openContextMenu(e, morphIdx) {{
menuTarget = morphIdx;
const menu = document.getElementById('context-menu');
const body = document.getElementById('menu-body');
let html = '<h4>📌 Tag (tipo)</h4>';
TAG_OPTIONS.forEach(tag => {{
const sel = morphemes[morphIdx].tag === tag ? ' selected' : '';
html += `<div class="menu-item${{sel}}" onclick="setTag('${{tag}}')">${{tag}}</div>`;
}});
html += '<div class="divider"></div><h4>📝 Glosa (função)</h4>';
GLOSS_OPTIONS.forEach(gloss => {{
const sel = morphemes[morphIdx].gloss === gloss ? ' selected' : '';
html += `<div class="menu-item gloss-opt${{sel}}" onclick="setGloss('${{gloss}}')">${{gloss}}</div>`;
}});
html += '<div class="divider"></div>';
html += `<div class="menu-item" onclick="promptCustomGloss()">✏️ Glosa personalizada...</div>`;
body.innerHTML = html;
const x = Math.min(e.clientX, window.innerWidth - 300);
const y = Math.min(e.clientY, window.innerHeight - 420);
menu.style.left = x + 'px'; menu.style.top = y + 'px';
menu.classList.add('visible');
document.getElementById('menu-search').value = '';
setTimeout(() => document.getElementById('menu-search').focus(), 50);
setTimeout(() => document.addEventListener('click', closeMenu, {{once: true}}), 10);
}}
function closeMenu() {{ document.getElementById('context-menu').classList.remove('visible'); }}
function filterMenu() {{
const q = document.getElementById('menu-search').value.toLowerCase();
document.querySelectorAll('#menu-body .menu-item').forEach(el => {{
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
}});
}}
function setTag(tag) {{ if(menuTarget!==null){{ morphemes[menuTarget].tag=tag; renderChips(); }} closeMenu(); }}
function setGloss(gloss) {{ if(menuTarget!==null){{ morphemes[menuTarget].gloss=gloss; renderChips(); }} closeMenu(); }}
function promptCustomGloss() {{
closeMenu();
const g = prompt('Glosa personalizada:');
if(g && menuTarget!==null){{ morphemes[menuTarget].gloss=g; renderChips(); }}
}}
// === Edit ===
function openEdit(idx) {{
editTarget = idx;
document.getElementById('edit-title').textContent = 'Editar: ' + morphemes[idx].text;
document.getElementById('edit-input').value = morphemes[idx].text;
document.getElementById('edit-overlay').classList.add('visible');
setTimeout(() => {{ const inp = document.getElementById('edit-input'); inp.focus(); inp.select(); }}, 50);
}}
function confirmEdit() {{
const val = document.getElementById('edit-input').value.trim();
if(val && editTarget!==null){{ morphemes[editTarget].text=val; renderWord(); }}
closeEdit();
}}
function closeEdit() {{ document.getElementById('edit-overlay').classList.remove('visible'); editTarget=null; }}
// === Guide ===
function toggleGuide() {{ document.getElementById('guide-modal').classList.toggle('visible'); }}
// === Navigation ===
let currentWordSaved = false;
function updateNextBtn() {{
const btn = document.getElementById('btn-next');
if(btn) btn.disabled = !currentWordSaved;
}}
function navPrev() {{ loadWord(currentWordIdx - 1); }}
function navNext() {{
if(!currentWordSaved) {{ showStatus('Salve a anotação antes de avançar','error'); return; }}
loadWord(currentWordIdx + 1);
}}
async function navNextUnannotated() {{
const d = await callAPI('next_unannotated', [annotatorId, currentWordIdx]);
loadWord(d.word_idx);
}}
// === Save ===
async function saveAnnotation() {{
if(!morphemes.length){{ showStatus('Segmente a palavra','error'); return false; }}
const duration = wordStartTime ? Math.round((Date.now() - wordStartTime) / 1000) : 0;
const d = await callAPI('save_annotation', [
annotatorId, currentWordId, JSON.stringify(morphemes),
document.getElementById('lemma-input').value,
document.getElementById('gram-class-input').value,
document.getElementById('comment-input').value,
document.getElementById('traducao-input').value + '|dur:' + duration
]);
if(d.error){{ showStatus(d.error,'error'); return false; }}
showStatus('✅ Anotação salva! (' + duration + 's)','success');
currentWordSaved = true;
updateNextBtn();
loadWord(currentWordIdx);
return true;
}}
async function saveAndNext() {{
if(!morphemes.length){{ showStatus('Segmente a palavra','error'); return; }}
const duration = wordStartTime ? Math.round((Date.now() - wordStartTime) / 1000) : 0;
const d = await callAPI('save_annotation', [
annotatorId, currentWordId, JSON.stringify(morphemes),
document.getElementById('lemma-input').value,
document.getElementById('gram-class-input').value,
document.getElementById('comment-input').value,
document.getElementById('traducao-input').value + '|dur:' + duration
]);
if(d.error){{ showStatus(d.error,'error'); return; }}
showStatus('✅ Salvo! (' + duration + 's)','success');
setTimeout(() => navNext(), 200);
}}
// === Status ===
function showStatus(msg, type) {{
const el = document.getElementById('status-msg');
el.textContent = msg; el.className = 'status-msg ' + type;
if(type==='success') setTimeout(clearStatus, 3000);
}}
function clearStatus() {{ const el=document.getElementById('status-msg'); el.textContent=''; el.className='status-msg'; }}
// === Keyboard ===
document.addEventListener('keydown', (e) => {{
if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT'||e.target.tagName==='TEXTAREA') return;
if(e.ctrlKey && e.key==='ArrowLeft'){{ e.preventDefault(); navPrev(); }}
if(e.ctrlKey && e.key==='ArrowRight'){{ e.preventDefault(); if(currentWordSaved) navNext(); }}
if(e.ctrlKey && e.key==='s'){{ e.preventDefault(); saveAnnotation(); }}
}});
// === Admin Functions ===
function showAdminLogin() {{
const row = document.getElementById('admin-password-row');
row.style.display = row.style.display === 'none' ? 'block' : 'none';
if (row.style.display === 'block') document.getElementById('admin-password-input').focus();
}}
async function doAdminLogin() {{
const pw = document.getElementById('admin-password-input').value;
if (!pw) return;
const d = await callAPI('admin_login', [pw]);
if (d.success) {{
document.getElementById('login-screen').style.display = 'none';
document.getElementById('admin-screen').style.display = 'block';
loadAdminStats();
}} else {{
alert('Senha incorreta!');
}}
}}
function adminLogout() {{
document.getElementById('admin-screen').style.display = 'none';
document.getElementById('login-screen').style.display = 'block';
document.getElementById('admin-password-input').value = '';
}}
async function loadAdminStats() {{
const d = await callAPI('admin_stats', ['stats']);
const container = document.getElementById('admin-stats');
const reviewSelect = document.getElementById('review-annotator-select');
if (!d.stats || d.stats.length === 0) {{
container.innerHTML = '<p style="color:var(--text-secondary)">Nenhum anotador registrado.</p>';
return;
}}
// Populate review dropdown
reviewSelect.innerHTML = '<option value="">Selecionar anotador...</option>';
d.stats.forEach(s => {{
reviewSelect.innerHTML += `<option value="${{s.annotator}}">${{s.annotator}} (${{s.done}} anotações)</option>`;
}});
let html = `<p style="color:var(--text-secondary);margin:8px 0">Total de palavras: <strong>${{d.total_words}}</strong> | Anotadores: <strong>${{d.total_annotators}}</strong></p>`;
html += '<div class="stats-grid">';
d.stats.forEach(s => {{
html += `<div class="stat-card">
<div class="stat-name">${{s.annotator}}</div>
<div class="stat-value">${{s.done}}/${{s.total_assigned}}</div>
<div style="font-size:12px;color:var(--text-secondary)">Overlap: ${{s.overlap_done}}/${{s.overlap_total}}</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:${{s.progress_pct}}%"></div></div>
</div>`;
}});
html += '</div>';
container.innerHTML = html;
}}
async function computeKappa() {{
const container = document.getElementById('kappa-results');
container.innerHTML = '<p>Calculando...</p>';
const d = await callAPI('compute_kappa', ['kappa']);
if (d.error) {{
container.innerHTML = `<p style="color:var(--error-text)">${{d.error}}</p>`;
return;
}}
if (!d.pairs || d.pairs.length === 0) {{
container.innerHTML = '<p>Sem dados suficientes para calcular.</p>';
return;
}}
let html = `<p style="margin:10px 0;font-size:13px;color:var(--text-secondary)">Palavras de overlap: <strong>${{d.overlap_count}}</strong></p>`;
html += '<table style="font-size:12px;width:100%;border-collapse:collapse"><thead><tr style="background:var(--bg-tertiary)">';
html += '<th style="padding:6px">Par</th><th>Comuns</th><th>Lemma</th><th>Raiz</th><th>Segmentação</th><th>Glosa</th><th>Classe</th><th>Kappa</th><th>Interp.</th>';
html += '</tr></thead><tbody>';
function renderRow(p) {{
let interp = '', badge = '';
if (p.kappa === null || p.kappa === undefined) {{
interp = p.message || 'N/A'; badge = 'poor';
}} else if (p.kappa >= 0.8) {{
interp = 'Excelente'; badge = 'excellent';
}} else if (p.kappa >= 0.6) {{
interp = 'Bom'; badge = 'good';
}} else if (p.kappa >= 0.4) {{
interp = 'Moderado'; badge = 'moderate';
}} else {{
interp = 'Fraco'; badge = 'poor';
}}
const kappaStr = p.kappa != null ? (typeof p.kappa === 'number' ? p.kappa.toFixed(4) : p.kappa) : '—';
const lemmaStr = p.lemma_agreement != null ? p.lemma_agreement + '%' : '—';
const rootStr = p.root_agreement != null ? p.root_agreement + '%' : '—';
const segStr = p.segmentation_agreement != null ? p.segmentation_agreement + '%' : '—';
const glossStr = p.gloss_agreement != null ? p.gloss_agreement + '%' : '—';
const gramStr = p.gram_class_agreement != null ? p.gram_class_agreement + '%' : '—';
const commonStr = typeof p.common === 'number' ? (Number.isInteger(p.common) ? p.common : p.common.toFixed(1)) : p.common;
return `<tr style="border-bottom:1px solid var(--border-light)">
<td style="padding:6px"><strong>${{p.annotator1}}</strong> / <strong>${{p.annotator2}}</strong></td>
<td style="padding:6px;text-align:center">${{commonStr}}</td>
<td style="padding:6px;text-align:center">${{lemmaStr}}</td>
<td style="padding:6px;text-align:center">${{rootStr}}</td>
<td style="padding:6px;text-align:center">${{segStr}}</td>
<td style="padding:6px;text-align:center">${{glossStr}}</td>
<td style="padding:6px;text-align:center">${{gramStr}}</td>
<td style="padding:6px;text-align:center"><strong>${{kappaStr}}</strong></td>
<td style="padding:6px"><span class="kappa-badge ${{badge}}">${{interp}}</span></td>
</tr>`;
}}
d.pairs.forEach(p => {{ html += renderRow(p); }});
// Summary "ALL" row
if (d.summary) {{
html += '<tr style="border-top:2px solid var(--accent);background:var(--bg-tertiary);font-weight:bold">';
html += renderRow(d.summary);
html = html.slice(0, -5); // remove last </tr> to re-close with style
}}
html += '</tbody></table>';
html += `<div style="margin-top:14px;font-size:11px;color:var(--text-secondary)">
<strong>Colunas:</strong> Lemma = acordo no lemma | Raiz = acordo na raiz (ROOT) | Segmentação = divisão completa | Glosa = glosa completa | Classe = classe gramatical<br>
<strong>Escala Kappa:</strong> &lt;0.2 Fraco | 0.2-0.4 Razoável | 0.4-0.6 Moderado | 0.6-0.8 Bom | &gt;0.8 Excelente
</div>`;
container.innerHTML = html;
}}
async function adminUploadWords() {{
const fileInput = document.getElementById('admin-upload-file');
const statusDiv = document.getElementById('upload-status');
if (!fileInput.files.length) {{
statusDiv.textContent = 'Selecione um ficheiro primeiro.';
return;
}}
const file = fileInput.files[0];
const formData = new FormData();
formData.append('files', file);
try {{
const base = window.location.origin.replace(/\\/$/, '');
// Upload file via Gradio upload endpoint (try both paths)
let uploadResp = await fetch(base + '/upload', {{ method: 'POST', body: formData }});
if (!uploadResp.ok) uploadResp = await fetch(base + '/gradio_api/upload', {{ method: 'POST', body: formData }});
if (!uploadResp.ok) throw new Error('Upload falhou');
const uploadData = await uploadResp.json();
const filePath = uploadData[0];
// Call the upload_words API
const d = await callAPI('upload_words', [filePath]);
statusDiv.innerHTML = `<span style="color:var(--success-text)">${{d}}</span>`;
}} catch(err) {{
statusDiv.innerHTML = `<span style="color:var(--error-text)">Erro: ${{err.message}}</span>`;
}}
}}
async function adminExportAll() {{
const statusDiv = document.getElementById('export-status');
statusDiv.textContent = 'Exportando...';
try {{
const base = window.location.origin.replace(/\\/$/, '');
const d = await callAPI('admin_export_all', ['export']);
if (d && d.path) {{
// Try multiple file serving paths for Gradio compatibility
const filePath = d.path;
const urls = [
`${{base}}/gradio_api/file=${{filePath}}`,
`${{base}}/file=${{filePath}}`
];
let html = '<div>';
urls.forEach(url => {{
html += `<a href="${{url}}" download="export_all_annotations.xlsx" style="color:var(--accent);display:block;margin:4px 0">📥 Download (clique aqui)</a>`;
}});
html += '</div>';
statusDiv.innerHTML = html;
}} else {{
statusDiv.textContent = 'Nenhuma anotação para exportar.';
}}
}} catch(err) {{
statusDiv.innerHTML = `<span style="color:var(--error-text)">Erro: ${{err.message}}</span>`;
}}
}}
async function loadReviewAnnotations() {{
const select = document.getElementById('review-annotator-select');
const container = document.getElementById('review-results');
const aid = select.value;
if (!aid) {{ container.innerHTML = '<p style="color:var(--text-secondary)">Selecione um anotador.</p>'; return; }}
container.innerHTML = '<p>Carregando...</p>';
const d = await callAPI('admin_review', [aid]);
if (d.error) {{ container.innerHTML = `<p style="color:var(--error-text)">${{d.error}}</p>`; return; }}
if (!d.annotations || d.annotations.length === 0) {{ container.innerHTML = '<p>Sem anotações.</p>'; return; }}
let html = `<p style="margin:8px 0;font-size:13px"><strong>${{d.count}}</strong> anotações de <strong>${{d.annotator}}</strong>
<label style="margin-left:12px;font-size:12px"><input type="checkbox" onclick="toggleAllReview(this)"> Selecionar todas</label></p>`;
html += '<table style="font-size:11px;width:100%;border-collapse:collapse">';
html += '<thead><tr style="background:var(--bg-tertiary)"><th style="padding:4px">✓</th><th>#</th><th>Palavra</th><th>Segmentação</th><th>Tags</th><th>Glosa</th><th>Lemma</th><th>Classe</th><th>Tradução</th></tr></thead><tbody>';
d.annotations.forEach(a => {{
const seg = a.morphemes.join('-');
const tags = a.tags.join('-');
html += `<tr style="border-bottom:1px solid var(--border-light)">
<td style="padding:3px;text-align:center"><input type="checkbox" class="review-check" value="${{a.idx}}"></td>
<td style="padding:3px">${{a.idx}}</td>
<td style="padding:3px"><strong>${{a.word}}</strong></td>
<td style="padding:3px">${{seg}}</td>
<td style="padding:3px">${{tags}}</td>
<td style="padding:3px">${{a.gloss_complete}}</td>
<td style="padding:3px">${{a.lemma}}</td>
<td style="padding:3px">${{a.grammatical_class}}</td>
<td style="padding:3px">${{a.traducao}}</td>
</tr>`;
}});
html += '</tbody></table>';
container.innerHTML = html;
}}
function toggleAllReview(master) {{
document.querySelectorAll('.review-check').forEach(cb => cb.checked = master.checked);
}}
async function deleteSelectedAnnotations() {{
const select = document.getElementById('review-annotator-select');
const aid = select.value;
if (!aid) {{ alert('Selecione um anotador primeiro.'); return; }}
const checked = [...document.querySelectorAll('.review-check:checked')];
if (checked.length === 0) {{ alert('Selecione pelo menos uma anotação para apagar.'); return; }}
if (!confirm(`Apagar ${{checked.length}} anotação(ões) de "${{aid}}"?`)) return;
const indices = checked.map(cb => parseInt(cb.value));
const d = await callAPI('admin_delete_annotations', [aid, JSON.stringify(indices)]);
if (d.error) {{ alert(d.error); return; }}
alert(d.message);
loadReviewAnnotations();
}}
// === Word Management ===
let allWordsCache = null;
async function searchWords() {{
const query = document.getElementById('word-search-input').value.trim().toLowerCase();
const container = document.getElementById('word-search-results');
if (!allWordsCache) {{
container.innerHTML = '<p>Carregando lista de palavras...</p>';
const d = await callAPI('admin_stats', ['']);
if (d && d.words) {{
allWordsCache = d.words;
}} else {{
container.innerHTML = '<p>Erro ao carregar palavras.</p>';
return;
}}
}}
let results = allWordsCache.map((w, i) => ({{word: w.word, annotator: w.annotator, idx: i}}));
if (query) {{
results = results.filter(r => r.word.toLowerCase().includes(query));
}}
if (results.length === 0) {{
container.innerHTML = '<p>Nenhuma palavra encontrada.</p>';
return;
}}
const showing = results.slice(0, 200);
let html = `<p style="font-size:12px;color:var(--text-secondary)">Mostrando ${{showing.length}} de ${{results.length}} resultados</p>`;
html += '<table style="font-size:12px;width:100%;border-collapse:collapse"><thead><tr style="background:var(--bg-tertiary)">';
html += '<th style="padding:4px"><input type="checkbox" id="word-select-all-table" onchange="toggleAllWords()"></th>';
html += '<th style="padding:4px;text-align:left">Idx</th><th style="padding:4px;text-align:left">Palavra</th><th style="padding:4px;text-align:left">Anotador</th></tr></thead><tbody>';
showing.forEach(r => {{
html += `<tr style="border-bottom:1px solid var(--border-light)">`;
html += `<td style="padding:3px"><input type="checkbox" class="word-check" value="${{r.idx}}"></td>`;
html += `<td style="padding:3px">${{r.idx}}</td>`;
html += `<td style="padding:3px;font-weight:500">${{r.word}}</td>`;
html += `<td style="padding:3px">${{r.annotator || ''}}</td></tr>`;
}});
html += '</tbody></table>';
container.innerHTML = html;
}}
function toggleAllWords() {{
const checks = document.querySelectorAll('.word-check');
const allChecked = [...checks].every(c => c.checked);
checks.forEach(c => c.checked = !allChecked);
}}
async function deleteSelectedWords() {{
const checked = [...document.querySelectorAll('.word-check:checked')];
if (checked.length === 0) {{ alert('Selecione pelo menos uma palavra para apagar.'); return; }}
if (!confirm(`Apagar ${{checked.length}} palavra(s)? Esta ação não pode ser desfeita.`)) return;
const indices = checked.map(cb => parseInt(cb.value));
const d = await callAPI('admin_delete_words', [JSON.stringify(indices)]);
if (d.error) {{ alert(d.error); return; }}
alert(d.message);
allWordsCache = null;
searchWords();
}}
</script>
</div>
"""
# Split into HTML (before <script>) and script
idx = result.index('<script>')
html_only = result[:idx] + '</div>'
# Extract just the JS code (without <script> tags)
script_block = result[idx:]
js_code = script_block.replace('<script>', '').replace('</script>', '').replace('\n</div>\n', '')
return html_only, js_code
# ============================================================
# Gradio App
# ============================================================
html_content, js_code = build_html()
head_script = "<script>\n" + js_code + "\n</script>"
def api_upload_words_from_path(file_path):
"""Handle upload via file path from admin panel."""
if not file_path:
return json.dumps("Nenhum ficheiro selecionado")
if file_path.endswith('.csv'):
df = pd.read_csv(file_path, encoding='utf-8')
if 'word' in df.columns:
words = df['word'].dropna().astype(str).tolist()
df.to_json(os.path.join(DATA_DIR, "words_metadata.json"), orient='records', force_ascii=False)
else:
words = df.iloc[:, 0].dropna().astype(str).tolist()
elif file_path.endswith('.xlsx'):
df = pd.read_excel(file_path)
if 'word' in df.columns:
words = df['word'].dropna().astype(str).tolist()
df.to_json(os.path.join(DATA_DIR, "words_metadata.json"), orient='records', force_ascii=False)
else:
words = df.iloc[:, 0].dropna().astype(str).tolist()
elif file_path.endswith('.txt'):
with open(file_path, 'r', encoding='utf-8') as f:
words = [line.strip() for line in f if line.strip()]
else:
return json.dumps("⚠️ Use .txt, .csv ou .xlsx")
out_path = os.path.join(DATA_DIR, "words.txt")
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(words))
return json.dumps(f"✅ {len(words)} palavras carregadas!")
def api_admin_export_all_wrapper():
path = api_admin_export_all()
if path:
return json.dumps({"path": path})
return json.dumps(None)
with gr.Blocks(title="Emakhuwa Morphological Annotation", theme=gr.themes.Soft(), head=head_script) as demo:
gr.HTML(html_content)
# Hidden API components
with gr.Column(visible=False):
api_in1 = gr.Textbox()
api_in2 = gr.Textbox()
api_in3 = gr.Textbox()
api_in4 = gr.Textbox()
api_in5 = gr.Textbox()
api_in6 = gr.Textbox()
api_in7 = gr.Textbox()
api_out = gr.Textbox()
get_word_btn = gr.Button("get_word")
get_word_btn.click(
fn=lambda a, b, *_: api_get_word(a, int(b)),
inputs=[api_in1, api_in2], outputs=[api_out],
api_name="get_word"
)
save_btn = gr.Button("save")
save_btn.click(
fn=lambda a, b, c, d, e, f, g: api_save_annotation(a, int(b), c, d, e, f, g),
inputs=[api_in1, api_in2, api_in3, api_in4, api_in5, api_in6, api_in7],
outputs=[api_out], api_name="save_annotation"
)
next_btn = gr.Button("next")
next_btn.click(
fn=lambda a, b, *_: api_next_unannotated(a, int(b)),
inputs=[api_in1, api_in2], outputs=[api_out],
api_name="next_unannotated"
)
# Admin APIs
admin_login_btn = gr.Button("admin_login")
admin_login_btn.click(
fn=lambda pw, *_: api_admin_login(pw),
inputs=[api_in1], outputs=[api_out],
api_name="admin_login"
)
admin_stats_btn = gr.Button("admin_stats")
admin_stats_btn.click(
fn=lambda *_: api_admin_stats(),
inputs=[api_in1], outputs=[api_out],
api_name="admin_stats"
)
compute_kappa_btn = gr.Button("compute_kappa")
compute_kappa_btn.click(
fn=lambda *_: compute_cohens_kappa(),
inputs=[api_in1], outputs=[api_out],
api_name="compute_kappa"
)
upload_words_btn = gr.Button("upload_words")
upload_words_btn.click(
fn=lambda path, *_: api_upload_words_from_path(path),
inputs=[api_in1], outputs=[api_out],
api_name="upload_words"
)
admin_export_btn = gr.Button("admin_export_all")
admin_export_btn.click(
fn=lambda *_: api_admin_export_all_wrapper(),
inputs=[api_in1], outputs=[api_out],
api_name="admin_export_all"
)
admin_review_btn = gr.Button("admin_review")
admin_review_btn.click(
fn=lambda aid, *_: api_admin_review(aid),
inputs=[api_in1], outputs=[api_out],
api_name="admin_review"
)
admin_delete_btn = gr.Button("admin_delete_annotations")
admin_delete_btn.click(
fn=lambda aid, indices, *_: api_admin_delete_annotations(aid, indices),
inputs=[api_in1, api_in2], outputs=[api_out],
api_name="admin_delete_annotations"
)
admin_delete_words_btn = gr.Button("admin_delete_words")
admin_delete_words_btn.click(
fn=lambda indices, *_: api_admin_delete_words(indices),
inputs=[api_in1], outputs=[api_out],
api_name="admin_delete_words"
)
demo.launch(
allowed_paths=[ANNOTATIONS_DIR, DATA_DIR],
server_name="0.0.0.0",
server_port=7860,
ssr_mode=False,
show_api=True
)