0 ? ($timeLimit - $elapsed) : 60; if ($remaining < 15) { return ['segments' => [], 'map' => []]; } $settings = AI_Translate_Core::settings(); $provider = isset($settings['api_provider']) ? (string)$settings['api_provider'] : ''; $models = isset($settings['models']) && is_array($settings['models']) ? $settings['models'] : []; $model = $provider !== '' ? AI_Translate_Core::effective_model($provider, (string) ($models[$provider] ?? '')) : ''; $apiKeys = isset($settings['api_keys']) && is_array($settings['api_keys']) ? $settings['api_keys'] : []; $apiKey = $provider !== '' ? AI_Translate_Core::api_key_for($settings, $provider) : ''; if (empty($segments)) { return ['segments' => [], 'map' => []]; } if ($provider === '' || $model === '' || $apiKey === '') { return ['segments' => [], 'map' => []]; } $baseUrl = \AITranslate\AI_Translate_Core::api_base_url($provider, $settings); if ($baseUrl === '') { return ['segments' => [], 'map' => []]; } // Build prompt with minimal deterministic context $system = self::buildSystemPrompt($source, $target, $context); $endpointPath = AI_Translate_Core::get_model_endpoint_path($model); $endpoint = rtrim($baseUrl, '/') . $endpointPath; $isResponses = ($endpointPath === '/responses'); // Build primary-id set by de-duplicating equal texts per type; cache per string to reduce calls $targetLang = (string) $target; $expiry_hours = isset($settings['cache_expiration']) ? (int) $settings['cache_expiration'] : (14*24); $expiry = $expiry_hours * HOUR_IN_SECONDS; $identicalWhitelist = self::getBrandWhitelist(); $primaryByKey = []; $idsByPrimary = []; $primarySegById = []; $cachedPrimary = []; $workSegments = []; $cacheHits = 0; $cacheMisses = 0; // Look every segment up in the persistent store with one query. Translations that only // exist as old ai_tr_seg_* transients are read once and copied into the store. $storeHashes = []; foreach ($segments as $seg) { $lookupText = trim((string) ($seg['text'] ?? '')); if ($lookupText === '' || mb_strlen($lookupText) < 2) { continue; } $storeHashes[] = AI_Segment_Store::hash(isset($seg['type']) ? (string) $seg['type'] : 'node', $lookupText, $targetLang); } $storedTranslations = AI_Segment_Store::get_many($targetLang, $storeHashes, $expiry); $legacyAllowed = AI_Segment_Store::legacy_allowed($targetLang); $legacyHashes = []; $migrateToStore = []; foreach ($segments as $seg) { $type = isset($seg['type']) ? (string)$seg['type'] : 'node'; $text = (string) ($seg['text'] ?? ''); $trimmed = trim($text); if ($trimmed === '' || mb_strlen($trimmed) < 2) { continue; // skip ultra-short } $key = strtolower($type) . '|' . md5($trimmed); if (!isset($primaryByKey[$key])) { $primaryByKey[$key] = (string)$seg['id']; $idsByPrimary[$seg['id']] = [ (string)$seg['id'] ]; $primarySegById[$seg['id']] = [ 'id' => (string)$seg['id'], 'text' => $trimmed, 'type' => $type ]; // check cache and validate for non-Latin target languages $segHash = AI_Segment_Store::hash($type, $trimmed, $targetLang); $cached = $storedTranslations[$segHash] ?? false; if ($cached === false && $legacyAllowed) { $cached = get_transient(AI_Segment_Store::legacy_transient_key($targetLang, $segHash)); if ($cached !== false) { $legacyHashes[$segHash] = true; } } if ($cached !== false) { $cachedText = (string) $cached; $srcLen = mb_strlen($trimmed); $cachedLen = mb_strlen($cachedText); $cacheInvalid = false; $srcLower = mb_strtolower(trim($trimmed)); $cachedLower = mb_strtolower(trim($cachedText)); // Check if source and target languages are the same - if so, identical is always valid $sourceLangLower = $source !== null ? strtolower((string) $source) : ''; $targetLangLower = strtolower($targetLang); $isSameLanguage = $sourceLangLower !== '' && $sourceLangLower === $targetLangLower; // For ALL languages and ALL lengths: check if translation is exactly identical // Only invalidate when text is longer than 15 chars and not whitelisted (brand terms allowed) // Short UI texts (≤15 chars) like "Send Message", "Submit", "Cancel" can legitimately be identical // If source and target languages are the same, identical is always valid (e.g., EN→EN) // Also accept if text contains whitelisted brand names (case-insensitive) // Also accept if text looks like a person name (starts with "- " or contains "van " or "de ") $containsWhitelisted = false; foreach ($identicalWhitelist as $brand) { if (stripos($srcLower, $brand) !== false) { $containsWhitelisted = true; break; } } // Check if text looks like a person name (starts with "- " or contains "van " or "de ") $looksLikeName = (strpos($trimmed, '- ') === 0) || preg_match('/\b(van|de|der|den|het|een)\s+/i', $trimmed); if (!$isSameLanguage && $srcLen > 15 && $srcLower === $cachedLower && !in_array($srcLower, $identicalWhitelist, true) && !$containsWhitelisted && !$looksLikeName) { $cacheInvalid = true; } // For non-Latin target languages the result must contain non-ASCII characters // in the target script. If the response is pure Latin (transliteration), invalidate. if (!$cacheInvalid) { if (AI_Lang::is_non_latin($targetLang)) { $hasNonAscii = preg_match('/[^\x00-\x7F]/', $cachedText) === 1; if (!$hasNonAscii && mb_strlen(trim($cachedText)) > 0 && !$containsWhitelisted) { $cacheInvalid = true; } elseif ($cachedLen > 100) { $latinCount = preg_match_all('/[a-zA-Z]/', $cachedText); $latinRatio = $cachedLen > 0 ? ($latinCount / $cachedLen) : 0; if ($latinRatio > 0.4) { $cacheInvalid = true; } } } } if ($cacheInvalid) { $workSegments[] = [ 'id' => (string)$seg['id'], 'text' => $trimmed, 'type' => $type ]; } else { $cachedPrimary[$seg['id']] = $cachedText; if (isset($legacyHashes[$segHash])) { $migrateToStore[$segHash] = [ 'text' => $cachedText, 'type' => $type ]; } } } else { $workSegments[] = [ 'id' => (string)$seg['id'], 'text' => $trimmed, 'type' => $type ]; } } else { $primary = $primaryByKey[$key]; if (!isset($idsByPrimary[$primary])) { $idsByPrimary[$primary] = []; } $idsByPrimary[$primary][] = (string)$seg['id']; } } if ($migrateToStore !== []) { AI_Segment_Store::set_many($targetLang, $migrateToStore); } // Skip API calls entirely when everything is already cached if (empty($workSegments)) { $final = []; foreach ($idsByPrimary as $pid => $ids) { $tr = isset($cachedPrimary[$pid]) ? (string) $cachedPrimary[$pid] : null; if ($tr === null) { continue; } foreach ($ids as $oid) { $final[$oid] = $tr; } } return ['segments' => $final, 'map' => []]; } // Optimized chunk sizes: larger batches = fewer API calls = faster translations // Modern models (GPT-4, GPT-5, Gemini, Claude) handle 15-20 segments easily $chunkSize = ($provider === 'deepseek') ? 12 : 15; $batches = ($chunkSize < count($workSegments)) ? array_chunk($workSegments, $chunkSize) : [ $workSegments ]; // Parallel processing: use for ALL providers (not just specific ones) $translationsPrimary = []; $canMulti = false; $reqClass = null; // Check if parallel requests library is available if (class_exists('\\WpOrg\\Requests\\Requests')) { $reqClass = '\\WpOrg\\Requests\\Requests'; $canMulti = true; } elseif (class_exists('\\Requests')) { $reqClass = '\\Requests'; $canMulti = true; } if ($canMulti) { $apiStart = microtime(true); // Detect cache-warming requests (reduce concurrency to prevent server overload) $userAgent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])) : ''; $isCacheWarming = stripos($userAgent, 'CacheWarmer') !== false; // Normal: 6 parallel requests; Cache-warming: 2 parallel requests (prevents 8×6=48 concurrent API calls) $concurrency = $isCacheWarming ? 2 : 6; $groups = array_chunk($batches, $concurrency); $timeoutSeconds = 45; // Balanced timeout: enough for API response, not excessive (was 60) foreach ($groups as $gIdx => $group) { $groupStart = microtime(true); $requests = []; $metas = []; foreach ($group as $batchSegs) { $userPayload = self::buildUserPayload($batchSegs); $body = self::buildApiBody($model, $system, $userPayload, $provider, $isResponses); $headers = self::buildApiHeaders($apiKey, $provider, $settings); $requests[] = [ 'url' => $endpoint, 'headers' => $headers, 'data' => wp_json_encode($body), 'type' => 'POST', 'options' => [ 'timeout' => $timeoutSeconds, 'verify' => true ], ]; $metas[] = $batchSegs; } // Single attempt + optional one retry for failures $doAttempt = function(array $requests) use ($reqClass) { try { return $reqClass::request_multiple($requests); } catch (\Throwable $e) { return $e; } }; $responses = $doAttempt($requests); // Auto-detect /responses API from parallel 404 errors if (!($responses instanceof \Throwable) && !$isResponses) { $detectedResponses = false; foreach ($responses as $resp) { if (is_object($resp) && property_exists($resp, 'status_code') && (int)$resp->status_code === 404) { $respBodyStr = is_object($resp) && property_exists($resp, 'body') ? (string)$resp->body : ''; if (AI_Translate_Core::is_responses_api_error($respBodyStr)) { $detectedResponses = true; break; } } } if ($detectedResponses) { AI_Translate_Core::mark_model_responses_api($model); $isResponses = true; $endpoint = rtrim($baseUrl, '/') . '/responses'; // Rebuild and retry this group with the correct endpoint $requests = []; foreach ($group as $batchSegs) { $userPayload = self::buildUserPayload($batchSegs); $body = self::buildApiBody($model, $system, $userPayload, $provider, true); $headers = self::buildApiHeaders($apiKey, $provider, $settings); $requests[] = [ 'url' => $endpoint, 'headers' => $headers, 'data' => wp_json_encode($body), 'type' => 'POST', 'options' => [ 'timeout' => $timeoutSeconds, 'verify' => true ], ]; } $responses = $doAttempt($requests); } } if ($responses instanceof \Throwable) { // Fallback to sequential handling for this group foreach ($group as $idx => $batchSegs) { // Reuse sequential path below by emulating one-batch run $batchesSingle = [$batchSegs]; foreach ($batchesSingle as $i => $batchSegs2) { $userPayload = self::buildUserPayload($batchSegs2); $body = self::buildApiBody($model, $system, $userPayload, $provider, $isResponses); $fallbackHeaders = self::buildApiHeaders($apiKey, $provider, $settings); $resp = self::postTranslation($endpoint, $fallbackHeaders, $body, $timeoutSeconds, $provider, $model); if (is_wp_error($resp)) { continue; } $code = (int) wp_remote_retrieve_response_code($resp); if ($code === 429) { $wait = self::get_retry_after_seconds($resp); if ($wait > 0) { sleep(min($wait, 90)); } $resp = AI_Translate_Core::remote_api_post($endpoint, $fallbackHeaders, $body, $timeoutSeconds); if (!is_wp_error($resp)) { $code = (int) wp_remote_retrieve_response_code($resp); } } if ($code !== 200) { continue; } $respBody = (string) wp_remote_retrieve_body($resp); $data = json_decode($respBody, true); $content = AI_Translate_Core::extract_message_content($data); if ($content === null) { continue; } $translations = self::parseTranslationResponse($content); if (is_array($translations)) { foreach ($translations as $k => $v) { $translationsPrimary[(string)$k] = (string)$v; } } } } continue; } // Process responses; retry failures (429 with Retry-After backoff) $failedIndexes = []; foreach ($responses as $idx => $resp) { $ok = is_object($resp) && property_exists($resp, 'status_code') ? ((int)$resp->status_code === 200) : false; if (!$ok) { $failedIndexes[] = (int)$idx; continue; } $content = ''; if (is_object($resp) && property_exists($resp, 'body')) { $content = (string) $resp->body; } $data = json_decode($content, true); $msg = AI_Translate_Core::extract_message_content($data); if ($msg === null) { $failedIndexes[] = (int)$idx; continue; } if (trim($msg) === '') { $failedIndexes[] = (int)$idx; continue; } $translations = self::parseTranslationResponse($msg); if (is_array($translations) && !empty($translations)) { foreach ($translations as $k => $v) { $translated = trim((string)$v); if ($translated !== '') { $translationsPrimary[(string)$k] = $translated; } } self::cacheTranslations($translations, $primarySegById, $targetLang); } else { $failedIndexes[] = (int)$idx; } } if (!empty($failedIndexes)) { $temperatureRejected = false; foreach ($failedIndexes as $fi) { $failResp = $responses[$fi] ?? null; $failBody = (is_object($failResp) && property_exists($failResp, 'body')) ? (string) $failResp->body : ''; if (AI_Translate_Core::is_unsupported_temperature_error($failBody)) { $temperatureRejected = true; break; } } if ($temperatureRejected) { AI_Translate_Core::remember_temperature_supported($provider, $model, false); foreach ($failedIndexes as $fi) { $decoded = json_decode((string) $requests[$fi]['data'], true); if (is_array($decoded)) { unset($decoded['temperature']); $requests[$fi]['data'] = wp_json_encode($decoded); } } } $retryReqs = []; $retryMetas = []; foreach ($failedIndexes as $fi) { $retryReqs[] = $requests[$fi]; $retryMetas[] = $metas[$fi]; } $max429Retries = 3; $retryRound = 0; while ($retryRound < $max429Retries) { $retryRes = $doAttempt($retryReqs); if ($retryRes instanceof \Throwable) { break; } $stillFailed = []; foreach ($retryRes as $rIdx => $resp) { $ok = is_object($resp) && property_exists($resp, 'status_code') ? ((int)$resp->status_code === 200) : false; if ($ok) { $content = is_object($resp) && property_exists($resp, 'body') ? (string)$resp->body : ''; $data = json_decode($content, true); $msg = AI_Translate_Core::extract_message_content($data); if ($msg !== null) { $translations = self::parseTranslationResponse($msg); if (is_array($translations)) { foreach ($translations as $k => $v) { $translated = trim((string) $v); if ($translated !== '') { $translationsPrimary[(string)$k] = $translated; } } self::cacheTranslations($translations, $primarySegById, $targetLang); } } } else { $stillFailed[] = ['idx' => $rIdx, 'resp' => $resp, 'origIdx' => $failedIndexes[$rIdx]]; } } if (empty($stillFailed)) { break; } $has429 = false; $maxWait = 0; foreach ($stillFailed as $sf) { $sc = is_object($sf['resp']) && property_exists($sf['resp'], 'status_code') ? (int)$sf['resp']->status_code : 0; if ($sc === 429) { $has429 = true; $maxWait = max($maxWait, self::get_retry_after_seconds($sf['resp'])); } } if (!$has429 || $retryRound >= $max429Retries - 1) { break; } sleep(min($maxWait, 120)); $retryReqs = []; $retryMetas = []; foreach ($stillFailed as $sf) { $retryReqs[] = $requests[$sf['origIdx']]; $retryMetas[] = $metas[$sf['origIdx']]; } $retryRound++; } } } return self::finalizePrimaryTranslations($translationsPrimary, $cachedPrimary, $primarySegById, $idsByPrimary, $targetLang, $identicalWhitelist, $system, $model, $provider, $apiKey, $endpoint, $timeoutSeconds, $settings, $target, $expiry); } // Sequential fallback (when parallel not available) $apiStart = microtime(true); $timeoutSeconds = 45; foreach ($batches as $i => $batchSegs) { $batchStart = microtime(true); $userPayload = self::buildUserPayload($batchSegs); $body = self::buildApiBody($model, $system, $userPayload, $provider, $isResponses); $headers = self::buildApiHeaders($apiKey, $provider, $settings); $requestTime = isset($_SERVER['REQUEST_TIME_FLOAT']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_TIME_FLOAT'])) : microtime(true); $timeRemaining = $timeLimit > 0 ? ($timeLimit - (microtime(true) - $requestTime)) : 60; $safeTimeout = min($timeoutSeconds, max(10, (int)($timeRemaining - 10))); $response = self::postTranslation($endpoint, $headers, $body, $safeTimeout, $provider, $model); if (is_wp_error($response)) { break; } $code = (int) wp_remote_retrieve_response_code($response); // Auto-detect /responses API on first sequential batch if ($code === 404 && !$isResponses && $i === 0) { $errBody = (string) wp_remote_retrieve_body($response); if (AI_Translate_Core::is_responses_api_error($errBody)) { AI_Translate_Core::mark_model_responses_api($model); $isResponses = true; $endpoint = rtrim($baseUrl, '/') . '/responses'; $body = self::buildApiBody($model, $system, $userPayload, $provider, true); $response = self::postTranslation($endpoint, $headers, $body, $safeTimeout, $provider, $model); if (is_wp_error($response)) { break; } $code = (int) wp_remote_retrieve_response_code($response); } } if ($code !== 200) { if ($code === 429) { $wait = self::get_retry_after_seconds($response); $elapsed = microtime(true) - $requestTime; if ($timeLimit > 0 && $elapsed < $timeLimit - 15 && $wait > 0) { sleep(min($wait, 90)); $response = AI_Translate_Core::remote_api_post($endpoint, $headers, $body, $safeTimeout); if (!is_wp_error($response)) { $code = (int) wp_remote_retrieve_response_code($response); } } } if ($code !== 200) { break; } } $respBody = (string) wp_remote_retrieve_body($response); $data = json_decode($respBody, true); $content = AI_Translate_Core::extract_message_content($data); if ($content === null) { break; } $translations = self::parseTranslationResponse($content); if (!is_array($translations)) { break; } foreach ($translations as $k => $v) { $translationsPrimary[(string) $k] = (string) $v; } self::cacheTranslations($translations, $primarySegById, $targetLang); } return self::finalizePrimaryTranslations($translationsPrimary, $cachedPrimary, $primarySegById, $idsByPrimary, $targetLang, $identicalWhitelist, $system, $model, $provider, $apiKey, $endpoint, $timeoutSeconds, $settings, $target, $expiry); } private static function finalizePrimaryTranslations(array $translationsPrimary, array $cachedPrimary, array $primarySegById, array $idsByPrimary, string $targetLang, array $identicalWhitelist, string $system, string $model, string $provider, string $apiKey, string $endpoint, int $timeoutSeconds, array $settings, string $target, int $expiry): array { $needsRetry = []; foreach ($primarySegById as $pid => $meta) { if (isset($translationsPrimary[$pid])) { if (self::needsTranslationRetry((string) $meta['text'], (string) $translationsPrimary[$pid], $targetLang, $identicalWhitelist)) { $needsRetry[] = $pid; } } elseif (!isset($cachedPrimary[$pid])) { $needsRetry[] = $pid; } } if (empty($translationsPrimary) && !empty($primarySegById)) { $needsRetry = array_keys($primarySegById); } if (!empty($needsRetry)) { $retryTranslations = self::retryFailedSegments($needsRetry, $primarySegById, $system, $model, $provider, $apiKey, $endpoint, $timeoutSeconds, $settings, $target); foreach ($retryTranslations as $k => $v) { $translationsPrimary[(string) $k] = $v; } } self::cacheTranslations($translationsPrimary, $primarySegById, $targetLang); foreach ($cachedPrimary as $pid => $tr) { $translationsPrimary[$pid] = $tr; } $final = []; foreach ($idsByPrimary as $pid => $ids) { $tr = isset($translationsPrimary[$pid]) ? (string) $translationsPrimary[$pid] : null; if ($tr === null) { continue; } foreach ($ids as $oid) { $final[$oid] = $tr; } } return ['segments' => $final, 'map' => []]; } /** * Translate the same segments into several languages at once and put the results in the * segment store, so the normal (sequential) code path that follows finds them in the cache. * Used where a request would otherwise wait for one API round trip per language, such as * the translated slugs of a page that is being published. * * Best effort: a language that fails, or whose answer does not look like a translation, is * left alone and gets translated by the normal path later. * * @param array $segments Segments to translate. * @param string[] $langs Target languages. * @param string $source Source language. * @param array $context Same context translate_plan() takes. * @return int Number of languages that were stored. */ public static function prefetch_for_languages(array $segments, array $langs, $source, array $context): int { $settings = AI_Translate_Core::settings(); $provider = isset($settings['api_provider']) ? (string) $settings['api_provider'] : ''; $models = isset($settings['models']) && is_array($settings['models']) ? $settings['models'] : []; $model = $provider !== '' ? AI_Translate_Core::effective_model($provider, (string) ($models[$provider] ?? '')) : ''; $apiKeys = isset($settings['api_keys']) && is_array($settings['api_keys']) ? $settings['api_keys'] : []; $apiKey = $provider !== '' ? AI_Translate_Core::api_key_for($settings, $provider) : ''; if ($provider === '' || $model === '' || $apiKey === '' || !class_exists('\\WpOrg\\Requests\\Requests')) { return 0; } $baseUrl = AI_Translate_Core::api_base_url($provider, $settings); if ($baseUrl === '') { return 0; } $endpointPath = AI_Translate_Core::get_model_endpoint_path($model); $endpoint = rtrim($baseUrl, '/') . $endpointPath; $isResponses = ($endpointPath === '/responses'); $expiry = (isset($settings['cache_expiration']) ? (int) $settings['cache_expiration'] : (14 * 24)) * HOUR_IN_SECONDS; // Which languages still need work, and for which segments $jobs = []; foreach ($langs as $lang) { $lang = (string) $lang; if ($lang === '') { continue; } $hashes = []; foreach ($segments as $seg) { $hashes[(string) $seg['id']] = AI_Segment_Store::hash((string) $seg['type'], trim((string) $seg['text']), $lang); } $stored = AI_Segment_Store::get_many($lang, array_values($hashes), $expiry); $legacyAllowed = AI_Segment_Store::legacy_allowed($lang); $work = []; foreach ($segments as $seg) { $hash = $hashes[(string) $seg['id']]; if (isset($stored[$hash])) { continue; } if ($legacyAllowed && get_transient(AI_Segment_Store::legacy_transient_key($lang, $hash)) !== false) { continue; } $work[] = [ 'id' => (string) $seg['id'], 'text' => trim((string) $seg['text']), 'type' => (string) $seg['type'] ]; } if ($work !== []) { $jobs[$lang] = $work; } } if ($jobs === []) { return 0; } $headers = self::buildApiHeaders($apiKey, $provider, $settings); $stored = 0; foreach (array_chunk($jobs, 13, true) as $chunk) { $requests = []; foreach ($chunk as $lang => $work) { $system = self::buildSystemPrompt($source, $lang, $context); $body = self::buildApiBody($model, $system, self::buildUserPayload($work), $provider, $isResponses); $requests[$lang] = [ 'url' => $endpoint, 'headers' => $headers, 'data' => wp_json_encode($body), 'type' => 'POST', 'options' => [ 'timeout' => 45, 'verify' => true ], ]; } try { $responses = \WpOrg\Requests\Requests::request_multiple($requests); } catch (\Throwable $e) { continue; } foreach ($responses as $lang => $resp) { $lang = (string) $lang; if (!is_object($resp) || !property_exists($resp, 'status_code') || (int) $resp->status_code !== 200) { continue; } $content = AI_Translate_Core::extract_message_content(json_decode((string) $resp->body, true)); $translations = $content === null ? null : self::parseTranslationResponse($content); if (!is_array($translations)) { continue; } $primary = []; foreach ($jobs[$lang] as $seg) { $primary[$seg['id']] = $seg; } $clean = []; foreach ($translations as $id => $value) { $id = (string) $id; if (!isset($primary[$id]) || !is_string($value) || trim($value) === '') { continue; } $src = $primary[$id]['text']; // Same sanity checks the normal path applies to cached translations if (mb_strlen($src) > 15 && mb_strtolower(trim($value)) === mb_strtolower($src)) { continue; } if (AI_Lang::is_non_latin($lang) && preg_match('/[^\x00-\x7F]/', $value) !== 1) { continue; } $clean[$id] = $value; } if ($clean !== []) { self::cacheTranslations($clean, $primary, $lang); $stored++; } } } return $stored; } private static function buildSystemPrompt($source, $target, array $ctx) { return \AITranslate\AI_Translate_Core::build_translation_system_prompt($source, $target, $ctx); } private static function buildUserPayload(array $segments) { $payload = [ 'segments' => [] ]; foreach ($segments as $seg) { $payload['segments'][] = [ 'id' => (string) $seg['id'], 'text' => (string) $seg['text'], 'type' => (string) $seg['type'], 'attr' => isset($seg['attr']) ? (string) $seg['attr'] : null, ]; } $payload['instruction'] = 'Translate every segment from the source language to the target language. You MUST translate every segment. Do NOT return the source text unchanged. Return ONLY valid JSON in this exact format: {"translations": {"": ""}}. Every segment ID must have a translated text in the target language. The translated_text must be in the target language, not the source language. No extra text, explanations, or comments outside the JSON.'; return wp_json_encode($payload); } /** * Parse an AI response message into a translations array. * Handles markdown-wrapped JSON and extracts the first valid JSON object. * * @param string $msg Raw message content from AI response. * @return array|null Associative array of translations, or null on failure. */ private static function parseTranslationResponse(string $msg): ?array { $parsed = json_decode($msg, true); if (is_array($parsed) && isset($parsed['translations']) && is_array($parsed['translations'])) { return $parsed['translations']; } $candidate = str_replace(["```json", "```JSON", "```"], '', $msg); $s = strpos($candidate, '{'); $e = strrpos($candidate, '}'); if ($s !== false && $e !== false && $e >= $s) { $candidate = substr($candidate, $s, $e - $s + 1); } $parsed = json_decode(trim($candidate), true); if (is_array($parsed) && isset($parsed['translations']) && is_array($parsed['translations'])) { return $parsed['translations']; } return null; } /** * Persist translated segments to the segment store. * * @param array $translations Associative array of pid => translated text. * @param array $primarySegById Metadata for each primary segment. * @param string $targetLang Target language code. * @return int Number of newly cached entries. */ private static function cacheTranslations(array $translations, array $primarySegById, string $targetLang): int { $count = 0; $storeRows = []; foreach ($translations as $pid => $tr) { $pid = (string) $pid; if (!isset($primarySegById[$pid])) { continue; } $tr = trim((string) $tr); if ($tr === '') { continue; } $storeRows[AI_Segment_Store::hash($primarySegById[$pid]['type'], $primarySegById[$pid]['text'], $targetLang)] = [ 'text' => $tr, 'type' => $primarySegById[$pid]['type'], ]; $count++; } AI_Segment_Store::set_many($targetLang, $storeRows); return $count; } /** * Build API request body for translation. * * @param string $model Model identifier. * @param string $system System prompt. * @param string $userPayload JSON user payload. * @param string $provider API provider name. * @return array Request body array. */ /** * Post a translation body. If the model rejects temperature 0, remember that and retry once without it. * * @param string $endpoint API endpoint. * @param array $headers Request headers. * @param array $body Request body. * @param int $timeout Timeout in seconds. * @param string $provider Provider key. * @param string $model Model ID. * @return array|\WP_Error */ private static function postTranslation(string $endpoint, array $headers, array $body, int $timeout, string $provider, string $model) { $resp = AI_Translate_Core::remote_api_post($endpoint, $headers, $body, $timeout); if (is_wp_error($resp) || (int) wp_remote_retrieve_response_code($resp) === 200) { return $resp; } $raw = (string) wp_remote_retrieve_body($resp); if (!AI_Translate_Core::is_unsupported_temperature_error($raw)) { return $resp; } AI_Translate_Core::remember_temperature_supported($provider, $model, false); unset($body['temperature']); return AI_Translate_Core::remote_api_post($endpoint, $headers, $body, $timeout); } private static function buildApiBody(string $model, string $system, string $userPayload, string $provider, bool $isResponses = false): array { $body = [ 'model' => $model, 'messages' => [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $userPayload], ], 'temperature' => 0, ]; $body = AI_Translate_Core::adjust_body_for_model($body, $model, $provider); if ($isResponses) { $body = AI_Translate_Core::convert_body_to_responses($body); } return $body; } /** * Build API request headers including provider-specific headers. * * @param string $apiKey API key. * @param string $provider API provider name. * @param array $settings Plugin settings. * @return array Headers array. */ private static function buildApiHeaders(string $apiKey, string $provider, array $settings): array { return AI_Translate_Core::build_api_headers($apiKey, $provider, $settings); } /** * Check if a translation needs retry (untranslated or bad quality). * * @param string $src Source text. * @param string $tr Translated text. * @param string $targetLang Target language code. * @param array $brandWhitelist Lowercased brand names to accept as identical. * @return bool True if the translation should be retried. */ private static function needsTranslationRetry(string $src, string $tr, string $targetLang, array $brandWhitelist): bool { $srcLen = mb_strlen($src); $trLen = mb_strlen($tr); $srcLower = mb_strtolower(trim($src)); $containsWhitelisted = false; foreach ($brandWhitelist as $brand) { if (stripos($srcLower, $brand) !== false) { $containsWhitelisted = true; break; } } if ($srcLen > 15 && trim($tr) === trim($src) && !$containsWhitelisted) { return true; } // For non-Latin target languages the translation must contain non-ASCII characters // (script of the target language). Pure-Latin responses are transliterations and need retry. if (AI_Lang::is_non_latin($targetLang)) { $trimmedTr = trim($tr); if ($trimmedTr !== '' && !$containsWhitelisted) { $hasNonAscii = preg_match('/[^\x00-\x7F]/', $trimmedTr) === 1; if (!$hasNonAscii) { return true; } } if ($trLen > 100) { $latinChars = preg_match_all('/[A-Za-z]/', $tr); $latinRatio = $trLen > 0 ? ($latinChars / $trLen) : 0; if ($latinRatio > 0.4) { return true; } } } return false; } /** * Retry translation for segments that failed or were returned untranslated. * * @param array $needsRetry Array of primary segment IDs to retry. * @param array $primarySegById Metadata for each primary segment. * @param string $system Base system prompt. * @param string $model Model identifier. * @param string $provider API provider name. * @param string $apiKey API key. * @param string $endpoint API endpoint URL. * @param int $timeoutSeconds Request timeout. * @param array $settings Plugin settings. * @param string $target Target language code. * @return array Associative array of pid => translated text. */ private static function retryFailedSegments(array $needsRetry, array $primarySegById, string $system, string $model, string $provider, string $apiKey, string $endpoint, int $timeoutSeconds, array $settings, string $target): array { $core = AI_Translate_Core::get_instance(); $targetLangName = ''; $availableLangs = $core->get_available_languages(); if (isset($availableLangs[$target])) { $targetLangName = $availableLangs[$target] . ' (' . strtoupper($target) . ')'; } else { $targetLangName = strtoupper($target); } $strictSystem = $system . "\n\nCRITICAL: You MUST translate every segment into " . $targetLangName . ". NEVER return the source text unchanged. If the source text is already in the target language, still translate it to ensure it matches the target language perfectly. Do not copy the source text. ALWAYS translate into the target language. Never return the source text unchanged unless it's a proper noun or brand name that should remain unchanged."; $retrySegs = []; foreach ($needsRetry as $pid) { if (isset($primarySegById[$pid])) { $retrySegs[] = $primarySegById[$pid]; } } $result = []; if (empty($retrySegs)) { return $result; } $retryChunks = array_chunk($retrySegs, 5); $headers = self::buildApiHeaders($apiKey, $provider, $settings); $retryIsResponses = (substr($endpoint, -10) === '/responses'); foreach ($retryChunks as $rc) { $userPayload = self::buildUserPayload($rc); $body = self::buildApiBody($model, $strictSystem, $userPayload, $provider, $retryIsResponses); $resp = self::postTranslation($endpoint, $headers, $body, $timeoutSeconds, $provider, $model); if (is_wp_error($resp)) { continue; } $code = (int) wp_remote_retrieve_response_code($resp); if ($code === 429) { $wait = self::get_retry_after_seconds($resp); if ($wait > 0) { sleep(min($wait, 90)); $resp = AI_Translate_Core::remote_api_post($endpoint, $headers, $body, $timeoutSeconds); if (!is_wp_error($resp)) { $code = (int) wp_remote_retrieve_response_code($resp); } } } if ($code !== 200) { continue; } $rb = (string) wp_remote_retrieve_body($resp); $d = json_decode($rb, true); $msg = AI_Translate_Core::extract_message_content($d); if ($msg === null) { continue; } $translations = self::parseTranslationResponse($msg); if (is_array($translations)) { foreach ($translations as $k => $v) { $translated = trim((string) $v); if ($translated !== '') { $result[(string) $k] = $translated; } } } } return $result; } /** * Get Retry-After delay in seconds from a 429 response. * Works with both Requests library Response and wp_remote_* response. * * @param object|array $response HTTP response (Requests Response or WP_HTTP_Response) * @return int Seconds to wait (default 60 if header missing or invalid) */ private static function get_retry_after_seconds($response): int { $val = null; if (is_array($response) && function_exists('wp_remote_retrieve_header')) { $val = wp_remote_retrieve_header($response, 'retry-after'); } if (($val === null || $val === '') && is_object($response) && isset($response->headers)) { $h = $response->headers; if (is_object($h)) { $val = isset($h['retry-after']) ? $h['retry-after'] : (isset($h['Retry-After']) ? $h['Retry-After'] : null); } elseif (is_array($h) && isset($h['retry-after'])) { $val = is_array($h['retry-after']) ? ($h['retry-after'][0] ?? null) : $h['retry-after']; } } if ($val === null || $val === '') { return 60; } $sec = (int) $val; return $sec > 0 ? min($sec, 120) : 60; } /** * Get the brand name whitelist from settings, falling back to defaults. * * @return array Lowercased brand names. */ private static function getBrandWhitelist(): array { $settings = AI_Translate_Core::settings(); if (isset($settings['brand_whitelist']) && is_array($settings['brand_whitelist']) && !empty($settings['brand_whitelist'])) { return array_map('strtolower', $settings['brand_whitelist']); } return ['wordpress']; } }