Fix: AI Blog Assistant retry logic and Subdomain routing for Akademi
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\BlogTopic;
|
||||
use App\Models\Blog; // Ensure this model exists and has 'status' column
|
||||
use App\Models\User;
|
||||
use App\Services\AiBlogService;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class GenerateBlogAssistant extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'blog:generate-assistant {--topic= : Specific topic to generate}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate a new AI blog post draft based on active topics.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(AiBlogService $aiService)
|
||||
{
|
||||
\Illuminate\Support\Facades\Log::info('GenerateBlogAssistant triggered. Args: ' . json_encode($_SERVER['argv'] ?? []));
|
||||
$this->info('Starting AI Blog Assistant...');
|
||||
|
||||
// 1. Select Topic
|
||||
$topicName = $this->option('topic');
|
||||
$topic = null;
|
||||
|
||||
if ($topicName) {
|
||||
$topic = BlogTopic::where('name', $topicName)->first();
|
||||
if (!$topic) {
|
||||
$this->error("Topic '{$topicName}' not found.");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Find oldest used active topic
|
||||
$topic = BlogTopic::active()
|
||||
->orderBy('last_used_at', 'asc') // Nulls first usually, or old dates
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$topic) {
|
||||
$this->warn('No active topics found. Please add topics to blog_topics table.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Selected Topic: {$topic->name}");
|
||||
|
||||
// 2. Generate Content
|
||||
$this->info('Generating content with Gemini API...');
|
||||
try {
|
||||
$contentData = $aiService->generateContent($topic->name, $topic->context);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Content generation failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (empty($contentData) || !isset($contentData['title']) || !isset($contentData['content'])) {
|
||||
$this->error('Valid content not returned from AI Service.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('Content generated successfully.');
|
||||
|
||||
// 3. Generate/Fetch Image
|
||||
$this->info('Fetching cover image...');
|
||||
$imagePath = null;
|
||||
if (isset($contentData['image_prompt'])) {
|
||||
$imagePath = $aiService->fetchAndSaveImage($topic->name, $contentData['image_title'] ?? $contentData['image_prompt']); // Pollinations uses prompt
|
||||
}
|
||||
|
||||
if (!$imagePath) {
|
||||
$this->warn('Could not fetch image, proceeding without cover image.');
|
||||
} else {
|
||||
$this->info("Image saved to: {$imagePath}");
|
||||
}
|
||||
|
||||
// 4. Create Draft Blog Post
|
||||
$author = User::first(); // Default to first user (Admin)
|
||||
|
||||
$blog = new Blog();
|
||||
$blog->title = $contentData['title'];
|
||||
$blog->slug = Str::slug($contentData['title']);
|
||||
$blog->content = $contentData['content'];
|
||||
$blog->excerpt = $contentData['excerpt'] ?? Str::limit(strip_tags($contentData['content']), 150);
|
||||
$blog->meta_title = $contentData['meta_title'] ?? $contentData['title'];
|
||||
$blog->meta_description = $contentData['meta_description'] ?? $blog->excerpt;
|
||||
$blog->status = 'draft'; // Verification required
|
||||
$blog->author_id = $author ? $author->id : 1;
|
||||
$blog->featured_image = $imagePath;
|
||||
$blog->published_at = null; // Draft
|
||||
$blog->tags = $contentData['tags'] ?? [];
|
||||
// category_id? We might need to guess or default.
|
||||
// For now leave null or set default if exists.
|
||||
|
||||
$blog->save();
|
||||
|
||||
// 5. Update Topic Usage
|
||||
$topic->last_used_at = Carbon::now();
|
||||
$topic->save();
|
||||
|
||||
$this->info("Blog post '{$blog->title}' created as DRAFT (ID: {$blog->id}).");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,49 @@ class PageController extends Controller
|
||||
* Display a specific page by slug
|
||||
*/
|
||||
public function show($slug)
|
||||
{
|
||||
return $this->handleShow($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle nested page routes (e.g. /kurumsal/hakkimizda)
|
||||
*/
|
||||
public function showNested($parentSlug, $slug)
|
||||
{
|
||||
return $this->handleShow($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle akademi.truncgil.com.tr subdomain
|
||||
*/
|
||||
public function showAkademi()
|
||||
{
|
||||
return $this->handleShow('truncgil-akademi');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified show logic
|
||||
*/
|
||||
protected function handleShow($slug)
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
|
||||
// 3a. Akademi Sayfası Yönlendirmeleri
|
||||
if ($slug === 'truncgil-akademi') {
|
||||
$host = request()->getHost();
|
||||
$path = request()->path();
|
||||
|
||||
// Eğer ana domainden geliyorsa -> Subdomain'e yönlendir
|
||||
if ($host !== 'akademi.truncgil.com.tr') {
|
||||
return redirect()->to('https://akademi.truncgil.com.tr/', 301);
|
||||
}
|
||||
|
||||
// Eğer subdoman'de ama slug ile geliyorsa (/truncgil-akademi) -> Ana dizine yönlendir (/)
|
||||
if ($host === 'akademi.truncgil.com.tr' && $path !== '/') {
|
||||
return redirect()->to('https://akademi.truncgil.com.tr/', 301);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Statik View Kontrolü
|
||||
if (view()->exists("templates.$slug")) {
|
||||
$page = Page::with(['headerTemplate', 'footerTemplate'])
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BlogTopic extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'context',
|
||||
'status',
|
||||
'last_used_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AiBlogService
|
||||
{
|
||||
protected $geminiKey;
|
||||
protected $unsplashKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->geminiKey = config('services.gemini.key', env('GEMINI_API_KEY'));
|
||||
$this->unsplashKey = config('services.unsplash.access_key', env('UNSPLASH_ACCESS_KEY'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate blog content using Gemini API
|
||||
*/
|
||||
public function generateContent(string $topic, ?string $context = null): array
|
||||
{
|
||||
$prompt = $this->buildPrompt($topic, $context);
|
||||
|
||||
// Debug logging
|
||||
$keyStatus = empty($this->geminiKey) ? 'MISSING' : 'PRESENT (' . substr($this->geminiKey, 0, 5) . '...)';
|
||||
Log::info("Gemini Service: Key status: $keyStatus");
|
||||
|
||||
try {
|
||||
$response = Http::withoutVerifying()
|
||||
->retry(3, 10000, function ($exception, $request) {
|
||||
if ($exception instanceof \Illuminate\Http\Client\RequestException && $exception->response->status() === 429) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$this->geminiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.7,
|
||||
'topK' => 40,
|
||||
'topP' => 0.95,
|
||||
'maxOutputTokens' => 8192,
|
||||
'responseMimeType' => 'application/json',
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Gemini API Error Status: ' . $response->status());
|
||||
Log::error('Gemini API Error Body: ' . $response->body());
|
||||
throw new \Exception('Gemini API request failed: ' . $response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) {
|
||||
Log::error('Gemini API Invalid Response: ' . json_encode($data));
|
||||
throw new \Exception('Invalid response from Gemini API.');
|
||||
}
|
||||
|
||||
$jsonContent = $data['candidates'][0]['content']['parts'][0]['text'];
|
||||
|
||||
// Clean markdown json blocks if present
|
||||
$jsonContent = str_replace(['```json', '```'], '', $jsonContent);
|
||||
|
||||
return json_decode($jsonContent, true);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AI Blog Generation Exception: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for Gemini
|
||||
*/
|
||||
private function buildPrompt(string $topic, ?string $context = null): string
|
||||
{
|
||||
$contextStr = $context ? "Additional Context: {$context}" : "";
|
||||
|
||||
return <<<EOT
|
||||
You are an expert content writer for "Trunçgil Teknoloji", a leading technology company specializing in web software, mobile apps, IoT, and digital transformation.
|
||||
Write a comprehensive, SEO-friendly blog post about the topic: "{$topic}".
|
||||
{$contextStr}
|
||||
|
||||
**IMPORTANT**: All content in the JSON response (title, content, excerpt, meta_data) MUST BE IN TURKISH language. Only the `image_prompt` should be in English.
|
||||
|
||||
Requirements:
|
||||
1. **Format**: Return ONLY a valid JSON object. No markdown formatting outside the JSON structure.
|
||||
2. **Language**: Turkish (Türkçe).
|
||||
3. **Tone**: Professional, authoritative, yet accessible. Enthusiastic about technology.
|
||||
4. **Brand Integration**: Naturally mention "Trunçgil Teknoloji" at least 2-3 times. Position Trunçgil Teknoloji as a solution provider or expert in this field. Use phrases like "Trunçgil Teknoloji olarak biz...", "Deneyimli ekibimizle...", etc.
|
||||
5. **Structure**:
|
||||
* `title`: Catchy, SEO-optimized title (Max 60 chars) - IN TURKISH.
|
||||
* `meta_title`: SEO Meta Title - IN TURKISH.
|
||||
* `meta_description`: SEO Meta Description (Max 160 chars) - IN TURKISH.
|
||||
* `content`: Full HTML blog post content.
|
||||
* Use <h2> and <h3> tags for headings.
|
||||
* Use <p> for paragraphs.
|
||||
* Include a bulleted list (<ul>/<li>) if appropriate.
|
||||
* Do NOT use <h1> or <html>/<body> tags.
|
||||
* Ensure the content is informative and valuable (min 600 words).
|
||||
* ALL CONTENT MUST BE TURKISH.
|
||||
* `excerpt`: A short summary (1-2 sentences) - IN TURKISH.
|
||||
* `tags`: Array of 5-8 relevant tags (Turkish).
|
||||
* `image_prompt`: A creative English prompt to generate a cover image for this article using an AI image generator.
|
||||
|
||||
JSON Schema:
|
||||
{
|
||||
"title": "string",
|
||||
"meta_title": "string",
|
||||
"meta_description": "string",
|
||||
"content": "html_string",
|
||||
"excerpt": "string",
|
||||
"tags": ["string"],
|
||||
"image_prompt": "string"
|
||||
}
|
||||
EOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and save a cover image
|
||||
*/
|
||||
public function fetchAndSaveImage(string $keyword, string $imagePrompt): ?string
|
||||
{
|
||||
$imageUrl = null;
|
||||
|
||||
// 1. Try Unsplash if key exists
|
||||
if ($this->unsplashKey) {
|
||||
try {
|
||||
$response = Http::withoutVerifying()->get('https://api.unsplash.com/search/photos', [
|
||||
'query' => $keyword,
|
||||
'orientation' => 'landscape',
|
||||
'per_page' => 1,
|
||||
'client_id' => $this->unsplashKey
|
||||
]);
|
||||
|
||||
if ($response->successful() && !empty($response['results'])) {
|
||||
$imageUrl = $response['results'][0]['urls']['regular'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Unsplash API failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to Pollinations AI (using the generated prompt)
|
||||
if (!$imageUrl) {
|
||||
// Encode the prompt
|
||||
$encodedPrompt = urlencode($imagePrompt . " high quality, technological, photorealistic, 4k");
|
||||
$imageUrl = "https://image.pollinations.ai/prompt/{$encodedPrompt}";
|
||||
}
|
||||
|
||||
if (!$imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Download and Save
|
||||
try {
|
||||
$imageContent = Http::withoutVerifying()->get($imageUrl)->body();
|
||||
$filename = 'blog/ai-generated-' . Str::slug($keyword) . '-' . time() . '.jpg';
|
||||
|
||||
Storage::disk('public')->put($filename, $imageContent);
|
||||
|
||||
return $filename;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Image download failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,12 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'gemini' => [
|
||||
'key' => env('GEMINI_API_KEY'),
|
||||
],
|
||||
|
||||
'unsplash' => [
|
||||
'access_key' => env('UNSPLASH_ACCESS_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('blog_topics', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('context')->nullable(); // Additional context/prompt for AI
|
||||
$table->string('status')->default('active'); // active, passive
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('blog_topics');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BlogTopicSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$topics = [
|
||||
'Yapay Zeka ve İş Dünyası',
|
||||
'Mobil Uygulama Geliştirme Trendleri',
|
||||
'E-Ticaretin Geleceği',
|
||||
'Siber Güvenlik Önlemleri',
|
||||
'Blockchain Teknolojisi',
|
||||
'Bulut Bilişim Avantajları',
|
||||
'Nesnelerin İnterneti (IoT) Uygulamaları',
|
||||
'Veri Analitiği ve Büyük Veri',
|
||||
'Dijital Dönüşüm Stratejileri',
|
||||
'Web Yazılım Teknolojileri'
|
||||
];
|
||||
|
||||
foreach ($topics as $topic) {
|
||||
DB::table('blog_topics')->insert([
|
||||
'name' => $topic,
|
||||
'context' => 'Trunçgil Teknoloji çözümlerine ve uzmanlığına vurgu yapın.',
|
||||
'status' => 'active',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
<h1 class="xl:!text-[3rem] !text-[calc(1.425rem_+_2.1vw)] font-semibold !leading-[1.15] !text-white !mb-4 md:!px-20 lg:!px-0 xl:!px-0" data-cue="slideInDown" data-group="page-title" data-show="true" style="animation-name: slideInDown; animation-duration: 700ms; animation-timing-function: ease; animation-delay: 0ms; animation-direction: normal; animation-fill-mode: both;">Hayatı <span class="!relative z-[2] after:content-[''] after:absolute after:z-[-1] after:block after:bg-no-repeat after:bg-bottom after:bottom-0 after:w-[110%] after:h-[0.3em] after:-translate-x-2/4 after:left-2/4 style-2 yellow">kolaylaştıran</span> çözümler sunuyoruz</h1>
|
||||
<p class="lead !text-[1.2rem] !text-white !leading-[1.5] font-medium !mb-7 md:mx-[4rem] lg:mx-[2.5rem] xl:mx-[2.5rem]" data-cue="slideInDown" data-group="page-title" data-show="true" style="animation-name: slideInDown; animation-duration: 700ms; animation-timing-function: ease; animation-delay: 300ms; animation-direction: normal; animation-fill-mode: both;">{{ t('Hayatı kolaylaştıran uygulamaları insan odaklı, akılcı ve sade bir biçimde gerçekleştirmek için var gücümüzle çalışıyoruz.') }}</p>
|
||||
<div data-cue="slideInDown" data-group="page-title" data-show="true" style="animation-name: slideInDown; animation-duration: 700ms; animation-timing-function: ease; animation-delay: 600ms; animation-direction: normal; animation-fill-mode: both;">
|
||||
<a class="btn btn-white !rounded-[50rem] !mb-10 xxl:!mb-5">{{ t('Daha Fazla Oku') }}</a>
|
||||
<a href="{{ route('page.show', ['slug' => 'neler-yapariz']) }}" class="btn btn-white !rounded-[50rem] !mb-10 xxl:!mb-5">{{ t('Daha Fazla Oku') }}</a>
|
||||
<a href="{{ route('page.show', ['slug' => 'online-odeme']) }}" class="btn btn-white !rounded-[50rem] !mb-10 xxl:!mb-5 !ml-2 md:!ml-4">{{ t('Online Ödeme') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /column -->
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
use App\Console\Commands\GenerateBlogAssistant;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Schedule the AI Blog Assistant
|
||||
// Run daily at 09:00 AM
|
||||
Schedule::command(GenerateBlogAssistant::class)->dailyAt('09:00');
|
||||
|
||||
@@ -7,6 +7,11 @@ use App\Http\Controllers\TemplatePreviewController;
|
||||
use App\Http\Controllers\Api\SiteTranslationController;
|
||||
use App\Models\Page;
|
||||
|
||||
// Subdomain Routes
|
||||
Route::domain('akademi.truncgil.com.tr')->group(function () {
|
||||
Route::get('/', [PageController::class, 'showAkademi']);
|
||||
});
|
||||
|
||||
// Debug Route - Veritabanı kontrolü
|
||||
Route::redirect('/index', '/', 301);
|
||||
Route::redirect('/index.php', '/', 301);
|
||||
@@ -126,5 +131,8 @@ Route::get('/logo-preview', function (Illuminate\Http\Request $request) {
|
||||
</html>";
|
||||
})->name('logo.preview');
|
||||
|
||||
// Nested Pages (Parent/Child)
|
||||
Route::get('/{parentSlug}/{slug}', [PageController::class, 'showNested'])->name('page.show_nested');
|
||||
|
||||
// Pages (en sonda olmalı - catch-all)
|
||||
Route::get('/{slug}', [PageController::class, 'show'])->name('page.show');
|
||||
|
||||
Reference in New Issue
Block a user