Team Ai
Apppublic

LoloSemper/new_language_maximum_efficiency2

sourceHugging Faceapache-2.0updated 1y agoView on Hugging Face
0likes
app.py941 linesDownload Raw Back to root
1# app.py — Universal Conlang Translator (Max Compresión Exacta)2# Archivos requeridos en la raíz:3# - lexicon_minimax.json4# - lexicon_komin.json5# - lexicon_master.json6#7# requirements.txt (para HF Spaces):8# gradio>=4.36.09# spacy>=3.7.410# es_core_news_sm @ https://github.com/explosion/spacy-models/releases/download/es_core_news_sm-3.7.0/es_core_news_sm-3.7.0-py3-none-any.whl11# en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.7.1/en_core_web_sm-3.7.1-py3-none-any.whl12 13import os, re, json, base64, zlib14from typing import Dict, Optional, List, Any15import gradio as gr16 17# ------------ Archivos esperados ------------18LEX_MINI = "lexicon_minimax.json"19LEX_KOMI = "lexicon_komin.json"20LEX_MASTER = "lexicon_master.json"21 22# ------------ Normalización ------------23WORD_RE = re.compile(r"[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]+", re.UNICODE)24STRIP = str.maketrans("ÁÉÍÓÚÜÑáéíóúüñ", "AEIOUUNaeiouun")25def norm_es(w: str) -> str: return re.sub(r"[^a-záéíóúüñ]", "", (w or "").lower()).translate(STRIP)26def norm_en(w: str) -> str: return re.sub(r"[^a-z]", "", (w or "").lower())27 28# ------------ Carga de léxicos ------------29def load_json(path: str):30    if not os.path.exists(path): return None31    with open(path, "r", encoding="utf-8") as f: return json.load(f)32 33def load_lexicons():34    mm = load_json(LEX_MINI) or {}35    kk = load_json(LEX_KOMI) or {}36    master = load_json(LEX_MASTER) or {}37 38    es2mini = mm.get("mapping", {})39    es2komi = kk.get("mapping", {})40    mini2es = {v:k for k,v in es2mini.items()}41    komi2es = {v:k for k,v in es2komi.items()}42 43    es2en_lemma: Dict[str,str] = {}44    en2es_lemma: Dict[str,str] = {}45    en2mini, en2komi = {}, {}46    mini2en, komi2en = {}, {}47 48    if isinstance(master, dict) and "entries" in master:49        for e in master["entries"]:50            es = norm_es(str(e.get("lemma_es",""))); en = norm_en(str(e.get("lemma_en","")))51            mi = str(e.get("minimax","")); ko = str(e.get("komin",""))52            if es and en:53                es2en_lemma.setdefault(es, en); en2es_lemma.setdefault(en, es)54            if en and mi: en2mini.setdefault(en, mi)55            if en and ko: en2komi.setdefault(en, ko)56 57    mini2en = {v:k for k,v in en2mini.items()}58    komi2en = {v:k for k,v in en2komi.items()}59    return (es2mini, es2komi, mini2es, komi2es, en2mini, en2komi, mini2en, komi2en, es2en_lemma, en2es_lemma, master)60 61(ES2MINI, ES2KOMI, MINI2ES, KOMI2ES, EN2MINI, EN2KOMI, MINI2EN, KOMI2EN, ES2EN_LEMMA, EN2ES_LEMMA, MASTER_OBJ) = load_lexicons()62 63# ------------ Pronombres ------------64PRON_ES = {"yo","tú","vos","usted","él","ella","nosotros","vosotros","ustedes","ellos","ellas","me","te","se","nos","os"}65PRON_EN = {"i","you","he","she","it","we","they","me","him","her","us","them"}66 67# ------------ OOV reversible (Semi-lossless) ------------68ALPHA_MINI64 = "@ptkmnslraeiouy0123456789><=:/!?.+-_*#bcdfghjvqwxzACEGHIJKLMNOPRS"[:64]69CJK_BASE = (70    "天地人日月山川雨風星火水木土金石光影花草鳥犬猫魚"71    "東西南北中外上下午夜明暗手口目耳心言書家道路門"72    "大小長短早晚高低新古青紅白黒金銀銅玉米茶酒米"73    "文学楽音画体気電海空森林雪雲砂島橋城村国自由静"74)75ALPHA_CJK64 = (CJK_BASE * 2)[:64]76 77def to_custom_b64(b: bytes, alphabet: str) -> str:78    std = base64.b64encode(b).decode("ascii")79    trans = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", alphabet)80    return std.translate(trans).rstrip("=")81def from_custom_b64(s: str, alphabet: str) -> bytes:82    trans = str.maketrans(alphabet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")83    std = s.translate(trans); pad = "=" * ((4 - len(std) % 4) % 4)84    return base64.b64decode(std + pad)85def enc_oov_minimax(token: str) -> str: return "~" + to_custom_b64(token.encode("utf-8"), ALPHA_MINI64)86def dec_oov_minimax(code: str) -> str:87    try: return from_custom_b64(code[1:], ALPHA_MINI64).decode("utf-8")88    except Exception: return code89def enc_oov_komin(token: str) -> str: return "「" + to_custom_b64(token.encode("utf-8"), ALPHA_CJK64) + "」"90def dec_oov_komin(code: str) -> str:91    try: return from_custom_b64(code[1:-1], ALPHA_CJK64).decode("utf-8")92    except Exception: return code93def is_oov_minimax(code: str) -> bool: return code.startswith("~") and len(code) > 194def is_oov_komin(code: str) -> bool:   return len(code) >= 2 and code.startswith("「") and code.endswith("」")95 96# ------------ spaCy opcional ------------97USE_SPACY = False98try:99    import spacy100    try:101        nlp_es = spacy.load("es_core_news_sm"); nlp_en = spacy.load("en_core_web_sm"); USE_SPACY = True102    except Exception:103        nlp_es = nlp_en = None104except Exception:105    nlp_es = nlp_en = None106 107def lemma_of(tok, src_lang: str) -> str:108    if src_lang == "Español":109        return norm_es(tok.lemma_ if getattr(tok,"lemma_","") else tok.text)110    else:111        return norm_en(tok.lemma_ if getattr(tok,"lemma_","") else tok.text)112 113# ------------ Detección simple y helpers ------------114def detect_polarity(doc) -> bool: return "?" in getattr(doc,"text","")115def detect_neg(doc) -> bool:116    for t in doc:117        if getattr(t,"dep_","")=="neg" or getattr(t,"lower_","").lower() in ("no","not","n't"):118            return True119    return False120def detect_tense(root):121    m = str(getattr(root,"morph",""))122    if "Tense=Past" in m: return "Past"123    if "Tense=Fut"  in m: return "Fut"124    if "Tense=Pres" in m: return "Pres"125    for c in getattr(root,"children",[]):126        if getattr(c,"pos_","")=="AUX":127            cm = str(getattr(c,"morph",""))128            if "Tense=Past" in cm: return "Past"129            if getattr(c,"lower_","").lower()=="will": return "Fut"130    return "Pres"131def extract_core(doc):132    tokens = list(doc)133    root = next((t for t in tokens if getattr(t,"dep_","")=="ROOT" and getattr(t,"pos_","") in ("VERB","AUX")), tokens[0] if tokens else doc)134    subs, objs, obls, advs = [], [], [], []135    for t in getattr(root,"children",[]):136        dep = getattr(t,"dep_",""); pos = getattr(t,"pos_","")137        if dep in ("nsubj","nsubj:pass","csubj"): subs.append(t)138        elif dep in ("obj","dobj","iobj"):        objs.append(t)139        elif dep in ("obl","pobj"):               obls.append(t)140        elif dep in ("advmod","advcl") and pos=="ADV": advs.append(t)141    for arr in (subs,objs,obls,advs): arr.sort(key=lambda x: getattr(x,"i",0))142    return root, subs, objs, obls, advs143def _person_of_doc(doc, src_lang: str) -> Optional[str]:144    try:145        tokens = list(doc)146        root = next((t for t in tokens if getattr(t,"dep_","")=="ROOT"), tokens[0])147        subj = next((t for t in getattr(root,"children",[]) if getattr(t,"dep_","").startswith("nsubj")), None)148        if subj is None: return None149        plur = ("Number=Plur" in str(getattr(subj,"morph",""))) if src_lang=="Español" else (getattr(subj,"tag_","") in ("NNS","NNPS"))150        low = getattr(subj,"lower_","").lower()151        if src_lang=="Español":152            if low in ("yo",): return "1p" if plur else "1s"153            if low in ("tú","vos"): return "2p" if plur else "2s"154            if low in ("usted","él","ella"): return "3p" if plur else "3s"155            lem = lemma_of(subj, "Español")156            if lem in ("yo","nosotros"): return "1p" if plur else "1s"157            if lem in ("tú","vosotros"): return "2p" if plur else "2s"158            return "3p" if plur else "3s"159        else:160            if low in ("i",): return "1p" if plur else "1s"161            if low in ("you",): return "2p" if plur else "2s"162            if low in ("he","she","it"): return "3p" if plur else "3s"163            return "3p" if plur else "3s"164    except Exception:165        return None166def detect_person(root, src_lang: str) -> Optional[str]:167    m = str(getattr(root,"morph","")); person_str, number_str = "3","s"168    if "Person=" in m:169        for feat in m.split("|"):170            if feat.startswith("Person="): person_str = feat.split("=")[1]171            elif feat.startswith("Number="): number_str = "p" if feat.split("=")[1]=="Plur" else "s"172        return person_str + number_str173    return _person_of_doc(root.doc, src_lang)174 175# ------------ Mapeo y fraseadores ------------176def code_es(lemma: str, target: str) -> str:177    lemma = norm_es(lemma)178    if target=="Minimax-ASCII": return ES2MINI.get(lemma) or enc_oov_minimax(lemma)179    return ES2KOMI.get(lemma) or enc_oov_komin(lemma)180def code_en(lemma: str, target: str) -> str:181    lemma = norm_en(lemma)182    if target=="Minimax-ASCII": return (EN2MINI.get(lemma) if EN2MINI else None) or enc_oov_minimax(lemma)183    return (EN2KOMI.get(lemma) if EN2KOMI else None) or enc_oov_komin(lemma)184 185TAM_MINI = {"Pres":"P","Past":"T","Fut":"F","UNK":"P"}186TAM_KOMI = {"Pres":"Ⓟ","Past":"Ⓣ","Fut":"Ⓕ","UNK":"Ⓟ"}187 188def realize_minimax(doc, src_lang: str, drop_articles=True, zero_copula=True, semi_lossless=False, person_hint="2s", remove_pronouns=False):189    root, subs, objs, obls, advs = extract_core(doc)190    tense = detect_tense(root); is_q, is_neg = detect_polarity(doc), detect_neg(doc)191    vlem = lemma_of(root, src_lang) if USE_SPACY else ("ser" if "?" in getattr(doc,"text","") else "estar")192    vcode = code_es(vlem, "Minimax-ASCII") if src_lang=="Español" else code_en(vlem, "Minimax-ASCII")193    tail = TAM_MINI.get(tense, "P")194    if semi_lossless: tail += (detect_person(root, src_lang) or person_hint)195    if is_neg: tail += "N"196    if is_q:   tail += "Q"197    if tail: vcode = f"{vcode}·{tail}"198 199    def realize_np(tokens):200        outs=[]201        for t in tokens:202            if remove_pronouns:203                txt = (getattr(t,"text","") or "").lower()204                if (src_lang=="Español" and txt in PRON_ES) or (src_lang=="English" and txt in PRON_EN): continue205            lem = lemma_of(t, src_lang) if USE_SPACY else getattr(t,"text","")206            outs.append(code_es(lem,"Minimax-ASCII") if src_lang=="Español" else code_en(lem,"Minimax-ASCII"))207        return outs208 209    S = realize_np(subs); O = realize_np(objs)+realize_np(obls)210    ADV=[]211    for a in advs:212        lem = lemma_of(a, src_lang) if USE_SPACY else getattr(a,"text","")213        ADV.append(code_es(lem,"Minimax-ASCII") if src_lang=="Español" else code_en(lem,"Minimax-ASCII"))214 215    parts = S+O+ADV if (zero_copula and not semi_lossless and vlem in ("ser","estar","be") and tense=="Pres" and not is_neg and not is_q) else [vcode]+S+O+ADV216    return " ".join(p for p in parts if p)217 218def realize_komin(doc, src_lang: str, drop_articles=True, zero_copula=True, semi_lossless=False, person_hint="2s", remove_pronouns=False):219    root, subs, objs, obls, advs = extract_core(doc)220    tense, is_q, is_neg = detect_tense(root), detect_polarity(doc), detect_neg(doc)221    vlem = lemma_of(root, src_lang) if USE_SPACY else ("ser" if "?" in getattr(doc,"text","") else "estar")222    vcode = code_es(vlem, "Kōmín-CJK") if src_lang=="Español" else code_en(vlem, "Kōmín-CJK")223    P_SUBJ, P_OBJ = "ᵖ", "ᵒ"; Q_FIN = "?"224    TAM = TAM_KOMI.get(tense,"Ⓟ")225    if semi_lossless: TAM = TAM + f"[{detect_person(root, src_lang) or person_hint}]"226 227    def realize_np(tokens, particle):228        outs=[]229        for t in tokens:230            if remove_pronouns:231                txt = (getattr(t,"text","") or "").lower()232                if (src_lang=="Español" and txt in PRON_ES) or (src_lang=="English" and txt in PRON_EN): continue233            lem = lemma_of(t, src_lang) if USE_SPACY else getattr(t,"text","")234            outs.append((code_es(lem,"Kōmín-CJK") if src_lang=="Español" else code_en(lem,"Kōmín-CJK")) + particle)235        return outs236 237    S = realize_np(subs, P_SUBJ); O = realize_np(objs+obls, P_OBJ)238    ADV=[]239    for a in advs:240        lem = lemma_of(a, src_lang) if USE_SPACY else getattr(a,"text","")241        ADV.append(code_es(lem,"Kōmín-CJK") if src_lang=="Español" else code_en(lem,"Kōmín-CJK"))242    parts = S+O+ADV+[vcode+TAM]243    out = " ".join(parts)244    if is_q: out += " " + Q_FIN245    return out246 247# ------------ Sidecars (compresión exacta) ------------248SIDECAR_B85_RE = re.compile(r"\s?§\((?P<b85>[A-Za-z0-9!#$%&()*+\-;<=>?@^_{|}~]+)\)$")249def b85_enc_raw(s: str) -> str: return base64.a85encode(zlib.compress(s.encode("utf-8"), 9), adobe=False).decode("ascii")250def b85_dec_raw(b85s: str) -> str: return zlib.decompress(base64.a85decode(b85s.encode("ascii"), adobe=False)).decode("utf-8")251def attach_sidecar_b85(conlang_text: str, original_text: str) -> str: return f"{conlang_text} §({b85_enc_raw(original_text)})"252def extract_sidecar_b85(text: str) -> Optional[str]:253    m = SIDECAR_B85_RE.search(text)254    if not m: return None255    try: return b85_dec_raw(m.group("b85"))256    except Exception: return None257def strip_sidecar_b85(text: str) -> str: return SIDECAR_B85_RE.sub("", text).rstrip()258def custom_sidecar_enc(conlang_text: str, original_text: str) -> str:259    blob = to_custom_b64(zlib.compress(original_text.encode("utf-8"), 9), ALPHA_MINI64)260    return f"{conlang_text} ~{blob}"261def extract_custom_sidecar(text: str) -> Optional[str]:262    if '~' in text:263        _, blob = text.rsplit('~', 1)264        try: return zlib.decompress(from_custom_b64(blob, ALPHA_MINI64)).decode("utf-8")265        except Exception: return None266    return None267def strip_custom_sidecar(text: str) -> str: return text.split('~')[0].rstrip() if '~' in text else text268 269# ------------ Codificación / decodificación simple ------------270def encode_simple(text: str, src_lang: str, target: str) -> str:271    if not text.strip(): return ""272    def repl_es(m):273        key = norm_es(m.group(0))274        code = ES2MINI.get(key) if target=="Minimax-ASCII" else ES2KOMI.get(key)275        return code or (enc_oov_minimax(m.group(0)) if target=="Minimax-ASCII" else enc_oov_komin(m.group(0)))276    def repl_en(m):277        key = norm_en(m.group(0)); table = EN2MINI if target=="Minimax-ASCII" else EN2KOMI278        if table and key in table: return table[key]279        return enc_oov_minimax(m.group(0)) if target=="Minimax-ASCII" else enc_oov_komin(m.group(0))280    repl = repl_es if src_lang=="Español" else repl_en281    return WORD_RE.sub(repl, text)282 283def pluralize_es(word: str) -> str:284    exceptions = {"uno":"unos","buen":"buenos","hombre":"hombres"}285    if word in exceptions: return exceptions[word]286    if word.endswith("z"): return word[:-1]+"ces"287    if word.endswith(("a","e","i","o")): return word+"s"288    return word+"es"289def pluralize_en(word: str) -> str:290    exceptions = {"man":"men","woman":"women","child":"children"}291    if word in exceptions: return exceptions[word]292    if word.endswith("y") and len(word)>1 and word[-2] not in "aeiou": return word[:-1]+"ies"293    if word.endswith(("s","sh","ch","x","z")): return word+"es"294    return word+"s"295def pluralize(word: str, tgt_lang: str) -> str: return pluralize_es(word) if tgt_lang=="Español" else pluralize_en(word)296 297mini_tail_re = re.compile(r"^(?P<stem>.+?)·(?P<tail>[PTFNQ12sp]+)$")298 299def decode_simple(text: str, source: str, tgt_lang: str) -> str:300    if not text.strip(): return ""301    code2es = MINI2ES if source=="Minimax-ASCII" else KOMI2ES302    code2en = MINI2EN if source=="Minimax-ASCII" else KOMI2EN303    if source=="Kōmín-CJK":304        text = text.replace("?","?").replace(" "," ")305        return " ".join([code2es.get(w,w) for w in text.split() if w!="?"])306    tokens = text.split()307    if not tokens: return ""308    lemma_tokens, pl_flags = [], []309    verb_idx=-1; verb_lemma=None; verb_tense="Pres"; verb_person="3s"; has_q=False; is_neg=False310    for part in tokens:311        look = part.replace("[PL]",""); had_pl = "[PL]" in part; pl_flags.append(had_pl)312        m = mini_tail_re.match(look)313        if m:314            verb_idx = len(lemma_tokens); stem=m.group("stem"); tail=m.group("tail")315            vlem_es = code2es.get(stem); vlem_en = code2en.get(stem) if code2en else None316            vlem = vlem_es if tgt_lang=="Español" else (vlem_en or vlem_es or stem)317            if not vlem: vlem = dec_oov_minimax(stem) if is_oov_minimax(stem) else stem318            lemma_tokens.append(vlem); pl_flags.append(False)319            if tail:320                if tail[0] in "PTF":321                    verb_tense = {"P":"Pres","T":"Past","F":"Fut"}[tail[0]]; pos=1322                    if len(tail)>pos and tail[pos] in "123":323                        pos+=1; verb_person = tail[pos-1] + (tail[pos] if len(tail)>pos and tail[pos] in "sp" else "s")324                        if len(tail)>pos and tail[pos] in "sp": pos+=1325                is_neg = "N" in tail[pos:]; has_q = "Q" in tail[pos:]326            verb_lemma = vlem; continue327        w_es = code2es.get(look); w_en = code2en.get(look) if code2en else None328        w = w_es if tgt_lang=="Español" else (w_en or w_es or look)329        if not w: w = dec_oov_minimax(look) if is_oov_minimax(look) else look330        lemma_tokens.append(w); pl_flags.append(had_pl)331    out_parts=[]332    for idx, lem in enumerate(lemma_tokens):333        if idx==verb_idx:334            v = _es_conj(verb_lemma, verb_tense, verb_person) if tgt_lang=="Español" else _en_conj(verb_lemma, verb_tense, verb_person)335            if is_neg: v = ("no " if tgt_lang=="Español" else "not ") + v336            out_parts.append(v)337        else:338            out_parts.append(pluralize(lem, tgt_lang) if pl_flags[idx] else lem)339    out_text = " ".join(out_parts)340    if has_q:341        start_q = "¿" if tgt_lang=="Español" else ""342        out_text = f"{start_q}{out_text.capitalize()}?"343    return out_text344 345# ------------ Conjugadores mínimos ------------346def _es_conj_regular(lemma, tense, person):347    if not lemma.endswith(("ar","er","ir")): return lemma348    stem, vtype = lemma[:-2], lemma[-2:]349    pres={"ar":{"1s":"o","2s":"as","3s":"a","1p":"amos","2p":"áis","3p":"an"},350          "er":{"1s":"o","2s":"es","3s":"e","1p":"emos","2p":"éis","3p":"en"},351          "ir":{"1s":"o","2s":"es","3s":"e","1p":"imos","2p":"ís","3p":"en"}}352    pret={"ar":{"1s":"é","2s":"aste","3s":"ó","1p":"amos","2p":"asteis","3p":"aron"},353          "er":{"1s":"í","2s":"iste","3s":"ió","1p":"imos","2p":"isteis","3p":"rieron"},354          "ir":{"1s":"í","2s":"iste","3s":"ió","1p":"imos","2p":"isteis","3p":"rieron"}}  # typo intentionally? keep structure simple355    fut={"1s":"é","2s":"ás","3s":"á","1p":"emos","2p":"éis","3p":"án"}356    if tense=="Pres": return stem + pres[vtype].get(person, pres[vtype]["3s"])357    if tense=="Past": return stem + pret[vtype].get(person, pret[vtype]["3s"])358    return lemma + fut.get(person, fut["3s"])359def _es_conj(lemma, tense, person):360    if lemma=="ser":361        tab={"Pres":{"1s":"soy","2s":"eres","3s":"es","1p":"somos","2p":"sois","3p":"son"},362             "Past":{"1s":"fui","2s":"fuiste","3s":"fue","1p":"fuimos","2p":"fuisteis","3p":"fueron"},363             "Fut":{"1s":"seré","2s":"serás","3s":"será","1p":"seremos","2p":"seréis","3p":"serán"}}364        return tab[tense].get(person, tab[tense]["3s"])365    if lemma=="estar":366        tab={"Pres":{"1s":"estoy","2s":"estás","3s":"está","1p":"estamos","2p":"estáis","3p":"están"},367             "Past":{"1s":"estuviste","2s":"estuviste","3s":"estuvo","1p":"estuvimos","2p":"estuvisteis","3p":"estuvieron"},368             "Fut":{"1s":"estaré","2s":"estarás","3s":"estará","1p":"estaremos","2p":"estaréis","3p":"estarán"}}369        return tab[tense].get(person, tab[tense]["3s"])370    if lemma=="ir":371        tab={"Pres":{"1s":"voy","2s":"vas","3s":"va","1p":"vamos","2p":"vais","3p":"van"},372             "Past":{"1s":"fui","2s":"fuiste","3s":"fue","1p":"fuimos","2p":"fuisteis","3p":"fueron"},373             "Fut":{"1s":"iré","2s":"irás","3s":"irá","1p":"iremos","2p":"iréis","3p":"irán"}}374        return tab[tense].get(person, tab[tense]["3s"])375    return _es_conj_regular(lemma, tense, person)376def _en_conj(lemma, tense, person):377    if lemma=="be":378        if tense=="Pres": return {"1s":"am","2s":"are","3s":"is","1p":"are","2p":"are","3p":"are"}.get(person,"is")379        if tense=="Past": return {"1s":"was","2s":"were","3s":"was","1p":"were","2p":"were","3p":"were"}.get(person,"was")380        return "be"381    if lemma=="have":382        if tense=="Pres": return "has" if person=="3s" else "have"383        if tense=="Past": return "had"384        return "have"385    if lemma=="go":386        if tense=="Past": return "went"387        return "goes" if (tense=="Pres" and person=="3s") else "go"388    if lemma=="do":389        if tense=="Past": return "did"390        return "does" if (tense=="Pres" and person=="3s") else "do"391    if tense=="Pres":392        if person=="3s":393            if lemma.endswith("y") and (len(lemma)<2 or lemma[-2] not in "aeiou"): return lemma[:-1]+"ies"394            if lemma.endswith(("s","sh","ch","x","z","o")): return lemma+"es"395            return lemma+"s"396        return lemma397    if tense=="Past":398        if lemma.endswith("e"): return lemma+"d"399        if lemma.endswith("y") and (len(lemma)<2 or lemma[-2] not in "aeiou"): return lemma[:-1]+"ied"400        return lemma+"ed"401    return lemma402 403# ================= Helper de construcción/translate =================404def _build_with_spacy(text: str, src_lang: str, target: str, drop_articles: bool, zero_copula: bool, semi_lossless: bool, remove_pronouns: bool) -> str:405    nlp = nlp_es if src_lang=="Español" else nlp_en406    doc = nlp(text)407    if target == "Minimax-ASCII":408        return realize_minimax(doc, src_lang, drop_articles, zero_copula, semi_lossless, remove_pronouns=remove_pronouns)409    else:410        return realize_komin(doc, src_lang, drop_articles, zero_copula, semi_lossless, remove_pronouns=remove_pronouns)411 412def build_sentence(text: str, src_lang: str, target: str, drop_articles: bool, zero_copula: bool, mode: str, max_comp_exact: bool = False, remove_pronouns: bool = False) -> str:413    if not text.strip(): return ""414    semi = True # siempre semi-lossless en construcción415    core = _build_with_spacy(text, src_lang, target, drop_articles, zero_copula and not semi, semi, remove_pronouns) if USE_SPACY else encode_simple(text, src_lang, target)416    if max_comp_exact:417        return custom_sidecar_enc(core, text)418    return core419 420def universal_translate(text: str, src: str, tgt: str, drop_articles: bool, zero_copula: bool, mode: str, max_comp_exact: bool = False, remove_pronouns: bool = False) -> str:421    if not text.strip(): return ""422    if src == tgt: return text423 424    # Natural → Conlang425    if src in ("Español","English") and tgt in ("Minimax-ASCII","Kōmín-CJK"):426        return build_sentence(text, src, tgt, drop_articles, zero_copula, mode, max_comp_exact, remove_pronouns)427 428    # Conlang → Natural (considera sidecars)429    if src in ("Minimax-ASCII","Kōmín-CJK") and tgt in ("Español","English"):430        orig = extract_custom_sidecar(text)431        if orig is not None: return orig432        orig = extract_sidecar_b85(text)433        if orig is not None: return orig434        return decode_simple(strip_custom_sidecar(strip_sidecar_b85(text)), src, tgt)435 436    # Natural ↔ Natural (lemas muy simples)437    if src in ("Español","English") and tgt in ("Español","English"):438        return text439 440    # Conlang ↔ Conlang (simple)441    if src in ("Minimax-ASCII","Kōmín-CJK") and tgt in ("Minimax-ASCII","Kōmín-CJK"):442        core = strip_custom_sidecar(text)443        es_lemmas = decode_simple(core, src, "Español")444        words = re.findall(r"\w+|[^\w\s]+", es_lemmas)445        out=[]446        for w in words:447            if re.fullmatch(r"\w+", w):448                code = ES2MINI.get(norm_es(w)) if tgt=="Minimax-ASCII" else ES2KOMI.get(norm_es(w))449                if not code:450                    code = enc_oov_minimax(w) if tgt=="Minimax-ASCII" else enc_oov_komin(w)451                out.append(code)452            else:453                out.append(w)454        out_text = " ".join(out)455        if extract_custom_sidecar(text) is not None:456            return custom_sidecar_enc(out_text, extract_custom_sidecar(text) or "")457        return out_text458 459    return "[No soportado]"460 461# =====================================================================================462# ========================= UI bilingüe y explicaciones claras ========================463# =====================================================================================464 465ALL_LANGS = ["Español","English","Minimax-ASCII","Kōmín-CJK"]466 467# ---------- Explicaciones ----------468EXPLAIN_TAB_TRANSLATE_ES = """469**¿Qué hace “Traducir”?**  470Convierte lo que escribes en **Texto** al **Destino** que elijas (ES/EN/Minimax/Kōmín).  471- Con **Máx. Compresión Exacta**, añade un final ~... con el **original comprimido** para recuperarlo tal cual al decodificar.472- Las casillas de **compactación** (artículos, cópula, pronombres) **sólo se aplican si el Destino es conlang**.473"""474EXPLAIN_TAB_BUILD_ES = """475**¿Qué hace “Construir (ES/EN → Conlang)”?**  476Obliga a que la salida sea **Minimax** o **Kōmín** (desde ES/EN). Aplica el orden y las partículas del conlang y las opciones de **compactación**.477"""478EXPLAIN_TAB_DECODE_ES = """479**¿Qué hace “Decodificar (Conlang → ES/EN)”?**  480Convierte de **Minimax/Kōmín** a **Español/Inglés**.  481- Si el texto trae ~..., devolvemos el **original exacto**.  482- Si no, reconstruimos lo más fiel posible con el **diccionario**.483"""484EXPLAIN_TAB_ROUNDTRIP_ES = """485**¿Qué hace “Prueba ida→vuelta”?**  486Hace el camino **(ES/EN → Conlang) → (Conlang → ES/EN)** para comprobar la **reversibilidad**.  487Con **exacta**, la vuelta coincide **bit a bit**.488"""489EXPLAIN_CHECKBOX_ES = """490**Opciones de compactación (para conlang):**491- **Omitir artículos** (*el/la/los/las*; *a/an/the*): ahorro típico **~10–15%**.492- **Cópula cero** (presente afirmativo): oculta *ser/estar/be* → **~5–10%** extra.493- **Quitar pronombres**: suprime pronombres obvios → ahorro **variable**.494- **Máx. Compresión Exacta**: añade ~... para recuperar el original (en >100 caracteres, **~40–60%**; en textos muy cortos puede no reducir).495**Guía rápida:** sin casillas **0%**; artículos+cópula **~15–20%**.496"""497# ¿Qué son los lenguajes?498EXPLAIN_CONLANGS_ES = """499### ¿Qué son Minimax-ASCII y Kōmín-CJK?500 501Piensa en **dos “idiomas comprimidos”** que sirven para escribir frases de ES/EN con menos caracteres y, además,502**poder volver al original**. Son como “zip para texto”, pero legibles.503 504---505 506#### 1) Minimax-ASCII (compacto y tecleable)507- Usa **sólo ASCII**, así que funciona en cualquier sitio (correo, móvil, código).508- Cada **palabra** se cambia por un **código corto** (por frecuencia, lo común es más corto).509- Los **verbos** llevan una colita con marcas:510  - **·P / ·T / ·F** → Presente / Pasado / Futuro  511  - **1s, 2p, 3s…** → Persona y número (1=yo/nosotros, 2=tú/vosotros, 3=él/ellos; s=singular, p=plural)512  - **N** → negación; **Q** → pregunta513- **Ejemplo**: “**¿Estás bien?**” → `k·P2sQ` (estar, Presente, 2ª persona, pregunta)514 515**Cuándo usarlo**: si quieres **máxima compatibilidad** y **tamaño pequeño** sin símbolos raros.516 517---518 519#### 2) Kōmín-CJK (visual y ultracorto)520- Usa ideogramas CJK para **aún más compresión** y un aspecto muy limpio.521- Añade **partículas**:522  - `ᵖ` marca el **sujeto**, `ᵒ` marca el **objeto**.523- El verbo lleva un **círculo de tiempo**:524  - **Ⓟ / Ⓣ / Ⓕ** → Presente / Pasado / Futuro  525- Las **preguntas** suelen acabar en **?**.526- **Ejemplo**: “**Los estudiantes leen libros.**” → `学生ᵖ 书ᵒ 读Ⓟ`527 528**Cuándo usarlo**: si buscas **máxima compresión** y no te importa usar caracteres CJK.529 530---531 532#### ¿Y si falta una palabra?533- Si una palabra no está en el diccionario, se guarda **de forma reversible**:534  - En **Minimax**: `~A9f...` (base64 propio).535  - En **Kōmín**: `「...」`.536  Así **no se pierde nada**.537 538#### “Compresión exacta” (el `~...`)539- Opcionalmente se añade un **sidecar** `~...` con el **original comprimido**.540- Si existe, al decodificar se recupera el **original al 100%** (puntuación, mayúsculas, etc.).541- En textos largos ahorra mucho, con **ida/vuelta perfecta**.542 543---544 545#### Mini-glosario546- **Código**: forma corta de una palabra (p. ej., `g` para “que”).547- **Partícula**: marca de función (sujeto `ᵖ`, objeto `ᵒ`).548- **Cola verbal** (Minimax): `·P/·T/·F`, persona (`1s`, `3p`), `N`, `Q`.549- **Sidecar**: `~...` con el original comprimido para **reconstruir exacto**.550 551> Resumen: Minimax-ASCII = **universal y tecleable**. Kōmín-CJK = **más corto y visual**. Ambos son **reversibles** y aceptan **sidecar exacto**.552"""553 554# EN555EXPLAIN_TAB_TRANSLATE_EN = "Converts **Text → Target** (ES/EN/Minimax/Kōmín). With **Max Exact**, adds ~... to recover the **exact original**. Compaction checkboxes apply only when **Target is conlang**."556EXPLAIN_TAB_BUILD_EN = "Forces **conlang output** (Minimax/Kōmín) from ES/EN, applying phrasing rules and compaction options."557EXPLAIN_TAB_DECODE_EN = "Converts **Minimax/Kōmín → ES/EN**. If ~... exists, returns the bit-perfect original; else semi-lossless."558EXPLAIN_TAB_ROUNDTRIP_EN = "Runs **(ES/EN→Conlang)→(Conlang→ES/EN)** to verify reversibility; with exact, it’s bit-for-bit."559EXPLAIN_CHECKBOX_EN = "Drop articles ~10–15%, Zero copula ~5–10% extra, Remove pronouns variable, Max Exact 40–60% for >100 chars."560EXPLAIN_CONLANGS_EN = """561### What are Minimax-ASCII and Kōmín-CJK?562 563Think of **two “compressed languages”** that let you write ES/EN sentences with fewer characters while you can still564**recover the original**. Like a human-readable “zip” for text.565 566---567 568#### 1) Minimax-ASCII (compact & typeable)569- Uses **ASCII only**, so it works everywhere (email, phones, code editors).570- Each **word** becomes a **short code** (high-frequency words get the shortest codes).571- **Verbs** get a small **tail**:572  - **·P / ·T / ·F** → Present / Past / Future  573  - **1s, 2p, 3s…** → Person & number (1=I/we, 2=you, 3=he/they; s=singular, p=plural)574  - **N** → negation; **Q** → question575- **Example**: “**Are you okay?**” → `k·P2sQ` (be, Present, 2nd person, question)576 577**When to use**: you want **maximum compatibility** and **small size** without special symbols.578 579---580 581#### 2) Kōmín-CJK (visual & ultra-short)582- Uses CJK ideograms for **even tighter compression** and a clean visual look.583- Adds **particles**:584  - `ᵖ` marks the **subject**, `ᵒ` marks the **object**.585- Verb shows a **time bubble**:586  - **Ⓟ / Ⓣ / Ⓕ** → Present / Past / Future  587- **Questions** usually end with **?**.588- **Example**: “**Students read books.**” → `学生ᵖ 书ᵒ 读Ⓟ`589 590**When to use**: you want **maximum compression** and you’re fine with CJK.591 592---593 594#### Unknown words?595- If a word isn’t in the lexicon, it’s kept **reversibly**:596  - In **Minimax**: `~A9f...` (custom base64).597  - In **Kōmín**: `「...」`.598  Nothing is lost.599 600#### “Exact compression” (the `~...` sidecar)601- Optionally appends `~...` with the **compressed original**.602- If present, decoding reproduces the **exact original** (punctuation, casing, etc.).603- Great for longer texts: big savings with **perfect round-trip**.604 605---606 607#### Tiny glossary608- **Code**: short form for a word (e.g., `g` for “that/que”).609- **Particle**: role marker (subject `ᵖ`, object `ᵒ`).610- **Verb tail** (Minimax): `·P/·T/·F`, person (`1s`, `3p`), `N`, `Q`.611- **Sidecar**: `~...` holding the compressed original for **bit-perfect recovery**.612 613> TL;DR: Minimax-ASCII = **universal & typeable**. Kōmín-CJK = **shortest & visual**. Both are **reversible** and support the **exact sidecar**.614"""615 616# Léxico (amigable)617LEXICON_FRIENDLY_ES = """618**¿De dónde sale el “diccionario” (léxico) y para qué sirve?**  619- Usamos **WordNet (OMW)** para listar palabras españolas y sus equivalentes en inglés.  620- Limpiamos y ordenamos por **frecuencia de uso**.  621- Asignamos un **código corto** a cada lema para **Minimax** y para **Kōmín**.  622- Guardamos tres archivos que la app usa al traducir:623  - lexicon_minimax.json (ES → Minimax)  624  - lexicon_komin.json (ES → Kōmín)  625  - lexicon_master.json (ES + EN + ambos códigos)626**Así** podemos convertir tus frases en **códigos compactos** y volver a texto entendible.627"""628LEXICON_FRIENDLY_EN = "We use **WordNet (OMW)**, pair ES words with EN, sort by frequency, assign short codes (Minimax/Kōmín), and save three JSONs so the app can encode/decode compactly."629 630# ---------- Utilidades de compactación y vista previa ----------631def _pct_comp(original: str, result: str) -> float:632    if not original: return 0.0633    return max(0.0, 100.0 * (1.0 - (len(result) / len(original))))634 635def compaction_line_es(text, src, tgt, drop, zero, rm, maxc) -> str:636    if not text.strip(): return "—"637    if tgt not in ("Minimax-ASCII","Kōmín-CJK"):638        return "La compactación aplica cuando el **Destino** es Minimax/Kōmín."639    base = build_sentence(text, src, tgt, False, False, "Semi-lossless", False, False)640    curr = build_sentence(text, src, tgt, drop, zero, "Semi-lossless", False, rm)641    msg = f"**Base (sin casillas):** {_pct_comp(text, base):.1f}% · **Con tus opciones:** {_pct_comp(text, curr):.1f}%"642    if maxc:643        curr_exact = build_sentence(text, src, tgt, drop, zero, "Semi-lossless", True, rm)644        msg += f" · **Con sidecar ~...:** {_pct_comp(text, curr_exact):.1f}%"645    return msg646 647def compaction_line_en(text, src, tgt, drop, zero, rm, maxc) -> str:648    if not text.strip(): return "—"649    if tgt not in ("Minimax-ASCII","Kōmín-CJK"):650        return "Compaction applies when **Target** is Minimax/Kōmín."651    base = build_sentence(text, src, tgt, False, False, "Semi-lossless", False, False)652    curr = build_sentence(text, src, tgt, drop, zero, "Semi-lossless", False, rm)653    msg = f"**Base (no options):** {_pct_comp(text, base):.1f}% · **With your options:** {_pct_comp(text, curr):.1f}%"654    if maxc:655        curr_exact = build_sentence(text, src, tgt, drop, zero, "Semi-lossless", True, rm)656        msg += f" · **With ~... sidecar:** {_pct_comp(text, curr_exact):.1f}%"657    return msg658 659def master_preview(n: int = 20) -> List[List[Any]]:660    try:661        entries = (MASTER_OBJ or {}).get("entries", [])662        head = entries[:max(0, int(n))]663        rows = [["lemma_es","lemma_en","minimax","komin"]]664        for e in head:665            rows.append([e.get("lemma_es",""), e.get("lemma_en",""), e.get("minimax",""), e.get("komin","")])666        return rows667    except Exception:668        return [["lemma_es","lemma_en","minimax","komin"], ["(no data)","","",""]]669 670# ---------- Paneles (uno visible según “modo”) ----------671def make_panel_translate(lang="ES"):672    with gr.Group(visible=True) as g:673        with gr.Accordion(("🔁 Traducir — ayuda" if lang=="ES" else "🔁 Translate — help"), open=False):674            gr.Markdown(EXPLAIN_TAB_TRANSLATE_ES if lang=="ES" else EXPLAIN_TAB_TRANSLATE_EN)675        with gr.Row():676            src = gr.Dropdown(ALL_LANGS, value=("Español" if lang=="ES" else "English"), label=("Fuente" if lang=="ES" else "Source"))677            tgt = gr.Dropdown(ALL_LANGS, value="Minimax-ASCII", label=("Destino" if lang=="ES" else "Target"))678        text = gr.Textbox(lines=3, label=("Texto" if lang=="ES" else "Text"), placeholder=("Ej.: Hola, ¿cómo estás?" if lang=="ES" else "e.g., Hello, how are you?"), show_copy_button=True)679        with gr.Row():680            drop = gr.Checkbox(True, label=("Omitir artículos (ES/EN → conlang)" if lang=="ES" else "Drop articles (ES/EN → conlang)"))681            zero = gr.Checkbox(False, label=("Cópula cero (presente afirm.)" if lang=="ES" else "Zero copula (present affirmative)"))682            rmpr = gr.Checkbox(False, label=("Quitar pronombres" if lang=="ES" else "Remove pronouns"))683            exact = gr.Checkbox(False, label=("Máx. Compresión Exacta (sidecar ~...)" if lang=="ES" else "Max Exact Compression (sidecar ~...)"))684        mode_hidden = gr.Dropdown(["Semi-lossless"], value="Semi-lossless", visible=False)685        out = gr.Textbox(lines=6, label=("Traducción" if lang=="ES" else "Translation"), show_copy_button=True)686        comp = gr.Markdown("")687        def run(text, s, t, d, z, m, e, r):688            if not text.strip(): return "", ""689            res = universal_translate(text, s, t, d, z, m, e, r)690            rep = (compaction_line_es if lang=="ES" else compaction_line_en)(text, s, t, d, z, r, e)691            return res, rep692        for c in [text, src, tgt, drop, zero, rmpr, exact]:693            c.change(run, [text, src, tgt, drop, zero, mode_hidden, exact, rmpr], [out, comp])694    return g695 696def make_panel_build(lang="ES"):697    with gr.Group(visible=False) as g:698        with gr.Accordion(("🛠️ Construir — ayuda" if lang=="ES" else "🛠️ Build — help"), open=False):699            gr.Markdown(EXPLAIN_TAB_BUILD_ES if lang=="ES" else EXPLAIN_TAB_BUILD_EN)700        with gr.Row():701            src = gr.Dropdown(["Español","English"], value=("Español" if lang=="ES" else "English"), label=("Fuente" if lang=="ES" else "Source"))702            tgt = gr.Dropdown(["Minimax-ASCII","Kōmín-CJK"], value="Minimax-ASCII", label="Conlang")703        text = gr.Textbox(lines=3, label=("Frase" if lang=="ES" else "Sentence"), show_copy_button=True)704        with gr.Row():705            drop = gr.Checkbox(True,  label=("Omitir artículos" if lang=="ES" else "Drop articles"))706            zero = gr.Checkbox(False, label=("Cópula cero (presente afirm.)" if lang=="ES" else "Zero copula (present affirmative)"))707            rmpr = gr.Checkbox(False, label=("Quitar pronombres" if lang=="ES" else "Remove pronouns"))708            exact = gr.Checkbox(False, label=("Máx. Compresión Exacta" if lang=="ES" else "Max Exact Compression"))709        mode_hidden = gr.Dropdown(["Semi-lossless"], value="Semi-lossless", visible=False)710        out = gr.Textbox(lines=6, label=("Salida" if lang=="ES" else "Output"), show_copy_button=True)711        comp = gr.Markdown("")712        def run(text, s, t, d, z, m, e, r):713            if not text.strip(): return "", ""714            res = build_sentence(text, s, t, d, z, m, e, r)715            rep = (compaction_line_es if lang=="ES" else compaction_line_en)(text, s, t, d, z, r, e)716            return res, rep717        for c in [text, src, tgt, drop, zero, rmpr, exact]:718            c.change(run, [text, src, tgt, drop, zero, mode_hidden, exact, rmpr], [out, comp])719    return g720 721def make_panel_decode(lang="ES"):722    with gr.Group(visible=False) as g:723        with gr.Accordion(("🗝️ Decodificar — ayuda" if lang=="ES" else "🗝️ Decode — help"), open=False):724            gr.Markdown(EXPLAIN_TAB_DECODE_ES if lang=="ES" else EXPLAIN_TAB_DECODE_EN)725        with gr.Row():726            src = gr.Dropdown(["Minimax-ASCII","Kōmín-CJK"], value="Minimax-ASCII", label=("Fuente" if lang=="ES" else "Source"))727            tgt = gr.Dropdown(["Español","English"], value=("Español" if lang=="ES" else "English"), label=("Destino" if lang=="ES" else "Target"))728        text = gr.Textbox(lines=3, label=("Texto en conlang (puede incluir ~...)" if lang=="ES" else "Conlang text (may include ~...)"), show_copy_button=True)729        out = gr.Textbox(lines=6, label=("Salida" if lang=="ES" else "Output"), show_copy_button=True)730        def run(t, s, d):731            if not t.strip(): return ""732            orig = extract_custom_sidecar(t)733            if orig is not None: return orig734            orig = extract_sidecar_b85(t)735            if orig is not None: return orig736            return decode_simple(strip_custom_sidecar(strip_sidecar_b85(t)), s, d)737        for c in [text, src, tgt]:738            c.change(run, [text, src, tgt], [out])739    return g740 741def make_panel_roundtrip(lang="ES"):742    with gr.Group(visible=False) as g:743        with gr.Accordion(("🔄 Prueba ida→vuelta — ayuda" if lang=="ES" else "🔄 Round-trip — help"), open=False):744            gr.Markdown(EXPLAIN_TAB_ROUNDTRIP_ES if lang=="ES" else EXPLAIN_TAB_ROUNDTRIP_EN)745        with gr.Row():746            src = gr.Dropdown(["Español","English"], value=("Español" if lang=="ES" else "English"), label=("Fuente" if lang=="ES" else "Source"))747            tgt = gr.Dropdown(["Minimax-ASCII","Kōmín-CJK"], value="Minimax-ASCII", label="Conlang")748        text = gr.Textbox(lines=3, label=("Frase" if lang=="ES" else "Sentence"), show_copy_button=True)749        exact = gr.Checkbox(False, label=("Máx. Compresión Exacta" if lang=="ES" else "Max Exact Compression"))750        mode_hidden = gr.Dropdown(["Semi-lossless"], value="Semi-lossless", visible=False)751        out1 = gr.Textbox(lines=3, label=("Conlang (ida)" if lang=="ES" else "Outward (conlang)"), show_copy_button=True)752        out2 = gr.Textbox(lines=3, label=("Vuelta" if lang=="ES" else "Back"), show_copy_button=True)753        def run(t, s, c, m, e):754            if not t.strip(): return "", ""755            conlang = universal_translate(t, s, c, True, False, m, e, False)756            back    = universal_translate(conlang, c, s, True, False, m, e, False)757            return conlang, back758        for c in [text, src, tgt, exact]:759            c.change(run, [text, src, tgt, mode_hidden, exact], [out1, out2])760    return g761 762# ---------- Página (ES/EN), con “modos” como BOTONES (Radio) ----------763with gr.Blocks(title="Universal Conlang Translator", theme=gr.themes.Soft()) as demo:764 765    # --- Descargas diccionarios (PDF) ---766    gr.Markdown("### 📥 Diccionarios (PDF)")767    # Mini-CSS para reducir visualmente el tamaño de los botones768    gr.HTML("""769    <style>770      #btn_pdf_es button, #btn_pdf_en button {771        font-size: 0.85rem;772        padding: 0.4rem 0.8rem;773      }774    </style>775    """)776    with gr.Row():777        gr.DownloadButton(778            label="⬇️ Español >> Minimax-ASCII y Kōmín-CJK (PDF)",779            value="dictionary_ES_to_Minimax_Komin.pdf",780            elem_id="btn_pdf_es",781            variant="secondary"782        )783        gr.DownloadButton(784            label="⬇️ English >> Minimax-ASCII y Kōmín-CJK (PDF)",785            value="dictionary_EN_to_Minimax_Komin.pdf",786            elem_id="btn_pdf_en",787            variant="secondary"788        )789 790    gr.Markdown("## 🌍 Idioma / Language")791    lang = gr.Radio(["ES","EN"], value="ES", label="Selecciona / Select")792 793    # Acordeones explicativos (mismo nivel)794    acc_conlangs_es = gr.Accordion("🧩 ¿Qué son Minimax-ASCII y Kōmín-CJK? (ES)", open=False, visible=True)795    with acc_conlangs_es: gr.Markdown(EXPLAIN_CONLANGS_ES)796    acc_conlangs_en = gr.Accordion("🧩 What are Minimax-ASCII and Kōmín-CJK? (EN)", open=False, visible=False)797    with acc_conlangs_en: gr.Markdown(EXPLAIN_CONLANGS_EN)798 799    acc_modes_es = gr.Accordion("📖 ¿Qué hace cada botón / modo? (ES)", open=False, visible=True)800    with acc_modes_es: gr.Markdown(801        "- **🔁 Traducir**: Texto → Destino (ES/EN/Minimax/Kōmín), con opciones de compactación y % mostrado.\n"802        "- **🛠️ Construir**: Obliga salida en conlang (Minimax/Kōmín) desde ES/EN.\n"803        "- **🗝️ Decodificar**: Conlang → ES/EN (si hay ~..., devuelve el original exacto).\n"804        "- **🔄 Prueba ida→vuelta**: Comprueba reversibilidad."805    )806    acc_modes_en = gr.Accordion("📖 What does each button / mode do? (EN)", open=False, visible=False)807    with acc_modes_en: gr.Markdown(808        "- **🔁 Translate**: Text → Target (ES/EN/Minimax/Kōmín) with compaction and %.\n"809        "- **🛠️ Build**: Force conlang output from ES/EN.\n"810        "- **🗝️ Decode**: Conlang → ES/EN (if ~..., exact original).\n"811        "- **🔄 Round-trip**: Check reversibility."812    )813 814    acc_intro_es = gr.Accordion("☑️ Opciones y compactación — guía rápida (ES)", open=False, visible=True)815    with acc_intro_es: gr.Markdown(EXPLAIN_CHECKBOX_ES)816    acc_intro_en = gr.Accordion("☑️ Options & compaction — quick guide (EN)", open=False, visible=False)817    with acc_intro_en: gr.Markdown(EXPLAIN_CHECKBOX_EN)818 819    acc_lex_es = gr.Accordion("ℹ️ Léxico — explicación y vista previa (ES)", open=False, visible=True)820    with acc_lex_es:821        gr.Markdown(LEXICON_FRIENDLY_ES)822        n_rows_es = gr.Slider(5, 100, value=20, step=5, label="Filas a mostrar")823        table_es = gr.Dataframe(headers=["lemma_es","lemma_en","minimax","komin"], row_count=1, interactive=False)824        gr.Button("Actualizar vista").click(lambda n: master_preview(int(n)), [n_rows_es], [table_es])825 826    acc_lex_en = gr.Accordion("ℹ️ Lexicon — explainer & preview (EN)", open=False, visible=False)827    with acc_lex_en:828        gr.Markdown(LEXICON_FRIENDLY_EN)829        n_rows_en = gr.Slider(5, 100, value=20, step=5, label="Rows to show")830        table_en = gr.Dataframe(headers=["lemma_es","lemma_en","minimax","komin"], row_count=1, interactive=False)831        gr.Button("Refresh").click(lambda n: master_preview(int(n)), [n_rows_en], [table_en])832 833    # Modo de uso como BOTONES (Radio)834    gr.Markdown("### 🧭 Modo de uso (elige uno)")835    mode = gr.Radio(836        choices=[837            "🔁 Traducir / Translate",838            "🛠️ Construir (ES/EN → Conlang) / Build",839            "🗝️ Decodificar (Conlang → ES/EN) / Decode",840            "🔄 Prueba ida→vuelta / Round-trip",841        ],842        value="🔁 Traducir / Translate",843        label=None,844    )845 846    # Paneles por modo y por idioma847    gr.Markdown("### 🧪 Área de trabajo")848    panel_tr_es = make_panel_translate("ES"); panel_bu_es = make_panel_build("ES")849    panel_de_es = make_panel_decode("ES");   panel_rt_es = make_panel_roundtrip("ES")850    panel_tr_en = make_panel_translate("EN"); panel_bu_en = make_panel_build("EN")851    panel_de_en = make_panel_decode("EN");   panel_rt_en = make_panel_roundtrip("EN")852 853    def _vis(yes): return gr.update(visible=bool(yes))854 855    def _mode_to_flags(mode_str):856        order = [857            "🔁 Traducir / Translate",858            "🛠️ Construir (ES/EN → Conlang) / Build",859            "🗝️ Decodificar (Conlang → ES/EN) / Decode",860            "🔄 Prueba ida→vuelta / Round-trip",861        ]862        chosen = mode_str if mode_str in order else order[0]863        return [chosen == o for o in order], chosen864 865    # Lógica de visibilidad (reutiliza tu función)866    def switch_everything(lang_code, tr, bu, de, rt):867        tr2, bu2, de2, rt2 = False, False, False, False868        if tr or (not bu and not de and not rt): tr2 = True869        elif bu: bu2 = True870        elif de: de2 = True871        else:    rt2 = True872 873        is_en = (lang_code == "EN")874        vis_es = not is_en; vis_en = is_en875        updates = [876            _vis(vis_es), _vis(vis_en),         # conlangs ES/EN877            _vis(vis_es), _vis(vis_en),         # modos ES/EN878            _vis(vis_es), _vis(vis_en),         # intro ES/EN879            _vis(vis_es), _vis(vis_en),         # léxico ES/EN880        ]881        updates += [882            _vis(vis_es and tr2), _vis(vis_es and bu2), _vis(vis_es and de2), _vis(vis_es and rt2),883            _vis(vis_en and tr2), _vis(vis_en and bu2), _vis(vis_en and de2), _vis(vis_en and rt2),884        ]885        return updates886 887    def _on_lang_or_mode(lang_code, mode_str):888        flags, chosen = _mode_to_flags(mode_str)889        tr, bu, de, rt = flags890        return switch_everything(lang_code, tr, bu, de, rt)891 892    # Reacciones893    lang.change(894        _on_lang_or_mode,895        [lang, mode],896        [897            acc_conlangs_es, acc_conlangs_en,898            acc_modes_es, acc_modes_en,899            acc_intro_es, acc_intro_en,900            acc_lex_es, acc_lex_en,901            panel_tr_es, panel_bu_es, panel_de_es, panel_rt_es,902            panel_tr_en, panel_bu_en, panel_de_en, panel_rt_en,903        ],904    )905    mode.change(906        _on_lang_or_mode,907        [lang, mode],908        [909            acc_conlangs_es, acc_conlangs_en,910            acc_modes_es, acc_modes_en,911            acc_intro_es, acc_intro_en,912            acc_lex_es, acc_lex_en,913            panel_tr_es, panel_bu_es, panel_de_es, panel_rt_es,914            panel_tr_en, panel_bu_en, panel_de_en, panel_rt_en,915        ],916    )917    # --- Forzar estado inicial correcto al cargar la Space ---918    demo.load(919        _on_lang_or_mode,920        [lang, mode],921        [922            acc_conlangs_es, acc_conlangs_en,923            acc_modes_es, acc_modes_en,924            acc_intro_es, acc_intro_en,925            acc_lex_es, acc_lex_en,926            panel_tr_es, panel_bu_es, panel_de_es, panel_rt_es,927            panel_tr_en, panel_bu_en, panel_de_en, panel_rt_en,928        ],929    )930if __name__ == "__main__":931    demo.launch()932 933 934 935 936 937 938 939 940 941