/opt/canhelp/apps/api/src
NameSizeModeActions
lib/-0755rm
middleware/-0755rm
routes/-0755rm
auth.ts156000644editdlrm
db.ts1740644editdlrm
index.ts239420644editdlrm
redis.ts2490644editdlrm
seed.ts830180644editdlrm
socket.ts57540644editdlrm
sync-categories-from-prod.ts27920644editdlrm
translate.ts220300644editdlrm
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 }