415 lines
13 KiB
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.73: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);
|