← Voltar ao blog

Uma chave de API, muitos inquilinos: Como isolamos as traduções de DeepL entre os clientes

O Rasepi usa uma única chave de API DeepL para todos os locatários. Veja como lidamos com glossários por cliente, regras de estilo, traduções em cache e isolamento em nível de bloco sem vazamento de nada.

Por baixo do capô
Uma chave de API, muitos inquilinos: Como isolamos as traduções de DeepL entre os clientes

Há uma pergunta que surge sempre que explico a arquitetura de tradução do Rasepi a outro programador: "Espera, então todos os teus inquilinos partilham uma chave de API DeepL? Como é que evita que os seus glossários e regras de estilo se infiltrem uns nos outros?"

É uma pergunta justa. E a resposta envolve mais trabalho de design do que seria de esperar.

Cobrimos a linha de tradução completa num post anterior, o hashing ao nível do bloco, o orquestrador, todo o fluxo desde a gravação do documento até à saída traduzida. Este post aborda o problema específico de multi-tenancy. Como se pega numa API de terceiros que não tem qualquer conceito de inquilinos e se constrói o isolamento de inquilinos em cima dela.

O problema: DeepL não sabe sobre seus clientes

A API do DeepL é autenticada com uma única chave de API. Tudo o que é criado com essa chave, glossários, listas de regras de estilo, histórico de tradução, pertence à mesma conta. Não há nenhum conceito de "este glossário pertence ao inquilino A" no lado do DeepL.

Quando se chama GET /v2/glossaries, obtém-se todos os glossários de todos os inquilinos. Quando se cria uma lista de regras de estilo, ela vive no mesmo espaço de nomes que as regras de estilo de todos os outros locatários. A API é plana.

Para um produto auto-hospedado em que cada cliente executa a sua própria instância com a sua própria chave DeepL, isto é ótimo. Para um SaaS multi-tenant em que gerimos a infraestrutura? É necessária uma camada de isolamento.

A base de dados é a fonte da verdade

A nossa principal decisão de conceção: **a base de dados possui todo o conteúdo do glossário e a configuração das regras de estilo. DeepL é um alvo de execução em tempo de execução, nada mais.

Todas as entidades TenantGlossary e TenantStyleRuleList implementam ITenantScoped, o que significa que os filtros de consulta global do EF Core automaticamente abrangem todas as leituras para o locatário atual. Uma consulta de glossários no contexto de solicitação do locatário A nunca retornará as entradas do locatário B. Este é o mesmo padrão de isolamento que usamos em todo o Rasepi, aplicado ao nível do ORM.

O que torna isto interessante é o seguinte. Quando um locatário edita um termo do glossário, não chamamos imediatamente DeepL. Actualizamos a linha da base de dados e definimos IsDirty = true. É só isso. O glossário DeepL real é criado (ou recriado) preguiçosamente, mesmo antes de a próxima tradução precisar dele.

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;
}

O filtro de consulta em TenantGlossaries faz o isolamento. O sinalizador IsDirty faz a sincronização preguiçosa. E a convenção de nomenclatura (rasepi-{glossary.Id}) é apenas para depuração no painel DeepL, não tem qualquer objetivo funcional.

Porquê preguiçosa? Porque os glossários de DeepL v2 são imutáveis. Não é possível editá-los. Qualquer alteração significa apagar e recriar. Se uma equipa importar um CSV com 200 termos e depois corrigir um erro de digitação numa entrada, não queremos apagar e recriar o glossário DeepL duas vezes. Basta definir IsDirty em ambas as vezes e a única recriação acontece quando a próxima tradução for executada.

Regras de estilo: mesmo padrão, API diferente

As regras de estilo do DeepL são mais novas (API v3) e realmente mutáveis, o que é melhor. Você pode atualizar regras configuradas no lugar com PUT /v3/style_rules/{style_id}/configured_rules, e instruções personalizadas podem ser adicionadas ou removidas individualmente.

Nós ainda usamos o mesmo padrão IsDirty. Um TenantStyleRuleList tem um DeepLStyleId que mapeia para o identificador de tempo de execução de DeepL, mais ConfiguredRulesJson para as regras de formatação e uma coleção de entradas TenantCustomInstruction para diretivas de tradução de texto livre.

O verdadeiro poder está nessas instruções personalizadas. Cada uma é uma diretiva de linguagem simples, até 300 caracteres, que molda a forma como DeepL traduz. Exemplos reais dos nossos inquilinos:

  • "Utilize sempre a forma 'Sie', nunca 'du'" para uma firma de advogados alemã
  • "Traduzir 'deployment' como 'Bereitstellung', nunca 'Deployment'" para termos dependentes do contexto que vão para além de simples mapeamentos de glossários
  • "Utilizar a ortografia do inglês britânico (cor, organização, licença)" para uma empresa do Reino Unido que esteja a traduzir entre variantes do inglês
  • "Colocar símbolos de moeda após o valor numérico" para convenções europeias

