← Torna al blog

Una chiave API, molti inquilini: Come isoliamo le traduzioni di DeepL tra i vari clienti

Rasepi utilizza un'unica chiave API DeepL per tutti gli affittuari. Ecco come gestiamo i glossari per cliente, le regole di stile, le traduzioni in cache e l'isolamento a livello di blocco senza che nulla trapeli.

Sotto il cofano
Una chiave API, molti inquilini: Come isoliamo le traduzioni di DeepL tra i vari clienti

C'è una domanda che sorge ogni volta che spiego l'architettura di traduzione di Rasepi ad un altro sviluppatore: "Aspetti, quindi tutti i suoi inquilini condividono una chiave API DeepL? Come fa a evitare che i loro glossari e le loro regole di stile si influenzino a vicenda?".

È una domanda giusta. E la risposta comporta più lavoro di progettazione di quanto ci si aspetti.

Abbiamo trattato la pipeline di traduzione completa in un post precedente, l'hashing a livello di blocco, l'orchestratore, l'intero flusso dal salvataggio del documento all'output tradotto. Questo post si concentra sul problema specifico della multi-tenancy. Come prendere un'API di terze parti che non ha un concetto di locatari e costruirci sopra l'isolamento dei locatari.

Il problema: DeepL non conosce i suoi clienti

L'API di DeepL si autentica con una singola chiave API. Tutto ciò che viene creato con quella chiave, glossari, elenchi di regole di stile, cronologia delle traduzioni, appartiene allo stesso account. Non esiste il concetto di "questo glossario appartiene all'inquilino A" da parte di DeepL.

Quando chiama GET /v2/glossaries, ottiene tutti i glossari di tutti gli inquilini. Quando crea un elenco di regole di stile, questo vive nello stesso spazio dei nomi delle regole di stile di ogni altro inquilino. L'API è piatta.

Per un prodotto self-hosted in cui ogni cliente gestisce la propria istanza con la propria chiave DeepL, questo va bene. Per un SaaS multi-tenant in cui gestiamo l'infrastruttura? È necessario un livello di isolamento.

Il database è la fonte della verità

La nostra decisione progettuale principale: il database possiede tutti i contenuti del glossario e la configurazione delle regole di stile. DeepL è un obiettivo di esecuzione in fase di runtime, niente di più.

Ogni entità TenantGlossary e TenantStyleRuleList implementa ITenantScoped, il che significa che i filtri delle query globali di EF Core estendono automaticamente tutte le letture al tenant corrente. Una query per i glossari nel contesto di richiesta del tenant A non restituirà mai le voci del tenant B. Questo è lo stesso modello di isolamento che utilizziamo ovunque in Rasepi, applicato a livello di ORM.

Ecco cosa rende questo interessante. Quando un inquilino modifica un termine del glossario, non chiamiamo immediatamente DeepL. Aggiorniamo la riga del database e impostiamo IsDirty = true. Questo è tutto. Il glossario effettivo DeepL viene creato (o ricreato) pigramente, proprio prima che la traduzione successiva ne abbia bisogno.

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

Il filtro di query su TenantGlossaries fa l'isolamento. Il flag IsDirty fa la sincronizzazione pigra. E la convenzione di denominazione (rasepi-{glossary.Id}) serve solo per il debug nella dashboard DeepL, non ha alcuno scopo funzionale.

Perché pigro? Perché i glossari di DeepL v2 sono immutabili. Non è possibile modificarli. Qualsiasi modifica significa cancellare e ricreare. Se un team importa un CSV con 200 termini e poi corregge un refuso in una voce, non vogliamo cancellare e ricreare il glossario DeepL due volte. Basta impostare IsDirty in entrambe le occasioni e la singola ricreazione avviene quando viene eseguita la traduzione successiva.

Regole di stile: stesso modello, API diversa

Le regole di stile di DeepL sono più recenti (API v3) e sono effettivamente mutabili, il che è più bello. Può aggiornare le regole configurate in loco con PUT /v3/style_rules/{style_id}/configured_rules, e le istruzioni personalizzate possono essere aggiunte o rimosse individualmente.

Tuttavia, utilizziamo ancora lo stesso schema IsDirty. Un TenantStyleRuleList ha un DeepLStyleId che corrisponde all'identificatore di runtime di DeepL, più ConfiguredRulesJson per le regole di formattazione e una collezione di voci TenantCustomInstruction per le direttive di traduzione a testo libero.

La vera potenza è in queste istruzioni personalizzate. Ognuna di esse è una direttiva in linguaggio semplice, fino a 300 caratteri, che modella il modo in cui DeepL traduce. Esempi reali dai nostri inquilini:

  • "Utilizzi sempre la forma 'Sie', mai 'du'" per uno studio legale tedesco.
  • "Traduca 'deployment' come 'Bereitstellung', mai 'Deployment'" per termini dipendenti dal contesto che vanno oltre le semplici mappature del glossario.
  • "Utilizzi l'ortografia inglese britannica (colore, organizzazione, licenza)" per un'azienda del Regno Unito che traduce tra le varianti inglesi
  • "Metta i simboli di valuta dopo l'importo numerico" per le convenzioni europee

