TESTED Microprogettazione — allineata al codice. Documento canonico del sottosistema multilingua di Metnos. Copre il pattern prompt-as-data + auto-allineamento bilingue e i tre strati vivi nel codice: runtime/prompts/<lang>/<role>.j2 (MiniJinja, 35 ruoli), il database dei messaggi i18n.sqlite e i manifest TOML degli executor con tabella [description].<lang> + file companion manifest.lang_state.json.
← Indice documentazione Microprogettazione › multilang

Metnos

multilang — tre strati, fonte di verità vince l'ultimo, add-language
Microprogettazione — allineata al codice.
Copre il pattern prompt-as-data e 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 strati multilingua
  3. Fonte di verità: vince l'ultimo
  4. Workflow add-language
  5. METNOS_LANG di processo vs fonte di verità 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; il sistema deve poter accogliere una terza lingua (francese, spagnolo, tedesco) senza toccare il codice.

prompt LLM prompts/<lang>/*.j2 description executor manifest + JSON messaggi utente i18n.sqlite
Figura 1 — I tre strati multilingua: prompt del LLM, descrizioni degli executor e messaggi rivolto all'utente; nessuna lingua canonica, vince l'ultimo editato.

Il documento copre:

Non copre, e demanda altrove:

2. I tre strati 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 strati seguenti. La separazione è per natura della stringa, non per comodità: nessuna stringa abita due strati.

Strato Cosa contiene Archiviazione Volume
Strato 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 35 ruoli × N lingue
Strato 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; file companion manifest.lang_state.json per version_hash + source_hash per lingua. ~75 executor × N lingue
Strato 3 — messaggi rivolto all'utente 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). chiavi standard + description migrate × N lingue

Strato 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
 … (32 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"). Catena di risoluzione per file (§K, ADR 0173): live → candidato _pending (usato in-vivo) → EN. Se manca il file della lingua chiesta, il loader usa il candidato del daemon se presente, altrimenti ricade su EN (default nel frattempo) — mai crash. Nessuna promozione manuale necessaria.

Strato 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 nella medesima cartella 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 (file companion)
{
 "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:qwen3.6-35b-a3b",
 "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, ripiego a EN esplicito (§K, ADR 0173), altrimenti prima lingua disponibile.

Strato 3 — messaggi rivolto all'utente 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. Fonte di verità: vince l'ultimo

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. Nessuna attivazione sulle altre lingue: la modifica e' solo locale a fr.
Perche' vince l'ultimo 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 innescare manualmente la traduzione subito:
 /opt/metnos/deploy/run_prompts_translator.sh

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

Quattro effetti, tutti idempotenti:

  1. Strato 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. Strato 2: nessuna azione immediata. Il daemon a ogni ciclo scansiona i manifest TOML con tabella [description].fr mancante e genera candidati nel manifest.lang_state.json (campo fr) marcato needs_review=true.
  3. Strato 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 in lotti.
  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 imposta METNOS_LANG=fr nei systemd unit del runtime e si riavvia il daemon. Finché una stringa non è tradotta, il runtime ricade su EN (default nel frattempo), mai su frammenti rotti.

Niente gate di approvazione (§K, ADR 0173). Il daemon notturno scrive candidati in _pending/ e il loader li usa in-vivo: nessuno revisiona a mano centinaia di stringhe. La revisione resta facoltativametnos-prompts review <role> --lang=fr mostra il diff, metnos-prompts mark-synced <role> --lang=fr canonicalizza il candidato a <role>.j2 (riattivando il linter prescrittivo §6.1) — ma non è un prerequisito perché la traduzione abbia effetto.

5. METNOS_LANG di processo vs fonte di verità 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 all'avvio dei systemd unit, costante per tutta la vita del processo.

Il fonte di verità (vince l'ultimo 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 fonte di verità per decidere da-cosa-tradurre-cosa, indipendentemente da METNOS_LANG.

AspettoMETNOS_LANGFonte di verità vince l'ultimo
GranularitàGlobale al processoPer risorsa (prompt / description / chiave)
Quando si risolveAll'avvio del systemd unitA ogni edit, a ogni ciclo del daemon
EffettoLingua mostrata all'utente / data al LLMLingua da cui rigenerare i candidati
ModificaEdit unit + restartAutomatica, ogni modifica innesca 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 vince l'ultimo 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 è facoltativo 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

Strato 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 strato 4 sarà la replica della disciplina strato 1 con sotto-albero channels/templates/<lang>/ e identica meccanica vince l'ultimo. Stima: 8-10 template, 2-3 ore di adattamento una volta che arriva la terza lingua.

Traduzione di frontiera facoltativa

Il flag --quality {wise,frontier} di metnos-prompts translate e translate-all permette di scegliere il tier LLM. wise e' il default (modello locale, gratuito); frontier usa Anthropic Opus 4.8 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 scelta cosciente: niente uso silente di un provider esterno.

Lingue non IRTL e RTL

Arabo, ebraico e persiano richiedono RTL nel rendering HTML dei canali (strato 4 quando arriverà) e attenzione al markup dir="rtl" nei template Jinja2. Strato 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 (strato 1+3, strato 2 al ciclo 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