Spaces:
Running
Running
| 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> <0.2 Fraco | 0.2-0.4 Razoável | 0.4-0.6 Moderado | 0.6-0.8 Bom | >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 | |
| ) | |