This commit is contained in:
F4ilji
2026-03-20 21:47:02 +05:00
parent ae7b42acbe
commit a3948c872e
1926 changed files with 2630 additions and 270 deletions
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -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
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Containers\Dashboard\Loaders;
class AliasesLoader
{
/**
* @var array
*/
public array $aliases = [];
}
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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