Changes
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
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,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
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
|
||||
{
|
||||
$categoryList = collect($categories)
|
||||
->map(fn($cat) => "{$cat->id}: {$cat->title}")
|
||||
->implode("\n");
|
||||
|
||||
$systemPrompt = $this->buildSystemPrompt($categoryList);
|
||||
|
||||
$response = Http::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',
|
||||
'reasoning' => ['enabled' => false],
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $text],
|
||||
],
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
'temperature' => 0,
|
||||
'max_tokens' => 2000,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$result = $response->json();
|
||||
return json_decode($result['choices'][0]['message']['content'], true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует системный промпт для 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,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
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(),
|
||||
'size' => $file->getSize(),
|
||||
'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);
|
||||
|
||||
Log::info('[ConvertDocToDocxTask] Файл сохранен', [
|
||||
'original_path' => $originalPath,
|
||||
'absolute_path' => $absoluteOriginalPath,
|
||||
'file_exists' => file_exists($absoluteOriginalPath),
|
||||
]);
|
||||
|
||||
// Проверяем существование файла
|
||||
if (!file_exists($absoluteOriginalPath)) {
|
||||
Log::error('[ConvertDocToDocxTask] Файл не сохранен', [
|
||||
'path' => $absoluteOriginalPath,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Директория для вывода
|
||||
$outputDir = dirname($absoluteOriginalPath);
|
||||
|
||||
// Конвертируем через LibreOffice
|
||||
$command = sprintf(
|
||||
'libreoffice --headless --convert-to docx --outdir %s %s 2>&1',
|
||||
escapeshellarg($outputDir),
|
||||
escapeshellarg($absoluteOriginalPath)
|
||||
);
|
||||
|
||||
Log::info('[ConvertDocToDocxTask] Выполнение команды', [
|
||||
'command' => $command,
|
||||
]);
|
||||
|
||||
$output = shell_exec($command);
|
||||
|
||||
Log::info('[ConvertDocToDocxTask] Результат конвертации', [
|
||||
'output' => $output,
|
||||
]);
|
||||
|
||||
// Ищем конвертированный файл
|
||||
$docxPath = preg_replace('/\.doc$/i', '.docx', $absoluteOriginalPath);
|
||||
|
||||
Log::info('[ConvertDocToDocxTask] Проверка результата', [
|
||||
'expected_path' => $docxPath,
|
||||
'file_exists' => file_exists($docxPath),
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
// Возвращаем относительный путь к .docx файлу
|
||||
$relativeDocxPath = preg_replace('/\.doc$/i', '.docx', $originalPath);
|
||||
|
||||
Log::info('[ConvertDocToDocxTask] Конвертация успешна', [
|
||||
'result_path' => $relativeDocxPath,
|
||||
]);
|
||||
|
||||
return $relativeDocxPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
use App\Containers\Article\Enums\PostStatus;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Services\Filament\Domain\Posts\PostDataProcessor;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreatePostFromAiDataTask
|
||||
{
|
||||
/**
|
||||
* Создаёт пост из данных, полученных от AI
|
||||
*
|
||||
* @param array $newsData Данные от AI (title, body, authors, tags, category_id)
|
||||
* @param string|null $documentPath Путь к основному документу
|
||||
* @param array $mediaPaths Пути к медиафайлам
|
||||
* @param array $attachedFiles Прикреплённые файлы (для блока files)
|
||||
*/
|
||||
public function run(array $newsData, ?string $documentPath, array $mediaPaths, array $attachedFiles): Post
|
||||
{
|
||||
// Формируем контент в формате редактора
|
||||
$content = $this->buildContent($newsData, $attachedFiles);
|
||||
|
||||
// Обрабатываем изображения
|
||||
['preview' => $previewPath, 'images' => $imagesPaths] = $this->processImages($mediaPaths);
|
||||
|
||||
// Подготавливаем данные для создания поста
|
||||
$postData = $this->preparePostData($newsData, $content, $previewPath, $imagesPaths);
|
||||
|
||||
// Обрабатываем через PostDataProcessor (как в админке)
|
||||
$processedData = (new PostDataProcessor())->processCreate($postData);
|
||||
|
||||
// Создаём пост
|
||||
$post = Post::create($processedData);
|
||||
|
||||
// Сохраняем теги
|
||||
$this->syncTags($post, $newsData);
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует контент в формате редактора
|
||||
*/
|
||||
private function buildContent(array $newsData, array $attachedFiles): array
|
||||
{
|
||||
$content = [
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'data' => [
|
||||
'seo_active' => true,
|
||||
'content' => $newsData['body'] ?? '',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($attachedFiles)) {
|
||||
$content[] = [
|
||||
'type' => 'files',
|
||||
'data' => [
|
||||
'file' => $attachedFiles,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает изображения: все изображения идут в gallery, первое — preview
|
||||
*/
|
||||
private function processImages(array $mediaPaths): array
|
||||
{
|
||||
$imagesPaths = [];
|
||||
|
||||
$imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'tiff'];
|
||||
|
||||
foreach ($mediaPaths as $path) {
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if (in_array($extension, $imageExtensions)) {
|
||||
$imagesPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'preview' => !empty($imagesPaths) ? reset($imagesPaths) : null,
|
||||
'images' => $imagesPaths,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает данные для создания поста
|
||||
*/
|
||||
private function preparePostData(array $newsData, array $content, ?string $previewPath, array $imagesPaths): array
|
||||
{
|
||||
return [
|
||||
'title' => $newsData['title'] ?? 'Без названия',
|
||||
'slug' => Str::slug($newsData['title'] ?? '') . '-' . time(),
|
||||
'content' => $content,
|
||||
'authors' => $newsData['authors'] ?? [],
|
||||
'category_id' => $newsData['category_id'] ?? null,
|
||||
'status' => PostStatus::VERIFICATION,
|
||||
'user_id' => auth()->id(),
|
||||
'preview' => $previewPath,
|
||||
'images' => !empty($imagesPaths) ? $imagesPaths : null,
|
||||
'publish_setting' => [
|
||||
'publish_after' => false,
|
||||
'publish_at' => null,
|
||||
],
|
||||
'publication' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет теги поста
|
||||
*/
|
||||
private function syncTags(Post $post, array $newsData): void
|
||||
{
|
||||
if (!empty($newsData['tags'])) {
|
||||
$tagNames = array_map(
|
||||
fn($tag) => str_replace('#', '', $tag),
|
||||
$newsData['tags']
|
||||
);
|
||||
$post->syncTags($tagNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
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;
|
||||
|
||||
use App\Containers\Dashboard\Tasks\ParseDocxTask;
|
||||
use App\Containers\Dashboard\Tasks\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,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
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());
|
||||
return in_array($ext, ['doc', 'docx']);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
use App\Containers\Article\Models\Post;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class GetDraftPostsTask
|
||||
{
|
||||
public function run(): Collection
|
||||
{
|
||||
return Post::query()
|
||||
->with(['category', 'author'])
|
||||
->whereNull('publish_at')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get([
|
||||
'id',
|
||||
'title',
|
||||
'slug',
|
||||
'preview_text',
|
||||
'content',
|
||||
'status',
|
||||
'authors',
|
||||
'preview',
|
||||
'images',
|
||||
'reading_time',
|
||||
'category_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tasks;
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user