$maxLength) { $value = substr($value, 0, $maxLength); } return $value; } function elapsed_ms(float $startedAt): int { return (int) round((microtime(true) - $startedAt) * 1000); } function translation_log(array $fields): void { $payload = array_merge([ 'event' => 'website_translation', 'timestamp' => gmdate('c'), ], $fields); $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json !== false) { error_log($json); } } function redis_encode_command(array $parts): string { $command = '*' . count($parts) . "\r\n"; foreach ($parts as $part) { $value = (string) $part; $command .= '$' . strlen($value) . "\r\n" . $value . "\r\n"; } return $command; } function redis_read_line($socket): ?string { $line = fgets($socket); if ($line === false) { return null; } return rtrim($line, "\r\n"); } function redis_read_response($socket): mixed { $prefix = fgetc($socket); if ($prefix === false) { return null; } if ($prefix === '+') { return redis_read_line($socket); } if ($prefix === '-' || $prefix === ':') { redis_read_line($socket); return null; } if ($prefix === '$') { $lengthLine = redis_read_line($socket); if ($lengthLine === null) { return null; } $length = (int) $lengthLine; if ($length < 0) { return null; } $value = ''; while (strlen($value) < $length) { $chunk = fread($socket, $length - strlen($value)); if ($chunk === false || $chunk === '') { return null; } $value .= $chunk; } fread($socket, 2); return $value; } if ($prefix === '*') { $countLine = redis_read_line($socket); if ($countLine === null) { return null; } $count = (int) $countLine; if ($count < 0) { return null; } $items = []; for ($i = 0; $i < $count; $i++) { $items[] = redis_read_response($socket); } return $items; } return null; } function redis_connect(): mixed { $host = trim((string) getenv('TRANSLATION_REDIS_HOST')); if ($host === '') { return null; } $port = (int) (getenv('TRANSLATION_REDIS_PORT') ?: 6379); $socket = @fsockopen($host, $port, $errno, $errstr, 0.2); if (!$socket) { return null; } stream_set_timeout($socket, 1); return $socket; } function translation_cache_key(string $prefix, string $model, string $targetLang, string $text): string { $safePrefix = preg_replace('/[^a-zA-Z0-9:_-]/', '', $prefix) ?: 'translation:v1'; return $safePrefix . ':' . hash('sha256', $model) . ':' . $targetLang . ':' . hash('sha256', $text); } function translation_cache_read(array $keys): array { if (!$keys) { return []; } $socket = redis_connect(); if (!$socket) { return []; } fwrite($socket, redis_encode_command(array_merge(['MGET'], $keys))); $values = redis_read_response($socket); fclose($socket); if (!is_array($values) || count($values) !== count($keys)) { return []; } $cached = []; foreach ($keys as $index => $key) { if (is_string($values[$index])) { $cached[$key] = clean_translate_text($values[$index]); } } return $cached; } function translation_cache_write(array $items): void { if (!$items) { return; } $ttl = (int) (getenv('TRANSLATION_CACHE_TTL_SECONDS') ?: 7776000); if ($ttl < 300) { $ttl = 300; } $socket = redis_connect(); if (!$socket) { return; } foreach ($items as $key => $value) { fwrite($socket, redis_encode_command(['SETEX', $key, (string) $ttl, $value])); redis_read_response($socket); } fclose($socket); } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { translate_response(405, ['error' => 'Method not allowed']); } if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 131072) { translate_response(413, ['error' => 'Request too large']); } $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body) || !isset($body['targetLang'], $body['texts']) || !is_array($body['texts'])) { translate_response(400, ['error' => 'Missing target language or texts']); } $targetLang = preg_replace('/[^a-z]/', '', strtolower((string) $body['targetLang'])); if (strlen($targetLang) < 2 || strlen($targetLang) > 5) { translate_response(400, ['error' => 'Invalid target language']); } $targetName = clean_translate_text((string) ($body['targetName'] ?? strtoupper($targetLang)), 80); $texts = []; $totalLength = 0; foreach ($body['texts'] as $text) { if (!is_string($text) && !is_numeric($text)) { continue; } $cleanText = clean_translate_text((string) $text); $totalLength += strlen($cleanText); $texts[] = $cleanText; } if (!$texts || count($texts) > 240 || $totalLength > 80000) { translate_response(400, ['error' => 'Invalid translation batch']); } $ollamaHost = rtrim(getenv('OLLAMA_HOST') ?: 'http://192.168.100.68:11434', '/'); $ollamaModel = getenv('OLLAMA_MODEL') ?: 'llama3.2:3b'; $cachePrefix = getenv('TRANSLATION_CACHE_PREFIX') ?: 'translation:v1'; $cacheKeys = []; foreach ($texts as $index => $text) { $cacheKeys[$index] = translation_cache_key($cachePrefix, $ollamaModel, $targetLang, $text); } $cachedTranslations = translation_cache_read(array_values(array_unique($cacheKeys))); $cleanTranslations = []; $missTexts = []; $missPositions = []; foreach ($texts as $index => $text) { $cacheKey = $cacheKeys[$index]; if (array_key_exists($cacheKey, $cachedTranslations)) { $cleanTranslations[$index] = $cachedTranslations[$cacheKey]; continue; } $missPositions[] = $index; $missTexts[] = $text; } $cacheHits = count($texts) - count($missTexts); $cacheMisses = count($missTexts); $failedJsonParseCount = 0; $ollamaTimeoutCount = 0; $ollamaLatencyMs = null; if (!$missTexts) { ksort($cleanTranslations); translation_log([ 'status' => 'success', 'target_lang' => $targetLang, 'model' => $ollamaModel, 'text_count' => count($texts), 'cache_hits' => $cacheHits, 'cache_misses' => $cacheMisses, 'ollama_latency_ms' => $ollamaLatencyMs, 'failed_json_parse_count' => $failedJsonParseCount, 'timeout_count' => $ollamaTimeoutCount, 'duration_ms' => elapsed_ms($requestStartedAt), ]); echo json_encode([ 'translations' => array_values($cleanTranslations), 'model' => $ollamaModel, 'cache' => [ 'hits' => $cacheHits, 'misses' => $cacheMisses, ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } $prompt = "Translate each item to {$targetName}.\n" . "Return ONLY a valid JSON array of translated strings in the same order, no explanations, no markdown.\n" . "Example input: [\"Hello\", \"How are you\"]\n" . "Example output: [\"Bonjour\", \"Comment allez-vous\"]\n\n" . 'Input: ' . json_encode($missTexts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $ollamaPayload = json_encode([ 'model' => $ollamaModel, 'prompt' => $prompt, 'stream' => false, ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($ollamaPayload === false) { translate_response(500, ['error' => 'Could not encode Ollama request']); } $curl = curl_init($ollamaHost . '/api/generate'); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => $ollamaPayload, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 130, ]); $ollamaStartedAt = microtime(true); $rawResponse = curl_exec($curl); $curlError = curl_error($curl); $curlErrno = curl_errno($curl); $statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $ollamaLatencyMs = elapsed_ms($ollamaStartedAt); if ($rawResponse === false || $statusCode < 200 || $statusCode >= 300) { $ollamaTimeoutCount = $curlErrno === CURLE_OPERATION_TIMEDOUT ? 1 : 0; translation_log([ 'status' => 'error', 'reason' => 'ollama_request_failed', 'target_lang' => $targetLang, 'model' => $ollamaModel, 'text_count' => count($texts), 'cache_hits' => $cacheHits, 'cache_misses' => $cacheMisses, 'ollama_latency_ms' => $ollamaLatencyMs, 'failed_json_parse_count' => $failedJsonParseCount, 'timeout_count' => $ollamaTimeoutCount, 'ollama_status_code' => $statusCode, 'curl_errno' => $curlErrno, 'duration_ms' => elapsed_ms($requestStartedAt), ]); translate_response(502, ['error' => 'Ollama request failed', 'status' => $statusCode, 'detail' => $curlError]); } $ollamaResponse = json_decode($rawResponse, true); if (!is_array($ollamaResponse) || !isset($ollamaResponse['response']) || !is_string($ollamaResponse['response'])) { if (json_last_error() !== JSON_ERROR_NONE) { $failedJsonParseCount++; } translation_log([ 'status' => 'error', 'reason' => 'invalid_ollama_response', 'target_lang' => $targetLang, 'model' => $ollamaModel, 'text_count' => count($texts), 'cache_hits' => $cacheHits, 'cache_misses' => $cacheMisses, 'ollama_latency_ms' => $ollamaLatencyMs, 'failed_json_parse_count' => $failedJsonParseCount, 'timeout_count' => $ollamaTimeoutCount, 'ollama_status_code' => $statusCode, 'duration_ms' => elapsed_ms($requestStartedAt), ]); translate_response(502, ['error' => 'Invalid Ollama response']); } $translationJson = trim(str_replace(['```json', '```'], '', $ollamaResponse['response'])); $translations = json_decode($translationJson, true); if (!is_array($translations) || count($translations) !== count($missTexts)) { if (json_last_error() !== JSON_ERROR_NONE) { $failedJsonParseCount++; } translation_log([ 'status' => 'error', 'reason' => 'unexpected_translation_response', 'target_lang' => $targetLang, 'model' => $ollamaModel, 'text_count' => count($texts), 'cache_hits' => $cacheHits, 'cache_misses' => $cacheMisses, 'ollama_latency_ms' => $ollamaLatencyMs, 'failed_json_parse_count' => $failedJsonParseCount, 'timeout_count' => $ollamaTimeoutCount, 'ollama_status_code' => $statusCode, 'duration_ms' => elapsed_ms($requestStartedAt), ]); translate_response(502, ['error' => 'Unexpected translation response']); } foreach ($translations as $index => $translation) { if (!is_string($translation) && !is_numeric($translation)) { translation_log([ 'status' => 'error', 'reason' => 'unexpected_translation_item', 'target_lang' => $targetLang, 'model' => $ollamaModel, 'text_count' => count($texts), 'cache_hits' => $cacheHits, 'cache_misses' => $cacheMisses, 'ollama_latency_ms' => $ollamaLatencyMs, 'failed_json_parse_count' => $failedJsonParseCount, 'timeout_count' => $ollamaTimeoutCount, 'ollama_status_code' => $statusCode, 'duration_ms' => elapsed_ms($requestStartedAt), ]); translate_response(502, ['error' => 'Unexpected translation item']); } $originalPosition = $missPositions[$index]; $cleanTranslations[$originalPosition] = clean_translate_text((string) $translation); } $cacheWrites = []; foreach ($missPositions as $position) { $cacheWrites[$cacheKeys[$position]] = $cleanTranslations[$position]; } translation_cache_write($cacheWrites); ksort($cleanTranslations); translation_log([ 'status' => 'success', 'target_lang' => $targetLang, 'model' => $ollamaModel, 'text_count' => count($texts), 'cache_hits' => $cacheHits, 'cache_misses' => $cacheMisses, 'ollama_latency_ms' => $ollamaLatencyMs, 'failed_json_parse_count' => $failedJsonParseCount, 'timeout_count' => $ollamaTimeoutCount, 'ollama_status_code' => $statusCode, 'duration_ms' => elapsed_ms($requestStartedAt), ]); echo json_encode([ 'translations' => array_values($cleanTranslations), 'model' => $ollamaModel, 'cache' => [ 'hits' => $cacheHits, 'misses' => $cacheMisses, ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);