diff --git a/app/Console/Commands/GenerateSummaryCommand.php b/app/Console/Commands/GenerateSummaryCommand.php new file mode 100644 index 0000000..202e4b2 --- /dev/null +++ b/app/Console/Commands/GenerateSummaryCommand.php @@ -0,0 +1,267 @@ +argument('book_id'); + $all = $this->option('all'); + $pending = $this->option('pending'); + $failed = $this->option('failed'); + $limit = (int) $this->option('limit'); + + // Handle single book + if ($bookId) { + return $this->generateForBook((int) $bookId); + } + + // Handle batch operations + if ($all) { + return $this->generateForBooksWithoutSummaries($limit); + } + + if ($pending) { + return $this->generateForPendingSummaries($limit); + } + + if ($failed) { + return $this->generateForFailedSummaries($limit); + } + + // No option provided + $this->error('Please specify a book ID or use one of the options: --all, --pending, --failed'); + $this->newLine(); + $this->comment('Examples:'); + $this->comment(' php artisan summary:generate 123'); + $this->comment(' php artisan summary:generate --all'); + $this->comment(' php artisan summary:generate --failed --limit=10'); + + return self::FAILURE; + } + + /** + * Generate summary for a specific book. + */ + protected function generateForBook(int $bookId): int + { + $book = Book::find($bookId); + + if (!$book) { + $this->error("Book with ID {$bookId} not found."); + return self::FAILURE; + } + + $this->info("Generating summary for: {$book->title}"); + $this->newLine(); + + if (!$book->full_text_url) { + $this->error('Book does not have a full text URL. Cannot generate summary.'); + return self::FAILURE; + } + + // Check existing summary + if ($book->summary) { + $this->warn('Book already has a summary:'); + $this->table( + ['Field', 'Value'], + [ + ['Status', $book->summary->processing_status], + ['Processed At', $book->summary->processed_at?->format('Y-m-d H:i:s') ?? 'N/A'], + ['Model Used', $book->summary->model_used ?? 'N/A'], + ['Has Error', $book->summary->processing_error ? 'Yes' : 'No'], + ] + ); + $this->newLine(); + + if (!$this->confirm('Do you want to regenerate the summary?', false)) { + $this->info('Operation cancelled.'); + return self::SUCCESS; + } + } + + $this->info('Dispatching summary generation job...'); + GenerateSummary::dispatch($bookId); + + $this->newLine(); + $this->info('✓ Summary generation job dispatched!'); + $this->comment('Run the queue worker: php artisan queue:work'); + + return self::SUCCESS; + } + + /** + * Generate summaries for books without summaries. + */ + protected function generateForBooksWithoutSummaries(int $limit): int + { + $this->info('Finding books without summaries...'); + + $books = Book::doesntHave('summary') + ->whereNotNull('full_text_url') + ->where('source', 'gutendex') + ->limit($limit) + ->get(); + + if ($books->isEmpty()) { + $this->info('No books found without summaries.'); + return self::SUCCESS; + } + + $this->info("Found {$books->count()} books without summaries."); + $this->newLine(); + + if (!$this->confirm("Dispatch {$books->count()} summary generation jobs?", true)) { + $this->info('Operation cancelled.'); + return self::SUCCESS; + } + + $progressBar = $this->output->createProgressBar($books->count()); + $progressBar->start(); + + foreach ($books as $index => $book) { + GenerateSummary::dispatch($book->id) + ->delay(now()->addSeconds($index * 10)); // Stagger by 10 seconds + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info("✓ Dispatched {$books->count()} summary generation jobs!"); + $this->comment('Jobs will be processed with 10-second intervals.'); + $this->comment('Run the queue worker: php artisan queue:work'); + + return self::SUCCESS; + } + + /** + * Generate summaries for pending summaries. + */ + protected function generateForPendingSummaries(int $limit): int + { + $this->info('Finding pending summaries...'); + + $summaries = Summary::pending() + ->with('book') + ->limit($limit) + ->get(); + + if ($summaries->isEmpty()) { + $this->info('No pending summaries found.'); + return self::SUCCESS; + } + + $this->info("Found {$summaries->count()} pending summaries."); + $this->newLine(); + + if (!$this->confirm("Retry {$summaries->count()} pending summaries?", true)) { + $this->info('Operation cancelled.'); + return self::SUCCESS; + } + + $progressBar = $this->output->createProgressBar($summaries->count()); + $progressBar->start(); + + foreach ($summaries as $index => $summary) { + if ($summary->book) { + GenerateSummary::dispatch($summary->book->id) + ->delay(now()->addSeconds($index * 10)); + } + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info("✓ Dispatched {$summaries->count()} retry jobs!"); + $this->comment('Run the queue worker: php artisan queue:work'); + + return self::SUCCESS; + } + + /** + * Generate summaries for failed summaries. + */ + protected function generateForFailedSummaries(int $limit): int + { + $this->info('Finding failed summaries...'); + + $summaries = Summary::failed() + ->with('book') + ->limit($limit) + ->get(); + + if ($summaries->isEmpty()) { + $this->info('No failed summaries found.'); + return self::SUCCESS; + } + + $this->warn("Found {$summaries->count()} failed summaries."); + $this->newLine(); + + // Show error details + $this->table( + ['Book ID', 'Title', 'Error'], + $summaries->map(fn($s) => [ + $s->book_id, + $s->book?->title ?? 'Unknown', + substr($s->processing_error ?? 'Unknown error', 0, 60) . '...', + ])->toArray() + ); + $this->newLine(); + + if (!$this->confirm("Retry {$summaries->count()} failed summaries?", true)) { + $this->info('Operation cancelled.'); + return self::SUCCESS; + } + + $progressBar = $this->output->createProgressBar($summaries->count()); + $progressBar->start(); + + foreach ($summaries as $index => $summary) { + if ($summary->book) { + GenerateSummary::dispatch($summary->book->id) + ->delay(now()->addSeconds($index * 15)); // Longer delay for failures + } + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info("✓ Dispatched {$summaries->count()} retry jobs!"); + $this->comment('Jobs will be processed with 15-second intervals.'); + $this->comment('Run the queue worker: php artisan queue:work'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ImportFromGutendex.php b/app/Console/Commands/ImportFromGutendex.php new file mode 100644 index 0000000..c46f626 --- /dev/null +++ b/app/Console/Commands/ImportFromGutendex.php @@ -0,0 +1,184 @@ +option('language'); + $limit = (int) $this->option('limit'); + $page = (int) $this->option('page'); + $useQueue = $this->option('queue'); + $generateSummary = !$this->option('no-summary'); + $copyright = $this->option('copyright'); + + $this->info('Gutendex Import'); + $this->info('=============='); + $this->newLine(); + + // Build filters + $filters = [ + 'languages' => $language, + 'copyright' => $copyright, + ]; + + $this->table( + ['Setting', 'Value'], + [ + ['Language', $language], + ['Limit', $limit], + ['Starting Page', $page], + ['Mode', $useQueue ? 'Queued' : 'Immediate'], + ['Generate Summaries', $generateSummary ? 'Yes' : 'No'], + ['Copyright Filter', $copyright], + ] + ); + + if (!$this->confirm('Do you want to proceed with this import?', true)) { + $this->warn('Import cancelled.'); + return self::SUCCESS; + } + + $this->newLine(); + + if ($useQueue) { + return $this->handleQueuedImport($filters, $page, $limit, $generateSummary); + } else { + return $this->handleImmediateImport($filters, $page, $limit, $generateSummary); + } + } + + /** + * Handle queued import. + */ + protected function handleQueuedImport(array $filters, int $page, int $limit, bool $generateSummary): int + { + $this->info('Dispatching batch import job...'); + + // Calculate end page based on limit (assuming ~32 books per page) + $booksPerPage = 32; + $endPage = $page + (int) ceil($limit / $booksPerPage) - 1; + + BatchImportFromGutendex::dispatch( + $filters, + $page, + $endPage, + $limit, + $generateSummary + ); + + $this->info('✓ Batch import job dispatched successfully!'); + $this->newLine(); + $this->comment('The import will be processed in the background.'); + $this->comment('Run the queue worker: php artisan queue:work'); + $this->comment('Monitor progress: php artisan summary:status'); + + return self::SUCCESS; + } + + /** + * Handle immediate import (synchronous). + */ + protected function handleImmediateImport(array $filters, int $page, int $limit, bool $generateSummary): int + { + $this->warn('⚠ Running immediate import. This may take a while...'); + $this->warn('⚠ For large imports, consider using --queue option.'); + $this->newLine(); + + $gutendexClient = app(\App\Services\GutendexClient::class); + + $totalImported = 0; + $totalSkipped = 0; + $currentPage = $page; + + $progressBar = $this->output->createProgressBar($limit); + $progressBar->start(); + + while ($totalImported < $limit) { + $response = $gutendexClient->searchBooks(null, $currentPage, $filters); + + if (empty($response['results'])) { + $this->newLine(); + $this->warn('No more results available.'); + break; + } + + foreach ($response['results'] as $bookData) { + if ($totalImported >= $limit) { + break 2; + } + + $gutenbergId = $bookData['id'] ?? null; + if (!$gutenbergId) { + continue; + } + + // Check if already exists + if (\App\Models\Book::where('gutenberg_id', $gutenbergId)->exists()) { + $totalSkipped++; + $progressBar->advance(); + continue; + } + + // Dispatch job + ImportBookFromGutendex::dispatch($gutenbergId, $generateSummary); + $totalImported++; + $progressBar->advance(); + } + + if (empty($response['next'])) { + break; + } + + $currentPage++; + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info('✓ Import jobs dispatched successfully!'); + $this->newLine(); + $this->table( + ['Metric', 'Count'], + [ + ['Jobs Dispatched', $totalImported], + ['Books Skipped (existing)', $totalSkipped], + ['Pages Processed', $currentPage - $page + 1], + ] + ); + + $this->newLine(); + $this->comment('Jobs have been dispatched to the queue.'); + $this->comment('Run the queue worker: php artisan queue:work'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SummaryStatus.php b/app/Console/Commands/SummaryStatus.php new file mode 100644 index 0000000..2b17ea8 --- /dev/null +++ b/app/Console/Commands/SummaryStatus.php @@ -0,0 +1,229 @@ +info('Summary Generation Status'); + $this->info('========================'); + $this->newLine(); + + $this->displayOverallStatistics(); + $this->newLine(); + + if ($this->option('detailed')) { + $this->displayDetailedBreakdown(); + $this->newLine(); + } + + $this->displayRecentActivity(); + $this->newLine(); + + $this->displayQueueStatus(); + + return self::SUCCESS; + } + + /** + * Display overall statistics. + */ + protected function displayOverallStatistics(): void + { + $totalBooks = Book::count(); + $gutendexBooks = Book::gutendex()->count(); + $booksWithText = Book::whereNotNull('full_text_url')->count(); + + $totalSummaries = Summary::count(); + $completedSummaries = Summary::completed()->count(); + $pendingSummaries = Summary::pending()->count(); + $processingSummaries = Summary::processing()->count(); + $failedSummaries = Summary::failed()->count(); + + $booksWithoutSummaries = Book::gutendex() + ->whereNotNull('full_text_url') + ->doesntHave('summary') + ->count(); + + $completionRate = $totalSummaries > 0 + ? round(($completedSummaries / $totalSummaries) * 100, 2) + : 0; + + $this->table( + ['Metric', 'Count', 'Percentage'], + [ + ['Total Books', number_format($totalBooks), '-'], + ['Gutendex Books', number_format($gutendexBooks), round(($gutendexBooks / max($totalBooks, 1)) * 100, 1) . '%'], + ['Books with Full Text', number_format($booksWithText), '-'], + ['Books without Summaries', number_format($booksWithoutSummaries), '-'], + ['---', '---', '---'], + ['Total Summaries', number_format($totalSummaries), '-'], + ['✓ Completed', number_format($completedSummaries), $completionRate . '%'], + ['⏳ Pending', number_format($pendingSummaries), round(($pendingSummaries / max($totalSummaries, 1)) * 100, 1) . '%'], + ['⚙ Processing', number_format($processingSummaries), round(($processingSummaries / max($totalSummaries, 1)) * 100, 1) . '%'], + ['✗ Failed', number_format($failedSummaries), round(($failedSummaries / max($totalSummaries, 1)) * 100, 1) . '%'], + ] + ); + } + + /** + * Display detailed breakdown by model and category. + */ + protected function displayDetailedBreakdown(): void + { + $this->info('Detailed Breakdown'); + $this->info('------------------'); + $this->newLine(); + + // By model + $byModel = Summary::completed() + ->select('model_used', DB::raw('COUNT(*) as count')) + ->groupBy('model_used') + ->get(); + + if ($byModel->isNotEmpty()) { + $this->info('Summaries by Model:'); + $this->table( + ['Model', 'Count'], + $byModel->map(fn($item) => [ + $item->model_used ?? 'Unknown', + number_format($item->count), + ])->toArray() + ); + $this->newLine(); + } + + // By language + $byLanguage = Book::gutendex() + ->select('language', DB::raw('COUNT(*) as count')) + ->whereNotNull('language') + ->groupBy('language') + ->orderByDesc('count') + ->limit(10) + ->get(); + + if ($byLanguage->isNotEmpty()) { + $this->info('Books by Language (Top 10):'); + $this->table( + ['Language', 'Count'], + $byLanguage->map(fn($item) => [ + $item->language, + number_format($item->count), + ])->toArray() + ); + $this->newLine(); + } + } + + /** + * Display recent activity. + */ + protected function displayRecentActivity(): void + { + $recentCount = (int) $this->option('recent'); + + $this->info("Recent Activity (Last {$recentCount})"); + $this->info('------------------'); + $this->newLine(); + + // Recent completed summaries + $recentCompleted = Summary::completed() + ->with('book') + ->orderByDesc('processed_at') + ->limit($recentCount) + ->get(); + + if ($recentCompleted->isNotEmpty()) { + $this->info('✓ Recently Completed:'); + $this->table( + ['Book ID', 'Title', 'Processed At', 'Model'], + $recentCompleted->map(fn($s) => [ + $s->book_id, + substr($s->book?->title ?? 'Unknown', 0, 40), + $s->processed_at?->diffForHumans() ?? 'N/A', + $s->model_used ?? 'N/A', + ])->toArray() + ); + $this->newLine(); + } + + // Recent failures + $recentFailed = Summary::failed() + ->with('book') + ->orderByDesc('updated_at') + ->limit($recentCount) + ->get(); + + if ($recentFailed->isNotEmpty()) { + $this->warn('✗ Recent Failures:'); + $this->table( + ['Book ID', 'Title', 'Error'], + $recentFailed->map(fn($s) => [ + $s->book_id, + substr($s->book?->title ?? 'Unknown', 0, 30), + substr($s->processing_error ?? 'Unknown error', 0, 60), + ])->toArray() + ); + $this->newLine(); + } + } + + /** + * Display queue status. + */ + protected function displayQueueStatus(): void + { + $this->info('Queue Status'); + $this->info('------------'); + $this->newLine(); + + // Count pending jobs (if using database queue) + try { + $pendingJobs = DB::table('jobs')->count(); + $failedJobs = DB::table('failed_jobs')->count(); + + $this->table( + ['Queue', 'Count'], + [ + ['Pending Jobs', number_format($pendingJobs)], + ['Failed Jobs', number_format($failedJobs)], + ] + ); + + if ($pendingJobs > 0) { + $this->comment('Run the queue worker: php artisan queue:work'); + } + + if ($failedJobs > 0) { + $this->warn("You have {$failedJobs} failed jobs. Review with: php artisan queue:failed"); + } + } catch (\Exception $e) { + $this->comment('Queue status unavailable (database driver not configured).'); + } + } +} diff --git a/app/Console/Commands/SyncBookFromGutendex.php b/app/Console/Commands/SyncBookFromGutendex.php new file mode 100644 index 0000000..2c35395 --- /dev/null +++ b/app/Console/Commands/SyncBookFromGutendex.php @@ -0,0 +1,134 @@ +argument('gutenberg_id'); + $force = $this->option('force'); + $generateSummary = !$this->option('no-summary'); + + if ($gutenbergId <= 0) { + $this->error('Invalid Gutenberg ID. Must be a positive integer.'); + return self::FAILURE; + } + + $this->info("Syncing book from Gutendex (ID: {$gutenbergId})"); + $this->newLine(); + + // Check if book already exists + $existingBook = Book::where('gutenberg_id', $gutenbergId)->first(); + + if ($existingBook && !$force) { + $this->warn('Book already exists in the database!'); + $this->newLine(); + $this->table( + ['Field', 'Value'], + [ + ['Database ID', $existingBook->id], + ['Title', $existingBook->title], + ['Author', $existingBook->author], + ['Slug', $existingBook->slug], + ['Category', $existingBook->category->name ?? 'N/A'], + ['Has Summary', $existingBook->summary ? 'Yes' : 'No'], + ['Summary Status', $existingBook->summary->processing_status ?? 'N/A'], + ] + ); + $this->newLine(); + + if (!$this->confirm('Use --force to re-import. Continue anyway?', false)) { + $this->info('Operation cancelled.'); + return self::SUCCESS; + } + + // Delete existing book to re-import + $this->warn('Deleting existing book...'); + $existingBook->delete(); + $this->info('✓ Existing book deleted.'); + $this->newLine(); + } + + // Fetch book info from Gutendex first to show details + $this->info('Fetching book details from Gutendex...'); + $gutendexClient = app(\App\Services\GutendexClient::class); + $gutendexBook = $gutendexClient->getBook($gutenbergId); + + if (!$gutendexBook) { + $this->error("Failed to fetch book {$gutenbergId} from Gutendex API."); + $this->comment('The book may not exist or the API may be unavailable.'); + return self::FAILURE; + } + + // Display book info + $this->info('✓ Book found on Gutendex'); + $this->newLine(); + $this->table( + ['Field', 'Value'], + [ + ['Gutenberg ID', $gutendexBook->id], + ['Title', $gutendexBook->title], + ['Author(s)', $gutendexBook->getAuthorNamesString()], + ['Language', $gutendexBook->getPrimaryLanguage()], + ['Public Domain', $gutendexBook->isPublicDomain() ? 'Yes' : 'No'], + ['Plain Text Available', $gutendexBook->getPlainTextUrl() ? 'Yes' : 'No'], + ['Download Count', number_format($gutendexBook->downloadCount)], + ] + ); + $this->newLine(); + + // Validate book + if (!$gutendexBook->isPublicDomain()) { + $this->error('This book is not in the public domain and cannot be imported.'); + return self::FAILURE; + } + + if (!$gutendexBook->getPlainTextUrl()) { + $this->error('This book does not have a plain text version available.'); + return self::FAILURE; + } + + // Dispatch import job + $this->info('Dispatching import job...'); + ImportBookFromGutendex::dispatch($gutenbergId, $generateSummary); + + $this->newLine(); + $this->info('✓ Import job dispatched successfully!'); + $this->newLine(); + $this->comment('The book will be imported in the background.'); + $this->comment('Run the queue worker: php artisan queue:work'); + + if ($generateSummary) { + $this->comment('Summary will be generated automatically after import.'); + } else { + $this->comment('Summary generation was skipped (--no-summary flag).'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/TestOllama.php b/app/Console/Commands/TestOllama.php new file mode 100644 index 0000000..cac4aa5 --- /dev/null +++ b/app/Console/Commands/TestOllama.php @@ -0,0 +1,192 @@ +info('Ollama API Test'); + $this->info('==============='); + $this->newLine(); + + // Override model if specified + $modelOption = $this->option('model'); + if ($modelOption) { + $ollamaService->setModel($modelOption); + $this->comment("Using model: {$modelOption}"); + $this->newLine(); + } + + // Display configuration + $this->displayConfiguration(); + $this->newLine(); + + // Test connection + if (!$this->testConnection($ollamaService)) { + return self::FAILURE; + } + $this->newLine(); + + // List available models + $this->listAvailableModels($ollamaService); + $this->newLine(); + + // Run test generation + if ($this->confirm('Do you want to run a test summary generation?', true)) { + $this->newLine(); + $this->testSummaryGeneration($ollamaService); + } + + return self::SUCCESS; + } + + /** + * Display Ollama configuration. + */ + protected function displayConfiguration(): void + { + $this->info('Configuration:'); + $this->table( + ['Setting', 'Value'], + [ + ['API URL', config('ollama.api_url')], + ['Model', config('ollama.model')], + ['Timeout', config('ollama.timeout') . ' seconds'], + ['Temperature', config('ollama.temperature')], + ['Max Tokens', config('ollama.max_tokens')], + ] + ); + } + + /** + * Test connection to Ollama API. + */ + protected function testConnection(OllamaService $ollamaService): bool + { + $this->info('Testing connection to Ollama API...'); + + if ($ollamaService->checkConnection()) { + $this->info('✓ Connection successful!'); + return true; + } else { + $this->error('✗ Connection failed!'); + $this->newLine(); + $this->warn('Possible issues:'); + $this->comment(' 1. Ollama is not running (start with: ollama serve)'); + $this->comment(' 2. Wrong API URL in config/ollama.php or .env'); + $this->comment(' 3. Network/firewall issues'); + $this->newLine(); + $this->comment('Current API URL: ' . config('ollama.api_url')); + return false; + } + } + + /** + * List available models. + */ + protected function listAvailableModels(OllamaService $ollamaService): void + { + $this->info('Available Models:'); + + $models = $ollamaService->listModels(); + + if (empty($models)) { + $this->warn('No models found or unable to fetch models.'); + $this->newLine(); + $this->comment('Pull a model with: ollama pull llama3'); + return; + } + + $currentModel = $ollamaService->getModel(); + $rows = []; + + foreach ($models as $model) { + $isCurrent = $model === $currentModel ? '✓' : ''; + $rows[] = [$model, $isCurrent]; + } + + $this->table(['Model Name', 'Current'], $rows); + + if (!in_array($currentModel, $models)) { + $this->warn("⚠ Configured model '{$currentModel}' is not available!"); + $this->comment("Pull it with: ollama pull {$currentModel}"); + } + } + + /** + * Test summary generation. + */ + protected function testSummaryGeneration(OllamaService $ollamaService): void + { + $this->info('Running test summary generation...'); + $this->newLine(); + + $testResult = $ollamaService->testGeneration(); + + if ($testResult['success']) { + $this->info('✓ Test generation successful!'); + $this->newLine(); + + $this->table( + ['Metric', 'Value'], + [ + ['Duration', $testResult['duration'] . ' seconds'], + ['Model Used', $testResult['model']], + ['Word Count', $testResult['word_count']], + ] + ); + + $this->newLine(); + $this->info('Generated Summary:'); + $this->line('------------------'); + $this->line($testResult['summary']); + $this->line('------------------'); + $this->newLine(); + + // Quality assessment + $wordCount = $testResult['word_count']; + $minWords = config('ollama.quality_checks.min_word_count', 100); + $maxWords = config('ollama.quality_checks.max_word_count', 1000); + + if ($wordCount < $minWords) { + $this->warn("⚠ Summary is shorter than recommended minimum ({$minWords} words)"); + } elseif ($wordCount > $maxWords) { + $this->warn("⚠ Summary is longer than recommended maximum ({$maxWords} words)"); + } else { + $this->info('✓ Summary length is within recommended range'); + } + + $avgTimePerWord = $testResult['duration'] / max($wordCount, 1); + $this->comment("Average: " . round($avgTimePerWord, 2) . " seconds per word"); + } else { + $this->error('✗ Test generation failed!'); + $this->newLine(); + $this->warn('This could indicate:'); + $this->comment(' 1. Model is not properly loaded'); + $this->comment(' 2. Insufficient system resources'); + $this->comment(' 3. Timeout issues (increase OLLAMA_TIMEOUT in .env)'); + } + } +} diff --git a/app/Http/Controllers/GutendexController.php b/app/Http/Controllers/GutendexController.php new file mode 100644 index 0000000..8ba6b20 --- /dev/null +++ b/app/Http/Controllers/GutendexController.php @@ -0,0 +1,201 @@ +getStatistics(); + $recentImports = $this->getRecentImports(); + $recentFailures = $this->getRecentFailures(); + + return view('admin.gutendex.dashboard', compact('stats', 'recentImports', 'recentFailures')); + } + + /** + * Display processing status. + */ + public function status() + { + $stats = $this->getStatistics(); + $processing = Summary::processing()->with('book')->get(); + $pending = Summary::pending()->with('book')->limit(20)->get(); + $failed = Summary::failed()->with('book')->limit(20)->get(); + + $queueStats = $this->getQueueStatistics(); + + return view('admin.gutendex.status', compact('stats', 'processing', 'pending', 'failed', 'queueStats')); + } + + /** + * Trigger a new import. + */ + public function import(Request $request) + { + $request->validate([ + 'language' => 'required|string|max:10', + 'limit' => 'required|integer|min:1|max:500', + 'page' => 'required|integer|min:1', + 'generate_summaries' => 'boolean', + ]); + + $language = $request->input('language', 'en'); + $limit = (int) $request->input('limit', 50); + $page = (int) $request->input('page', 1); + $generateSummaries = $request->boolean('generate_summaries', true); + + $filters = [ + 'languages' => $language, + 'copyright' => 'false', + ]; + + // Calculate end page + $booksPerPage = 32; + $endPage = $page + (int) ceil($limit / $booksPerPage) - 1; + + // Dispatch batch import job + BatchImportFromGutendex::dispatch( + $filters, + $page, + $endPage, + $limit, + $generateSummaries + ); + + return response()->json([ + 'success' => true, + 'message' => "Import job dispatched for up to {$limit} books", + 'details' => [ + 'language' => $language, + 'limit' => $limit, + 'start_page' => $page, + 'end_page' => $endPage, + 'generate_summaries' => $generateSummaries, + ], + ]); + } + + /** + * Retry a failed book summary. + */ + public function retry(Book $book) + { + if (!$book->summary) { + return response()->json([ + 'success' => false, + 'message' => 'Book does not have a summary record', + ], 404); + } + + if ($book->summary->processing_status === 'completed') { + return response()->json([ + 'success' => false, + 'message' => 'Summary is already completed', + ], 400); + } + + // Dispatch job to regenerate summary + GenerateSummary::dispatch($book->id); + + return response()->json([ + 'success' => true, + 'message' => "Retry job dispatched for: {$book->title}", + 'book_id' => $book->id, + ]); + } + + /** + * Get overall statistics. + */ + protected function getStatistics(): array + { + $totalBooks = Book::count(); + $gutendexBooks = Book::gutendex()->count(); + $booksWithText = Book::whereNotNull('full_text_url')->count(); + $booksWithoutSummaries = Book::gutendex() + ->whereNotNull('full_text_url') + ->doesntHave('summary') + ->count(); + + $totalSummaries = Summary::count(); + $completedSummaries = Summary::completed()->count(); + $pendingSummaries = Summary::pending()->count(); + $processingSummaries = Summary::processing()->count(); + $failedSummaries = Summary::failed()->count(); + + $completionRate = $totalSummaries > 0 + ? round(($completedSummaries / $totalSummaries) * 100, 2) + : 0; + + return [ + 'total_books' => $totalBooks, + 'gutendex_books' => $gutendexBooks, + 'books_with_text' => $booksWithText, + 'books_without_summaries' => $booksWithoutSummaries, + 'total_summaries' => $totalSummaries, + 'completed_summaries' => $completedSummaries, + 'pending_summaries' => $pendingSummaries, + 'processing_summaries' => $processingSummaries, + 'failed_summaries' => $failedSummaries, + 'completion_rate' => $completionRate, + ]; + } + + /** + * Get recent imports. + */ + protected function getRecentImports(int $limit = 10): array + { + return Book::gutendex() + ->with('category') + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->toArray(); + } + + /** + * Get recent failures. + */ + protected function getRecentFailures(int $limit = 10): array + { + return Summary::failed() + ->with('book') + ->orderByDesc('updated_at') + ->limit($limit) + ->get() + ->toArray(); + } + + /** + * Get queue statistics. + */ + protected function getQueueStatistics(): array + { + try { + $pendingJobs = DB::table('jobs')->count(); + $failedJobs = DB::table('failed_jobs')->count(); + + return [ + 'pending_jobs' => $pendingJobs, + 'failed_jobs' => $failedJobs, + ]; + } catch (\Exception $e) { + return [ + 'pending_jobs' => 0, + 'failed_jobs' => 0, + ]; + } + } +} diff --git a/app/Http/Middleware/IsAdmin.php b/app/Http/Middleware/IsAdmin.php new file mode 100644 index 0000000..e5a60c3 --- /dev/null +++ b/app/Http/Middleware/IsAdmin.php @@ -0,0 +1,30 @@ +check()) { + return redirect()->route('login')->with('error', 'You must be logged in to access this page.'); + } + + // Check if user is admin + if (!auth()->user()->is_admin) { + abort(403, 'Unauthorized access. Admin privileges required.'); + } + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ef683ff..7e18bbd 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withMiddleware(function (Middleware $middleware): void { $middleware->append(GoatCounterMiddleware::class); + $middleware->alias([ + 'admin' => IsAdmin::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/database/migrations/2026_03_29_130715_add_is_admin_to_users_table.php b/database/migrations/2026_03_29_130715_add_is_admin_to_users_table.php new file mode 100644 index 0000000..b15a101 --- /dev/null +++ b/database/migrations/2026_03_29_130715_add_is_admin_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_admin')->default(false)->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/resources/views/admin/gutendex/dashboard.blade.php b/resources/views/admin/gutendex/dashboard.blade.php new file mode 100644 index 0000000..6800e92 --- /dev/null +++ b/resources/views/admin/gutendex/dashboard.blade.php @@ -0,0 +1,273 @@ +@extends('layouts.app') + +@section('content') +
+
+
+

