my-homelab-configs/apps/website/translate.php

415 lines
13 KiB
PHP

<?php
header('Content-Type: application/json');
$requestStartedAt = microtime(true);
function translate_response(int $status, array $body): never {
http_response_code($status);
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function clean_translate_text(string $value, int $maxLength = 4000): string {
$value = strip_tags($value);
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
$value = trim($value);
if (strlen($value) > $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);