doxa-maps-cdn/server/utils/translate.ts
corsacca 7dfad0ee07 replace deepl with llm translation via openrouter
- translateText/translateTexts call OpenRouter chat-completions; glossary
  terms are prompt-injected from config/glossaries.ts, generated from the
  translation repo's TSVs by scripts/build-glossaries.ts
- model resolution: per-language override in config/languages.ts →
  translation.model site setting (new admin field) → TRANSLATION_MODEL env
  → anthropic/claude-opus-5, at low reasoning effort
- blank fragments are held back from the model, which drops them and breaks
  the one-translation-per-fragment count the caller maps results back on
- OpenRouter failures map to a typed error with a human-readable message;
  an unrecoverable one (no credits, bad key, rejected model) stops the run
  instead of repeating the same call for every remaining locale
- machine translations record source 'auto-translate'; 'deepl' stays
  readable for snapshots written before the switch
2026-09-07 16:30:48 +01:00

108 lines
3.4 KiB
TypeScript

// Translation utility — translates plain text, batched text, and Tiptap JSON
// documents with an LLM via OpenRouter. Slim counterpart of
// campaigns-sever/server/utils/translate.ts, without the Bible / verse-node
// handling since marketing-rebuild has no verse content.
import { openrouterTranslateTexts, isOpenRouterConfigured } from './openrouter'
export interface TiptapNode {
type: string
content?: TiptapNode[]
text?: string
marks?: any[]
attrs?: Record<string, any>
}
export function isTranslationConfigured(): boolean {
return isOpenRouterConfigured()
}
export async function translateText(
text: string,
targetLanguage: string,
sourceLanguage?: string
): Promise<string> {
const translated = await translateTexts([text], targetLanguage, sourceLanguage)
return translated[0] ?? ''
}
export async function translateTexts(
texts: string[],
targetLanguage: string,
sourceLanguage?: string
): Promise<string[]> {
if (texts.length === 0) return []
// Blank fragments never reach the model: it has nothing to translate and
// tends to drop them, which would break the one-translation-per-fragment
// count the caller relies on to map results back.
const indexes: number[] = []
const translatable: string[] = []
texts.forEach((text, i) => {
if (text.trim().length > 0) {
indexes.push(i)
translatable.push(text)
}
})
if (translatable.length === 0) return [...texts]
const translated = await openrouterTranslateTexts(translatable, targetLanguage, sourceLanguage)
const out = [...texts]
indexes.forEach((target, i) => { out[target] = translated[i]! })
return out
}
// Extract plain text nodes from a Tiptap JSON tree so we can send them
// to the translator as a batch, then write the translations back at the
// same positions.
export function extractTexts(
node: TiptapNode,
path: number[] = []
): Array<{ path: number[]; text: string }> {
const results: Array<{ path: number[]; text: string }> = []
if (node.type === 'text' && node.text) {
results.push({ path: [...path], text: node.text })
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach((child, index) => {
results.push(...extractTexts(child, [...path, index]))
})
}
return results
}
export function setTextAtPath(node: TiptapNode, path: number[], text: string): void {
if (path.length === 0) {
node.text = text
return
}
const [index, ...rest] = path as [number, ...number[]]
if (node.content && node.content[index]) {
setTextAtPath(node.content[index], rest, text)
}
}
export async function translateTiptapContent(
contentJson: TiptapNode,
targetLanguage: string,
sourceLanguage?: string
): Promise<TiptapNode> {
const cloned: TiptapNode = JSON.parse(JSON.stringify(contentJson))
const entries = extractTexts(cloned)
if (entries.length === 0) return cloned
// Chunk size balances request count against the fragment-alignment
// contract, which gets harder for the model to honor on long batches
const CHUNK_SIZE = 40
const allTranslated: string[] = []
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
if (i > 0) await new Promise(r => setTimeout(r, 200))
const chunk = entries.slice(i, i + CHUNK_SIZE)
const translated = await translateTexts(chunk.map(e => e.text), targetLanguage, sourceLanguage)
allTranslated.push(...translated)
}
entries.forEach((entry, i) => setTextAtPath(cloned, entry.path, allTranslated[i]!))
return cloned
}