Gutendex Management Dashboard

+

Import and manage books from Project Gutenberg

+
+
+ + +
+
+
+
+
Total Books
+

{{ number_format($stats['total_books']) }}

+ {{ number_format($stats['gutendex_books']) }} from Gutendex +
+
+
+
+
+
+
Completed Summaries
+

{{ number_format($stats['completed_summaries']) }}

+ {{ $stats['completion_rate'] }}% completion rate +
+
+
+
+
+
+
Pending/Processing
+

{{ number_format($stats['pending_summaries'] + $stats['processing_summaries']) }}

+ {{ number_format($stats['pending_summaries']) }} pending, {{ number_format($stats['processing_summaries']) }} processing +
+
+
+
+
+
+
Failed
+

{{ number_format($stats['failed_summaries']) }}

+ Need attention +
+
+
+
+ +
+ +
+
+
+
Import Books from Gutendex
+
+
+
+ @csrf +
+ + +
+
+ + + Maximum 500 books per import +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+
Quick Actions
+
+ +
+
+ + +
+ +
+
+
Recent Imports
+
+
+ @if(count($recentImports) > 0) +
+ + + + + + + + + + + @foreach($recentImports as $book) + + + + + + + @endforeach + +
TitleAuthorCategoryImported
{{ Str::limit($book['title'], 30) }}{{ Str::limit($book['author'], 20) }}{{ $book['category']['name'] ?? 'N/A' }}{{ \Carbon\Carbon::parse($book['created_at'])->diffForHumans() }}
+
+ @else +

