Logotip de Softcatalà. Un cercle vermell, més fosc a la part superior i més clar cap a la inferior. Al centre hi ha un avió de paper estilitzat amb les franges de la senyera, vermell i groc, i una ombra fosca a sota. Sota l'avió, en majúscules blanques, hi ha el text «SOFTCATALÀ». Per sota del text hi ha tres ratlles blanques inclinades com a detall decoratiu.

Traducció local amb dos motors de codi obert que s'executen al propi equip. Al medialab els hem integrat a Hermes-Ai com a servidor MCP compartit: tots els perfils de Hermes poden demanar traduccions des de la conversa, i el text no surt en direcció a cap servei de traduccio extern.

Hi ha dos motors:

  • Softcatalà per a català↔castellà. Són models entrenats per a la parella, en format CTranslate2, que funcionen amb la CPU.
  • NLLB-200 1.3B (Meta, versio destil·lada) per a la resta de parelles: català, castellà, anglès, francès i portuguès entre si. Un sol model cobreix totes les direccions sense passar per un idioma intermedi.

Avantatges

  • Sense cost per ús. No consumeix tokens ni crèdits d'API.
  • Traducció local. El servei escolta només a l'adreça local (127.0.0.1). No cal compte ni clau d'API.
  • Sense connexió. Després de la instal·lació, les traduccions no depenen d'Internet.
  • Traduccions directes. Cada direccio es tradueix directament, sense passar per l'anglès.
  • Un sol servei per a tots els perfils. El servidor s'executa una vegada i els perfils de Hermes s'hi connecten per HTTP. Així el model no es carrega una vegada per perfil.
  • Errors visibles. Si un motor genera text incoherent (repeticions o longitud absurda), el servei no el retorna: respon amb un error.

Qualitat mesurada

Les xifres surten de comparar els dos motors amb el mateix text de referencia: el bloc «Codi» de terata.org, en català, castellà i anglès, i un conjunt de 5 frases curtes per direccio. chrF mesura la coincidencia de caracters amb la traduccio humana: com mes alt, millor. La mostra es petita, i diferencies de 3 o 4 punts no son concloents.

Direcció Softcatalà (text Codi) NLLB-1.3B (text Codi) Softcatalà (frases) NLLB-1.3B (frases)
català → castellà 95,4 92,1 95,7 97,2
castellà → català 94,9 90,5 88,7 92,3

Per a les parelles que no tenen Softcatalà, amb el text Codi: anglès → castellà 83,3; anglès → català 83,1; castellà → anglès 76,4, i català → anglès 73,1. La direccio català → anglès es la mes feble de les mesurades.

En les proves no s'ha produit cap text degenerat, ni amb Softcatalà ni amb NLLB.

Límits

Qualitat

Per a textos tecnics senzills i esborranys es util. Per a textos literaris, de matís alt o molt llargs, cal revisio humana. Tots dos motors cometen errors de persona gramatical: en el text Codi, «tengáis» surt com a «tendrás» o «tendrá».

Textos llargs

L'eina accepta fins a 5.000 caracters per crida. NLLB esta pensat per a frases i paragrafs curts (limit de 512 tokens). El servei parteix el text per linies i per frases, pero els documents llargs s'han de dividir.

Deteccio d'idioma

La deteccio es fiable amb frases completes. Una salutacio o una sola paraula dona una confiança baixa.

Privadesa

La traduccio es fa al propi equip. Pero Hermes consulta el seu model de llenguatge per entendre la peticio. Si aquest model es un servei extern, el text que es vol traduir hi viatja. Vegeu Hermes per a la diferencia entre el client i el model.

Requisits

  • Disc: uns 8 GB entre models i dependencies de Python.
  • RAM: en CPU, cal uns 6 GB lliures en carregar NLLB.
  • GPU: opcional. Amb una GPU NVIDIA, NLLB triga entre 6 i 13 s per traduccio; en CPU, entre 13 i 28 s. Softcatalà va be en CPU (uns 2 s).

Llicencies

  • NLLB-200 es CC-BY-NC 4.0: nomes us no comercial.
  • Els models de Softcatalà figuren com a «Other» al repositori. La llicencia encara s'ha de verificar abans d'un us fora del medialab.

Instal·lació amb un prompt a Hermes

L'instal·lacio no requereix cap comandament de terminal ni script. Copia el text següent en una conversa amb Hermes-AI i deixa que ell la resolgui. Revisa el resum final: ha d'indicar què ha fet i què ha comprovat.