Ogni locatario può avere istruzioni completamente diverse per ogni lingua di destinazione, tutte dietro la stessa chiave API. L'isolamento deriva dal fatto che ogni chiamata di traduzione include solo i glossary_id e style_id appartenenti al tenant richiedente. Le risorse DeepL di altri tenant non vengono mai citate.

La chiamata di traduzione: tutto si compone

Quando l'orchestratore traduce un blocco, assembla tutte le impostazioni specifiche del tenant in un'unica richiesta:

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

Tutti i parametri qui sono in base all'inquilino. Il glossaryId è stato risolto attraverso una query filtrata dall'inquilino. Il styleId è stato risolto allo stesso modo. Il formality proviene da TenantLanguageConfig, anch'esso in base al tenant-scoped. Anche il context (paragrafi circostanti inviati per migliorare la qualità della traduzione, non fatturati) proviene dal documento del locatario.

Una cosa che voglio sottolineare: quando style_id è impostato, DeepL utilizza automaticamente il loro modello quality_optimized. Non è possibile combinare le regole di stile con latency_optimized. Si tratta di un vincolo di DeepL, ma onestamente ragionevole. Se sta investendo in regole di stile personalizzate, probabilmente vuole un risultato della migliore qualità.

Caching a livello di blocco: il database come memoria di traduzione

Non chiamiamo DeepL per i blocchi che non sono stati modificati. Il meccanismo di caching è la tabella TranslationBlock stessa.

Ogni EntryBlock sorgente ha un ContentHash, una SHA256 del suo contenuto semantico (con attributi di metadati come blockId e deleted eliminati). Ogni TranslationBlock memorizza il SourceContentHash corrente al momento della traduzione. Quando il blocco sorgente cambia, cambia anche il suo hash. L'orchestratore confronta gli hash e accoda solo i blocchi che non corrispondono.

L'albero decisionale per ogni blocco si presenta come segue:

  1. Corrispondenza hash, traduzione esistente = salta (memorizzato nella cache, aggiornato)
  2. Hash cambiato, traduzione automatica, non bloccato = ritraduce automaticamente
  3. Hash modificato, modificato dall'uomo o bloccato = contrassegnare come Stale, non sovrascrivere

Il terzo caso è fondamentale. Se il suo traduttore tedesco rifinisce manualmente un paragrafo, non lo sovrascriviamo solo perché la fonte inglese è cambiata. Lo contrassegniamo come stantio, in modo che sappiano che deve essere rivisto, ma il testo tradotto rimane intatto.

Il risultato pratico: la modifica di un paragrafo in un documento di 30 paragrafi attiva esattamente una chiamata API DeepL (beh, un lotto che include un blocco). Gli altri 29 paragrafi, in tutte le lingue, sono già memorizzati nella cache e non costano nulla.

Perché non utilizzare una chiave separata per ogni inquilino?

Ci ho pensato seriamente. Dare a ciascun inquilino la propria chiave API DeepL, eliminando completamente il problema dell'isolamento.

Tre motivi per non farlo:

  1. **Ogni inquilino avrebbe avuto bisogno di un proprio abbonamento a DeepL o di un modo per fornire sub-account. DeepL non offre la gestione delle chiavi multi-tenant in modo nativo.
  2. Efficienza dei costi. L'infrastruttura condivisa significa limiti tariffari e sconti sul volume condivisi. Il nostro utilizzo aggregato consente di ottenere prezzi migliori.
  3. **Una sola chiave da ruotare, una sola quota da monitorare, una sola integrazione da mantenere.

Il compromesso è che abbiamo bisogno del livello di isolamento che ho descritto. Ma dato che abbiamo già delle query EF Core con scala per inquilino per tutto il resto del sistema, l'aggiunta ai glossari e alle regole di stile è stata semplice. Il modello era già presente.

Cosa protegge effettivamente

Per riassumere le garanzie di isolamento:

  • Le voci del glossario** sono memorizzate in TenantGlossary (implementa ITenantScoped), filtrate dai filtri di query globali di EF Core. DeepL Gli ID del glossario sono riferimenti opachi che vengono risolti solo nel contesto dell'inquilino.
  • Le regole di stile e le istruzioni personalizzate seguono lo stesso schema attraverso TenantStyleRuleList.
  • Il contenuto tradotto risiede in TranslationBlock, con uno scope attraverso la catena del suo genitore EntryHub, anch'esso con uno scope da inquilino.
  • La guardia SaveChanges imposta TenantId automaticamente sulle nuove entità e lancia le scritture cross-tenant.
  • Nessun IgnoreQueryFilters()** nel codice di produzione. Mai.

Il principio di progettazione è semplice: DeepL vede gli ID delle risorse. Rasepi vede le entità con scala di inquilino. La mappatura tra loro non attraversa mai i confini degli inquilini, perché la query che risolve la mappatura è fisicamente incapace di restituire i dati di un altro inquilino.

Se sta costruendo un SaaS multi-tenant che si integra con API di terze parti senza supporto nativo del tenant, questo modello funziona bene. Tratta l'API esterna come un motore di esecuzione stateless, conserva tutta la configurazione nel suo database con scala tenant, sincronizza in modo pigro e non si affida mai agli elenchi di risorse esterne per l'isolamento.

Mantieni la tua documentazione aggiornata. Automaticamente.

Rasepi impone date di revisione, monitora la qualità dei contenuti e pubblica in oltre 40 lingue.

Inizia gratis →