Il nostro [precedente post sull'architettura] (/it/blog/how-plugin-guardrail-and-pipeline-systems-work/) ha trattato i plugin, le action guard e il sistema di pipeline. Questo post approfondisce il motore di traduzione, la parte che rende Rasepi fondamentalmente diversa da ogni altra piattaforma di documentazione.
Non il marketing che parla di tradurre paragrafi invece di pagine. Il codice vero e proprio. Come vengono risolti i glossari per ogni tenant, come le regole di stile di DeepL e le istruzioni personalizzate danno forma a ogni traduzione, come l'hashing dei contenuti guida il rilevamento degli stalli e come l'orchestratore decide quali blocchi ritradurre.
La pipeline di traduzione
Quando un utente salva un documento, il sistema non si limita a ritradurre tutto. Esegue una sequenza piuttosto specifica:
- Analizza il JSON di TipTap in singoli blocchi.
- Confronta gli hash dei contenuti per individuare quali blocchi sono stati effettivamente modificati
- Per i blocchi modificati, risolvere il glossario del locatario e l'elenco delle regole di stile per la coppia di lingue.
- Applicare le regole di stile, le istruzioni personalizzate e la formalità dalla configurazione del tenant.
- Inviare solo i blocchi modificati a DeepL
- Aggiornare i blocchi di traduzione e sincronizzare gli hash dei contenuti
Ogni fase è un servizio a sé stante con una propria interfaccia. Questo è importante perché ogni fase può essere sostituita da qualcos'altro, un diverso fornitore di traduzioni, un diverso algoritmo di hashing, una diversa fonte di glossario.
Risoluzione del glossario: su base affittuaria, DeepL-sincronizzata
I glossari di DeepL hanno un vincolo che la maggior parte delle persone non conosce: **Non è possibile modificare un glossario DeepL. Ogni modifica comporta la cancellazione del vecchio glossario e la creazione di uno nuovo.
Rasepi gestisce questo aspetto trattando il database come fonte di verità e i glossari DeepL come artefatti di runtime da buttare. L'entità TenantGlossary memorizza tutto localmente:
public class TenantGlossary : ITenantScoped
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; }
public string SourceLanguage { get; set; } // e.g. "en"
public string TargetLanguage { get; set; } // e.g. "de"
public string? DeepLGlossaryId { get; set; } // Runtime DeepL ID
public DateTime? LastSyncedAt { get; set; }
public bool IsDirty { get; set; } = true; // Triggers re-sync
public ICollection<TenantGlossaryEntry> Entries { get; set; }
}
Quando un utente aggiunge una voce di glossario, ad esempio mappando "Sprint Review" a "Sprint-Überprüfung" per EN→DE, il record del database si aggiorna immediatamente e IsDirty viene impostato a true. Il glossario DeepL non viene ricreato subito. Viene ricreato pigramente, la volta successiva che una traduzione ne ha effettivamente bisogno.
Il flusso di sincronizzazione
Prima di ogni chiamata di traduzione, il sistema risolve il glossario:
public async Task<string?> GetOrSyncDeepLGlossaryIdAsync(
string sourceLanguage, string targetLanguage,
CancellationToken ct = default)
{
var glossary = await _db.TenantGlossaries
.Include(g => g.Entries)
.FirstOrDefaultAsync(g =>
g.SourceLanguage == sourceLanguage &&
g.TargetLanguage == targetLanguage, ct);
if (glossary is null || glossary.Entries.Count == 0)
return null;
if (!glossary.IsDirty && glossary.DeepLGlossaryId is not null)
return glossary.DeepLGlossaryId;
// Dirty - delete old, create new
if (glossary.DeepLGlossaryId is not null)
await _deepL.DeleteGlossaryAsync(glossary.DeepLGlossaryId);
var entries = glossary.Entries
.ToDictionary(e => e.SourceTerm, e => e.TargetTerm);
var deepLGlossary = await _deepL.CreateGlossaryAsync(
$"rasepi-{glossary.Id}",
glossary.SourceLanguage,
glossary.TargetLanguage,
entries);
glossary.DeepLGlossaryId = deepLGlossary.GlossaryId;
glossary.IsDirty = false;
glossary.LastSyncedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return glossary.DeepLGlossaryId;
}
Tre cose da notare:
- **Si fa ricorso all'API DeepL solo quando è effettivamente necessaria una traduzione. La modifica in blocco delle voci del glossario non comporta decine di chiamate all'API.
- **La query viene eseguita attraverso i filtri globali di EF, quindi
TenantGlossariesviene automaticamente isolato. Le voci del glossario del tenant A non si disperdono mai nelle traduzioni del tenant B. - **DeepL lo fa rispettare in ogni caso. Un glossario EN→DE, un glossario EN→FR e così via. La coppia
(SourceLanguage, TargetLanguage)è unica per ogni locatario.
Voci del glossario
Le singole voci sono solo mappature di termini:
CODICEBLOCCO_2
L'API offre un CRUD completo e l'importazione/esportazione di CSV per la gestione in blocco:
CODICE_3
L'importazione CSV è utilissima per i team che migrano da sistemi di memoria di traduzione esistenti. Esportate i termini, ripuliteli, importateli in Rasepi e la traduzione successiva utilizzerà automaticamente il nuovo glossario.
Regole di stile, istruzioni personalizzate e formalità
I glossari gestiscono la terminologia. Ma la terminologia è solo la metà. Una traduzione può usare tutte le parole giuste eppure suonare male. Tono sbagliato, formato della data sbagliato, convenzioni di punteggiatura sbagliate.
La Style Rules API (v3) di DeepL risolve questo problema. È possibile creare elenchi di regole di stile riutilizzabili che combinano due tipi di controlli:
- Regole configurate, convenzioni di formattazione predefinite per date, orari, punteggiatura, numeri e altro ancora.
- Istruzioni personalizzate, direttive di testo libero che modellano il tono, la formulazione e le convenzioni specifiche del dominio.
Rasepi crea e gestisce queste istruzioni per tenant e per lingua di destinazione. L'entità TenantStyleRuleList memorizza il DeepL style_id insieme alle regole configurate e alle istruzioni personalizzate del tenant:
public class TenantStyleRuleList : ITenantScoped
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; }
public string TargetLanguage { get; set; } // e.g. "de"
public string? DeepLStyleId { get; set; } // Runtime DeepL style_id
public string ConfiguredRulesJson { get; set; } // Serialized configured rules
public bool IsDirty { get; set; } = true;
public DateTime? LastSyncedAt { get; set; }
public ICollection<TenantCustomInstruction> CustomInstructions { get; set; }
}
Creazione di elenchi di regole di stile
Quando un amministratore imposta le regole di traduzione per il tedesco, Rasepi chiama l'API v3 di DeepL per creare l'elenco delle regole di stile. Ecco come si presenta:
public async Task<string> CreateOrSyncStyleRuleListAsync(
TenantStyleRuleList ruleList, CancellationToken ct = default)
{
if (!ruleList.IsDirty && ruleList.DeepLStyleId is not null)
return ruleList.DeepLStyleId;
// DeepL style rule lists are mutable - we can update in place
if (ruleList.DeepLStyleId is not null)
{
// Replace configured rules on existing list
await _httpClient.PutAsJsonAsync(
$"v3/style_rules/{ruleList.DeepLStyleId}/configured_rules",
JsonSerializer.Deserialize<JsonElement>(ruleList.ConfiguredRulesJson),
ct);
// Sync custom instructions
await SyncCustomInstructionsAsync(ruleList, ct);
ruleList.IsDirty = false;
ruleList.LastSyncedAt = DateTime.UtcNow;
return ruleList.DeepLStyleId;
}
// Create new style rule list
var payload = new
{
name = $"rasepi-{ruleList.TenantId}-{ruleList.TargetLanguage}",
language = ruleList.TargetLanguage,
configured_rules = JsonSerializer.Deserialize<JsonElement>(
ruleList.ConfiguredRulesJson),
custom_instructions = ruleList.CustomInstructions.Select(ci => new
{
label = ci.Label,
prompt = ci.Prompt,
source_language = ci.SourceLanguage
})
};
var response = await _httpClient.PostAsJsonAsync("v3/style_rules", payload, ct);
var result = await response.Content.ReadFromJsonAsync<StyleRuleResponse>(ct);
ruleList.DeepLStyleId = result.StyleId;
ruleList.IsDirty = false;
ruleList.LastSyncedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return ruleList.DeepLStyleId;
}
A differenza dei glossari, gli elenchi di regole di stile di DeepL sono mutabili. È possibile sostituire le regole configurate con PUT /v3/style_rules/{style_id}/configured_rules e le istruzioni personalizzate possono essere aggiunte, aggiornate o eliminate individualmente. Molto più semplice per un perfezionamento iterativo.
Come appaiono le regole configurate
Le regole configurate riguardano le convenzioni di formattazione che variano a seconda della lingua o delle preferenze aziendali. Cose come:
{
"dates_and_times": {
"time_format": "use_24_hour_clock",
"calendar_era": "use_bc_and_ad"
},
"punctuation": {
"periods_in_academic_degrees": "do_not_use"
},
"numbers": {
"decimal_separator": "use_comma"
}
}
Sembrano banali, ma si accumulano velocemente. Un documento tedesco che utilizza il formato orario AM/PM e i decimali separati da un punto viene letto come "tradotto dall'inglese" da un lettore tedesco. L'impostazione di use_24_hour_clock e use_comma per i separatori decimali in tutte le traduzioni in tedesco elimina immediatamente questo problema.
Istruzioni personalizzate: questo è il vero potere
Le istruzioni personalizzate sono direttive a testo libero, fino a 200 per ogni elenco di regole di stile, ciascuna di 300 caratteri. In pratica si dice a DeepL come modellare la traduzione in un linguaggio semplice:
public class TenantCustomInstruction
{
public Guid Id { get; set; }
public Guid StyleRuleListId { get; set; }
public string Label { get; set; } // e.g. "Tone instruction"
public string Prompt { get; set; } // e.g. "Use a friendly, diplomatic tone"
public string? SourceLanguage { get; set; } // Optional source lang filter
}
Esempi reali dai nostri inquilini:
"Use a friendly, diplomatic tone"per una startup che vuole una documentazione accessibile"Always use 'Sie' form, never 'du'"per uno studio legale tedesco"Translate 'deployment' as 'Bereitstellung', never 'Deployment'"per termini che necessitano di una gestione dipendente dal contesto che vada oltre la semplice mappatura del glossario"Use British English spelling (colour, organisation, licence)"per aziende con sede nel Regno Unito che traducono tra varianti dell'inglese"Put currency symbols after the numeric amount"per adeguarsi alle convenzioni europee
Le istruzioni personalizzate sono davvero potenti per le convenzioni specifiche del dominio che non si adattano alle voci del glossario. Un glossario mappa un termine ad un altro. Un'istruzione personalizzata può dire "quando si traducono i documenti API, usare l'imperativo invece della voce passiva". È un tipo di controllo completamente diverso.
Formalità
Il parametro formality di DeepL (default, more, less, prefer_more, prefer_less) è ancora disponibile come controllo separato insieme alle regole di stile. Il tedesco "du" rispetto a "Sie", il francese "tu" rispetto a "vous", i livelli di cortesia del giapponese. Questi sono impostati per ogni lingua del locatario tramite TenantLanguageConfig:
public class TenantLanguageConfig : ITenantScoped
{
public string LanguageCode { get; set; }
public string DisplayName { get; set; }
public bool IsEnabled { get; set; }
public TranslationTrigger Trigger { get; set; }
public string? Formality { get; set; } // "more", "less", "prefer_more", etc.
public string? StyleRuleListId { get; set; } // Links to TenantStyleRuleList
public string? TranslationProvider { get; set; }
public int SortOrder { get; set; }
public bool IsDefault { get; set; }
}
Formalità, regole di stile e glossari si compongono. Una singola chiamata di traduzione può contenere tutte e tre le cose:
var glossaryId = await GetOrSyncDeepLGlossaryIdAsync(sourceLang, targetLang, ct);
var styleId = await GetOrSyncStyleRuleListAsync(targetLang, ct);
var formality = tenantLanguageConfig.Formality ?? "default";
// Build the v2/translate request payload
var payload = new
{
text = new[] { blockContent },
source_lang = NormalizeLanguageCode(sourceLang),
target_lang = NormalizeLanguageCode(targetLang),
glossary_id = glossaryId,
style_id = styleId,
formality = formality,
preserve_formatting = true,
context = surroundingContext, // Adjacent blocks, not billed
model_type = "quality_optimized"
};
Due cose che vale la pena di notare:
- **Passiamo i blocchi adiacenti come contesto per migliorare la qualità della traduzione. DeepL lo usa per risolvere le ambiguità, ma non lo traduce né lo fattura. Un paragrafo sulle "celle" si traduce in modo diverso se il contesto circostante è un documento di biologia rispetto a un manuale di foglio elettronico.
- **Qualsiasi richiesta con
style_idocustom_instructionsutilizza automaticamente il modelloquality_optimizeddi DeepL. Questo è il livello di qualità più alto. Non è possibile combinare questi modelli conlatency_optimized, e questa è una limitazione voluta da DeepL. La personalizzazione dello stile richiede il modello completo.
Perché questo è importante più di quanto si pensi
Immaginate un'azienda che scrive documenti interni in tedesco con l'informale "du" che improvvisamente passa al formale "Sie" in una sezione tradotta. Nel migliore dei casi sembra incoerente, nel peggiore poco professionale. La formalità si occupa di questo. Ma la formalità, da sola, non può catturare un documento che usa i timestamp AM/PM quando l'ufficio tedesco usa il formato 24 ore, o che mette il simbolo della valuta prima del numero invece che dopo.
Tutti questi elementi (regole di stile, istruzioni personalizzate, formalità, glossari) producono traduzioni che sembrano scritte da qualcuno del vostro team. Non come se fossero state scritte da una macchina che non conosce l'esistenza della vostra azienda.
Il livello di servizio DeepL
Tutte le comunicazioni di DeepL passano attraverso IDeepLService. Questo strato avvolge l'SDK ufficiale di DeepL .NET e gestisce le chiamate all'API v3 per le regole di stile:
public interface IDeepLService
{
// Text translation (v2)
Task<TextResult> TranslateTextAsync(
string text, string sourceLanguage, string targetLanguage,
string? options = null);
Task<TextResult[]> TranslateTextBatchAsync(
IEnumerable<string> texts, string sourceLanguage,
string targetLanguage, string? options = null);
// Glossary management (v2)
Task<GlossaryInfo> CreateGlossaryAsync(
string name, string sourceLang, string targetLang,
Dictionary<string, string> entries);
Task DeleteGlossaryAsync(string glossaryId);
Task<GlossaryInfo> GetGlossaryAsync(string glossaryId);
Task<GlossaryInfo[]> ListGlossariesAsync();
Task<Dictionary<string, string>> GetGlossaryEntriesAsync(
string glossaryId);
// Style rules (v3)
Task<StyleRuleResponse> CreateStyleRuleListAsync(
string name, string language,
JsonElement configuredRules,
IEnumerable<CustomInstructionRequest> customInstructions);
Task ReplaceConfiguredRulesAsync(
string styleId, JsonElement configuredRules);
Task<CustomInstructionResponse> AddCustomInstructionAsync(
string styleId, string label, string prompt,
string? sourceLanguage = null);
Task DeleteCustomInstructionAsync(
string styleId, string instructionId);
Task DeleteStyleRuleListAsync(string styleId);
// Usage tracking
Task<Usage> GetUsageAsync();
Task<Language[]> GetSourceLanguagesAsync();
Task<Language[]> GetTargetLanguagesAsync();
}
L'implementazione gestisce la normalizzazione del codice del linguaggio. DeepL richiede EN-US o EN-GB invece di en e PT-PT o PT-BR invece di pt:
CODICEBLOCCO_11
La traduzione batch utilizza un chunking di 50 voci per rimanere entro i limiti dell'API di DeepL e massimizzare il throughput:
public async Task<TranslationBatchResult> TranslateBatchAsync(
Dictionary<string, string> texts,
string sourceLanguage, string targetLanguage)
{
var translations = new Dictionary<string, string>();
long totalChars = 0;
foreach (var chunk in texts.Chunk(50))
{
var results = await _deepL.TranslateTextBatchAsync(
chunk.Select(kv => kv.Value),
sourceLanguage, targetLanguage);
for (int i = 0; i < chunk.Length; i++)
{
translations[chunk[i].Key] = results[i].Text;
totalChars += chunk[i].Value.Length;
}
}
return new TranslationBatchResult
{
Translations = translations,
BilledCharacters = totalChars
};
}
Poiché inviamo solo blocchi di testo, e non interi documenti, un tipico batch di traduzione per una singola modifica contiene 1-3 blocchi invece di oltre 40. Ecco da dove deriva la riduzione dei costi del 94%.
L'orchestratore di traduzione
Il TranslationOrchestrator decide cosa fare con ogni blocco quando il documento di partenza cambia. Esaminiamo l'albero delle decisioni:
public async Task OrchestrateTranslationAsync(
Guid entryId, List<Guid> changedBlockIds,
CancellationToken ct = default)
{
var entry = await _db.Entries
.FirstOrDefaultAsync(e => e.Id == entryId, ct);
var translations = await _db.EntryTranslations
.Where(t => t.EntryId == entryId)
.ToListAsync(ct);
foreach (var translation in translations)
{
var langConfig = await GetLanguageConfigAsync(
translation.Language, ct);
var translationBlocks = await _db.TranslationBlocks
.Where(tb => changedBlockIds.Contains(tb.SourceBlockId)
&& tb.Language == translation.Language)
.ToListAsync(ct);
foreach (var block in translationBlocks)
{
if (block.IsLocked || block.TranslatedById is not null)
{
// Human-edited or locked - mark stale, don't overwrite
block.Status = TranslationStatus.Stale;
}
else if (langConfig.Trigger == TranslationTrigger.AlwaysTranslate)
{
// Machine-translated, auto mode - retranslate now
await RetranslateBlockAsync(block, translation.Language, ct);
}
else
{
// TranslateOnFirstVisit - mark stale, translate later
block.Status = TranslationStatus.Stale;
}
}
}
await _db.SaveChangesAsync(ct);
}
Il punto chiave: **Se un traduttore ha modificato manualmente un blocco, magari aggiungendo un contesto culturale o riformulando per chiarezza, il sistema rispetta il suo lavoro. Segna il blocco come obsoleto, in modo che il traduttore sappia che la fonte è cambiata, ma non sostituisce silenziosamente le sue modifiche.
I blocchi tradotti a macchina con AlwaysTranslate abilitato vengono ritradotti immediatamente. I blocchi tradotti a macchina con TranslateOnFirstVisit sono contrassegnati come stantii e tradotti quando qualcuno apre effettivamente il documento in quella lingua.
Inneschi di traduzione: quando avvengono le traduzioni
Ogni lingua ha un TranslationTrigger che controlla la tempistica:
public enum TranslationTrigger
{
AlwaysTranslate, // Translate on every save
TranslateOnFirstVisit // Translate when first opened in that language
}
Il AlwaysTranslate è utile per le lingue ad alta priorità in cui si desidera che le traduzioni siano immediatamente aggiornate. Francese per un'azienda con una grande sede a Parigi. Tedesco per un'azienda con sede a Monaco.
TranslateOnFirstVisit è utile per le lingue che sono occasionalmente necessarie, ma che non valgono il costo API di mantenere sempre perfettamente aggiornate. Quando qualcuno apre il documento in quella lingua, i blocchi obsoleti vengono tradotti al volo.
Entrambe le modalità utilizzano la stessa risoluzione del glossario, le stesse impostazioni di formalità e lo stesso hashing dei contenuti. L'unica differenza è la tempistica.
Adattamento unico di contenuto e struttura
È qui che l'architettura dà i suoi frutti, al di là della semplice traduzione.
Quando un traduttore tedesco aggiunge una sezione di conformità DSGVO che non esiste in inglese, la aggiunge come nuovo blocco nella versione tedesca. Quel blocco non ha SourceBlockId, è contrassegnato come contenuto unico. Il sistema non lo invia mai per la ritraduzione perché non c'è una fonte da cui tradurre. Esiste solo in tedesco.
Quando un traduttore giapponese cambia un elenco puntato in un elenco numerato (una convenzione comune nella scrittura tecnica giapponese), il flag IsStructureAdapted del blocco lo conserva per i futuri cicli di ritraduzione:
var translation = new TranslationBlock
{
SourceBlockId = sourceBlockId,
Language = targetLanguage,
BlockType = translatedBlockType,
SourceBlockType = sourceBlock.BlockType,
IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
StructureAdaptationNotes = "Numbered list preferred in JP technical docs",
SourceContentHash = sourceBlock.ContentHash,
Status = TranslationStatus.UpToDate,
};
Il flag IsNoTranslate gestisce i contenuti che devono essere copiati alla lettera: blocchi di codice, URL, nomi di prodotti, notazioni matematiche. Il fornitore di traduzione li salta completamente.
Mettere tutto insieme
Vediamo il flusso completo. Un utente di Londra modifica un paragrafo del documento di origine inglese e il vostro ufficio di Monaco ha il tedesco impostato su AlwaysTranslate:
- L'utente salva. TipTap invia JSON all'API.
- Estrazione dei blocchi.
CreateBlocksFromDocumentAsyncanalizza JSON, ricalcola gli hash dei contenuti. - **Il sistema confronta gli hash vecchi e nuovi e identifica il blocco modificato.
- Esegue l'orchestratore. Trova il
EntryTranslationtedesco, controlla il blocco tedesco. - Il blocco è tradotto a macchina. Non bloccato, non modificato dall'uomo → idoneo per la ritraduzione
- Risoluzione del glossario.
GetOrSyncDeepLGlossaryIdAsync("en", "de")restituisce l'ID del glossario (si sincronizza se è sporco). - Risoluzione delle regole di stile.
GetOrSyncStyleRuleListAsync("de")restituisce il DeepLstyle_idcon le regole di formattazione e le istruzioni personalizzate configurate. - Formalità + contesto. Formalità impostata su "more" (formale "Sie"), blocchi adiacenti passati come contesto per la disambiguazione, preservare la formattazione su
- Chiamata a DeepL. Blocco singolo inviato con ID glossario, ID stile, formalità e contesto.
- Blocco aggiornato. Contenuto tradotto memorizzato,
SourceContentHashsincronizzato, stato impostato aUpToDate - **Un blocco tradotto invece di 40+. I restanti 39 blocchi? Non sono stati toccati.
Nel frattempo, il vostro ufficio di Tokyo ha impostato il giapponese su TranslateOnFirstVisit. La stessa modifica contrassegna il blocco di traduzione giapponese come Stale. Quando qualcuno a Tokyo apre il documento, i passaggi 5-9 avvengono al volo. L'adattamento della struttura (elenco numerato) viene conservato. I blocchi unici rimangono esattamente dove sono.
Il motore di traduzione è la parte di Rasepi che offre il valore più visibile. Traduzioni che utilizzano la vostra terminologia, seguono le vostre convenzioni di formattazione, obbediscono alle vostre istruzioni personalizzate, corrispondono al vostro tono, rispettano il lavoro dei vostri traduttori e costano una frazione di quanto costerebbe la ritraduzione di un intero documento. L'architettura rende tutto questo automatico e si tiene fuori dai giochi quando l'uomo vuole intervenire.
Lo stesso motore DeepL che alimenta le traduzioni scritte alimenta anche Talk to Docs, la nostra interfaccia di documentazione conversazionale, con DeepL Voice che gestisce l'interazione vocale. Stessi glossari, stesse regole di stile, stessa formalità, stessa coerenza. Che il vostro team legga la documentazione o vi parli, la qualità del linguaggio è identica.