This commit is contained in:
F4ilji
2026-04-07 17:54:26 +05:00
parent 04adba6682
commit 936b1fb5b0
468 changed files with 43498 additions and 4176 deletions
@@ -0,0 +1,93 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CallAiServiceForFileSelectionTask
{
/**
* Отправляет фрагменты файлов в AI для определения основного файла
*
* @param array $fragments Массив фрагментов [['filename' => '...', 'text' => '...'], ...]
* @return array|null Ответ AI с названием основного файла
*/
public function run(array $fragments): ?array
{
Log::info('[CallAiServiceForFileSelectionTask] Начало LLM запроса', [
'fragments_count' => count($fragments),
]);
$prompt = $this->buildPrompt($fragments);
Log::info('[CallAiServiceForFileSelectionTask] Отправка запроса к AI');
try {
$response = Http::timeout(30) // Таймаут 30 секунд
->withHeaders([
'Authorization' => 'Bearer ' . env('QWEN_API_KEY'),
'Content-Type' => 'application/json',
])->post('https://routerai.ru/api/v1/chat/completions', [
'model' => 'qwen/qwen3.5-flash-02-23',
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0,
'max_tokens' => 500,
]);
Log::info('[CallAiServiceForFileSelectionTask] Ответ от AI', [
'status' => $response->status(),
'successful' => $response->successful(),
]);
if ($response->successful()) {
$result = $response->json();
$llmResponse = json_decode($result['choices'][0]['message']['content'], true);
Log::info('[CallAiServiceForFileSelectionTask] Распознан ответ', [
'main_file' => $llmResponse['main_file'] ?? null,
]);
return $llmResponse;
} else {
Log::error('[CallAiServiceForFileSelectionTask] Ошибка AI запроса', [
'status' => $response->status(),
'body' => $response->body(),
]);
}
} catch (\Exception $e) {
Log::error('[CallAiServiceForFileSelectionTask] Исключение при запросе к AI', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
return null;
}
/**
* Формирует промпт для AI
*/
private function buildPrompt(array $fragments): string
{
$filesInfo = "";
foreach ($fragments as $index => $fragment) {
$filesInfo .= "Файл #{$index}: {$fragment['filename']}\n";
$filesInfo .= "Текст: {$fragment['text']}\n\n";
}
return "Ты — ассистент для определения основного файла с новостью. " .
"Тебе предоставлены фрагменты текста из нескольких DOCX файлов.\n\n" .
"СПИСОК ФАЙЛОВ:\n{$filesInfo}\n" .
"ЗАДАЧА: Определи, какой файл содержит основной текст новости (не приложение, не документ, а именно новость/статью).\n\n" .
"ВЕРНИ СТРОГО JSON:\n" .
"{\n" .
' "main_file": "точное название файла с расширением .docx",' . "\n" .
' "reason": "краткое объяснение выбора"' . "\n" .
"}\n\n" .
"Если не можешь определить — верни первый файл из списка.";
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CallAiServiceTask
{
/**
* Отправляет текст в 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::info('[CallAiServiceTask] Начало AI запроса', [
'text_length' => strlen($text),
'categories_count' => $categories->count(),
]);
// Проверяем наличие API ключа
$apiKey = env('QWEN_API_KEY');
if (empty($apiKey)) {
Log::error('[CallAiServiceTask] API ключ не настроен', [
'env_key' => 'QWEN_API_KEY',
]);
throw new \RuntimeException('AI API ключ не настроен в окружении');
}
$categoryList = collect($categories)
->map(fn($cat) => "{$cat->id}: {$cat->title}")
->implode("\n");
$systemPrompt = $this->buildSystemPrompt($categoryList);
Log::info('[CallAiServiceTask] Отправка запроса к 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::warning('[CallAiServiceTask] Попытка не удалась, повтор...', [
'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::info('[CallAiServiceTask] Ответ от AI', [
'status' => $response->status(),
'successful' => $response->successful(),
]);
if ($response->successful()) {
$result = $response->json();
$content = $result['choices'][0]['message']['content'] ?? null;
if (empty($content)) {
Log::error('[CallAiServiceTask] Пустой ответ от AI', [
'response' => $result,
]);
return null;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('[CallAiServiceTask] Ошибка парсинга JSON ответа', [
'error' => json_last_error_msg(),
'content' => substr($content, 0, 500),
]);
return null;
}
Log::info('[CallAiServiceTask] AI данные успешно распознаны', [
'title' => $data['title'] ?? 'N/A',
'has_body' => isset($data['body']),
'category_id' => $data['category_id'] ?? 'N/A',
]);
return $data;
}
Log::error('[CallAiServiceTask] AI запрос не удался', [
'status' => $response->status(),
'body' => $response->body(),
'headers' => $response->headers(),
]);
return null;
} catch (\Exception $e) {
Log::error('[CallAiServiceTask] Критическая ошибка 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"
. "}";
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
class ConvertDocToDocxTask
{
/**
* Конвертирует .doc файл в .docx с помощью LibreOffice
*
* @param UploadedFile $file Исходный .doc файл
* @return string|null Путь к конвертированному .docx файлу или null при ошибке
*/
public function run(UploadedFile $file): ?string
{
Log::info('[ConvertDocToDocxTask] Начало конвертации', [
'file' => $file->getClientOriginalName(),
'extension' => $file->getClientOriginalExtension(),
]);
if (strtolower($file->getClientOriginalExtension()) !== 'doc') {
Log::warning('[ConvertDocToDocxTask] Неверное расширение', ['extension' => $file->getClientOriginalExtension()]);
return null;
}
// Сохраняем исходный файл
$originalPath = $file->storeAs('temp/conversion', 'original_' . time() . '_' . Str::random(10) . '.doc', 'local');
$absoluteOriginalPath = Storage::disk('local')->path($originalPath);
if (!file_exists($absoluteOriginalPath)) {
Log::error('[ConvertDocToDocxTask] Файл не сохранен', ['path' => $absoluteOriginalPath]);
return null;
}
$outputDir = dirname($absoluteOriginalPath);
// Создаем уникальный путь для профиля LibreOffice в /tmp, чтобы избежать ошибок прав в /var/www
$userProfileDir = '/tmp/libreoffice_profile_' . uniqid();
// Формируем команду с изоляцией профиля и установкой HOME
// -env:UserInstallation указывает LibreOffice использовать временную папку вместо системной .cache
$command = sprintf(
'export HOME=/tmp && libreoffice -env:UserInstallation=file://%s --headless --convert-to docx --outdir %s %s 2>&1',
escapeshellarg($userProfileDir),
escapeshellarg($outputDir),
escapeshellarg($absoluteOriginalPath)
);
Log::info('[ConvertDocToDocxTask] Выполнение команды', ['command' => $command]);
$output = shell_exec($command);
// Ожидаемый путь результата
$docxPath = preg_replace('/\.doc$/i', '.docx', $absoluteOriginalPath);
// Удаляем временный профиль LibreOffice сразу после выполнения
if (is_dir($userProfileDir)) {
shell_exec('rm -rf ' . escapeshellarg($userProfileDir));
}
if (!file_exists($docxPath)) {
Log::error('[ConvertDocToDocxTask] Конвертация не удалась', [
'file' => $file->getClientOriginalName(),
'output' => $output,
'expected_path' => $docxPath,
]);
Storage::disk('local')->delete($originalPath);
return null;
}
// Удаляем исходный .doc
Storage::disk('local')->delete($originalPath);
$relativeDocxPath = preg_replace('/\.doc$/i', '.docx', $originalPath);
Log::info('[ConvertDocToDocxTask] Конвертация успешна', ['result_path' => $relativeDocxPath]);
return $relativeDocxPath;
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class ExtractTextFragmentTask
{
/**
* Извлекает фрагмент текста (первые N символов) из DOCX или DOC файла
*/
public function run(UploadedFile $file, int $maxLength = 300): string
{
Log::info('[ExtractTextFragmentTask] Начало извлечения фрагмента', [
'file' => $file->getClientOriginalName(),
'extension' => $file->getClientOriginalExtension(),
]);
$extension = strtolower($file->getClientOriginalExtension());
// Для DOCX — парсим напрямую
if ($extension === 'docx') {
return $this->extractFromDocx($file, $maxLength);
}
// Для DOC — конвертируем и парсим
if ($extension === 'doc') {
return $this->extractFromDoc($file, $maxLength);
}
Log::warning('[ExtractTextFragmentTask] Неизвестный формат', [
'extension' => $extension,
]);
return '(неподдерживаемый формат)';
}
/**
* Извлекает фрагмент из DOCX
*/
private function extractFromDocx(UploadedFile $file, int $maxLength): string
{
// Сохраняем во временную директорию
$tempPath = $file->storeAs('temp', 'temp_fragment_' . time() . '.docx', 'local');
$absoluteTempPath = Storage::disk('local')->path($tempPath);
// Парсим файл через ParseDocxTask
$fullText = app(ParseDocxTask::class)->run($absoluteTempPath);
// Удаляем временный файл
Storage::disk('local')->delete($tempPath);
return $this->truncateText($fullText, $maxLength);
}
/**
* Извлекает фрагмент из DOC (через конвертацию)
*/
private function extractFromDoc(UploadedFile $file, int $maxLength): string
{
// Используем ExtractTextFromDocumentTask для конвертации и извлечения
$extractor = app(ExtractTextFromDocumentTask::class);
$fullText = $extractor->run($file);
return $this->truncateText($fullText, $maxLength);
}
/**
* Обрезает текст до нужной длины
*/
private function truncateText(?string $text, int $maxLength): string
{
if (empty($text)) {
return '(пустой файл)';
}
if (strlen($text) > $maxLength) {
return substr($text, 0, $maxLength) . '...';
}
return $text;
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use App\Containers\Dashboard\Tasks\AI\ParseDocxTask;
use App\Containers\Dashboard\Tasks\AI\ConvertDocToDocxTask;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class ExtractTextFromDocumentTask
{
public function __construct(
private readonly ParseDocxTask $parseDocxTask,
private readonly ConvertDocToDocxTask $convertDocToDocxTask,
) {}
/**
* Извлекает текст из DOCX или DOC файла
* Автоматически определяет формат и конвертирует при необходимости
*/
public function run(UploadedFile $file): ?string
{
$extension = strtolower($file->getClientOriginalExtension());
Log::info('[ExtractTextFromDocumentTask] Начало извлечения текста', [
'file' => $file->getClientOriginalName(),
'extension' => $extension,
'size' => $file->getSize(),
]);
// Если DOCX — парсим напрямую
if ($extension === 'docx') {
Log::info('[ExtractTextFromDocumentTask] Обработка DOCX файла');
return $this->extractFromDocx($file);
}
// Если DOC — конвертируем в DOCX, затем парсим
if ($extension === 'doc') {
Log::info('[ExtractTextFromDocumentTask] Обработка DOC файла (требуется конвертация)');
return $this->extractFromDoc($file);
}
Log::warning('[ExtractTextFromDocumentTask] Неизвестный формат файла', [
'extension' => $extension,
]);
return null;
}
/**
* Извлекает текст из DOCX файла
*/
private function extractFromDocx(UploadedFile $file): ?string
{
Log::info('[ExtractTextFromDocumentTask:extractFromDocx] Начало');
// Сохраняем во временную директорию
$tempPath = $file->storeAs('temp', 'temp_doc_' . time() . '.docx', 'local');
$absoluteTempPath = Storage::disk('local')->path($tempPath);
Log::info('[ExtractTextFromDocumentTask:extractFromDocx] Файл сохранен', [
'path' => $tempPath,
'absolute_path' => $absoluteTempPath,
]);
// Парсим файл
$extractedText = $this->parseDocxTask->run($absoluteTempPath);
Log::info('[ExtractTextFromDocumentTask:extractFromDocx] Результат парсинга', [
'text_length' => strlen($extractedText ?? ''),
]);
// Удаляем временный файл
Storage::disk('local')->delete($tempPath);
return $extractedText;
}
/**
* Извлекает текст из DOC файла (через конвертацию)
*/
private function extractFromDoc(UploadedFile $file): ?string
{
Log::info('[ExtractTextFromDocumentTask:extractFromDoc] Начало конвертации');
// Конвертируем DOC → DOCX
$docxPath = $this->convertDocToDocxTask->run($file);
if (!$docxPath) {
Log::error('[ExtractTextFromDocumentTask:extractFromDoc] Не удалось конвертировать DOC файл', [
'file' => $file->getClientOriginalName(),
]);
return null;
}
Log::info('[ExtractTextFromDocumentTask:extractFromDoc] Конвертация успешна', [
'docx_path' => $docxPath,
]);
// Получаем абсолютный путь
$absoluteDocxPath = Storage::disk('local')->path($docxPath);
// Парсим конвертированный файл
$extractedText = $this->parseDocxTask->run($absoluteDocxPath);
Log::info('[ExtractTextFromDocumentTask:extractFromDoc] Результат парсинга', [
'text_length' => strlen($extractedText ?? ''),
]);
// Удаляем конвертированный файл
Storage::disk('local')->delete($docxPath);
return $extractedText;
}
}
@@ -0,0 +1,155 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class FindMainNewsFileTask
{
/**
* Ключевые слова для определения файла с новостью
*/
private const NEWS_KEYWORDS = [
'новость', 'новости', 'заметка', 'заметки', 'статья', 'статьи',
'материал', 'материалы', 'текст', 'контент', 'публикация',
'news', 'article', 'post', 'material', 'text',
'релиз', 'пресс', 'отчет', 'отчёт', 'доклад'
];
/**
* Находит основной файл с текстом новости среди всех загруженных файлов
*
* @param Collection<UploadedFile> $files Все загруженные файлы
* @return UploadedFile|null Основной файл или null если не найден
*/
public function run(Collection $files): ?UploadedFile
{
Log::info('[FindMainNewsFileTask] Начало поиска основного файла', [
'files_count' => $files->count(),
'files' => $files->map(function($f) {
return $f->getClientOriginalName() . ' (' . $f->getClientOriginalExtension() . ')';
})->toArray(),
]);
// Фильтруем DOC и DOCX файлы (поддерживаем оба формата)
$docFiles = $files->filter(function($file) {
$ext = strtolower($file->getClientOriginalExtension());
$mimeType = $file->getMimeType();
// Проверяем по расширению
if (in_array($ext, ['doc', 'docx'])) {
return true;
}
// Проверяем по MIME-типу (для случаев, когда расширение не определено)
$documentMimes = [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (in_array($mimeType, $documentMimes, true)) {
return true;
}
// Проверяем по содержимому имени файла (для MIME-кодированных имён)
$filename = $file->getClientOriginalName();
if (str_contains(strtolower($filename), '.docx') || str_contains(strtolower($filename), '.doc')) {
return true;
}
return false;
});
Log::info('[FindMainNewsFileTask] Найдено DOC/DOCX файлов', [
'count' => $docFiles->count(),
'files' => $docFiles->map(fn($f) => $f->getClientOriginalName())->toArray(),
]);
if ($docFiles->isEmpty()) {
Log::warning('[FindMainNewsFileTask] Не найдено DOC/DOCX файлов');
return null;
}
// Если только один файл — используем его
if ($docFiles->count() === 1) {
Log::info('[FindMainNewsFileTask] Найден один файл, используем его', [
'file' => $docFiles->first()->getClientOriginalName(),
]);
return $docFiles->first();
}
// Ищем файл с ключевыми словами в названии
foreach ($docFiles as $file) {
$filename = mb_strtolower($file->getClientOriginalName());
foreach (self::NEWS_KEYWORDS as $keyword) {
if (str_contains($filename, $keyword)) {
Log::info('[FindMainNewsFileTask] Найден файл по ключевому слову', [
'file' => $file->getClientOriginalName(),
'keyword' => $keyword,
]);
return $file;
}
}
}
Log::info('[FindMainNewsFileTask] Не найдено файлов по ключевым словам, используем LLM');
// Если не нашли по ключевым словам — используем LLM
return $this->findViaLLM($docFiles);
}
/**
* Определяет основной файл через LLM
*/
private function findViaLLM(Collection $docFiles): ?UploadedFile
{
Log::info('[FindMainNewsFileTask:findViaLLM] Начало LLM определения', [
'files_count' => $docFiles->count(),
]);
if ($docFiles->isEmpty()) {
return null;
}
if ($docFiles->count() === 1) {
return $docFiles->first();
}
// Собираем фрагменты текста из каждого файла
$fragments = [];
foreach ($docFiles as $file) {
Log::info('[FindMainNewsFileTask:findViaLLM] Извлечение фрагмента', [
'file' => $file->getClientOriginalName(),
]);
$fragment = app(ExtractTextFragmentTask::class)->run($file, 300);
$fragments[] = [
'filename' => $file->getClientOriginalName(),
'text' => $fragment,
];
}
// Отправляем запрос к LLM
Log::info('[FindMainNewsFileTask:findViaLLM] Отправка запроса к LLM');
$llmResponse = app(CallAiServiceForFileSelectionTask::class)->run($fragments);
if ($llmResponse && isset($llmResponse['main_file'])) {
Log::info('[FindMainNewsFileTask:findViaLLM] LLM вернула результат', [
'main_file' => $llmResponse['main_file'],
]);
$mainFilename = $llmResponse['main_file'];
foreach ($docFiles as $file) {
if ($file->getClientOriginalName() === $mainFilename) {
return $file;
}
}
}
Log::warning('[FindMainNewsFileTask:findViaLLM] LLM не помогла, используем первый файл');
// Fallback — первый файл
return $docFiles->first();
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Containers\Dashboard\Tasks\AI;
use ZipArchive;
use Illuminate\Support\Facades\Log;
class ParseDocxTask
{
/**
* Извлекает текст из .docx файла с подробным логированием ошибок
*/
public function run(string $filePath): ?string
{
// 1. Проверяем, существует ли физически файл по этому пути
if (!file_exists($filePath)) {
Log::error("[ParseDocxTask] Файл не найден по пути: {$filePath}");
return "ОШИБКА ДЕБАГА: Файл не найден на сервере.";
}
$zip = new ZipArchive();
// 2. Пытаемся открыть архив
$res = $zip->open($filePath);
// Если не true, значит произошла ошибка
if ($res !== true) {
$errorMsg = $this->getZipError($res);
Log::error("[ParseDocxTask] Ошибка ZipArchive: {$errorMsg} | Путь: {$filePath}");
// Возвращаем текст ошибки прямо на фронт, чтобы сразу увидеть причину!
return "ОШИБКА ДЕБАГА ZipArchive: {$errorMsg}";
}
// 3. Пытаемся достать нужный XML файл
$xmlString = $zip->getFromName('word/document.xml');
// Обязательно закрываем архив, чтобы не было утечек памяти
$zip->close();
if ($xmlString === false) {
Log::error("[ParseDocxTask] Файл word/document.xml не найден внутри архива. Возможно, это не настоящий DOCX.");
return "ОШИБКА ДЕБАГА: Внутри архива нет word/document.xml";
}
// 4. Парсим XML
try {
$text = str_replace('</w:p>', " \n", $xmlString);
$text = strip_tags($text);
return trim($text);
} catch (\Exception $e) {
Log::error("[ParseDocxTask] Ошибка очистки тегов: " . $e->getMessage());
return "ОШИБКА ДЕБАГА при очистке текста.";
}
}
/**
* Превращает числовой код ошибки ZipArchive в текст
*/
private function getZipError(int $code): string
{
$errors = [
ZipArchive::ER_EXISTS => 'Файл уже существует.',
ZipArchive::ER_INCONS => 'Несовместимый ZIP-архив.',
ZipArchive::ER_INVAL => 'Недопустимый аргумент.',
ZipArchive::ER_MEMORY => 'Ошибка выделения памяти (Malloc).',
ZipArchive::ER_NOENT => 'Нет такого файла.',
ZipArchive::ER_NOZIP => 'Это НЕ ZIP-архив (Скорее всего файл переименован или битый).',
ZipArchive::ER_OPEN => 'Невозможно открыть файл (Проблема с правами).',
ZipArchive::ER_READ => 'Ошибка чтения.',
ZipArchive::ER_SEEK => 'Ошибка поиска (Seek error).',
];
return $errors[$code] ?? "Неизвестная ошибка с кодом: {$code}";
}
}