doxa-maps-cdn/server/mcp/tools/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

117 lines
5.5 KiB
TypeScript

// LLM text-translation tool. Generic primitive — Claude composes
// with upsert_page_translation when applying translations to pages.
//
// The vendored glossary for each target locale is always injected into
// the translation prompt, keeping MCP-driven translations terminologically
// consistent with admin auto-translates.
import { defineMcpTool, mcpLog } from '#mcp-layer'
import { translateTextInput, translatePageInput } from '../schemas'
import { translateText, translatePage } from '../../services/cmsTranslate'
const DESCRIPTION = `Translate a piece of text into one or more locales.
Use this when you've authored a section in one language (typically English) and need versions in the site's other locales — for example, "translate this paragraph then add it to each page in every locale."
Inputs:
- \`text\`: the source text. Plain text or markdown — any markup is preserved as-is in the output.
- \`source_locale\`: defaults to "en"; must be one of the site's enabled locales.
- \`target_locales\`: array of locale codes to translate into; each must be enabled and different from the source.
Output: \`translations: [{locale, text, glossary_used}]\`. \`glossary_used\` is true when a per-locale terminology glossary was applied (consistent with admin auto-translate behavior). After this returns, embed each translated text into the corresponding locale's page via upsert_page_translation.
Limits: 100 KB per call. For longer content, split into sections and translate each separately.`
export const translateTextTool = defineMcpTool({
name: 'translate_text',
description: DESCRIPTION,
scope: 'pages.write',
input: translateTextInput,
async handler(input, ctx) {
const result = await translateText({
text: input.text,
source_locale: input.source_locale,
target_locales: input.target_locales
})
// Audit the call so the activity log records when translation spend
// happened on Claude's behalf. Don't include the full text in
// metadata — keep the audit row small and non-PII-leaky.
await mcpLog('CREATE', 'translations.cache', `${result.source_locale}->${result.translations.map(t => t.locale).join(',')}`, ctx, {
source_locale: result.source_locale,
target_locales: result.translations.map(t => t.locale),
char_count: input.text.length
})
return {
content: [{
type: 'text',
text: `Translated ${input.text.length} chars from ${result.source_locale} → ${result.translations.length} locale(s).`
}],
structuredContent: {
source_locale: result.source_locale,
translations: result.translations
}
}
}
})
// ── Page-level translate ────────────────────────────────────────────
const PAGE_DESCRIPTION = `Translate an entire CMS page (title, excerpt, meta_title, meta_description, body) from one locale into one or more target locales.
Use this for the "make this page exist in all locales" workflow. For surgical edits — translating one new section and inserting it into existing translations — prefer translate_text composed with upsert_page_translation.
Inputs:
- \`page_id\`: the CMS page id.
- \`source_locale\`: defaults to "en". Must be an enabled locale with an existing translation row.
- \`target_locales\`: array of enabled locale codes. Per-locale failures don't abort the batch — the model flaking on one locale lets the rest proceed. A failure no retry can fix (no credits, bad key, rejected model) does stop the run, reporting the same reason for the untried locales.
- \`overwrite\`: defaults to false. When false, locales that already have a translation row are silently skipped (no model call). Set true to replace.
- \`status\`: \`draft\` (default) or \`published\` for the new translation rows.
Output: \`{source_locale, results: [{locale, ok? | skipped? | error?}]}\`. Each result entry reports one of three outcomes; \`ok\` means a row was written and the public cache was purged for that locale.
Cost note: this triggers one model call for the metadata plus one per 40 body fragments, per target locale. A "translate to all 9 locales" run on a typical page is ~20-30 model requests.`
export const translatePageTool = defineMcpTool({
name: 'translate_page',
description: PAGE_DESCRIPTION,
scope: 'pages.write',
input: translatePageInput,
async handler(input, ctx) {
const result = await translatePage({
page_id: input.page_id,
source_locale: input.source_locale,
target_locales: input.target_locales,
overwrite: input.overwrite,
status: input.status,
actor_user_id: ctx.auth.userId ?? null
})
await mcpLog('UPDATE', 'pages', input.page_id, ctx, {
event: 'TRANSLATE',
source_locale: result.source_locale,
target_locales: input.target_locales,
results: result.results.map(r => ({
locale: r.locale,
outcome: r.ok ? 'ok' : r.skipped ? 'skipped' : 'error'
}))
})
const okCount = result.results.filter(r => r.ok).length
const skipCount = result.results.filter(r => r.skipped).length
const errCount = result.results.filter(r => r.error).length
return {
content: [{
type: 'text',
text: `Translated page from ${result.source_locale}: ${okCount} written, ${skipCount} skipped, ${errCount} errored.`
}],
structuredContent: {
source_locale: result.source_locale,
results: result.results
}
}
}
})
export const TRANSLATE_TOOLS = [translateTextTool, translatePageTool] as const