/opt/canhelp/apps/api/src
Edit: /opt/canhelp/apps/api/src/translate.ts (22030B)
import Anthropic from '@anthropic-ai/sdk'
import OpenAI from 'openai'
import { db } from './db.js'
import { aiLogs } from '@canhelp/db'
const LANG_NAMES: Record
= {
el: 'Greek',
en: 'English',
ru: 'Russian',
uk: 'Ukrainian',
}
const PROMPT = (from: string, to: string, title: string, description: string) =>
`Translate the following task listing from ${LANG_NAMES[from]} to ${LANG_NAMES[to]}.
Return ONLY a JSON object with keys "title" and "description". No extra text.
Title: ${title}
Description: ${description}`
function parseJson(text: string): { title: string; description: string } | null {
const match = text.match(/\{[\s\S]*\}/)
if (!match) return null
try {
return JSON.parse(match[0])
} catch {
return null
}
}
// Pricing per 1M tokens (as of 2025)
const PRICING: Record = {
'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00 },
'claude-haiku-4-5': { input: 0.80, output: 4.00 },
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
'gpt-4o-mini': { input: 0.15, output: 0.60 },
'gpt-4o': { input: 2.50, output: 10.00 },
}
function calcCost(model: string, inputTokens?: number, outputTokens?: number): number | undefined {
const p = PRICING[model]
if (!p || (!inputTokens && !outputTokens)) return undefined
return ((inputTokens ?? 0) * p.input + (outputTokens ?? 0) * p.output) / 1_000_000
}
async function writeLog(entry: {
provider: string
model: string
action: string
fromLocale?: string
toLocale?: string
inputTokens?: number
outputTokens?: number
totalTokens?: number
durationMs?: number
costUsd?: number
success: boolean
error?: string
}) {
await db.insert(aiLogs).values(entry).catch((e) => {
console.error('[translate] log write failed:', e)
})
}
// ─── Anthropic ────────────────────────────────────────────────────────────
const ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001'
const anthropic = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null
async function translateViaAnthropic(
title: string,
description: string,
from: string,
to: string,
): Promise<{ title: string; description: string } | null> {
if (!anthropic) return null
const start = Date.now()
try {
const msg = await anthropic.messages.create({
model: ANTHROPIC_MODEL,
max_tokens: 1024,
messages: [{ role: 'user', content: PROMPT(from, to, title, description) }],
})
const durationMs = Date.now() - start
const result = parseJson((msg.content[0] as Anthropic.TextBlock).text)
await writeLog({
provider: 'anthropic',
model: ANTHROPIC_MODEL,
action: 'translate',
fromLocale: from,
toLocale: to,
inputTokens: msg.usage.input_tokens,
outputTokens: msg.usage.output_tokens,
totalTokens: msg.usage.input_tokens + msg.usage.output_tokens,
durationMs,
costUsd: calcCost(ANTHROPIC_MODEL, msg.usage.input_tokens, msg.usage.output_tokens),
success: result !== null,
error: result === null ? 'Failed to parse JSON response' : undefined,
})
return result
} catch (err: any) {
await writeLog({
provider: 'anthropic',
model: ANTHROPIC_MODEL,
action: 'translate',
fromLocale: from,
toLocale: to,
durationMs: Date.now() - start,
success: false,
error: err?.message ?? String(err),
})
return null
}
}
// ─── OpenAI ───────────────────────────────────────────────────────────────
const OPENAI_MODEL = 'gpt-4o-mini'
const openai = process.env.OPENAI_API_KEY
? new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
: null
async function translateViaOpenAI(
title: string,
description: string,
from: string,
to: string,
): Promise<{ title: string; description: string } | null> {
if (!openai) return null
const start = Date.now()
try {
const msg = await openai.chat.completions.create({
model: OPENAI_MODEL,
messages: [{ role: 'user', content: PROMPT(from, to, title, description) }],
max_tokens: 1024,
})
const durationMs = Date.now() - start
const text = msg.choices[0]?.message?.content ?? ''
const result = parseJson(text)
await writeLog({
provider: 'openai',
model: OPENAI_MODEL,
action: 'translate',
fromLocale: from,
toLocale: to,
inputTokens: msg.usage?.prompt_tokens,
outputTokens: msg.usage?.completion_tokens,
totalTokens: msg.usage?.total_tokens,
durationMs,
costUsd: calcCost(OPENAI_MODEL, msg.usage?.prompt_tokens, msg.usage?.completion_tokens),
success: result !== null,
error: result === null ? 'Failed to parse JSON response' : undefined,
})
return result
} catch (err: any) {
await writeLog({
provider: 'openai',
model: OPENAI_MODEL,
action: 'translate',
fromLocale: from,
toLocale: to,
durationMs: Date.now() - start,
success: false,
error: err?.message ?? String(err),
})
return null
}
}
// ─── Public API ───────────────────────────────────────────────────────────
export async function translateTask(
title: string,
description: string,
fromLocale: string,
toLocale: string,
): Promise<{ title: string; description: string } | null> {
if (fromLocale === toLocale) return { title, description }
const provider = process.env.TRANSLATE_PROVIDER ?? 'anthropic'
if (provider === 'openai') {
return (await translateViaOpenAI(title, description, fromLocale, toLocale))
?? (await translateViaAnthropic(title, description, fromLocale, toLocale))
}
return (await translateViaAnthropic(title, description, fromLocale, toLocale))
?? (await translateViaOpenAI(title, description, fromLocale, toLocale))
}
export async function translateTaskAllLocales(
title: string,
description: string,
fromLocale: string,
): Promise> {
const targets = ['el', 'en', 'ru', 'uk'].filter((l) => l !== fromLocale)
const results: Record = {
[fromLocale]: { title, description },
}
await Promise.all(
targets.map(async (loc) => {
const t = await translateTask(title, description, fromLocale, loc)
if (t) results[loc] = t
}),
)
return results
}
const DESCRIPTION_PROMPT = (categories: string[], locale: string) =>
`Write a natural, professional service-card description in ${LANG_NAMES[locale] ?? locale} for a marketplace specialist.
Categories: ${categories.join(', ')}.
Write in the first person, using the natural first-person form for the requested language. Use 2 to 4 short readable paragraphs separated by blank lines. Write 50 to 150 words for each category where the total allows it, never exceed 500 words overall, and cover every category. Do not use a heading, markdown, contact details, prices, or claims that cannot be verified.`
function formatDescriptionParagraphs(description: string): string {
const normalized = description.replace(/\r\n/g, '\n').trim()
if (normalized.includes('\n\n')) return normalized
const sentences = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? []
if (sentences.length < 3) return normalized
const chunkSize = Math.ceil(sentences.length / Math.min(3, sentences.length))
const paragraphs = Array.from(
{ length: Math.ceil(sentences.length / chunkSize) },
(_, index) =>
sentences
.slice(index * chunkSize, (index + 1) * chunkSize)
.join(' ')
.trim(),
)
return paragraphs.join('\n\n')
}
export async function generateSpecialistDescription(
categories: string[],
locale: string,
): Promise {
const prompt = DESCRIPTION_PROMPT(categories, locale)
const provider = process.env.TRANSLATE_PROVIDER ?? 'anthropic'
const generateWithAnthropic = async (): Promise => {
if (!anthropic) return null
const start = Date.now()
try {
const message = await anthropic.messages.create({
model: ANTHROPIC_MODEL,
max_tokens: 800,
messages: [{ role: 'user', content: prompt }],
})
const description = formatDescriptionParagraphs((message.content[0] as Anthropic.TextBlock).text)
await writeLog({
provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'specialist_description',
inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens,
totalTokens: message.usage.input_tokens + message.usage.output_tokens,
durationMs: Date.now() - start,
costUsd: calcCost(ANTHROPIC_MODEL, message.usage.input_tokens, message.usage.output_tokens),
success: !!description,
})
return description || null
} catch (error: any) {
await writeLog({ provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'specialist_description', durationMs: Date.now() - start, success: false, error: error?.message ?? String(error) })
return null
}
}
const generateWithOpenAI = async (): Promise => {
if (!openai) return null
const start = Date.now()
try {
const completion = await openai.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: 800,
messages: [{ role: 'user', content: prompt }],
})
const description = formatDescriptionParagraphs(completion.choices[0]?.message?.content ?? '')
await writeLog({
provider: 'openai', model: OPENAI_MODEL, action: 'specialist_description',
inputTokens: completion.usage?.prompt_tokens, outputTokens: completion.usage?.completion_tokens,
totalTokens: completion.usage?.total_tokens, durationMs: Date.now() - start,
costUsd: calcCost(OPENAI_MODEL, completion.usage?.prompt_tokens, completion.usage?.completion_tokens),
success: !!description,
})
return description || null
} catch (error: any) {
await writeLog({ provider: 'openai', model: OPENAI_MODEL, action: 'specialist_description', durationMs: Date.now() - start, success: false, error: error?.message ?? String(error) })
return null
}
}
if (provider === 'openai') {
return (await generateWithOpenAI()) ?? (await generateWithAnthropic())
}
return (await generateWithAnthropic()) ?? (await generateWithOpenAI())
}
// ─── Single-string translation ────────────────────────────────────────────────
const STR_PROMPT = (from: string, to: string, text: string) =>
`Translate the following short text from ${LANG_NAMES[from] ?? from} to ${LANG_NAMES[to] ?? to}.
Return ONLY the translated text, no quotes, no explanations.
${text}`
async function translateStringViaAnthropic(text: string, from: string, to: string): Promise {
if (!anthropic) return null
const start = Date.now()
try {
const msg = await anthropic.messages.create({
model: ANTHROPIC_MODEL,
max_tokens: 256,
messages: [{ role: 'user', content: STR_PROMPT(from, to, text) }],
})
const result = (msg.content[0] as Anthropic.TextBlock).text.trim()
await writeLog({
provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'translate_string',
fromLocale: from, toLocale: to,
inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens,
totalTokens: msg.usage.input_tokens + msg.usage.output_tokens,
durationMs: Date.now() - start,
costUsd: calcCost(ANTHROPIC_MODEL, msg.usage.input_tokens, msg.usage.output_tokens),
success: true,
})
return result || null
} catch (err: any) {
await writeLog({ provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'translate_string', fromLocale: from, toLocale: to, durationMs: Date.now() - start, success: false, error: err?.message ?? String(err) })
return null
}
}
async function translateStringViaOpenAI(text: string, from: string, to: string): Promise {
if (!openai) return null
const start = Date.now()
try {
const chat = await openai.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: 256,
messages: [{ role: 'user', content: STR_PROMPT(from, to, text) }],
})
const result = chat.choices[0]?.message?.content?.trim()
await writeLog({
provider: 'openai', model: OPENAI_MODEL, action: 'translate_string',
fromLocale: from, toLocale: to,
inputTokens: chat.usage?.prompt_tokens, outputTokens: chat.usage?.completion_tokens,
totalTokens: chat.usage?.total_tokens,
durationMs: Date.now() - start,
costUsd: calcCost(OPENAI_MODEL, chat.usage?.prompt_tokens, chat.usage?.completion_tokens),
success: !!result,
})
return result || null
} catch (err: any) {
await writeLog({ provider: 'openai', model: OPENAI_MODEL, action: 'translate_string', fromLocale: from, toLocale: to, durationMs: Date.now() - start, success: false, error: err?.message ?? String(err) })
return null
}
}
export async function translateString(text: string, fromLocale: string, toLocale: string): Promise {
if (fromLocale === toLocale) return text
const provider = process.env.TRANSLATE_PROVIDER ?? 'anthropic'
if (provider === 'openai') {
return (await translateStringViaOpenAI(text, fromLocale, toLocale))
?? (await translateStringViaAnthropic(text, fromLocale, toLocale))
}
return (await translateStringViaAnthropic(text, fromLocale, toLocale))
?? (await translateStringViaOpenAI(text, fromLocale, toLocale))
}
// ─── Batch string translation ─────────────────────────────────────────────────
const BATCH_PROMPT = (from: string, to: string, texts: string[]) =>
`Translate the following ${texts.length} texts from ${LANG_NAMES[from] ?? from} to ${LANG_NAMES[to] ?? to}.
Return ONLY a valid JSON array with exactly ${texts.length} translated strings in the same order. No markdown, no extra text.
${JSON.stringify(texts)}`
function parseBatchResult(text: string, count: number): (string | null)[] {
const match = text.match(/\[[\s\S]*\]/)
if (!match) return Array(count).fill(null)
try {
const arr = JSON.parse(match[0])
if (!Array.isArray(arr)) return Array(count).fill(null)
return arr.map((v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null))
} catch {
return Array(count).fill(null)
}
}
async function translateBatchViaAnthropic(texts: string[], from: string, to: string): Promise<(string | null)[]> {
if (!anthropic) return Array(texts.length).fill(null)
const start = Date.now()
try {
const msg = await anthropic.messages.create({
model: ANTHROPIC_MODEL,
max_tokens: 8192,
messages: [{ role: 'user', content: BATCH_PROMPT(from, to, texts) }],
})
const result = parseBatchResult((msg.content[0] as Anthropic.TextBlock).text, texts.length)
await writeLog({
provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'translate_batch',
fromLocale: from, toLocale: to,
inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens,
totalTokens: msg.usage.input_tokens + msg.usage.output_tokens,
durationMs: Date.now() - start,
costUsd: calcCost(ANTHROPIC_MODEL, msg.usage.input_tokens, msg.usage.output_tokens),
success: true,
})
return result
} catch (err: any) {
await writeLog({ provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'translate_batch', fromLocale: from, toLocale: to, durationMs: Date.now() - start, success: false, error: err?.message ?? String(err) })
return Array(texts.length).fill(null)
}
}
async function translateBatchViaOpenAI(texts: string[], from: string, to: string): Promise<(string | null)[]> {
if (!openai) return Array(texts.length).fill(null)
const start = Date.now()
try {
const chat = await openai.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: 8192,
messages: [{ role: 'user', content: BATCH_PROMPT(from, to, texts) }],
})
const text = chat.choices[0]?.message?.content ?? ''
const result = parseBatchResult(text, texts.length)
await writeLog({
provider: 'openai', model: OPENAI_MODEL, action: 'translate_batch',
fromLocale: from, toLocale: to,
inputTokens: chat.usage?.prompt_tokens, outputTokens: chat.usage?.completion_tokens,
totalTokens: chat.usage?.total_tokens,
durationMs: Date.now() - start,
costUsd: calcCost(OPENAI_MODEL, chat.usage?.prompt_tokens, chat.usage?.completion_tokens),
success: !!text,
})
return result
} catch (err: any) {
await writeLog({ provider: 'openai', model: OPENAI_MODEL, action: 'translate_batch', fromLocale: from, toLocale: to, durationMs: Date.now() - start, success: false, error: err?.message ?? String(err) })
return Array(texts.length).fill(null)
}
}
export async function translateStringsBatch(texts: string[], fromLocale: string, toLocale: string): Promise<(string | null)[]> {
if (texts.length === 0) return []
if (fromLocale === toLocale) return texts
// Split into chunks so each AI call stays within token limits
const CHUNK = 50
const results: (string | null)[] = new Array(texts.length).fill(null)
const callBatch = async (chunk: string[]): Promise<(string | null)[]> => {
const provider = process.env.TRANSLATE_PROVIDER ?? 'anthropic'
if (provider === 'openai') {
const r = await translateBatchViaOpenAI(chunk, fromLocale, toLocale)
if (r.some((x) => x !== null)) return r
return translateBatchViaAnthropic(chunk, fromLocale, toLocale)
}
const r = await translateBatchViaAnthropic(chunk, fromLocale, toLocale)
if (r.some((x) => x !== null)) return r
return translateBatchViaOpenAI(chunk, fromLocale, toLocale)
}
for (let i = 0; i < texts.length; i += CHUNK) {
const chunk = texts.slice(i, i + CHUNK)
const chunkResults = await callBatch(chunk)
for (let j = 0; j < chunkResults.length; j++) {
results[i + j] = chunkResults[j]
}
}
return results
}
// ─── Message translation ─────────────────────────────────────────────────────
const MSG_PROMPT = (to: string, text: string) =>
`Translate the following chat message to ${LANG_NAMES[to]}. Return ONLY the translated text, no explanations, no extra text.\n\n${text}`
async function translateMessageViaAnthropic(text: string, to: string): Promise {
if (!anthropic) return null
const start = Date.now()
try {
const msg = await anthropic.messages.create({
model: ANTHROPIC_MODEL,
max_tokens: 512,
messages: [{ role: 'user', content: MSG_PROMPT(to, text) }],
})
const result = (msg.content[0] as Anthropic.TextBlock).text.trim()
await writeLog({
provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'translate_message',
toLocale: to, inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens,
totalTokens: msg.usage.input_tokens + msg.usage.output_tokens,
durationMs: Date.now() - start,
costUsd: calcCost(ANTHROPIC_MODEL, msg.usage.input_tokens, msg.usage.output_tokens),
success: true,
})
return result
} catch (err: any) {
await writeLog({ provider: 'anthropic', model: ANTHROPIC_MODEL, action: 'translate_message', toLocale: to, durationMs: Date.now() - start, success: false, error: err?.message })
return null
}
}
async function translateMessageViaOpenAI(text: string, to: string): Promise {
if (!openai) return null
const start = Date.now()
try {
const msg = await openai.chat.completions.create({
model: OPENAI_MODEL,
messages: [{ role: 'user', content: MSG_PROMPT(to, text) }],
max_tokens: 512,
})
const result = msg.choices[0]?.message?.content?.trim() ?? ''
await writeLog({
provider: 'openai', model: OPENAI_MODEL, action: 'translate_message',
toLocale: to, inputTokens: msg.usage?.prompt_tokens, outputTokens: msg.usage?.completion_tokens,
totalTokens: msg.usage?.total_tokens, durationMs: Date.now() - start,
costUsd: calcCost(OPENAI_MODEL, msg.usage?.prompt_tokens, msg.usage?.completion_tokens),
success: true,
})
return result
} catch (err: any) {
await writeLog({ provider: 'openai', model: OPENAI_MODEL, action: 'translate_message', toLocale: to, durationMs: Date.now() - start, success: false, error: err?.message })
return null
}
}
/** Translates a chat message to all 4 locales, returns map { el, en, ru, uk } */
export async function translateMessageAllLocales(
text: string,
fromLocale: string,
): Promise> {
const targets = ['el', 'en', 'ru', 'uk'].filter((l) => l !== fromLocale)
const result: Record = { [fromLocale]: text }
const provider = process.env.TRANSLATE_PROVIDER ?? 'anthropic'
await Promise.all(
targets.map(async (to) => {
let translated: string | null = null
if (provider === 'openai') {
translated = await translateMessageViaOpenAI(text, to) ?? await translateMessageViaAnthropic(text, to)
} else {
translated = await translateMessageViaAnthropic(text, to) ?? await translateMessageViaOpenAI(text, to)
}
if (translated) result[to] = translated
}),
)
return result
}