396 lines
12 KiB
PHP
396 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Odświeża cache menu ręcznie (tylko CLI / terminal).
|
|
*
|
|
* 1) Upstream PL/EN/DE → api/cache/menu_{lang}.json
|
|
* 2) Języki source=ai (ES/IT/KO): tłumaczenie OpenAI ChatGPT z PL
|
|
* (z --force-ai zawsze; bez flagi tylko gdy zmienił się fingerprint PL)
|
|
*
|
|
* Użycie:
|
|
* php scripts/refresh_menu_cache.php
|
|
* php scripts/refresh_menu_cache.php --force-ai
|
|
*
|
|
* Klucz OpenAI: config/menu_ai.local.php
|
|
* Instrukcja: README.md → „Odświeżanie cache menu”
|
|
*/
|
|
|
|
require_once __DIR__ . '/../api/menu_helpers.php';
|
|
|
|
$forceAi = in_array('--force-ai', $argv ?? [], true);
|
|
$hadError = false;
|
|
|
|
$config = require __DIR__ . '/../config/menu.php';
|
|
$languages = is_array($config['languages'] ?? null) ? $config['languages'] : [];
|
|
$timeout = max(3, (int) ($config['timeout_seconds'] ?? 12));
|
|
$base = rtrim((string) ($config['upstream_base'] ?? ''), '/');
|
|
|
|
function logLine(string $msg): void
|
|
{
|
|
echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
|
|
}
|
|
|
|
try {
|
|
assertMenuCacheWritable();
|
|
} catch (Throwable $e) {
|
|
logLine('ERROR: ' . $e->getMessage());
|
|
exit(1);
|
|
}
|
|
|
|
function targetLanguageName(string $code): string
|
|
{
|
|
return match ($code) {
|
|
'es' => 'Spanish',
|
|
'it' => 'Italian',
|
|
'ko' => 'Korean',
|
|
'uk' => 'Ukrainian',
|
|
'fr' => 'French',
|
|
default => $code,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array>|null
|
|
*/
|
|
function openaiTranslateBatch(array $aiCfg, string $targetLang, array $payloadItems): ?array
|
|
{
|
|
$apiKey = trim((string) ($aiCfg['api_key'] ?? ''));
|
|
$baseUrl = rtrim((string) ($aiCfg['base_url'] ?? 'https://api.openai.com/v1'), '/');
|
|
$model = (string) ($aiCfg['model'] ?? 'gpt-4o');
|
|
$timeout = max(30, (int) ($aiCfg['timeout_seconds'] ?? 90));
|
|
|
|
if ($apiKey === '' || str_contains($apiKey, 'REPLACE')) {
|
|
return null;
|
|
}
|
|
|
|
$langName = targetLanguageName($targetLang);
|
|
$system = <<<PROMPT
|
|
You translate a Polish restaurant menu (Karczma Biesiada — traditional Polish inn cuisine) into {$langName}.
|
|
Return ONLY a JSON object whose keys are the exact input "key" strings.
|
|
Examples:
|
|
{"cat:1":{"text":"..."},"sec:0":{"text":"..."},"item:3:386":{"title":"...","description":"..."}}
|
|
For category/section units use {"text":"..."}.
|
|
For item units use {"title":"...","description":"..."}.
|
|
Translate fully into {$langName}. Empty description stays "". Do not invent prices. Do not wrap in an array.
|
|
PROMPT;
|
|
|
|
$body = [
|
|
'model' => $model,
|
|
'temperature' => 0.2,
|
|
'response_format' => ['type' => 'json_object'],
|
|
'messages' => [
|
|
['role' => 'system', 'content' => $system],
|
|
[
|
|
'role' => 'user',
|
|
'content' => json_encode(['items' => $payloadItems], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
],
|
|
],
|
|
];
|
|
|
|
$ch = curl_init($baseUrl . '/chat/completions');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $apiKey,
|
|
],
|
|
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$errno = curl_errno($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($errno !== 0 || $raw === false || $httpCode < 200 || $httpCode >= 300) {
|
|
logLine("OpenAI HTTP {$httpCode} errno={$errno}");
|
|
if (is_string($raw) && $raw !== '') {
|
|
logLine('OpenAI body: ' . substr($raw, 0, 240));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($raw, true);
|
|
$content = $decoded['choices'][0]['message']['content'] ?? '';
|
|
if (!is_string($content) || $content === '') {
|
|
logLine('OpenAI empty content');
|
|
return null;
|
|
}
|
|
|
|
$parsed = json_decode($content, true);
|
|
if (!is_array($parsed)) {
|
|
logLine('OpenAI response is not JSON object: ' . substr($content, 0, 200));
|
|
return null;
|
|
}
|
|
|
|
$out = normalizeOpenAiTranslationMap($parsed);
|
|
if ($out === []) {
|
|
logLine('OpenAI parsed 0 translation keys. Sample: ' . substr($content, 0, 280));
|
|
return null;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Akceptuje mapę klucz→obiekt, {items|translations: mapa|lista}, albo listę {key,...}.
|
|
*
|
|
* @return array<string, array>
|
|
*/
|
|
function normalizeOpenAiTranslationMap(array $parsed): array
|
|
{
|
|
foreach (['items', 'translations', 'results', 'data'] as $wrap) {
|
|
if (isset($parsed[$wrap]) && is_array($parsed[$wrap])) {
|
|
$parsed = $parsed[$wrap];
|
|
break;
|
|
}
|
|
}
|
|
|
|
$out = [];
|
|
|
|
// Lista obiektów z polem key
|
|
$isList = array_is_list($parsed);
|
|
if ($isList) {
|
|
foreach ($parsed as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
$key = (string) ($row['key'] ?? '');
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
unset($row['key'], $row['type']);
|
|
$out[$key] = $row;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
foreach ($parsed as $key => $val) {
|
|
if (!is_array($val)) {
|
|
continue;
|
|
}
|
|
$keyStr = is_string($key) ? $key : (string) ($val['key'] ?? '');
|
|
if ($keyStr === '') {
|
|
continue;
|
|
}
|
|
unset($val['key'], $val['type']);
|
|
$out[$keyStr] = $val;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
function extractPrevItemHashes(array $aiMenu): array
|
|
{
|
|
$hashes = [];
|
|
if (!empty($aiMenu['itemHashes']) && is_array($aiMenu['itemHashes'])) {
|
|
return $aiMenu['itemHashes'];
|
|
}
|
|
|
|
return $hashes;
|
|
}
|
|
|
|
function buildAiMenuFromPl(array $plMenu, string $lang, array $aiCfg, bool $force, array $prevAi): array
|
|
{
|
|
$units = menuCollectTranslatableUnits($plMenu);
|
|
$prevHashes = extractPrevItemHashes($prevAi);
|
|
$prevTranslations = [];
|
|
|
|
// Odtwórz poprzednie tłumaczenia z cache AI (po kluczach).
|
|
if ($prevAi) {
|
|
foreach ($prevAi['categories'] ?? [] as $cat) {
|
|
if (!is_array($cat)) {
|
|
continue;
|
|
}
|
|
$id = (string) ($cat['id'] ?? '');
|
|
if ($id !== '') {
|
|
$prevTranslations['cat:' . $id] = ['text' => (string) ($cat['name'] ?? '')];
|
|
}
|
|
}
|
|
foreach ($prevAi['sections'] ?? [] as $sIdx => $section) {
|
|
if (!is_array($section)) {
|
|
continue;
|
|
}
|
|
$prevTranslations['sec:' . $sIdx] = ['text' => (string) ($section['categoryName'] ?? '')];
|
|
foreach ($section['items'] ?? [] as $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
$key = 'item:' . ($item['categoryId'] ?? '') . ':' . ($item['position'] ?? '');
|
|
$prevTranslations[$key] = [
|
|
'title' => (string) ($item['title'] ?? ''),
|
|
'description' => (string) ($item['description'] ?? ''),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$toSend = [];
|
|
$newHashes = [];
|
|
|
|
foreach ($units as $key => $unit) {
|
|
if (($unit['type'] ?? '') === 'item') {
|
|
$hash = (string) ($unit['hash'] ?? '');
|
|
$newHashes[$key] = $hash;
|
|
$unchanged = !$force && isset($prevHashes[$key]) && $prevHashes[$key] === $hash && isset($prevTranslations[$key]);
|
|
if ($unchanged) {
|
|
continue;
|
|
}
|
|
$toSend[] = [
|
|
'key' => $key,
|
|
'type' => 'item',
|
|
'title' => $unit['title'] ?? '',
|
|
'description' => $unit['description'] ?? '',
|
|
];
|
|
continue;
|
|
}
|
|
|
|
// Kategorie / sekcje — zawsze dołączamy gdy force lub brak poprzedniego tłumaczenia.
|
|
if (!$force && isset($prevTranslations[$key]['text']) && $prevTranslations[$key]['text'] !== '') {
|
|
continue;
|
|
}
|
|
$toSend[] = [
|
|
'key' => $key,
|
|
'type' => $unit['type'],
|
|
'text' => $unit['text'] ?? '',
|
|
];
|
|
}
|
|
|
|
$merged = [];
|
|
$batchSize = max(5, min(40, (int) ($aiCfg['batch_size'] ?? 25)));
|
|
$apiHits = 0;
|
|
|
|
// Przy --force nie bierz poprzedniego (często PL po nieudanym parsowaniu).
|
|
if (!$force) {
|
|
$merged = $prevTranslations;
|
|
}
|
|
|
|
if ($toSend === []) {
|
|
logLine("AI {$lang}: nothing to translate (reuse cache)");
|
|
} else {
|
|
logLine('AI ' . $lang . ': translating ' . count($toSend) . ' units via OpenAI…');
|
|
for ($i = 0; $i < count($toSend); $i += $batchSize) {
|
|
$chunk = array_slice($toSend, $i, $batchSize);
|
|
$result = openaiTranslateBatch($aiCfg, $lang, $chunk);
|
|
if ($result === null) {
|
|
logLine("AI {$lang}: batch failed — aborting language (keeping old file if any)");
|
|
if ($prevAi && !$force) {
|
|
return $prevAi;
|
|
}
|
|
// force + fail → better return PL-labelled failure than silent Polish as "ai"
|
|
throw new RuntimeException("OpenAI batch failed for {$lang}");
|
|
}
|
|
$apiHits += count($result);
|
|
foreach ($result as $k => $v) {
|
|
$merged[$k] = $v;
|
|
}
|
|
logLine('AI ' . $lang . ': batch ok, +' . count($result) . ' keys (total api keys ' . $apiHits . ')');
|
|
}
|
|
}
|
|
|
|
$fallbackCount = 0;
|
|
foreach ($units as $key => $unit) {
|
|
if (isset($merged[$key])) {
|
|
continue;
|
|
}
|
|
$fallbackCount++;
|
|
if (($unit['type'] ?? '') === 'item') {
|
|
$merged[$key] = [
|
|
'title' => $unit['title'] ?? '',
|
|
'description' => $unit['description'] ?? '',
|
|
];
|
|
} else {
|
|
$merged[$key] = ['text' => $unit['text'] ?? ''];
|
|
}
|
|
}
|
|
|
|
if ($apiHits === 0 && $toSend !== []) {
|
|
throw new RuntimeException("AI {$lang}: no keys applied from OpenAI");
|
|
}
|
|
|
|
logLine("AI {$lang}: applied api={$apiHits}, fallback_pl={$fallbackCount}");
|
|
|
|
$out = menuApplyTranslations($plMenu, $merged, $lang);
|
|
$out['itemHashes'] = $newHashes;
|
|
|
|
return $out;
|
|
}
|
|
|
|
// --- upstream ---
|
|
foreach ($languages as $code => $meta) {
|
|
if (($meta['source'] ?? '') !== 'upstream') {
|
|
continue;
|
|
}
|
|
$code = (string) $code;
|
|
$url = $base . '/' . rawurlencode($code);
|
|
logLine("Upstream fetch {$code}: {$url}");
|
|
$raw = fetchUpstreamMenu($url, $timeout);
|
|
if ($raw === null) {
|
|
logLine("Upstream {$code}: FAILED — keeping old cache if any");
|
|
$hadError = true;
|
|
continue;
|
|
}
|
|
try {
|
|
$mapped = mapUpstreamMenu($raw, $code, 'upstream');
|
|
writeMenuCache(menuCachePath($code), $mapped);
|
|
logLine("Upstream {$code}: OK (" . count($mapped['sections'] ?? []) . ' sections)');
|
|
} catch (Throwable $e) {
|
|
logLine("Upstream {$code}: ERROR " . $e->getMessage());
|
|
$hadError = true;
|
|
}
|
|
}
|
|
|
|
$plMenu = readMenuCache(menuCachePath('pl'));
|
|
if (!$plMenu) {
|
|
logLine('No PL cache — abort AI step');
|
|
exit(1);
|
|
}
|
|
|
|
$fp = (string) ($plMenu['contentFingerprint'] ?? menuContentFingerprint($plMenu));
|
|
$fpFile = menuFingerprintPath();
|
|
$prevFp = is_file($fpFile) ? trim((string) file_get_contents($fpFile)) : '';
|
|
$plChanged = $forceAi || $prevFp === '' || !hash_equals($prevFp, $fp);
|
|
|
|
if (@file_put_contents($fpFile, $fp, LOCK_EX) === false) {
|
|
logLine('ERROR: nie zapisano fingerprint: ' . $fpFile);
|
|
$hadError = true;
|
|
} else {
|
|
logLine('PL fingerprint: ' . substr($fp, 0, 12) . '… changed=' . ($plChanged ? 'yes' : 'no'));
|
|
}
|
|
|
|
$aiCfg = loadMenuAiConfig();
|
|
if ($aiCfg === null) {
|
|
logLine('No config/menu_ai.local.php — skip AI languages');
|
|
exit($hadError ? 1 : 0);
|
|
}
|
|
|
|
foreach ($languages as $code => $meta) {
|
|
if (($meta['source'] ?? '') !== 'ai') {
|
|
continue;
|
|
}
|
|
$code = (string) $code;
|
|
$cachePath = menuCachePath($code);
|
|
$prevAi = readMenuCache($cachePath) ?? [];
|
|
|
|
if (!$plChanged && $prevAi && !$forceAi) {
|
|
logLine("AI {$code}: skipped (PL unchanged)");
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$built = buildAiMenuFromPl($plMenu, $code, $aiCfg, $forceAi || !$prevAi, $prevAi);
|
|
writeMenuCache($cachePath, $built);
|
|
logLine("AI {$code}: wrote cache");
|
|
} catch (Throwable $e) {
|
|
logLine('AI ' . $code . ': ERROR ' . $e->getMessage());
|
|
$hadError = true;
|
|
}
|
|
}
|
|
|
|
logLine('Done.');
|
|
exit($hadError ? 1 : 0);
|