Vull que instal·lis en aquest equip un servei de traducció local (català↔castellà amb Softcatalà i la resta de parelles amb NLLB) i que el connectis a Hermes com a servidor MCP. Segueix els passos en ordre i comprova cada un abans de continuar. No facis commit ni push de res, no modifiquis configuracions que no s'indiquen aquí i no mostris secrets.

ABANS DE COMENÇAR
1. Comprova que hi ha Linux amb systemd d'usuari (systemctl --user status funciona), Python 3.10 o superior i almenys 12 GB lliures al disc d'inici. Si alguna cosa falla, para i explica-m'ho.
2. Comprova si hi ha una GPU NVIDIA amb nvidia-smi. Si n'hi ha, usa torch amb CUDA. Si no, usa torch per a CPU. Sense GPU el servei funciona, però NLLB és més lent: a l'equip de referència, la ruta de CPU carrega NLLB en uns 8 s i cada traducció triga entre 13 i 28 s (ca→es amb Softcatalà triga 2 s). Necessita uns 6 GB de RAM lliures en carregar.
3. Pregunta'm abans de baixar res. Són uns 8 GB entre models i dependències.

PAS 1 - ENTORN
- Crea el directori ~/.local/share/mt-candidatos/ i dins un entorn virtual: python3 -m venv venv
- Instal·la al venv: "mcp<2" (important: la versió 2 canvia l'API i trenca el servidor), transformers, sentencepiece, ctranslate2, lingua-language-detector i torch (CUDA si hi ha GPU; per a CPU, usa l'índex https://download.pytorch.org/whl/cpu).
- Comprova que aquests imports funcionen: torch, transformers, ctranslate2, sentencepiece, lingua i mcp.server.fastmcp.

PAS 2 - SOFTCATALÀ (català↔castellà)
- Baixa de https://www.softcatala.org/pub/softcatala/opennmt/models/big/ aquests dos fitxers:
  cat-spa-2024-11-04.zip (ha de mesurar 1528397863 bytes)
  spa-cat-2024-10-21.zip (ha de mesurar 1531334064 bytes)
- Baixa amb curl -fL -C - per poder reprendre. Si la mida no coincideix, reprèn la descàrrega. No continuïs amb un fitxer incomplet.
- Descomprimeix cada zip dins de ~/.local/share/mt-candidatos/softcatala/cat-spa/ i ~/.local/share/mt-candidatos/softcatala/spa-cat/. El zip ja porta una carpeta arrel amb el mateix nom, així que el resultat ha de ser softcatala/cat-spa/cat-spa/ctranslate2/ i softcatala/cat-spa/cat-spa/tokenizer/sp_m.model. Comprova que existeixen les dues carpetes en tots dos parells.
- Esborra els zips només quan les carpetes estiguin comprovades.

PAS 3 - NLLB-200 1.3B DESTIL·LAT (la resta de parelles)
- Crea ~/.local/share/mt-candidatos/nllb-1.3B/ i baixa de https://huggingface.co/facebook/nllb-200-distilled-1.3B/resolve/main/ aquests fitxers: config.json, generation_config.json, sentencepiece.bpe.model, special_tokens_map.json, tokenizer.json, tokenizer_config.json i pytorch_model.bin.
- pytorch_model.bin ha de mesurar exactament 5482882236 bytes. Reprèn amb curl -fL -C - si no coincideix.
- Llicència: NLLB és CC-BY-NC 4.0, només ús no comercial. Recorda-m'ho al final.

PAS 4 - SERVIDOR
- Escriu ~/.local/share/mt-candidatos/mt_server.py amb exactament el contingut del bloc 1.
- Escriu ~/.config/systemd/user/mt-traduccion.service amb exactament el contingut del bloc 2.
- Executa: systemctl --user daemon-reload && systemctl --user enable --now mt-traduccion.service
- Espera que el port 5010 estigui escoltant (ss -ltn | grep 5010). Si no arrenca, revisa journalctl --user -u mt-traduccion.service -n 30. Si el port 5010 ja està ocupat, canvia Environment=MT_PORT a la unitat i usa el mateix port al pas 5.

PAS 5 - CONNECTAR AMB HERMES
- Pregunta'm a quin perfil o perfils ho vull. Per a cadascun, executa:
  hermes -p <perfil> mcp add traduccion --url http://127.0.0.1:5010/mcp --connect-timeout 60
- Quan pregunti si el servidor requereix autenticació, respon n. Quan pregunti si habilita les 3 eines, respon y.
- Atenció: sense -p, l'ordre escriu al perfil ACTIU, no al default. Comprova amb hermes -p <perfil> mcp list que apareix traduccion com a enabled amb 3 eines.

PAS 6 - PROVA
- En una sessió nova de Hermes, demana que tradueixi "Quan arribi el tren, truca'm, si us plau." del català al francès. Resultat esperat, amb possibles variacions: "Quand le train arrivera, appelez-moi, s'il vous plaît."
- Demana que tradueixi "Bom dia a todos. Vamos começar a reunião." del portuguès al castellà. Resultat esperat, amb possibles variacions: "Buenos días a todos. Vamos a comenzar la reunión."
- Si una traducció retorna error, mostra'm l'error literal. No inventis cap traducció.

LLIURAMENT
Al final, dona'm un resum amb: què has instal·lat, les rutes, el perfil o perfils configurats i què has comprovat amb sortida real. Què no hagis comprovat, digues-ho. Recorda que ca↔es va a Softcatalà, que la resta de parelles van a NLLB i que la primera traducció amb NLLB és més lenta, perquè el model es carrega en aquell moment (uns 30 s en GPU).

BLOC 1 - mt_server.py
#!/usr/bin/env python3
"""Servidor MCP de traduccion local para Hermes.

- Un unico proceso compartido por todos los perfiles, por HTTP en 127.0.0.1:MT_PORT/mcp.
- ca<->es: Softcatala (CTranslate2, CPU).
- Resto de pares (es, en, fr, pt): NLLB-200 1.3B destilado (fp16, GPU).
- Los modelos se cargan la primera vez que se necesitan.
- Si el motor devuelve texto degenerado (repeticiones, longitud absurda o vacio),
  NO se devuelve: la herramienta responde con error.
"""
import os
import re
import threading

from lingua import Language, LanguageDetectorBuilder
from mcp.server.fastmcp import FastMCP

BASE = os.path.expanduser("~/.local/share/mt-candidatos")
PORT = int(os.environ.get("MT_PORT", "5010"))
LANGS = ["ca", "es", "en", "fr", "pt"]
SC_DIR = {("ca", "es"): "cat-spa", ("es", "ca"): "spa-cat"}
NLLB_CODE = {"ca": "cat_Latn", "es": "spa_Latn", "en": "eng_Latn", "fr": "fra_Latn", "pt": "por_Latn"}
MAX_CHARS = 5000

mcp = FastMCP("traduccion", host="127.0.0.1", port=PORT)
_lock = threading.Lock()
_sc = {}
_nllb = None
_nllb_tok = {}
_LINGUA = {Language.CATALAN: "ca", Language.SPANISH: "es", Language.ENGLISH: "en",
           Language.FRENCH: "fr", Language.PORTUGUESE: "pt"}
_detector = LanguageDetectorBuilder.from_languages(*_LINGUA.keys()).build()


def motor(src, tgt):
    return "softcatala" if (src, tgt) in SC_DIR else "nllb"


def _sc_carga(par):
    if par not in _sc:
        import ctranslate2
        import sentencepiece as spm
        d = os.path.join(BASE, "softcatala", par, par)
        sp = spm.SentencePieceProcessor(model_file=os.path.join(d, "tokenizer", "sp_m.model"))
        tr = ctranslate2.Translator(os.path.join(d, "ctranslate2"), device="cpu")
        _sc[par] = (sp, tr)
    return _sc[par]


def _nllb_carga():
    global _nllb
    if _nllb is None:
        import torch
        from transformers import AutoModelForSeq2SeqLM
        ruta = os.path.join(BASE, "nllb-1.3B")
        dispositivo = "cuda" if torch.cuda.is_available() else "cpu"
        dtype = torch.float16 if dispositivo == "cuda" else torch.float32
        modelo = AutoModelForSeq2SeqLM.from_pretrained(ruta, dtype=dtype, low_cpu_mem_usage=True)
        _nllb = (modelo.to(dispositivo).eval(), dispositivo)
    return _nllb


def _nllb_tokenizer(src):
    if src not in _nllb_tok:
        from transformers import AutoTokenizer
        _nllb_tok[src] = AutoTokenizer.from_pretrained(os.path.join(BASE, "nllb-1.3B"), src_lang=NLLB_CODE[src])
    return _nllb_tok[src]


def _traducir_segmentos(textos, src, tgt):
    with _lock:
        if motor(src, tgt) == "softcatala":
            sp, tr = _sc_carga(SC_DIR[(src, tgt)])
            tokens = [sp.encode(t, out_type=str) for t in textos]
            res = tr.translate_batch(tokens, beam_size=4, max_decoding_length=300)
            return [sp.decode(r.hypotheses[0]) for r in res]
        import torch
        modelo, dispositivo = _nllb_carga()
        tok = _nllb_tokenizer(src)
        enc = tok(textos, return_tensors="pt", padding=True, truncation=True, max_length=400).to(dispositivo)
        with torch.no_grad():
            out = modelo.generate(**enc, forced_bos_token_id=tok.convert_tokens_to_ids(NLLB_CODE[tgt]),
                                  num_beams=4, max_new_tokens=300)
        return tok.batch_decode(out, skip_special_tokens=True)


def _degenerada(salida, entrada):
    if not salida.strip():
        return "vacia"
    if re.search(r"(\w{3,})(\1){4,}", salida.replace(" ", "")):
        return "repeticiones"
    ratio = len(salida) / max(len(entrada), 1)
    if ratio > 3 or ratio < 0.2:
        return f"longitud anomala (x{ratio:.1f})"
    return ""


def _segmentar(texto):
    """Lineas no vacias como segmentos; lineas largas se parten por frases."""
    segmentos = []
    for linea in texto.split("\n"):
        if not linea.strip():
            segmentos.append(None)
            continue
        if len(linea) <= 400:
            segmentos.append(linea.strip())
        else:
            partes = re.split(r"(?<=[.!?…])\s+", linea.strip())
            segmentos.extend(p for p in partes if p)
    return segmentos


@mcp.tool()
def translate(text: str, source: str, target: str) -> str:
    """Traduce texto entre catalan (ca), castellano (es), ingles (en), frances (fr) y portugues (pt).

    Usa Softcatala para ca<->es y NLLB para el resto. Devuelve error si la traduccion
    sale degenerada, en vez de devolver texto inutil."""
    if source not in LANGS or target not in LANGS:
        raise ValueError(f"Idiomas no soportados. Usa uno de: {', '.join(LANGS)}")
    if source == target:
        raise ValueError("Origen y destino son iguales.")
    if len(text) > MAX_CHARS:
        raise ValueError(f"Texto demasiado largo ({len(text)} caracteres, maximo {MAX_CHARS}). Divide el texto.")
    segmentos = _segmentar(text)
    a_traducir = [s for s in segmentos if s]
    if not a_traducir:
        return ""
    salidas = _traducir_segmentos(a_traducir, source, target)
    for entrada, salida in zip(a_traducir, salidas):
        motivo = _degenerada(salida, entrada)
        if motivo:
            raise ValueError(
                f"Salida degenerada del motor {motor(source, target)} ({motivo}) para el segmento: "
                f"{entrada[:80]!r}. No se devuelve traduccion."
            )
    it = iter(salidas)
    lineas = [next(it) if s else "" for s in segmentos]
    return "\n".join(lineas)


@mcp.tool()
def detect(text: str) -> dict:
    """Detecta el idioma (ca, es, en, fr o pt) con una confianza de 0 a 1.

    Las frases cortas, como un saludo, dan confianza baja: usa frases completas."""
    if not text.strip():
        raise ValueError("Texto vacio.")
    confianzas = _detector.compute_language_confidence_values(text)
    top = confianzas[0]
    return {"language": _LINGUA[top.language], "confidence": round(float(top.value), 3)}


@mcp.tool()
def languages() -> dict:
    """Lista los idiomas soportados y el motor que se usa para cada par."""
    return {
        "idiomas": LANGS,
        "motores": {
            "ca<->es": "Softcatala (CTranslate2, CPU)",
            "resto de pares": "NLLB-200 1.3B destilado (fp16, GPU)",
        },
        "nota": "Las frases cortas dan detecciones poco fiables.",
    }


if __name__ == "__main__":
    mcp.run(transport="streamable-http")

BLOC 2 - mt-traduccion.service
[Unit]
Description=Traduccion local MCP (Softcatala + NLLB) para Hermes
After=network.target

[Service]
Type=simple
WorkingDirectory=%h/.local/share/mt-candidatos
ExecStart=%h/.local/share/mt-candidatos/venv/bin/python %h/.local/share/mt-candidatos/mt_server.py
Environment=MT_PORT=5010
Environment=PYTHONUNBUFFERED=1
Restart=on-failure
RestartSec=10

[Install]
WantedBy=default.target

Verificació

Quan Hermes acabi, obre una sessio nova i demana: «Tradueix "Quan arribi el tren, truca'm, si us plau." del català al francès». La resposta ha de ser una traduccio al francès.

Per comprovar el servei, pots preguntar a Hermes l'estat del servei de traduccio local. Si no respon, demana-li el motiu.

Vegeu també