Nuestro post anterior sobre arquitectura trataba de los plugins, los guardianes de acción y el sistema de canalización. Este artículo profundiza en el motor de traducción, la parte que hace a Rasepi fundamentalmente diferente de cualquier otra plataforma de documentación.
No el discurso de marketing sobre traducir párrafos en lugar de páginas. El código real. Cómo se resuelven los glosarios por inquilino, cómo las reglas de estilo de DeepL y las instrucciones personalizadas dan forma a cada traducción, cómo el hashing de contenido impulsa la detección de contenido obsoleto y cómo el orquestador decide qué bloques retraducir.
Motor de traducción: glosarios, reglas de estilo y retraducción inteligente](/es/blog/img/translation-engine-deep-dive.svg)
El proceso de traducción
Cuando un usuario guarda un documento, el sistema no se limita a retraducirlo todo. Ejecuta una secuencia bastante específica:
-
- Analiza el JSON de TipTap en bloques individuales
- Compara los hashes de contenido para detectar qué bloques cambiaron realmente
- Para los bloques cambiados, resolver el glosario del inquilino y la lista de reglas de estilo para el par de idiomas
- Aplicar las reglas de estilo, las instrucciones personalizadas y la formalidad de la configuración del inquilino
- Envíe sólo los bloques modificados a DeepL
- Actualizar bloques de traducción y sincronizar hashes de contenido
Cada paso es un servicio propio con su propia interfaz. Esto es importante porque cualquier paso puede cambiarse por otra cosa, un proveedor de traducción diferente, un algoritmo hash diferente, una fuente de glosario diferente.
Resolución del glosario: tenant-scoped, DeepL-synced
DeepL los glosarios tienen una limitación que la mayoría de la gente desconoce: **No se puede modificar un glosario DeepL. Cualquier cambio implica borrar el anterior y crear uno nuevo.
Rasepi maneja esto tratando la base de datos como la fuente de la verdad y los glosarios de DeepL como artefactos de tiempo de ejecución desechables. La entidad TenantGlossary almacena todo 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; }
}
Cuando un usuario añade una entrada al glosario, por ejemplo asignando "Sprint Review" a "Sprint-Überprüfung" para EN→DE, el registro de la base de datos se actualiza inmediatamente y IsDirty se establece en true. El glosario DeepL no se vuelve a crear en ese momento. Se vuelve a crear perezosamente, la próxima vez que una traducción lo necesite.
El flujo de sincronización
Antes de cada llamada a traducción, el sistema resuelve el glosario:
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;
}
Tres cosas que vale la pena señalar aquí:
- **Sólo llamamos a la API DeepL cuando realmente se necesita una traducción. La edición masiva de entradas del glosario no provoca docenas de llamadas a la API.
- **La consulta se ejecuta a través de los filtros de consulta globales de EF, por lo que
TenantGlossariesse delimita automáticamente. Las entradas del glosario del Tenant A nunca se filtran a las traducciones del Tenant B. - Un glosario por par de idiomas. DeepL impone esto de todos modos. Un glosario EN→DE, un glosario EN→FR, y así sucesivamente. El par
(SourceLanguage, TargetLanguage)es único por inquilino.
Entradas del glosario
Las entradas individuales son sólo asignaciones de términos:
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"
}
La API le ofrece CRUD completo, además de importación/exportación CSV para la gestión masiva:
POST /api/admin/glossaries Create glossary
POST /api/admin/glossaries/{id}/entries Add term
PUT /api/admin/glossaries/{id}/entries/{entryId} Update term
DELETE /api/admin/glossaries/{id}/entries/{entryId} Remove term
POST /api/admin/glossaries/{id}/import Import CSV
GET /api/admin/glossaries/{id}/export Export CSV
POST /api/admin/glossaries/{id}/sync Force DeepL sync
La importación CSV es muy útil para los equipos que migran desde sistemas de memoria de traducción existentes. Exporta tus términos, límpialos, impórtalos en Rasepi y la siguiente traducción utilizará el nuevo glosario automáticamente.
Reglas de estilo, instrucciones personalizadas y formalidad
Los glosarios se ocupan de la terminología. Pero la terminología es sólo la mitad. Una traducción puede utilizar todas las palabras correctas y aun así sonar mal. Tono incorrecto, formato de fecha incorrecto, convenciones de puntuación incorrectas.
La API Reglas de estilo (v3) de DeepL lo soluciona. Puedes crear listas de reglas de estilo reutilizables que combinan dos tipos de controles:
- Reglas configuradas, convenciones de formato predefinidas para fechas, horas, puntuación, números, etc.
- Instrucciones personalizadas, directivas de texto libre que dan forma al tono, la redacción y las convenciones específicas del dominio.
Rasepi las crea y gestiona por inquilino, por idioma de destino. La entidad TenantStyleRuleList almacena el DeepL style_id junto con las reglas configuradas y las instrucciones personalizadas 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; }
}
Creación de listas de reglas de estilo
Cuando un administrador configura reglas de traducción para el alemán, Rasepi llama a la API v3 de DeepL para crear la lista de reglas de estilo. Esto es lo que parece:
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 diferencia de los glosarios, las listas de reglas de estilo de DeepL son mutables. Puede reemplazar las reglas configuradas en su lugar con PUT /v3/style_rules/{style_id}/configured_rules, y las instrucciones personalizadas pueden añadirse, actualizarse o eliminarse individualmente. Mucho más fácil para el refinamiento iterativo.
Qué aspecto tienen las reglas configuradas
Las reglas configuradas cubren convenciones de formato que varían según el idioma o las preferencias de la empresa. Cosas como:
{
"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"
}
}
Parecen triviales, pero se acumulan rápidamente. Un documento alemán que utiliza el formato AM/PM y decimales separados por puntos se lee como "traducido del inglés" para un lector alemán. Establecer use_24_hour_clock y use_comma para los separadores decimales en todas las traducciones al alemán elimina esto inmediatamente.
Instrucciones personalizadas: este es el verdadero poder
Las instrucciones personalizadas son directivas de texto libre, hasta 200 por lista de reglas de estilo, cada una de hasta 300 caracteres. Básicamente le dicen a DeepL cómo dar forma a la traducción en lenguaje llano:
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
}
Ejemplos reales de nuestros inquilinos:
"Use a friendly, diplomatic tone"para una startup que quiere documentos accesibles"Always use 'Sie' form, never 'du'"para un bufete de abogados alemán"Translate 'deployment' as 'Bereitstellung', never 'Deployment'"para términos que necesitan un tratamiento dependiente del contexto más allá de la simple asignación de un glosario"Use British English spelling (colour, organisation, licence)"para empresas con sede en el Reino Unido que traducen entre variantes del inglés"Put currency symbols after the numeric amount"para adaptarse a las convenciones europeas
Las instrucciones personalizadas son realmente potentes para las convenciones específicas del dominio que no caben en las entradas del glosario. Un glosario asigna un término a otro. Una instrucción personalizada puede decir "al traducir documentos de API, utilice el modo imperativo en lugar de la voz pasiva". Es un tipo de control completamente diferente.
Formalidad
El parámetro formality de DeepL (default, more, less, prefer_more, prefer_less) sigue estando disponible como control independiente junto con las reglas de estilo. Alemán "du" frente a "Sie", francés "tu" frente a "vous", niveles de cortesía en japonés. Se establecen por idioma del inquilino mediante 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 formalidad, las normas de estilo y los glosarios se componen. Una sola llamada de traducción puede llevar los tres:
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"
};
Aquí hay que señalar dos cosas:
- **Pasamos bloques adyacentes como contexto para mejorar la calidad de la traducción. DeepL lo utiliza para resolver ambigüedades, pero no traduce ni factura por ello. Un párrafo sobre "celdas" se traduce de forma diferente cuando el contexto circundante es un documento de biología frente a un manual de hoja de cálculo.
- **Cualquier solicitud con
style_idocustom_instructionsutiliza automáticamente el modeloquality_optimizedde DeepL. Este es el nivel de calidad más alto. No se pueden combinar conlatency_optimized, y es una restricción deliberada de DeepL. La personalización del estilo necesita el modelo completo.
Por qué esto importa más de lo que crees
Imagina una empresa que escribe documentos internos en alemán con un "du" informal que de repente cambia a un "Sie" formal en una sección traducida. Parece incoherente en el mejor de los casos, poco profesional en el peor. La formalidad se encarga de eso. Pero la formalidad por sí sola no detectará un documento que utiliza marcas de tiempo AM/PM cuando su oficina alemana utiliza el formato de 24 horas, o que pone el símbolo de moneda antes del número en lugar de después.
Todos estos elementos juntos (reglas de estilo, instrucciones personalizadas, formalidad, glosarios) producen traducciones que se leen como si alguien de su equipo las hubiera escrito. No como salidas de una máquina que no sabe que su empresa existe.
La capa de servicio DeepL
Toda la comunicación de DeepL pasa por IDeepLService. Envuelve el SDK .NET oficial de DeepL y maneja las llamadas a la API v3 para las reglas de estilo:
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();
}
La implementación gestiona la normalización del código del lenguaje. DeepL requiere EN-US o EN-GB en lugar de en, y PT-PT o PT-BR en lugar de pt:
private static string NormalizeLanguageCode(string code)
=> code.ToLower() switch
{
"en" => "EN-US",
"pt" => "PT-PT",
_ => code.ToUpper()
};
La traducción por lotes utiliza la fragmentación de 50 elementos para respetar los límites de la API de DeepL y maximizar el rendimiento:
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
};
}
Dado que sólo enviamos bloques obsoletos, no documentos enteros, un lote de traducción típico para una sola edición contiene de 1 a 3 bloques en lugar de más de 40. De ahí la reducción de costes del 94%.
El orquestador de traducción
El TranslationOrchestrator decide qué hacer con cada bloque cuando cambia el documento fuente. Recorramos el árbol de decisiones:
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);
}
La clave: **Si un traductor ha modificado manualmente un bloque, por ejemplo añadiendo contexto cultural o cambiando la redacción para darle más claridad, el sistema respeta ese trabajo. Marca el bloque como obsoleto para que el traductor sepa que la fuente ha cambiado, pero no sustituye silenciosamente sus ediciones.
Los bloques traducidos automáticamente con AlwaysTranslate activado se retraducen inmediatamente. Los bloques traducidos automáticamente con TranslateOnFirstVisit se marcan como obsoletos y se traducen cuando alguien abre el documento en ese idioma.
Activadores de traducción: cuándo se producen las traducciones
Cada idioma tiene un TranslationTrigger que controla los tiempos:
public enum TranslationTrigger
{
AlwaysTranslate, // Translate on every save
TranslateOnFirstVisit // Translate when first opened in that language
}
AlwaysTranslate es útil para los idiomas de alta prioridad en los que se desea que las traducciones estén inmediatamente actualizadas. Francés para una empresa con una gran oficina en París. Alemán para una empresa con sede en Múnich.
TranslateOnFirstVisit es útil para idiomas que se necesitan ocasionalmente pero no merece la pena el coste de API de mantener perfectamente actualizados en todo momento. Cuando alguien abre el documento en ese idioma, los bloques obsoletos se traducen sobre la marcha.
Ambos modos utilizan la misma resolución de glosario, los mismos ajustes de formalidad y el mismo hash de contenido. La única diferencia es el tiempo.
Adaptación única del contenido y la estructura
Aquí es donde la arquitectura realmente vale la pena más allá de la mera traducción.
Cuando un traductor alemán añade una sección de cumplimiento de la DSGVO que no existe en inglés, la añade como un nuevo bloque en la versión alemana. Ese bloque no tiene SourceBlockId, está marcado como contenido único. El sistema nunca lo envía a retraducción porque no hay fuente de la que traducir. Sólo existe en alemán.
Cuando un traductor japonés cambia una lista con viñetas por una lista numerada (una convención común en la escritura técnica japonesa), el indicador IsStructureAdapted del bloque lo conserva para futuros ciclos de retraducción:
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,
};
El indicador IsNoTranslate se encarga del contenido que debe copiarse literalmente: bloques de código, URL, nombres de productos, notación matemática. El proveedor de traducción los omite por completo.
Ponerlo todo junto
Veamos el flujo completo. Un usuario en Londres edita un párrafo en el documento fuente en inglés, y su oficina de Múnich tiene el alemán configurado en AlwaysTranslate:
- El usuario guarda. TipTap envía JSON a la API.
- Extracción de bloques.
CreateBlocksFromDocumentAsyncanaliza JSON, recalcula los hashes de contenido - Detección de cambios. El sistema compara los hashes antiguos y los nuevos, identifica el bloque cambiado
- **Encuentra el
EntryTranslationalemán, comprueba el bloque alemán. - El bloque es traducido por la máquina. No bloqueado, no editado por humanos → elegible para retraducción.
- Resolución del glosario.
GetOrSyncDeepLGlossaryIdAsync("en", "de")devuelve el ID del glosario (se sincroniza si está sucio). - Resolución de reglas de estilo.
GetOrSyncStyleRuleListAsync("de")devuelve el DeepLstyle_idcon reglas de formato configuradas e instrucciones personalizadas. - Formalidad + contexto. Formalidad configurada a "más" (formal "Sie"), bloques adyacentes pasados como contexto para desambiguación, preservar formato en
- Llamada DeepL. Se envía un único bloque con ID de glosario, ID de estilo, formalidad y contexto
-
- Bloque actualizado. Contenido traducido almacenado,
SourceContentHashsincronizado, estado establecido enUpToDate.
- Bloque actualizado. Contenido traducido almacenado,
- **Un bloque traducido en lugar de 40+. ¿Los 39 bloques restantes? Sin tocar.
Mientras tanto, su oficina de Tokio tiene el japonés configurado como TranslateOnFirstVisit. La misma edición marca el bloque de traducción japonés como Stale. Cuando alguien en Tokio abre el documento, los pasos 5-9 suceden sobre la marcha. Se conserva la adaptación de su estructura (lista numerada). Sus bloques únicos permanecen exactamente donde están.
El motor de traducción es la parte de Rasepi que ofrece el valor más visible. Traducciones que utilizan su terminología, siguen sus convenciones de formato, obedecen sus instrucciones personalizadas, coinciden con su tono, respetan el trabajo de sus traductores y cuestan una fracción de lo que costaría la retraducción de un documento completo. La arquitectura hace que todo esto sea automático y se mantiene al margen cuando los humanos quieren tomar el relevo.
El mismo motor DeepL que impulsa las traducciones escritas también impulsa Talk to Docs, nuestra interfaz de documentación conversacional, con DeepL Voice gestionando la interacción hablada. Los mismos glosarios, las mismas normas de estilo, la misma formalidad, la misma coherencia. Tanto si su equipo lee la documentación como si habla con ella, la calidad del lenguaje es idéntica.