179 lines
6.9 KiB
PHP
179 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\Translation\Model\Ai;
|
|
|
|
use Esun\Translation\Api\TranslatorInterface;
|
|
use Esun\Translation\Model\Config;
|
|
use Magento\Framework\HTTP\Client\Curl;
|
|
use Magento\Framework\Serialize\Serializer\Json;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class OpenAiCompatibleTranslator implements TranslatorInterface
|
|
{
|
|
public function __construct(
|
|
private readonly Config $config,
|
|
private readonly Curl $curl,
|
|
private readonly Json $json,
|
|
private readonly LoggerInterface $logger
|
|
) {
|
|
}
|
|
|
|
public function translate(array $texts, string $sourceLocale, string $targetLocale): array
|
|
{
|
|
$texts = array_filter($texts, static fn (string $value): bool => trim($value) !== '');
|
|
if ($texts === []) {
|
|
return [];
|
|
}
|
|
|
|
$apiKey = $this->config->getApiKey();
|
|
$baseUrl = $this->config->getApiBaseUrl();
|
|
$model = $this->config->getModel();
|
|
|
|
if ($apiKey === '' || $baseUrl === '' || $model === '') {
|
|
throw new \RuntimeException('AI provider is not configured.');
|
|
}
|
|
|
|
$payload = [
|
|
'model' => $model,
|
|
'temperature' => $this->config->getTemperature(),
|
|
'response_format' => $this->buildResponseFormat(array_keys($texts)),
|
|
'messages' => [
|
|
[
|
|
'role' => 'system',
|
|
'content' => 'You translate Magento ecommerce content. Return only JSON that conforms to the supplied schema. The translations object must contain every input key exactly once. Do not rename keys. Preserve placeholders like __ESUN_TRANSLATION_PRESERVE_0__ exactly.',
|
|
],
|
|
[
|
|
'role' => 'user',
|
|
'content' => $this->json->serialize([
|
|
'source_locale' => $sourceLocale,
|
|
'target_locale' => $targetLocale,
|
|
'required_keys' => array_keys($texts),
|
|
'texts' => $texts,
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
try {
|
|
$this->curl->setTimeout($this->config->getTimeout());
|
|
$this->curl->addHeader('Content-Type', 'application/json');
|
|
$this->curl->addHeader('Authorization', 'Bearer ' . $apiKey);
|
|
$this->curl->post($baseUrl . '/chat/completions', $this->json->serialize($payload));
|
|
$status = $this->curl->getStatus();
|
|
$body = $this->curl->getBody();
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
if ($this->shouldRetryWithoutJsonSchema($status, $body)) {
|
|
$payload['response_format'] = ['type' => 'json_object'];
|
|
$payload['messages'][0]['content'] = 'You translate Magento ecommerce content. Return only JSON in this exact shape: {"translations":{"field_key":"translated text"}}. The translations object must contain every input key exactly once. Do not rename keys. Do not add commentary. Preserve placeholders like __ESUN_TRANSLATION_PRESERVE_0__ exactly.';
|
|
$this->curl->post($baseUrl . '/chat/completions', $this->json->serialize($payload));
|
|
$status = $this->curl->getStatus();
|
|
$body = $this->curl->getBody();
|
|
}
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
throw new \RuntimeException(sprintf('AI provider returned HTTP %d.', $status));
|
|
}
|
|
}
|
|
|
|
$response = $this->json->unserialize($body);
|
|
$content = $response['choices'][0]['message']['content'] ?? null;
|
|
if (!is_string($content) || trim($content) === '') {
|
|
throw new \RuntimeException('AI provider response did not contain translated content.');
|
|
}
|
|
|
|
$translated = $this->json->unserialize($content);
|
|
if (!is_array($translated)) {
|
|
throw new \RuntimeException('AI provider response content is not a JSON object.');
|
|
}
|
|
|
|
return $this->normalizeTranslations($texts, $translated);
|
|
} catch (\Throwable $exception) {
|
|
$this->logger->error('Esun Translation AI request failed.', ['exception' => $exception]);
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $sourceTexts
|
|
* @param array<mixed> $translated
|
|
* @return array<string, string>
|
|
*/
|
|
private function normalizeTranslations(array $sourceTexts, array $translated): array
|
|
{
|
|
if (!isset($translated['translations']) || !is_array($translated['translations'])) {
|
|
throw new \RuntimeException('AI response must be a JSON object with a "translations" object.');
|
|
}
|
|
|
|
$translations = $translated['translations'];
|
|
$result = [];
|
|
foreach ($sourceTexts as $key => $sourceText) {
|
|
if (!array_key_exists($key, $translations) || !is_scalar($translations[$key])) {
|
|
throw new \RuntimeException(sprintf('AI response is missing translation key "%s".', $key));
|
|
}
|
|
$result[$key] = (string)$translations[$key];
|
|
}
|
|
|
|
$unexpectedKeys = array_diff(array_keys($translations), array_keys($sourceTexts));
|
|
if ($unexpectedKeys !== []) {
|
|
throw new \RuntimeException(sprintf(
|
|
'AI response contains unexpected translation key "%s".',
|
|
(string)reset($unexpectedKeys)
|
|
));
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $keys
|
|
* @return array<mixed>
|
|
*/
|
|
private function buildResponseFormat(array $keys): array
|
|
{
|
|
$properties = [];
|
|
foreach ($keys as $key) {
|
|
$properties[$key] = [
|
|
'type' => 'string',
|
|
'description' => 'Translated text for input key ' . $key,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'type' => 'json_schema',
|
|
'json_schema' => [
|
|
'name' => 'product_translation_response',
|
|
'strict' => true,
|
|
'schema' => [
|
|
'type' => 'object',
|
|
'additionalProperties' => false,
|
|
'required' => ['translations'],
|
|
'properties' => [
|
|
'translations' => [
|
|
'type' => 'object',
|
|
'additionalProperties' => false,
|
|
'required' => $keys,
|
|
'properties' => $properties,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
private function shouldRetryWithoutJsonSchema(int $status, string $body): bool
|
|
{
|
|
if ($status !== 400 && $status !== 422) {
|
|
return false;
|
|
}
|
|
|
|
$lowerBody = strtolower($body);
|
|
|
|
return str_contains($lowerBody, 'json_schema')
|
|
|| str_contains($lowerBody, 'response_format')
|
|
|| str_contains($lowerBody, 'unsupported');
|
|
}
|
|
}
|