154 lines
7.1 KiB
PHP
154 lines
7.1 KiB
PHP
<?php
|
||
|
||
namespace App\Containers\Dashboard\Tasks\AI;
|
||
|
||
use App\Containers\Dashboard\Tasks\IntegrationCredentials\GetIntegrationCredentialTask;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
class CallAiServiceTask
|
||
{
|
||
public function __construct(
|
||
private readonly GetIntegrationCredentialTask $getCredentialTask,
|
||
) {}
|
||
|
||
/**
|
||
* Отправляет текст в AI сервис для извлечения структурированных данных
|
||
*
|
||
* @param string $text Исходный текст новости
|
||
* @param Collection $categories Коллекция категорий для выбора
|
||
* @return array|null Распознанные данные (title, body, authors, tags, category_id)
|
||
*/
|
||
public function run(string $text, Collection $categories): ?array
|
||
{
|
||
try {
|
||
Log::channel('ai')->info('Начало AI запроса', [
|
||
'text_length' => strlen($text),
|
||
'categories_count' => $categories->count(),
|
||
]);
|
||
|
||
$credential = $this->getCredentialTask->run('qwen');
|
||
if (!$credential) {
|
||
Log::channel('ai')->error('API ключ не настроен', [
|
||
'provider' => 'qwen',
|
||
]);
|
||
throw new \RuntimeException('AI API ключ не настроен');
|
||
}
|
||
$apiKey = $credential->payload['api_key'] ?? '';
|
||
|
||
$categoryList = collect($categories)
|
||
->map(fn($cat) => "{$cat->id}: {$cat->title}")
|
||
->implode("\n");
|
||
|
||
$systemPrompt = $this->buildSystemPrompt($categoryList);
|
||
|
||
Log::channel('ai')->info('Отправка запроса к AI', [
|
||
'url' => 'https://routerai.ru/api/v1/chat/completions',
|
||
'model' => 'qwen/qwen3.5-flash-02-23',
|
||
]);
|
||
|
||
$response = Http::withHeaders([
|
||
'Authorization' => 'Bearer ' . $apiKey,
|
||
'Content-Type' => 'application/json',
|
||
])
|
||
->timeout(60) // Увеличиваем timeout до 60 секунд
|
||
->retry(3, 1000, function ($exception, $response) {
|
||
// Повторяем только при ошибках соединения или 5xx
|
||
if ($response && $response->successful()) {
|
||
return false;
|
||
}
|
||
Log::channel('ai')->warning('Попытка не удалась, повтор...', [
|
||
'exception' => $exception->getMessage(),
|
||
]);
|
||
return true;
|
||
})
|
||
->post('https://routerai.ru/api/v1/chat/completions', [
|
||
'model' => 'qwen/qwen3.5-flash-02-23',
|
||
'reasoning' => ['enabled' => false],
|
||
'messages' => [
|
||
['role' => 'system', 'content' => $systemPrompt],
|
||
['role' => 'user', 'content' => $text],
|
||
],
|
||
'response_format' => ['type' => 'json_object'],
|
||
'temperature' => 0,
|
||
'max_tokens' => 2000,
|
||
]);
|
||
|
||
Log::channel('ai')->info('Ответ от AI', [
|
||
'status' => $response->status(),
|
||
'successful' => $response->successful(),
|
||
]);
|
||
|
||
if ($response->successful()) {
|
||
$result = $response->json();
|
||
$content = $result['choices'][0]['message']['content'] ?? null;
|
||
|
||
if (empty($content)) {
|
||
Log::channel('ai')->error('Пустой ответ от AI', [
|
||
'response' => $result,
|
||
]);
|
||
return null;
|
||
}
|
||
|
||
$data = json_decode($content, true);
|
||
|
||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
Log::channel('ai')->error('Ошибка парсинга JSON ответа', [
|
||
'error' => json_last_error_msg(),
|
||
'content' => substr($content, 0, 500),
|
||
]);
|
||
return null;
|
||
}
|
||
|
||
Log::channel('ai')->info('AI данные успешно распознаны', [
|
||
'title' => $data['title'] ?? 'N/A',
|
||
'has_body' => isset($data['body']),
|
||
'category_id' => $data['category_id'] ?? 'N/A',
|
||
]);
|
||
|
||
return $data;
|
||
}
|
||
|
||
Log::channel('ai')->error('AI запрос не удался', [
|
||
'status' => $response->status(),
|
||
'body' => $response->body(),
|
||
'headers' => $response->headers(),
|
||
]);
|
||
|
||
return null;
|
||
} catch (\Exception $e) {
|
||
Log::channel('ai')->error('Критическая ошибка AI запроса', [
|
||
'error' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Формирует системный промпт для AI
|
||
*/
|
||
private function buildSystemPrompt(string $categoryList): string
|
||
{
|
||
return "Ты — строгий агент по извлечению данных. Твоя задача — преобразовать входящий текст новости в JSON.\n\n"
|
||
. "СПИСОК КАТЕГОРИЙ (ID: Название):\n"
|
||
. $categoryList
|
||
. "\n\nПРАВИЛА:\n"
|
||
. "1. Поля \"title\" и \"body\" копируй ТОЧЬ-В-ТОЧЬ из источника. Запрещено исправлять ошибки, менять пунктуацию или добавлять Markdown-разметку. Только чистый текст.\n"
|
||
. "2. В конце новости извлеки имя автора(-ов) и их должности, это должен быть массив \"authors\". Если оно не указано явно — верни null.\n"
|
||
. "3. \"tags\" извлекай ТОЛЬКО слова, начинающиеся с символа решетки (#). Если таких слов в тексте нет — верни null. Не создавай свои теги.\n"
|
||
. "4. В поле \"category_id\" запиши идентификатор из предоставленного списка выше.\n"
|
||
. "5. Полностью игнорируй комментарии пользователей и рекламный мусор, не включая их в \"body\".\n\n"
|
||
. "ОТВЕТ ДОЛЖЕН БЫТЬ СТРОГО В JSON:\n"
|
||
. "{\n"
|
||
. ' "title": "string",' . "\n"
|
||
. ' "body": "string",' . "\n"
|
||
. ' "authors": ["ФИО и должность"] или null,' . "\n"
|
||
. ' "tags": ["#тег1"] или null,' . "\n"
|
||
. ' "category_id": number/string' . "\n"
|
||
. "}";
|
||
}
|
||
}
|