Cada locatário pode ter instruções completamente diferentes por língua de destino, todas elas com a mesma chave API. O isolamento resulta do facto de cada chamada de tradução incluir apenas o glossary_id e o style_id pertencentes ao locatário requerente. Os recursos DeepL de outros locatários nunca são referenciados.

A chamada de tradução: tudo compõe

Quando o orquestrador traduz um bloco, ele reúne todas as configurações específicas do locatário em uma única solicitação:

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
};

Todos os parâmetros aqui são específicos do locatário. O glossaryId foi resolvido por meio de uma consulta filtrada por locatário. O styleId foi resolvido da mesma forma. O formality provém do TenantLanguageConfig, também com escopo de locatário. Mesmo o context (parágrafos envolventes enviados para melhorar a qualidade da tradução, não facturados) provém do próprio documento do locatário.

Um aspeto que quero realçar: quando style_id é definido, DeepL utiliza automaticamente o seu modelo quality_optimized. Não é possível combinar regras de estilo com latency_optimized. Essa é uma restrição do DeepL, mas honestamente uma restrição razoável. Se está a investir em regras de estilo personalizadas, provavelmente quer a melhor qualidade de saída.

Caching a nível de bloco: a base de dados como memória de tradução

Nós não chamamos DeepL para blocos que não foram alterados. O mecanismo de cache é a própria tabela TranslationBlock.

Cada EntryBlock de origem tem um ContentHash, um SHA256 do seu conteúdo semântico (com atributos de metadados como blockId e deleted eliminados). Cada TranslationBlock armazena o SourceContentHash que estava atual quando a tradução foi feita. Quando o bloco de origem muda, seu hash muda. O orquestrador compara os hashes e só coloca em fila os blocos com incompatibilidades.

A árvore de decisão para cada bloco se parece com isso:

  1. Hash corresponde, existe tradução = ignorar (em cache, atualizado)
  2. Hash alterado, tradução automática, não bloqueado = retraduzir automaticamente
  3. Hash alterado, editado por humanos ou bloqueado = marcar como obsoleto, não sobrescrever

Este terceiro caso é crucial. Se o seu tradutor de alemão tiver refinado manualmente um parágrafo, não o apagamos só porque a fonte inglesa mudou. Marcamo-lo como obsoleto para que ele saiba que precisa de ser revisto, mas o texto traduzido permanece intacto.

O resultado prático: a edição de um parágrafo num documento de 30 parágrafos desencadeia exatamente uma chamada à API DeepL (bem, um lote que inclui um bloco). Os outros 29 parágrafos, em todas as línguas, já estão armazenados em cache e não custam nada.

Porque não usar uma chave separada por inquilino?

Pensei seriamente nisso. Dar a cada inquilino a sua própria chave API DeepL, eliminando completamente o problema de isolamento.

Três razões para não o fazermos:

  1. Complexidade de faturação Cada inquilino precisaria da sua própria subscrição de DeepL ou de uma forma de fornecer subcontas. O DeepL não oferece gestão de chaves multi-tenant nativamente.
  2. **Eficiência de custos: Infraestrutura partilhada significa limites de taxas e descontos por volume partilhados. A nossa utilização agregada obtém melhores preços.
  3. **Simplicidade operacional: uma chave para rodar, uma quota para monitorizar, uma integração para manter.

A desvantagem é que precisamos da camada de isolamento que descrevi. Mas como já temos consultas EF Core com escopo de locatário para todo o resto do sistema, adicioná-las a glossários e regras de estilo foi simples. O padrão já estava lá.

O que realmente o protege

Para resumir as garantias de isolamento:

  • Entradas do glossário são armazenadas em TenantGlossary (implementa ITenantScoped), filtradas pelos filtros de consulta global do EF Core. DeepL IDs de glossário são referências opacas que só são resolvidas dentro do contexto do locatário.
  • As regras de estilo e as instruções personalizadas** seguem o mesmo padrão através do TenantStyleRuleList.
  • O conteúdo traduzido** reside em TranslationBlock, com escopo através da cadeia EntryHub, que também tem escopo de locatário.
  • O SaveChanges guard** define TenantId automaticamente em novas entidades e lança em escritas entre inquilinos.
  • Não há IgnoreQueryFilters()** em código de produção. Sempre.

O princípio de design é simples: DeepL vê IDs de recursos. O Rasepi vê entidades com escopo de locatário. O mapeamento entre eles nunca ultrapassa os limites do locatário porque a consulta que resolve o mapeamento é fisicamente incapaz de devolver os dados de outro locatário.

Se estiver a construir um SaaS multilocatário que se integra com APIs de terceiros sem suporte nativo do locatário, este padrão funciona bem. Trate a API externa como um mecanismo de execução sem estado, mantenha toda a configuração em seu próprio banco de dados com escopo de locatário, sincronize preguiçosamente e nunca confie em listagens de recursos externos para isolamento.

Mantenha a sua documentação atualizada. Automaticamente.

O Rasepi impõe datas de revisão, monitoriza a qualidade do conteúdo e publica em mais de 40 idiomas.

Comece gratuitamente →