diff --git a/apps/website/translate.php b/apps/website/translate.php index 3cc6d57..dcf1323 100644 --- a/apps/website/translate.php +++ b/apps/website/translate.php @@ -18,6 +18,147 @@ function clean_translate_text(string $value, int $maxLength = 4000): string { return $value; } +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']); } @@ -52,14 +193,47 @@ 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; +} + +if (!$missTexts) { + ksort($cleanTranslations); + echo json_encode([ + 'translations' => array_values($cleanTranslations), + 'model' => $ollamaModel, + 'cache' => [ + 'hits' => count($texts), + 'misses' => 0, + ], + ], 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($texts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + . 'Input: ' . json_encode($missTexts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); -$ollamaHost = rtrim(getenv('OLLAMA_HOST') ?: 'http://192.168.100.68:11434', '/'); -$ollamaModel = getenv('OLLAMA_MODEL') ?: 'llama3.2:3b'; $ollamaPayload = json_encode([ 'model' => $ollamaModel, 'prompt' => $prompt, @@ -96,19 +270,30 @@ if (!is_array($ollamaResponse) || !isset($ollamaResponse['response']) || !is_str $translationJson = trim(str_replace(['```json', '```'], '', $ollamaResponse['response'])); $translations = json_decode($translationJson, true); -if (!is_array($translations) || count($translations) !== count($texts)) { +if (!is_array($translations) || count($translations) !== count($missTexts)) { translate_response(502, ['error' => 'Unexpected translation response']); } -$cleanTranslations = []; -foreach ($translations as $translation) { +foreach ($translations as $index => $translation) { if (!is_string($translation) && !is_numeric($translation)) { translate_response(502, ['error' => 'Unexpected translation item']); } - $cleanTranslations[] = clean_translate_text((string) $translation); + $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); echo json_encode([ - 'translations' => $cleanTranslations, + 'translations' => array_values($cleanTranslations), 'model' => $ollamaModel, + 'cache' => [ + 'hits' => count($texts) - count($missTexts), + 'misses' => count($missTexts), + ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);