UNDER APPROVAL Microprogettazione v1.1 — 6 maggio 2026. Documento canonico del sottosistema multilingua di Metnos. Allinea con ADR 0092 (prompt-as-data + auto-allineamento bilingue) e con i tre layer già vivi nel codice: runtime/prompts/<lang>/<role>.j2 (MiniJinja, 26 ruoli), runtime/i18n.sqlite (118 chiavi) e i manifest TOML degli executor con tabella [description].<lang> + file companion manifest.lang_state.json.
← Indice documentazione Microprogettazione › multilang

Metnos

multilang — tre layer, source-of-truth latest-wins, add-language
Microprogettazione v1.1 — 6 maggio 2026
Allineata con ADR 0092 (prompt-as-data) e con i moduli
runtime/prompt_loader.py, runtime/i18n.py, runtime/i18n_translator.py, runtime/admin/prompts_cli.py.

Pubblico: chi aggiunge una nuova lingua a Metnos,
chi rivede le traduzioni candidate, chi ne audita la coerenza.
Lettura: 14 minuti.

Indice

  1. Scopo e confini
  2. I tre layer multilingua
  3. Source-of-truth: latest-wins
  4. Workflow add-language
  5. METNOS_LANG di processo vs source-of-truth dinamico
  6. Edge case: retro-translate manuale
  7. Estensioni future
  8. CLI di riferimento

1. Scopo e confini

Questo documento definisce come Metnos parla più lingue: dove vivono le stringhe, chi può modificarle, come si propaga un cambio da una lingua all'altra, come si aggiunge una nuova lingua. Italiano e inglese sono entrambi presenti dal primo giorno; da settembre 2026 il sistema deve poter accogliere una terza lingua (francese, spagnolo, tedesco) senza toccare il codice.

Il documento copre:

Non copre, e demanda altrove:

2. I tre layer multilingua

Ogni stringa che Metnos pronuncia, o che un LLM legge come parte di una sua istruzione, vive in uno e un solo dei tre layer seguenti. La separazione è per natura della stringa, non per comodità: nessuna stringa abita due layer.

Layer Cosa contiene Storage Volume
Layer 1 — prompt LLM Il testo che istruisce il pianificatore, il vaglio, il describe, il classify, il synt 5-stage. Template MiniJinja con variabili ({{ tools }}, {{ history }}). runtime/prompts/<lang>/<role>.j2 26 ruoli × N lingue
Layer 2 — manifest description Description e affinity keywords degli executor. Sono il prompt del tool per il pianificatore (CLAUDE.md §2.5). Tabella [description].<lang> dentro al manifest TOML; companion file manifest.lang_state.json per version_hash + source_hash per lingua. ~52 executor × N lingue
Layer 3 — messaggi user-facing ERR_*, WARN_*, MSG_*, LOG_* (errori, warning, conferme, notifiche al canale). Description executor migrate qui in lettura runtime. SQLite ~/.local/share/metnos/i18n.sqlite; tabella i18n(key, lang, text, needs_translation, source_lang, version_hash). 118 chiavi standard + 79 description = 197 entry × N lingue

Layer 1 — prompt LLM

La directory runtime/prompts/ ha un sotto-albero per lingua. Ogni ruolo (planner, vaglio_judge, describe_entries, …) è un file .j2 con stessa struttura in ogni lingua: stesse variabili, stessa intelaiatura di sezioni, solo testo localizzato.

runtime/prompts/
  it/
    planner.j2
    vaglio_judge.j2
    describe_entries.j2
    … (26 file)
  en/
    planner.j2          # stessa intelaiatura, testo in EN
    …
    _pending/
      planner.j2.candidate   # candidato in attesa di review
  fr/                    # opzionale, dopo metnos-prompts add-language fr
    …

Il loader runtime/prompt_loader.py è chiamato da ogni componente runtime con prompt_loader.get(role, lang, **vars). Lingua risolta come os.environ["METNOS_LANG"], fallback config.DEFAULT_LANG (oggi "it"). Se manca il file della lingua chiesta, il loader fa fallback alla lingua sorgente (di norma it) e logga un evento prompt.lang_fallback.

Layer 2 — manifest description executor

Il manifest TOML di un executor (executors/<name>/manifest.toml) ha la sezione [description] a tabella per lingua, e un companion JSON manifest.lang_state.json nel medesimo folder per tenere traccia di chi e' source e quando.

# manifest.toml
[description]
it = """
Cerca file su filesystem per pattern e finestra temporale.
…
"""
en = """
Find files on the filesystem by pattern and time window.
…
"""

[affinity]
it = ["cerca", "trova", "file", "ricerca", "…"]
en = ["find", "search", "files", "lookup", "…"]
// manifest.lang_state.json (companion file)
{
  "lang_state": {
    "it": {
      "version_hash": "a3f9...",
      "source_hash": null,           // it = source corrente
      "source_lang": null,
      "translated_by": null,
      "reviewed_at": "2026-04-22T10:30Z"
    },
    "en": {
      "version_hash": "7c1b...",
      "source_hash": "a3f9...",      // EN derivato da hash IT a3f9
      "source_lang": "it",
      "translated_by": "wise:gemma4-26b",
      "reviewed_at": "2026-04-22T11:05Z"
    }
  }
}

