Notre [précédent article sur l'architecture] (/fr/blog/how-plugin-guardrail-and-pipeline-systems-work/) couvrait les plugins, les action guards et le système de pipeline. Celui-ci va plus loin dans le moteur de traduction, la partie qui, selon moi, rend Rasepi fondamentalement différent de toutes les autres plateformes de documentation.
Pas le discours marketing sur la traduction de paragraphes au lieu de pages. Le code réel. Comment les glossaires sont résolus par locataire, comment les règles de style et les instructions personnalisées de DeepL façonnent chaque traduction, comment le hachage de contenu permet de détecter les documents périmés et comment l'orchestrateur décide quels blocs doivent être retraduits.
Le pipeline de traduction
Lorsqu'un utilisateur enregistre un document, le système ne se contente pas de tout retraduire. Il exécute une séquence assez spécifique :
- Analyser le JSON TipTap en blocs individuels
- Comparer les hachages de contenu pour détecter les blocs qui ont réellement changé
- Pour les blocs modifiés, résoudre le glossaire du locataire et la liste des règles de style pour la paire de langues.
- Appliquer les règles de style, les instructions personnalisées et la formalité de la configuration du locataire
- Envoyer uniquement les blocs modifiés à DeepL
- Mise à jour des blocs de traduction et synchronisation des hachages de contenu
Chaque étape est son propre service avec sa propre interface. C'est important car chaque étape peut être remplacée par autre chose, un autre fournisseur de traduction, un autre algorithme de hachage, une autre source de glossaire.
Résolution du glossaire : à l'échelle du locataire, synchronisé avec DeepL
Les glossaires DeepL ont une contrainte que la plupart des gens ignorent : **Il n'est pas possible de modifier un glossaire DeepL. Toute modification entraîne la suppression de l'ancien glossaire et la création d'un nouveau.
Rasepi gère cela en considérant la base de données comme la source de vérité et DeepL les glossaires comme des artefacts jetables. L'entité TenantGlossary stocke tout localement :
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; }
}
Lorsqu'un utilisateur ajoute une entrée de glossaire, par exemple en faisant correspondre "Sprint Review" à "Sprint-Überprüfung" pour EN→DE, l'enregistrement de la base de données est immédiatement mis à jour et IsDirty devient true. Le glossaire DeepL n'est pas recréé immédiatement. Il est recréé paresseusement, la prochaine fois qu'une traduction en aura besoin.
Le flux de synchronisation
Avant chaque appel de traduction, le système résout le glossaire :
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;
}
Trois choses méritent d'être notées ici :
- Synchronisation paresseuse Nous n'appelons l'API DeepL que lorsqu'une traduction est réellement nécessaire. La modification en masse des entrées du glossaire ne déclenche pas des dizaines d'appels à l'API.
- **La requête passe par les filtres de requête globaux d'EF, de sorte que
TenantGlossariesest automatiquement délimité. Les entrées du glossaire du locataire A ne se retrouvent jamais dans les traductions du locataire B. - Un glossaire par paire de langues. DeepL applique cette règle de toute façon. Un glossaire EN→DE, un glossaire EN→FR, et ainsi de suite. La paire
(SourceLanguage, TargetLanguage)est unique pour chaque locataire.
Entrées du glossaire
Les entrées individuelles ne sont que des correspondances de termes :
public class TenantGlossaryEntry
{
public Guid Id { get; set; }
public Guid GlossaryId { get; set; }
public string SourceTerm { get; set; } // e.g. "Sprint Review"
public string TargetTerm { get; set; } // e.g. "Sprint-Überprüfung"
}
L'API vous offre un CRUD complet ainsi qu'une importation/exportation CSV pour une gestion en masse :
POST /admin/glossaries Create glossary
POST /admin/glossaries/{id}/entries Add term
PUT /admin/glossaries/{id}/entries/{entryId} Update term
DELETE /admin/glossaries/{id}/entries/{entryId} Remove term
POST /admin/glossaries/{id}/import Import CSV
GET /admin/glossaries/{id}/export Export CSV
POST /admin/glossaries/{id}/sync Force DeepL sync
L'importation CSV est très utile pour les équipes qui migrent à partir de systèmes de mémoire de traduction existants. Exportez vos termes, nettoyez-les, importez-les dans Rasepi et la prochaine traduction utilisera automatiquement le nouveau glossaire.
Règles de style, instructions personnalisées et formalités
Les glossaires traitent la terminologie. Mais la terminologie ne représente que la moitié du travail. Une traduction peut utiliser tous les bons mots et pourtant sonner faux. Mauvais ton, mauvais format de date, mauvaises conventions de ponctuation.
L'API Règles de style (v3) de DeepL résout ce problème. Vous pouvez créer des listes de règles de style réutilisables qui combinent deux types de contrôles :
- Règles configurées, conventions de formatage prédéfinies pour les dates, les heures, la ponctuation, les nombres, etc.
- Instructions personnalisées, directives en texte libre qui déterminent le ton, la formulation et les conventions spécifiques au domaine.
Rasepi crée et gère ces instructions par locataire et par langue cible. L'entité TenantStyleRuleList stocke le DeepL style_id avec les règles configurées et les instructions personnalisées du locataire :
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; }
}
Création de listes de règles de style
Lorsqu'un administrateur définit des règles de traduction pour l'allemand, Rasepi fait appel à l'API v3 de DeepL pour créer la liste de règles de style. Voici à quoi cela ressemble :
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;
}
Contrairement aux glossaires, les listes de règles de style de DeepL sont mutables. Vous pouvez remplacer les règles configurées en place par PUT /v3/style_rules/{style_id}/configured_rules, et les instructions personnalisées peuvent être ajoutées, mises à jour ou supprimées individuellement. C'est beaucoup plus convivial pour le raffinement itératif.
A quoi ressemblent les règles configurées
Les règles configurées couvrent les conventions de formatage qui varient en fonction de la langue ou des préférences de l'entreprise. Des choses comme :
{
"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"
}
}
Ces règles peuvent sembler anodines, mais elles sont vite compliquées. Un document allemand qui utilise le format horaire AM/PM et des décimales séparées par des points se lit simplement comme "traduit de l'anglais" pour un lecteur allemand. Le fait de définir use_24_hour_clock et use_comma pour les séparateurs décimaux dans toutes les traductions allemandes élimine immédiatement ce problème.
Instructions personnalisées : c'est la vraie puissance
Les instructions personnalisées sont des directives en texte libre, jusqu'à 200 par liste de règles de style, chacune pouvant contenir jusqu'à 300 caractères. En gros, vous dites à DeepL comment façonner la traduction en langage clair :
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
}
Exemples réels de nos locataires :
"Use a friendly, diplomatic tone"pour une startup qui veut des documents accessibles"Always use 'Sie' form, never 'du'"pour un cabinet d'avocats allemand"Translate 'deployment' as 'Bereitstellung', never 'Deployment'"pour des termes qui ont besoin d'être traités en fonction du contexte au-delà d'un simple mappage de glossaire- pour les entreprises basées au Royaume-Uni qui traduisent d'une variante anglaise à l'autre
"Put currency symbols after the numeric amount"pour correspondre aux conventions européennes
Les instructions personnalisées sont très utiles pour les conventions spécifiques à un domaine qui n'ont pas leur place dans les entrées d'un glossaire. Un glossaire associe un terme à un autre. Une instruction personnalisée peut dire "lorsque vous traduisez des documents d'API, utilisez l'humeur impérative au lieu de la voix passive". Il s'agit d'un type de contrôle complètement différent.
Formalité
Le paramètre formality de DeepL (default, more, less, prefer_more, prefer_less) est toujours disponible en tant que contrôle séparé avec les règles de style. Allemand "du" contre "Sie", français "tu" contre "vous", niveaux de politesse japonais. Ceux-ci sont définis pour chaque langue du locataire via 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; }
}
La formalité, les règles de style et les glossaires se complètent. Un seul appel de traduction peut contenir ces trois éléments :
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"
};
Deux choses méritent d'être notées ici :
- **Le paramètre
context** Nous transmettons les blocs adjacents comme contexte pour améliorer la qualité de la traduction. DeepL l'utilise pour résoudre les ambiguïtés, mais ne le traduit pas et ne le facture pas. Un paragraphe sur les "cellules" se traduit différemment selon que le contexte environnant est un document de biologie ou un manuel de tableur. - **Toute demande avec
style_idoucustom_instructionsutilise automatiquement le modèlequality_optimizedde DeepL. Il s'agit du niveau de qualité le plus élevé. Vous ne pouvez pas les combiner aveclatency_optimized, et c'est une contrainte délibérée de DeepL. La personnalisation du style nécessite le modèle complet.
Pourquoi cela est plus important que vous ne le pensez
Imaginez une entreprise rédigeant des documents internes en allemand avec un "du" informel qui passe soudainement au "Sie" formel dans une section traduite. Cela semble au mieux incohérent, au pire non professionnel. La formalité permet d'y remédier. Mais la formalité seule ne permet pas de repérer un document qui utilise des horodatages AM/PM alors que votre bureau allemand utilise le format 24 heures, ou qui place le symbole de la devise avant le nombre au lieu de le placer après.
Tous ces éléments superposés (règles de style, instructions personnalisées, formalisme, glossaires) produisent des traductions qui se lisent comme si elles avaient été écrites par un membre de votre équipe. Et non comme le résultat d'une machine qui ne sait pas que votre entreprise existe.
La couche de service DeepL
Toutes les communications DeepL passent par IDeepLService. Elle englobe le SDK officiel DeepL .NET et gère les appels de l'API v3 pour les règles de style :
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'implémentation gère la normalisation du code de la langue. DeepL nécessite EN-US ou EN-GB au lieu de en, et PT-PT ou PT-BR au lieu de pt :
private static string NormalizeLanguageCode(string code)
=> code.ToLower() switch
{
"en" => "EN-US",
"pt" => "PT-PT",
_ => code.ToUpper()
};
La traduction par lots utilise un découpage en morceaux de 50 éléments pour rester dans les limites de l'API de DeepL tout en maximisant le débit :
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
};
}
Comme nous n'envoyons que des blocs périmés, et non des documents entiers, un lot de traduction typique pour une seule édition contient 1 à 3 blocs au lieu de plus de 40. C'est de là que vient la réduction de 94 % des coûts.
L'orchestrateur de traduction
Le TranslationOrchestrator décide de ce qu'il faut faire avec chaque bloc lorsque le document source change. Passons en revue l'arbre de décision :
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);
}
Le point clé : **Si un traducteur a modifié manuellement un bloc, par exemple en y ajoutant un contexte culturel ou en le reformulant pour plus de clarté, le système respecte ce travail. Il marque le bloc comme périmé pour que le traducteur sache que la source a changé, mais il ne remplacera pas silencieusement ses modifications.
Les blocs traduits en machine pour lesquels l'option AlwaysTranslate est activée sont retraduits immédiatement. Les blocs traduits automatiquement avec TranslateOnFirstVisit sont marqués comme périmés et traduits lorsque quelqu'un ouvre le document dans cette langue.
Déclencheurs de traduction : quand les traductions ont lieu
Chaque langue possède un TranslationTrigger qui contrôle le timing :
public enum TranslationTrigger
{
AlwaysTranslate, // Translate on every save
TranslateOnFirstVisit // Translate when first opened in that language
}
Le AlwaysTranslate est utile pour les langues prioritaires dont les traductions doivent être immédiatement à jour. Le français pour une entreprise ayant un grand bureau à Paris. L'allemand pour une entreprise dont le siège se trouve à Munich.
TranslateOnFirstVisit est utile pour les langues dont on a besoin occasionnellement, mais qui ne valent pas le coût de l'API pour qu'elles soient toujours parfaitement à jour. Lorsque quelqu'un ouvre le document dans cette langue, les blocs périmés sont traduits à la volée.
Les deux modes utilisent la même résolution de glossaire, les mêmes paramètres de formalité et le même hachage de contenu. La seule différence réside dans le timing.
Adaptation unique du contenu et de la structure
C'est ici que l'architecture porte vraiment ses fruits, au-delà de la simple traduction.
Lorsqu'un traducteur allemand ajoute une section de conformité DSGVO qui n'existe pas en anglais, il l'ajoute en tant que nouveau bloc dans la version allemande. Ce bloc n'a pas de SourceBlockId, il est signalé comme un contenu unique. Le système ne l'envoie jamais à la retraduction parce qu'il n'y a pas de source à partir de laquelle traduire. Il n'existe qu'en allemand.
Lorsqu'un traducteur japonais remplace une liste à puces par une liste numérotée (une convention courante dans la rédaction technique japonaise), l'indicateur IsStructureAdapted du bloc préserve ce contenu lors des prochains cycles de retraduction :
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,
};
L'indicateur IsNoTranslate gère le contenu qui doit être copié mot pour mot : les blocs de code, les URL, les noms de produits, les notations mathématiques. Le prestataire de services de traduction ne s'en préoccupe pas.
Mettre le tout ensemble
Examinons le flux complet. Un utilisateur à Londres édite un paragraphe dans le document source anglais, et votre bureau de Munich a réglé l'allemand sur AlwaysTranslate :
- **TipTap envoie JSON à l'API.
- Extraction des blocs et détection des modifications
CreateBlocksFromDocumentAsyncanalyse le JSON, recalcule les hachages de contenu et compare les anciens et les nouveaux hachages afin d'identifier les blocs qui ont réellement changé. - Orchestrator s'exécute. Trouve le
EntryTranslationallemand, vérifie le bloc allemand. Il s'agit d'une traduction automatique, non verrouillée, non éditée par l'homme, donc éligible à la retraduction. - Configuration de la traduction chargée ID du glossaire résolu via
GetOrSyncDeepLGlossaryIdAsync("en", "de"), règles de style viaGetOrSyncStyleRuleListAsync("de"), formalité fixée à "more" (formel "Sie"), blocs adjacents transmis en tant que contexte pour la désambiguïsation. - DeepL call. Bloc unique envoyé avec l'ID du glossaire, l'ID du style, la formalité et le contexte.
- **Le contenu traduit est stocké,
SourceContentHashest synchronisé, le statut est défini surUpToDate. Un bloc traduit au lieu de plus de 40. Les 39 blocs restants ? Ils n'ont pas été modifiés.
Pendant ce temps, votre bureau de Tokyo a réglé le japonais sur TranslateOnFirstVisit. Le même éditeur marque le bloc de traduction japonais comme Stale. Lorsque quelqu'un à Tokyo ouvre le document, les étapes 5 à 9 se déroulent à la volée. L'adaptation de leur structure (liste numérotée) est préservée. Les blocs uniques restent exactement à leur place.
Je pense que le moteur de traduction est la partie de Rasepi qui apporte la valeur la plus visible. Des traductions qui utilisent votre terminologie, suivent vos conventions de formatage, obéissent à vos instructions personnalisées, correspondent à votre ton, respectent le travail de vos traducteurs et coûtent une fraction de ce que coûterait la retraduction d'un document complet. L'architecture rend tout cela automatique et se tient à l'écart lorsque les humains veulent prendre le relais.
Le même moteur DeepL qui alimente les traductions écrites alimente également Talk to Docs, notre interface de documentation conversationnelle, DeepL Voice gérant l'interaction orale. Mêmes glossaires, mêmes règles de style, même formalité, même cohérence. Que votre équipe lise la documentation ou qu'elle lui parle, la qualité linguistique est identique.