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>
149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
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)");
|
||
}
|