- 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
117 lines
5.5 KiB
TypeScript
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
|