No recent imports

+ @endif +
+
+ + +
+
+
Recent Failures
+
+
+ @if(count($recentFailures) > 0) +
+ + + + + + + + + + @foreach($recentFailures as $summary) + + + + + + @endforeach + +
BookErrorAction
{{ Str::limit($summary['book']['title'] ?? 'Unknown', 25) }}{{ Str::limit($summary['processing_error'] ?? 'Unknown error', 30) }} + +
+
+ @else +

No recent failures

+ @endif +
+
+
+
+
+ + +@endsection diff --git a/resources/views/admin/gutendex/status.blade.php b/resources/views/admin/gutendex/status.blade.php new file mode 100644 index 0000000..8e14194 --- /dev/null +++ b/resources/views/admin/gutendex/status.blade.php @@ -0,0 +1,269 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+

Processing Status

+

Monitor summary generation progress

+
+ + Back to Dashboard + +
+
+
+ + +
+
+
+
+
Total Summaries
+

{{ number_format($stats['total_summaries']) }}

+
+
+
+
+
+
+
Completed
+

{{ number_format($stats['completed_summaries']) }}

+ {{ $stats['completion_rate'] }}% +
+
+
+
+
+
+
Pending/Processing
+

{{ number_format($stats['pending_summaries'] + $stats['processing_summaries']) }}

