Files
karczma-aplikacja-stoliki/scripts/refresh_menu_cache.php
T
2026-09-25 18:16:53 +02:00

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);