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:
co-authored by
Claude Sonnet 5
parent
739de7de5c
commit
00195ae7d7
@@ -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)");
|
||||
}
|
||||
Reference in New Issue
Block a user