Changes
This commit is contained in:
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions;
|
||||
|
||||
use App\Containers\Article\Models\Category;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Containers\Dashboard\Tasks\CallAiServiceForFileSelectionTask;
|
||||
use App\Containers\Dashboard\Tasks\CallAiServiceTask;
|
||||
use App\Containers\Dashboard\Tasks\CreatePostFromAiDataTask;
|
||||
use App\Containers\Dashboard\Tasks\ExtractTextFromDocumentTask;
|
||||
use App\Containers\Dashboard\Tasks\ExtractTextFragmentTask;
|
||||
use App\Containers\Dashboard\Tasks\FindMainNewsFileTask;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessMixedFilesAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindMainNewsFileTask $findMainNewsFileTask,
|
||||
private readonly ExtractTextFromDocumentTask $extractTextFromDocumentTask,
|
||||
private readonly CallAiServiceTask $callAiServiceTask,
|
||||
private readonly CreatePostFromAiDataTask $createPostFromAiDataTask,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Обрабатывает смешанные файлы и создает пост
|
||||
*
|
||||
* @param Collection<UploadedFile> $files Все загруженные файлы
|
||||
* @return array Данные о созданном посте
|
||||
*/
|
||||
public function run(Collection $files): array
|
||||
{
|
||||
Log::info('[ProcessMixedFilesAction] Начало обработки файлов', [
|
||||
'files_count' => $files->count(),
|
||||
'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(),
|
||||
]);
|
||||
|
||||
// Находим основной файл с текстом новости
|
||||
$mainFile = $this->findMainNewsFileTask->run($files);
|
||||
|
||||
if (!$mainFile) {
|
||||
Log::error('[ProcessMixedFilesAction] Не найден файл для извлечения текста');
|
||||
throw new \RuntimeException('Не найден DOC/DOCX файл для извлечения текста');
|
||||
}
|
||||
|
||||
Log::info('[ProcessMixedFilesAction] Основной файл найден', [
|
||||
'file' => $mainFile->getClientOriginalName(),
|
||||
'extension' => $mainFile->getClientOriginalExtension(),
|
||||
]);
|
||||
|
||||
// Извлекаем текст из основного файла (поддерживает и .doc, и .docx)
|
||||
$extractedText = $this->extractTextFromDocumentTask->run($mainFile);
|
||||
|
||||
Log::info('[ProcessMixedFilesAction] Результат извлечения текста', [
|
||||
'text_length' => strlen($extractedText ?? ''),
|
||||
'has_text' => !empty($extractedText),
|
||||
]);
|
||||
|
||||
if (empty($extractedText)) {
|
||||
Log::warning('[ProcessMixedFilesAction] Пустой текст после извлечения', [
|
||||
'file' => $mainFile->getClientOriginalName(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Сохраняем все файлы
|
||||
['documentPath' => $documentPath, 'mediaPaths' => $mediaPaths] = $this->saveFiles($files, $mainFile);
|
||||
|
||||
// Обрабатываем прикреплённые файлы из media
|
||||
$attachedFiles = $this->processAttachedFiles($files, $mainFile);
|
||||
|
||||
// Получаем категории
|
||||
$categories = Category::all();
|
||||
|
||||
// Отправляем текст в AI
|
||||
$newsData = $this->callAiServiceTask->run($extractedText, $categories);
|
||||
|
||||
if (!$newsData) {
|
||||
throw new \RuntimeException('Не удалось распознать данные через AI сервис');
|
||||
}
|
||||
|
||||
// Создаём пост
|
||||
$post = $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles);
|
||||
|
||||
// Возвращаем данные для отображения
|
||||
return $this->prepareResponse($post, $newsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет все файлы
|
||||
*/
|
||||
private function saveFiles(Collection $files, UploadedFile $mainFile): array
|
||||
{
|
||||
// Сохраняем основной документ
|
||||
$documentPath = $mainFile->store('documents', 'local');
|
||||
|
||||
// Сохраняем остальные файлы как медиа
|
||||
$mediaPaths = $files
|
||||
->filter(fn($file) => $file !== $mainFile)
|
||||
->map(fn($file) => $file->store('media', 'public'))
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'documentPath' => $documentPath,
|
||||
'mediaPaths' => $mediaPaths,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает прикреплённые файлы (для добавления в контент)
|
||||
*/
|
||||
private function processAttachedFiles(Collection $files, UploadedFile $mainFile): array
|
||||
{
|
||||
$attachedFiles = [];
|
||||
$fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar'];
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Пропускаем основной файл
|
||||
if ($file === $mainFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
// Пропускаем изображения
|
||||
if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обрабатываем только файлы-вложения
|
||||
if (!in_array($extension, $fileExtensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$savedPath = $file->store('media/attachments', 'public');
|
||||
|
||||
$attachedFiles[] = [
|
||||
'expansion' => $extension,
|
||||
'size' => $this->formatFileSize($file->getSize()),
|
||||
'time_added' => time(),
|
||||
'title' => $file->getClientOriginalName(),
|
||||
'path' => $savedPath,
|
||||
];
|
||||
}
|
||||
|
||||
return $attachedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает ответ для возврата
|
||||
*/
|
||||
private function prepareResponse(Post $post, array $newsData): array
|
||||
{
|
||||
$post->load('category');
|
||||
|
||||
return [
|
||||
'post' => $post,
|
||||
'newsData' => $newsData,
|
||||
'preview_url' => $post->preview ? asset('storage/' . $post->preview) : null,
|
||||
'images_urls' => $post->images
|
||||
? array_map(fn($img) => asset('storage/' . $img), $post->images)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует размер файла
|
||||
*/
|
||||
private function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['б', 'КиБ', 'МиБ', 'ГиБ'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions;
|
||||
|
||||
use App\Containers\Article\Models\Category;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Containers\Dashboard\Tasks\CallAiServiceTask;
|
||||
use App\Containers\Dashboard\Tasks\CreatePostFromAiDataTask;
|
||||
use App\Containers\Dashboard\Tasks\ExtractTextFromDocumentTask;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ProcessUploadedFilesAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExtractTextFromDocumentTask $extractTextFromDocumentTask,
|
||||
private readonly CallAiServiceTask $callAiServiceTask,
|
||||
private readonly CreatePostFromAiDataTask $createPostFromAiDataTask,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Обрабатывает загруженные файлы и создаёт пост
|
||||
*
|
||||
* @param UploadedFile $document Основной документ
|
||||
* @param array $mediaFiles Массив медиафайлов
|
||||
* @return array Данные о созданном посте
|
||||
*/
|
||||
public function run(UploadedFile $document, array $mediaFiles = []): array
|
||||
{
|
||||
// Извлекаем текст из DOCX
|
||||
$extractedText = $this->extractText($document);
|
||||
|
||||
// Сохраняем документ
|
||||
$documentPath = $document->store('documents', 'local');
|
||||
|
||||
// Сохраняем медиафайлы
|
||||
$mediaPaths = $this->saveMediaFiles($mediaFiles);
|
||||
|
||||
// Обрабатываем прикреплённые файлы из media (DOCX, PDF и т.д.)
|
||||
$attachedFiles = $this->processAttachedFiles($mediaFiles);
|
||||
|
||||
// Получаем категории
|
||||
$categories = Category::all();
|
||||
|
||||
// Отправляем текст в AI
|
||||
$newsData = $this->callAiService($extractedText, $categories);
|
||||
|
||||
if (!$newsData) {
|
||||
throw new \RuntimeException('Не удалось распознать данные через AI сервис');
|
||||
}
|
||||
|
||||
// Создаём пост
|
||||
$post = $this->createPost($newsData, $documentPath, $mediaPaths, $attachedFiles);
|
||||
|
||||
// Возвращаем данные для отображения
|
||||
return $this->prepareResponse($post, $newsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает текст из документа
|
||||
*/
|
||||
private function extractText(UploadedFile $document): ?string
|
||||
{
|
||||
return $this->extractTextFromDocumentTask->run($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет медиафайлы
|
||||
*/
|
||||
private function saveMediaFiles(array $mediaFiles): array
|
||||
{
|
||||
return collect($mediaFiles)
|
||||
->map(fn($file) => $file->store('media', 'public'))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает прикреплённые файлы (для добавления в контент)
|
||||
*/
|
||||
private function processAttachedFiles(array $mediaFiles): array
|
||||
{
|
||||
$attachedFiles = [];
|
||||
$fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar'];
|
||||
|
||||
foreach ($mediaFiles as $file) {
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
// Пропускаем изображения
|
||||
if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обрабатываем только файлы-вложения
|
||||
if (!in_array($extension, $fileExtensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$savedPath = $file->store('media/attachments', 'public');
|
||||
|
||||
$attachedFiles[] = [
|
||||
'expansion' => $extension,
|
||||
'size' => $this->formatFileSize($file->getSize()),
|
||||
'time_added' => time(),
|
||||
'title' => $file->getClientOriginalName(),
|
||||
'path' => $savedPath,
|
||||
];
|
||||
}
|
||||
|
||||
return $attachedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывает AI сервис для распознавания данных
|
||||
*/
|
||||
private function callAiService(?string $text, $categories): ?array
|
||||
{
|
||||
if (!$text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->callAiServiceTask->run($text, $categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт пост из данных
|
||||
*/
|
||||
private function createPost(array $newsData, ?string $documentPath, array $mediaPaths, array $attachedFiles): Post
|
||||
{
|
||||
return $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает ответ для возврата
|
||||
*/
|
||||
private function prepareResponse(Post $post, array $newsData): array
|
||||
{
|
||||
$post->load('category');
|
||||
|
||||
return [
|
||||
'post' => $post,
|
||||
'newsData' => $newsData,
|
||||
'preview_url' => $post->preview ? asset('storage/' . $post->preview) : null,
|
||||
'images_urls' => $post->images
|
||||
? array_map(fn($img) => asset('storage/' . $img), $post->images)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует размер файла
|
||||
*/
|
||||
private function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['б', 'КиБ', 'МиБ', 'ГиБ'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions;
|
||||
|
||||
use App\Containers\Article\Enums\PostStatus;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Services\Filament\Domain\Posts\PostDataProcessor;
|
||||
use App\Services\Filament\Domain\Posts\PostNotificationService;
|
||||
use App\Services\Filament\Domain\Posts\PostSliderService;
|
||||
use App\Services\Filament\Domain\Posts\VkPostPublisher;
|
||||
use App\Dto\MainSliderDTO;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PublishPostAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostDataProcessor $postDataProcessor,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Публикует черновик поста
|
||||
*
|
||||
* @param Post $post Пост для публикации
|
||||
* @return Post Опубликованный пост
|
||||
*/
|
||||
public function run(Post $post): Post
|
||||
{
|
||||
// Обновляем данные поста
|
||||
$updatedData = $this->postDataProcessor->processUpdate([
|
||||
...$post->toArray(),
|
||||
'status' => PostStatus::PUBLISHED,
|
||||
'publish_setting' => [
|
||||
'publish_after' => false,
|
||||
'publish_at' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$post->update($updatedData);
|
||||
|
||||
// Отправляем уведомления
|
||||
$this->sendNotifications($post);
|
||||
|
||||
// Публикуем в VK
|
||||
$this->publishToVk($post);
|
||||
|
||||
// Обновляем слайд, если есть
|
||||
$this->updateSlide($post);
|
||||
|
||||
return $post->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет уведомления о публикации
|
||||
*/
|
||||
private function sendNotifications(Post $post): void
|
||||
{
|
||||
(new PostNotificationService())->send($post);
|
||||
}
|
||||
|
||||
/**
|
||||
* Публикует пост в VK
|
||||
*/
|
||||
private function publishToVk(Post $post): void
|
||||
{
|
||||
// Публикуем в VK по умолчанию (аналогично Filament, где есть чекбокс)
|
||||
(new VkPostPublisher())->publish(['vk' => true], $post);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет слайд для поста
|
||||
*/
|
||||
private function updateSlide(Post $post): void
|
||||
{
|
||||
// Если у поста уже есть слайд, обновляем его статус
|
||||
if ($post->slide) {
|
||||
$post->slide->update([
|
||||
'is_active' => true,
|
||||
'start_time' => $post->publish_at ?? Carbon::now(),
|
||||
]);
|
||||
}
|
||||
// Если слайда нет, но есть превью - создаём новый
|
||||
elseif ($post->preview) {
|
||||
$slideData = [
|
||||
'is_active' => true,
|
||||
'start_time' => $post->publish_at ?? Carbon::now(),
|
||||
'title' => $post->title,
|
||||
'description' => $post->preview_text,
|
||||
'image' => $post->preview,
|
||||
];
|
||||
|
||||
$sliderDTO = MainSliderDTO::fromArray($slideData);
|
||||
(new PostSliderService($sliderDTO, $post))->create();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Loaders;
|
||||
|
||||
class AliasesLoader
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public array $aliases = [];
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Loaders;
|
||||
|
||||
class MiddlewareLoader
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public array $middleware = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public array $middlewareGroups = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public array $routeMiddleware = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public array $middlewarePriority = [];
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Loaders;
|
||||
|
||||
class ProvidersLoader
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public array $providers = [];
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Tests\Unit;
|
||||
|
||||
use App\Containers\Article\Enums\PostStatus;
|
||||
use App\Containers\Article\Models\Post;
|
||||
use App\Services\Filament\Domain\Posts\VkPostPublisher;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Тест генерации текста поста для VK
|
||||
*
|
||||
* Запуск: docker exec ntspi-php php artisan test --filter=VkPostGenerationTest
|
||||
*/
|
||||
class VkPostGenerationTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Тест: генерация текста для поста с файлами
|
||||
*/
|
||||
public function test_vk_post_generation_with_files(): void
|
||||
{
|
||||
// Создаём тестовый пост с вложениями
|
||||
$post = Post::create([
|
||||
'title' => 'Тестовая новость с вложениями',
|
||||
'slug' => 'test-news-with-files-' . time(),
|
||||
'preview_text' => 'Это тестовая новость для проверки генерации VK поста',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'data' => [
|
||||
'seo_active' => true,
|
||||
'content' => '<p>Это основной текст новости. Он содержит важную информацию о событии.</p>',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'heading',
|
||||
'data' => [
|
||||
'content' => '<h2>Детали мероприятия</h2>',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'data' => [
|
||||
'content' => '<p>Здесь находится подробное описание мероприятия.</p>',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'files',
|
||||
'data' => [
|
||||
'file' => [
|
||||
[
|
||||
'title' => 'Презентация мероприятия.pdf',
|
||||
'path' => 'media/attachments/presentation.pdf',
|
||||
'expansion' => 'pdf',
|
||||
'size' => '2.5 МиБ',
|
||||
],
|
||||
[
|
||||
'title' => 'Список участников.xlsx',
|
||||
'path' => 'media/attachments/participants.xlsx',
|
||||
'expansion' => 'xlsx',
|
||||
'size' => '156 КиБ',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'authors' => ['Иванов Иван'],
|
||||
'status' => PostStatus::PUBLISHED,
|
||||
'publish_at' => now(),
|
||||
'images' => [
|
||||
'media/image1.jpg',
|
||||
'media/image2.png',
|
||||
],
|
||||
'preview' => 'media/image1.jpg',
|
||||
'user_id' => 1,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
// Генерируем текст для VK
|
||||
$publisher = new VkPostPublisher();
|
||||
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
|
||||
|
||||
// Выводим результат для визуальной проверки
|
||||
$this->info("\n" . str_repeat('═', 60));
|
||||
$this->info('📝 СГЕНЕРИРОВАННЫЙ ТЕКСТ ДЛЯ VK');
|
||||
$this->info(str_repeat('═', 60));
|
||||
$this->info($generatedText);
|
||||
$this->info(str_repeat('═', 60) . "\n");
|
||||
|
||||
// Проверки
|
||||
$this->assertStringContainsString('Тестовая новость с вложениями', $generatedText);
|
||||
$this->assertStringContainsString('Иванов Иван', $generatedText);
|
||||
|
||||
// Проверяем наличие ссылок на файлы в формате VK [url|text]
|
||||
$this->assertStringContainsString('📎 Презентация мероприятия.pdf', $generatedText);
|
||||
$this->assertStringContainsString('📎 Список участников.xlsx', $generatedText);
|
||||
|
||||
// Проверяем формат ссылок VK
|
||||
$this->assertMatchesRegularExpression('/\[https?:\/\/[^\|]+\|📎 [^\]]+\]/', $generatedText);
|
||||
|
||||
// Очищаем
|
||||
$post->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Тест: генерация текста без файлов
|
||||
*/
|
||||
public function test_vk_post_generation_without_files(): void
|
||||
{
|
||||
$post = Post::create([
|
||||
'title' => 'Новость без вложений',
|
||||
'slug' => 'news-without-files-' . time(),
|
||||
'preview_text' => 'Новость без прикреплённых файлов',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'data' => [
|
||||
'seo_active' => true,
|
||||
'content' => '<p>Текст новости без файлов.</p>',
|
||||
],
|
||||
],
|
||||
],
|
||||
'authors' => ['Петров Пётр'],
|
||||
'status' => PostStatus::PUBLISHED,
|
||||
'publish_at' => now(),
|
||||
'images' => [],
|
||||
'preview' => null,
|
||||
'user_id' => 1,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$publisher = new VkPostPublisher();
|
||||
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
|
||||
|
||||
$this->info("\n" . str_repeat('─', 60));
|
||||
$this->info('📝 ТЕКСТ БЕЗ ФАЙЛОВ');
|
||||
$this->info(str_repeat('─', 60));
|
||||
$this->info($generatedText);
|
||||
$this->info(str_repeat('─', 60) . "\n");
|
||||
|
||||
$this->assertStringContainsString('Новость без вложений', $generatedText);
|
||||
$this->assertStringNotContainsString('Прикреплённые файлы:', $generatedText);
|
||||
|
||||
$post->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Тест: генерация текста с несколькими блоками файлов
|
||||
*/
|
||||
public function test_vk_post_generation_with_multiple_file_blocks(): void
|
||||
{
|
||||
$post = Post::create([
|
||||
'title' => 'Новость с несколькими блоками файлов',
|
||||
'slug' => 'news-multiple-files-' . time(),
|
||||
'preview_text' => 'Тест с несколькими блоками файлов',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'data' => [
|
||||
'content' => '<p>Основной текст.</p>',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'files',
|
||||
'data' => [
|
||||
'file' => [
|
||||
[
|
||||
'title' => 'Документ 1.docx',
|
||||
'path' => 'media/attachments/doc1.docx',
|
||||
'expansion' => 'docx',
|
||||
'size' => '500 КиБ',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'data' => [
|
||||
'content' => '<p>Ещё текст.</p>',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'files',
|
||||
'data' => [
|
||||
'file' => [
|
||||
[
|
||||
'title' => 'Документ 2.pdf',
|
||||
'path' => 'media/attachments/doc2.pdf',
|
||||
'expansion' => 'pdf',
|
||||
'size' => '1.2 МиБ',
|
||||
],
|
||||
[
|
||||
'title' => 'Документ 3.zip',
|
||||
'path' => 'media/attachments/doc3.zip',
|
||||
'expansion' => 'zip',
|
||||
'size' => '5.6 МиБ',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'authors' => [],
|
||||
'status' => PostStatus::PUBLISHED,
|
||||
'publish_at' => now(),
|
||||
'images' => [],
|
||||
'preview' => null,
|
||||
'user_id' => 1,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$publisher = new VkPostPublisher();
|
||||
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
|
||||
|
||||
$this->info("\n" . str_repeat('─', 60));
|
||||
$this->info('📝 ТЕКСТ С НЕСКОЛЬКИМИ БЛОКАМИ ФАЙЛОВ');
|
||||
$this->info(str_repeat('─', 60));
|
||||
$this->info($generatedText);
|
||||
$this->info(str_repeat('─', 60) . "\n");
|
||||
|
||||
// Проверяем, что все файлы присутствуют
|
||||
$this->assertStringContainsString('📎 Документ 1.docx', $generatedText);
|
||||
$this->assertStringContainsString('📎 Документ 2.pdf', $generatedText);
|
||||
$this->assertStringContainsString('📎 Документ 3.zip', $generatedText);
|
||||
|
||||
$post->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Тест: проверка формата ссылок VK
|
||||
*/
|
||||
public function test_vk_link_format(): void
|
||||
{
|
||||
$post = Post::create([
|
||||
'title' => 'Проверка формата ссылок',
|
||||
'slug' => 'check-link-format-' . time(),
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'files',
|
||||
'data' => [
|
||||
'file' => [
|
||||
[
|
||||
'title' => 'Файл для проверки.pdf',
|
||||
'path' => 'media/attachments/test.pdf',
|
||||
'expansion' => 'pdf',
|
||||
'size' => '100 КиБ',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'authors' => [],
|
||||
'status' => PostStatus::PUBLISHED,
|
||||
'publish_at' => now(),
|
||||
'images' => [],
|
||||
'preview' => null,
|
||||
'user_id' => 1,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$publisher = new VkPostPublisher();
|
||||
$generatedText = $this->invokePrivateMethod($publisher, 'generateContentForVk', [$post]);
|
||||
|
||||
// Проверяем точный формат ссылки VK
|
||||
$expectedPattern = '/\[https?:\/\/[^\/]+\/storage\/media\/attachments\/test\.pdf\|📎 Файл для проверки\.pdf\]/';
|
||||
$this->assertMatchesRegularExpression($expectedPattern, $generatedText);
|
||||
|
||||
$post->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызов приватного метода через рефлексию
|
||||
*/
|
||||
private function invokePrivateMethod(object $object, string $methodName, array $parameters = []): mixed
|
||||
{
|
||||
$reflection = new \ReflectionClass($object);
|
||||
$method = $reflection->getMethod($methodName);
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($object, ...$parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\UI\WEB\Controllers;
|
||||
|
||||
use App\Containers\Dashboard\Tasks\GetDraftPostsTask;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class IndexDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GetDraftPostsTask $getDraftPostsTask,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): \Inertia\Response
|
||||
{
|
||||
$draftPosts = $this->getDraftPostsTask->run();
|
||||
|
||||
return inertia()->render('Dashboard/Main', [
|
||||
'draftPosts' => $draftPosts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user