← Volver al blog

Una clave API, muchos inquilinos: Cómo aislamos DeepL Traducciones entre clientes

Rasepi utiliza una única clave API DeepL para todos los arrendatarios. Así es como gestionamos los glosarios por cliente, las reglas de estilo, las traducciones en caché y el aislamiento a nivel de bloque sin que nada se filtre.

Bajo el capó
Una clave API, muchos inquilinos: Cómo aislamos DeepL Traducciones entre clientes

Hay una pregunta que surge cada vez que explico la arquitectura de traducción de Rasepi a otro desarrollador: "Espere, ¿así que todos sus inquilinos comparten una clave API DeepL? ¿Cómo evita que sus glosarios y reglas de estilo se filtren unos a otros?".

Es una pregunta justa. Y la respuesta implica más trabajo de diseño del que cabría esperar.

Ya tratamos el proceso de traducción completo en un post anterior, el hash a nivel de bloque, el orquestador, todo el flujo desde que se guarda el documento hasta que se traduce. Este post se centra en el problema específico de la multitenencia. Cómo se toma una API de terceros que no tiene el concepto de inquilinos y se construye el aislamiento de inquilinos sobre ella.

El problema: DeepL no conoce a sus clientes

La API de DeepL se autentica con una única clave de API. Todo lo que se crea con esa clave, glosarios, listas de reglas de estilo, historial de traducciones, pertenece a la misma cuenta. No existe el concepto de "este glosario pertenece al arrendatario A" por parte de DeepL.

Cuando llama a GET /v2/glossaries, obtiene todos los glosarios de todos los inquilinos. Cuando crea una lista de reglas de estilo, ésta vive en el mismo espacio de nombres que las reglas de estilo de todos los demás arrendatarios. La API es plana.

Para un producto autoalojado en el que cada cliente ejecuta su propia instancia con su propia clave DeepL, esto está bien. ¿Para un SaaS multiarrendatario en el que gestionamos la infraestructura? Necesita una capa de aislamiento.

La base de datos es la fuente de la verdad

Nuestra principal decisión de diseño: la base de datos es la propietaria de todo el contenido del glosario y de la configuración de las reglas de estilo. DeepL es un objetivo de ejecución en tiempo de ejecución, nada más.

Cada entidad TenantGlossary y TenantStyleRuleList implementa ITenantScoped, lo que significa que los filtros de consulta global del núcleo de EF amplían automáticamente todas las lecturas al inquilino actual. Una consulta de glosarios en el contexto de solicitud del arrendatario A nunca devolverá las entradas del arrendatario B. Este es el mismo patrón de aislamiento que utilizamos en todas partes en Rasepi, aplicado a nivel ORM.

Esto es lo que lo hace interesante. Cuando un arrendatario edita un término del glosario, no llamamos inmediatamente a DeepL. Actualizamos la fila de la base de datos y establecemos IsDirty = true. Y ya está. El glosario DeepL real se crea (o se vuelve a crear) perezosamente, justo antes de que la siguiente traducción lo necesite.

