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>
This commit is contained in:
AyrisAI
2026-08-29 01:46:19 +03:00
co-authored by Claude Sonnet 5
parent 739de7de5c
commit 00195ae7d7
6 changed files with 378 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
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)");
}