+ {{ number_format($stats['pending_summaries']) }} + {{ number_format($stats['processing_summaries']) }} +
+
+
+
+
+
+
Failed
+

{{ number_format($stats['failed_summaries']) }}

+
+
+
+
+ + +
+
+
+
+
Queue Status
+
+
+
+
+
Pending Jobs
+

{{ number_format($queueStats['pending_jobs']) }}

+ @if($queueStats['pending_jobs'] > 0) + Jobs waiting to be processed + @else + Queue is empty + @endif +
+
+
Failed Jobs
+

{{ number_format($queueStats['failed_jobs']) }}

+ @if($queueStats['failed_jobs'] > 0) + Requires attention + @else + No failed jobs + @endif +
+
+
+ +
+
+
+
+ + + @if($processing->count() > 0) +
+
+
+
+
Currently Processing ({{ $processing->count() }})
+
+
+
+ + + + + + + + + + + + @foreach($processing as $summary) + + + + + + + + @endforeach + +
Book IDTitleAuthorStatusStarted
{{ $summary->book_id }}{{ Str::limit($summary->book->title ?? 'Unknown', 40) }}{{ Str::limit($summary->book->author ?? 'Unknown', 30) }}Processing{{ $summary->updated_at->diffForHumans() }}
+
+
+
+
+
+ @endif + + + @if($pending->count() > 0) +
+
+
+
+
Pending Summaries ({{ $pending->count() }})
+
+
+
+ + + + + + + + + + + + @foreach($pending as $summary) + + + + + + + + @endforeach + +
Book IDTitleAuthorCreatedAction
{{ $summary->book_id }}{{ Str::limit($summary->book->title ?? 'Unknown', 40) }}{{ Str::limit($summary->book->author ?? 'Unknown', 30) }}{{ $summary->created_at->diffForHumans() }} + +
+
+
+
+
+
+ @endif + + + @if($failed->count() > 0) +
+
+
+
+
Failed Summaries ({{ $failed->count() }})
+
+
+
+ + + + + + + + + + + + @foreach($failed as $summary) + + + + + + + + @endforeach + +
Book IDTitleErrorFailed AtAction
{{ $summary->book_id }}{{ Str::limit($summary->book->title ?? 'Unknown', 30) }}{{ Str::limit($summary->processing_error ?? 'Unknown error', 60) }}{{ $summary->updated_at->diffForHumans() }} + +
+
+
+
+
+
+ @endif + + + @if($processing->count() == 0 && $pending->count() == 0 && $failed->count() == 0) +
+
+
+
No Active Processing
+

All summaries are either completed or no jobs are currently queued.

+ Start New Import +
+
+
+ @endif +
+ + +@endsection diff --git a/routes/web.php b/routes/web.php index 0911519..516f71b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\SummaryReportController; use App\Http\Controllers\SummaryRequestController; use App\Http\Controllers\SummaryReviewController; use App\Http\Controllers\SitemapController; +use App\Http\Controllers\GutendexController; use App\Models\Book; Route::get('/', function () { @@ -72,3 +73,11 @@ Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name // Sitemap Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap'); + +// Admin - Gutendex Management (requires authentication and admin privileges) +Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { + Route::get('/gutendex', [GutendexController::class, 'dashboard'])->name('gutendex.dashboard'); + Route::get('/gutendex/status', [GutendexController::class, 'status'])->name('gutendex.status'); + Route::post('/gutendex/import', [GutendexController::class, 'import'])->name('gutendex.import'); + Route::post('/gutendex/retry/{book}', [GutendexController::class, 'retry'])->name('gutendex.retry'); +});