public async Task<string?> GetOrSyncDeepLGlossaryIdAsync(
    string sourceLanguage, string targetLanguage)
{
    var glossary = await _db.TenantGlossaries
        .Include(g => g.Entries)
        .FirstOrDefaultAsync(g =>
            g.SourceLanguage == sourceLanguage &&
            g.TargetLanguage == targetLanguage);

    if (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 created = await _deepL.CreateGlossaryAsync(
        $"rasepi-{glossary.Id}",
        glossary.SourceLanguage,
        glossary.TargetLanguage,
        entries);

    glossary.DeepLGlossaryId = created.GlossaryId;
    glossary.IsDirty = false;
    glossary.LastSyncedAt = DateTime.UtcNow;
    await _db.SaveChangesAsync();

    return glossary.DeepLGlossaryId;
}

El filtro de consulta en TenantGlossaries se encarga del aislamiento. La bandera IsDirty hace la sincronización perezosa. Y la convención de nomenclatura (rasepi-{glossary.Id}) es sólo para depuración en el tablero DeepL, no tiene ningún propósito funcional.

¿Por qué perezosa? Porque los glosarios de DeepL v2 son inmutables. No se pueden editar. Cualquier cambio significa borrar y volver a crear. Si un equipo importa un CSV con 200 términos y luego corrige una errata en una entrada, no queremos borrar y volver a crear el glosario DeepL dos veces. Simplemente establecemos IsDirty las dos veces y la única recreación se produce cuando se ejecuta la siguiente traducción.

Reglas de estilo: mismo patrón, diferente API

Las reglas de estilo de DeepL son más nuevas (API v3) y realmente mutables, lo que es más agradable. Puede actualizar las reglas configuradas in situ con PUT /v3/style_rules/{style_id}/configured_rules, y las instrucciones personalizadas pueden añadirse o eliminarse individualmente.

Sin embargo, seguimos utilizando el mismo patrón IsDirty. Un TenantStyleRuleList tiene un DeepLStyleId que mapea al identificador de tiempo de ejecución de DeepL, además de ConfiguredRulesJson para las reglas de formato y una colección de entradas TenantCustomInstruction para las directivas de traducción de texto libre.

El verdadero poder está en esas instrucciones personalizadas. Cada una es una directiva de texto libre, de hasta 300 caracteres, que da forma a cómo traduce DeepL. Ejemplos reales de nuestros inquilinos:

  • "Utilice siempre la forma 'Sie', nunca 'du'" para un bufete de abogados alemán
  • "Traduzca 'deployment' como 'Bereitstellung', nunca 'Deployment'" para términos dependientes del contexto que van más allá de las simples correspondencias del glosario
  • "Utilice la ortografía del inglés británico (color, organización, licencia)" para una empresa del Reino Unido que traduce entre variantes del inglés
  • "Ponga símbolos de moneda después del importe numérico" para las convenciones europeas

Cada arrendatario puede tener instrucciones completamente diferentes por idioma de destino, todas detrás de la misma clave API. El aislamiento proviene del hecho de que cada llamada de traducción incluye únicamente el glossary_id y el style_id pertenecientes al tenant solicitante. Nunca se hace referencia a los recursos DeepL de otros arrendatarios.

La llamada de traducción: todo compone

Cuando el orquestador traduce un bloque, ensambla todos los ajustes específicos del inquilino en una única solicitud:

var glossaryId = await _glossaryService
    .GetOrSyncDeepLGlossaryIdAsync(sourceLang, targetLang);
var styleId = await _styleRuleService
    .GetOrSyncStyleIdAsync(targetLang);
var formality = langConfig.Formality ?? "default";

var options = new TranslationOptions
{
    GlossaryId = glossaryId,
    StyleId = styleId,
    Formality = formality,
    Context = documentContext,
    ModelType = styleId != null ? "quality_optimized" : null
};

Aquí todos los parámetros son específicos del inquilino. El glossaryId se resolvió mediante una consulta filtrada por el inquilino. El styleId se resolvió de la misma manera. El formality proviene del TenantLanguageConfig, también tenant-scoped. Incluso el context (párrafos circundantes enviados para mejorar la calidad de la traducción, no facturados) procede del propio documento del arrendatario.

Una cosa que quiero destacar: cuando se establece style_id, DeepL utiliza automáticamente su modelo quality_optimized. No puede combinar reglas de estilo con latency_optimized. Es una restricción de DeepL, pero sinceramente razonable. Si está invirtiendo en reglas de estilo personalizadas, probablemente querrá la mejor calidad de salida.

Almacenamiento en caché a nivel de bloque: la base de datos como memoria de traducción

No llamamos a DeepL para los bloques que no han cambiado. El mecanismo de almacenamiento en caché es la propia tabla TranslationBlock.

Cada EntryBlock fuente tiene un ContentHash, un SHA256 de su contenido semántico (con atributos de metadatos como blockId y deleted despojados). Cada TranslationBlock almacena el SourceContentHash que estaba vigente cuando se hizo la traducción. Cuando cambia el bloque fuente, cambia su hash. El orquestador compara los hashes y sólo pone en cola los bloques que no coinciden.

El árbol de decisión para cada bloque tiene el siguiente aspecto:

  1. Hash coincide, traducción existe = omitir (en caché, actualizado)
  2. Hash cambiado, traducción automática, no bloqueada = retraducir automáticamente
  3. Hash cambiado, editado por humanos o bloqueado = marcar como Stale, no sobrescribir

El tercer caso es crucial. Si su traductor de alemán refinó manualmente un párrafo, no lo sobrescribimos sólo porque la fuente inglesa cambió. Lo marcamos como rancio para que sepan que necesita revisión, pero el texto traducido permanece intacto.

El resultado práctico: la edición de un párrafo en un documento de 30 párrafos desencadena exactamente una llamada a la API DeepL (bueno, un lote que incluye un bloque). Los otros 29 párrafos, en todos los idiomas, ya están almacenados en caché y no cuestan nada.

¿Por qué no utilizar una clave distinta por inquilino?

Lo he considerado seriamente. Dé a cada inquilino su propia clave API DeepL y elimine por completo el problema del aislamiento.

Tres razones por las que no lo hicimos:

  1. Complejidad de facturación. Cada arrendatario necesitaría su propia suscripción a DeepL o una forma de aprovisionar subcuentas. DeepL no ofrece gestión de claves multiarrendatario de forma nativa.
  2. Eficiencia de costes. Una infraestructura compartida significa límites de tarifas y descuentos por volumen compartidos. Nuestro uso agregado obtiene mejores precios.
  3. Simplicidad operativa. Una llave que rotar, una cuota que supervisar, una integración que mantener.

La contrapartida es que necesitamos la capa de aislamiento que he descrito. Pero dado que ya tenemos consultas EF Core de ámbito tenant para todo lo demás en el sistema, añadirlo a los glosarios y a las reglas de estilo fue sencillo. El patrón ya estaba ahí.

Lo que realmente le protege

Para resumir las garantías de aislamiento:

  • Las entradas del glosario se almacenan en TenantGlossary (implementa ITenantScoped), filtradas por los filtros de consulta globales de EF Core. DeepL Los ID de glosario son referencias opacas que sólo se resuelven dentro del contexto del inquilino.
  • Las reglas de estilo y las instrucciones personalizadas siguen el mismo patrón a través de TenantStyleRuleList.
  • El contenido traducido** se encuentra en TranslationBlock, con alcance a través de su cadena padre EntryHub, que también tiene alcance de inquilino.
  • La guardia SaveChanges** establece TenantId automáticamente en nuevas entidades y lanza en escrituras cruzadas entre inquilinos.
  • No hay IgnoreQueryFilters()** en el código de producción. Nunca.

El principio de diseño es simple: DeepL ve IDs de recursos. Rasepi ve entidades tenant-scoped. El mapeo entre ellos nunca cruza los límites de los tenants porque la consulta que resuelve el mapeo es físicamente incapaz de devolver los datos de otro tenant.

Si está construyendo un SaaS multi-tenant que se integra con APIs de terceros sin soporte nativo tenant, este patrón funciona bien. Trate la API externa como un motor de ejecución sin estado, mantenga toda la configuración en su propia base de datos de ámbito de inquilino, sincronice perezosamente y nunca confíe en los listados de recursos externos para el aislamiento.

Mantén tu documentación actualizada. Automáticamente.

Rasepi impone fechas de revisión, supervisa la salud del contenido y publica en más de 40 idiomas.

Empieza gratis →