> /var/log/menu_refresh.log 2>&1 * * Klucz OpenAI: skopiuj config/menu_ai.local.php.example → config/menu_ai.local.php */ require_once __DIR__ . '/../api/menu_helpers.php'; $forceAi = in_array('--force-ai', $argv ?? [], true); $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; } function targetLanguageName(string $code): string { return match ($code) { 'es' => 'Spanish', 'it' => 'Italian', 'ko' => 'Korean', 'uk' => 'Ukrainian', 'fr' => 'French', default => $code, }; } /** * @return 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 = << $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 */ 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"); continue; } $mapped = mapUpstreamMenu($raw, $code, 'upstream'); writeMenuCache(menuCachePath($code), $mapped); logLine("Upstream {$code}: OK (" . count($mapped['sections'] ?? []) . ' sections)'); } $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); file_put_contents($fpFile, $fp, LOCK_EX); 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 (copy from .example)'); exit(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()); } } logLine('Done.');