Files
menulio/apps/api/src/lib/translator.ts
T
AyrisAIandClaude Sonnet 5 00195ae7d7 feat(api): AI-powered multi-language menu translation
Adds enabled_languages on restaurants and translations JSONB on menu
categories/items. New /restaurants/:id/languages endpoints bulk-translate
the full menu via Gemini/GPT-4o-mini (reusing the ai-scanner dual-provider
pattern) when a language is enabled. New categories/items are auto-
translated in the background into any already-enabled languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:46:19 +03:00

149 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { env } from "../env.js";
export interface TranslatableItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatableCategory {
id: string;
name: string;
items: TranslatableItem[];
}
export interface TranslatedItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatedCategory {
id: string;
name: string;
items: TranslatedItem[];
}
const LANGUAGE_NAMES: Record<string, string> = {
en: "English",
de: "German",
ar: "Arabic",
ru: "Russian",
fr: "French",
};
function buildPrompt(targetLanguageName: string): string {
return `Sen profesyonel bir restoran menüsü çevirmenisin. Sana Türkçe bir restoran menüsü (kategori adları, ürün adları ve açıklamaları) JSON olarak verilecek.
Bunları ${targetLanguageName} diline çevir. Yemek isimlerini o mutfağın doğal terimleriyle çevir (örn. "Adana Kebap" -> "Adana Kebab", tamamen literal çeviri yapma, restoran menüsü diline uygun doğal bir üslup kullan).
Her kategori ve ürünün "id" alanını AYNEN koru, sadece "name" ve "description" alanlarını çevir. description null ise null bırak.
SADECE aşağıdaki JSON formatında geçerli bir JSON yanıtı ver, başka hiçbir metin ekleme:
{
"categories": [
{ "id": "...", "name": "çevrilmiş kategori adı", "items": [ { "id": "...", "name": "çevrilmiş ürün adı", "description": "çevrilmiş açıklama veya null" } ] }
]
}`;
}
function cleanAndParse(text: string): { categories: TranslatedCategory[] } {
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const parsed = JSON.parse(cleaned) as { categories: TranslatedCategory[] };
if (!parsed.categories || !Array.isArray(parsed.categories)) {
throw new Error("Invalid structure from translator: categories array missing");
}
return parsed;
}
async function translateWithGemini(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{
parts: [
{ text: buildPrompt(targetLanguageName) },
{ text: JSON.stringify({ categories }) },
],
},
],
generationConfig: { response_mime_type: "application/json", temperature: 0.2 },
}),
});
if (!response.ok) {
throw new Error(`Gemini translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!rawText) throw new Error("No text response from Gemini translate");
return cleanAndParse(rawText).categories;
}
async function translateWithOpenAI(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: "gpt-4o-mini",
response_format: { type: "json_object" },
messages: [
{ role: "system", content: buildPrompt(targetLanguageName) },
{ role: "user", content: JSON.stringify({ categories }) },
],
temperature: 0.2,
}),
});
if (!response.ok) {
throw new Error(`OpenAI translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("No message content from OpenAI translate");
return cleanAndParse(content).categories;
}
export function resolveLanguageName(code: string): string {
return LANGUAGE_NAMES[code] ?? code;
}
export async function translateMenu(
categories: TranslatableCategory[],
languageCode: string,
): Promise<TranslatedCategory[]> {
if (categories.length === 0) return [];
const targetLanguageName = resolveLanguageName(languageCode);
if (env.GEMINI_API_KEY) {
try {
return await translateWithGemini(categories, targetLanguageName, env.GEMINI_API_KEY);
} catch (err) {
console.warn("Gemini translate failed, falling back:", err);
}
}
if (env.OPENAI_API_KEY) {
return translateWithOpenAI(categories, targetLanguageName, env.OPENAI_API_KEY);
}
throw new Error("No translation provider configured (GEMINI_API_KEY / OPENAI_API_KEY missing)");
}