A run time il pianificatore legge la description nella lingua corrente (METNOS_LANG) via loader.describe(name, lang); se la lingua manca, fallback IT con log evento.

Layer 3 — messaggi user-facing in i18n.sqlite

Il modulo runtime/i18n.py espone i18n.get(key, lang=None, **vars) con interpolazione stile {name}. Le chiavi seguono la convenzione SCOPE_DOMAIN_DETAIL: ERR_FILE_NOT_FOUND, WARN_CAP_REACHED, MSG_APPROVAL_GRANTED, LOG_TURN_ENDED.

Le description degli executor sono tutte migrate nel DB con prefisso EX_DESCRIPTION_<name> e EX_AFFINITY_<name>; il loader del manifest le legge dal DB invece che dal TOML. Il TOML resta autoritativo per i nuovi executor; il DB le importa al primo load.

3. Source-of-truth: latest-wins

Nessuna lingua e' canonica per costruzione. Chi e' stato editato per ultimo (in qualunque lingua) diventa source per le altre.

Per ogni risorsa multilingua — un prompt, una description, una chiave i18n — teniamo due hash:

Trigger del re-allineamento

Quando un editor (umano o batch) cambia il testo della lingua L1 di una risorsa, il version_hash di L1 cambia. Questo invalida implicitamente tutte le altre lingue L2, L3, … che hanno source_hash uguale al vecchio version_hash di L1: non sono più sincronizzate. Il daemon notturno i18n_translator.run_loop() le ritrova come pending e genera un nuovo candidato in _pending/.

EditatoEffetto immediatoNotte successiva
prompts/it/planner.j2 version_hash(it) nuovo. source_hash(en) ora obsoleto. Il translator rigenera prompts/en/_pending/planner.j2.candidate a partire dal nuovo IT. metnos-prompts review planner --lang=en mostra il diff.
prompts/en/planner.j2 (modifica diretta in EN) version_hash(en) nuovo. EN diventa source corrente: source_lang=en per IT. Il translator considera ora EN come sorgente. Se source_hash(it) e' obsoleto, rigenera prompts/it/_pending/planner.j2.candidate.
i18n.set("MSG_APPROVAL_GRANTED", "fr", "…") Solo la riga fr di quella chiave cambia. Nessun trigger sulle altre lingue: la modifica e' solo locale a fr.
Perche' latest-wins e non IT-canonical? Per disciplina: in italiano si scrive il primo getto perche' Metnos e' nato in IT, ma la qualita' editoriale di una stringa può venire da qualunque lingua. Non vogliamo che un piccolo refactor in EN venga sovrascritto la notte da un IT vecchio. Il source è sempre l'ultimo editor, sempre.

Determinismo del confronto (no LLM nel critical path)

Il confronto version_hash vs source_hash è deterministico (CLAUDE.md §7.9). L'LLM interviene solo per generare il candidato di traduzione; il quando rigenerare e il quale e' la sorgente sono codice puro, niente prompt.

4. Workflow add-language

Il flusso completo per aggiungere una nuova lingua è un comando solo:

$ metnos-prompts add-language fr
Lingua 'fr' bootstrap completato.
  - prompts/fr/ creata (vuota, daemon notturno generera' candidate)
  - i18n.sqlite: fr bootstrap rows pending
  - Manifest description: il daemon scansionera' al prossimo cycle

Per triggerare manualmente la traduzione subito:
  /opt/myclaw/deploy/run_prompts_translator.sh

Per attivare la lingua: METNOS_LANG=fr nei systemd unit + restart

Quattro effetti, tutti idempotenti:

  1. Layer 1: crea runtime/prompts/fr/ vuota. Il daemon notturno la riempirà con candidati a partire dalla lingua sorgente (default it; specificabile con --source-lang en se EN e' diventata la lingua piu' aggiornata).
  2. Layer 2: nessuna azione immediata. Il daemon a ogni cycle scansiona i manifest TOML con tabella [description].fr mancante e genera candidati nel manifest.lang_state.json (campo fr) marcato needs_review=true.
  3. Layer 3: i18n_cli.add-lang fr popola la tabella i18n con un placeholder per ogni chiave esistente, needs_translation=1, source_lang=it. Il daemon traduce a batch.
  4. Audit: scrive una entry in ~/.local/share/metnos/multilang/audit.jsonl con timestamp, codice ISO, source_lang scelta.

La lingua resta inattiva finché non si setta METNOS_LANG=fr nei systemd unit del runtime e si riavvia il daemon. Questo evita che frammenti tradotti a metà appaiano in produzione mentre il review umano e' ancora in corso.

Review umana obbligatoria. Il daemon notturno scrive candidati in _pending/, non promuove. metnos-prompts review <role> --lang=fr mostra il diff; metnos-prompts mark-synced <role> --lang=fr promuove il candidato a runtime, ma solo se la validation passa (placeholder match, sintassi MiniJinja, len ratio entro [0.6, 2.0]).

5. METNOS_LANG di processo vs source-of-truth dinamico

METNOS_LANG e' la lingua di lettura del processo: quale lingua il pianificatore vede come prompt, quale lingua compare nei messaggi all'utente. E' una variabile statica, risolta al boot dei systemd unit, costante per tutta la vita del processo.

Il source-of-truth (latest-wins di §3) e' un attributo dinamico per risorsa: per la risorsa A può essere IT, per la B EN, per la C FR — dipende da chi ha editato per ultimo. Il daemon translator usa il source-of-truth per decidere da-cosa-tradurre-cosa, indipendentemente da METNOS_LANG.

AspettoMETNOS_LANGSource-of-truth latest-wins
GranularitàGlobale al processoPer risorsa (prompt / description / chiave)
Quando si risolveAl boot del systemd unitA ogni edit, a ogni cycle del daemon
EffettoLingua mostrata all'utente / data al LLMLingua da cui rigenerare i candidati
ModificaEdit unit + restartAutomatica, ogni edit triggera ricalcolo hash

6. Edge case: retro-translate manuale

Capita che un editor preferisca scrivere una nuova versione di un prompt direttamente nella lingua secondaria (es. EN), per poi voler riportare la modifica in IT. Il latest-wins lo fa automaticamente: quando l'edit EN va in commit, il version_hash(en) diventa nuovo, EN diventa source corrente, e la notte successiva il candidato IT viene rigenerato in prompts/it/_pending/<role>.j2.candidate.

Lo stesso comando di review/mark-synced funziona simmetrico: metnos-prompts review planner --lang=it mostra il diff, mark-synced planner --lang=it promuove. La direzione di traduzione e' codificata nel source_lang del companion JSON, non nel verso del comando.

Retro-translate è opt-in nel senso che richiede un edit diretto del file della lingua secondaria. Non c'e' un comando retro-translate separato: la simmetria del meccanismo lo rende implicito. Il vincolo è non editare entrambe le lingue contemporaneamente con cambi divergenti: l'ultima ad essere salvata vince e l'altra perde (con log multilang.simultaneous_edit_warning).

7. Estensioni future

Layer 4 — HTML templates di rendering chat

Oggi i template Jinja2 di channels/templates/ (carta di approval, dialog form, summary di turno) sono solo IT. Il layer 4 sarà la replica della disciplina layer 1 con sotto-albero channels/templates/<lang>/ e identica meccanica latest-wins. Stima: 8-10 template, 2-3 ore di porting una volta che arriva la terza lingua.

Traduzione frontier opt-in

Il flag --quality {wise,frontier} di metnos-prompts translate e translate-all permette di scegliere il tier LLM. wise e' il default (Gemma 4 26B locale, gratuito); frontier usa Anthropic Opus 4.7 con costo ~$0.015/call. La differenza emerge sui prompt lunghi e sui ruoli (es. vaglio_judge) dove la sfumatura semantica e' alta. Documentato nel man del CLI come opt-in cosciente: niente uso silente di un provider esterno.

Lingue non IRTL e RTL

Arabo, ebraico e persiano richiedono RTL nel rendering HTML dei canali (layer 4 quando arriverà) e attenzione al markup dir="rtl" nei template Jinja2. Layer 1-3 sono RTL-agnostici: le stringhe sono solo testo, il canale e' responsabile del rendering.

8. CLI di riferimento

ComandoEffetto
metnos-prompts listTabella prompt × lingue + size + last commit.
metnos-prompts show <role> [--lang=it]Render finale del prompt con vars stub.
metnos-prompts validateLint sintassi MiniJinja + invariant check.
metnos-prompts translate <role> [--to=en] [--quality=wise|frontier]One-shot: traduce IT → --to; salva candidato in _pending/.
metnos-prompts translate-all [--to=en] [--quality=wise|frontier]Batch: traduce ogni .j2 mancante.
metnos-prompts sync-statusTabella mtime/lag/candidate per ruolo.
metnos-prompts review <role> [--lang=en]Mostra diff + validation di un candidato. Non promuove.
metnos-prompts mark-synced <role> [--lang=en]Promuove _pending a runtime se validation OK.
metnos-prompts validate-cross-langVerifica placeholder + sintassi + len ratio cross-lang.
metnos-prompts add-language <code> [--source-lang=it]Bootstrap nuova lingua (layer 1+3, layer 2 al cycle successivo).

Il manifest centrale di installazione (install/manifest.toml) non elenca le lingue: sono dati persistiti, non capability. Aggiungere FR non modifica i systemd unit né il manifest, solo METNOS_LANG=fr al momento di attivarla.

Riferimenti