From d41ccbf3c3fe0d075086d9349c3ae4bf9799736b Mon Sep 17 00:00:00 2001 From: Layton Berth Date: Tue, 10 Mar 2026 15:05:44 +0100 Subject: [PATCH] Implement scheduled AI blog generation --- .env.example | 11 + app/Console/Commands/GenerateBlogPost.php | 40 +++ app/Services/BlogGenerationService.php | 351 ++++++++++++++++++++++ config/blog-generation.php | 157 ++++++++++ routes/console.php | 8 + 5 files changed, 567 insertions(+) create mode 100644 app/Console/Commands/GenerateBlogPost.php create mode 100644 app/Services/BlogGenerationService.php create mode 100644 config/blog-generation.php diff --git a/.env.example b/.env.example index a6aa749..498e01c 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,14 @@ EXPERIENCE_CHAT_HISTORY_TURNS=4 EXPERIENCE_CHAT_CONTACT_FORM_MAX_TOKENS=1000 EXPERIENCE_CHAT_SMART_FILTER_MAX_TOKENS=200 EXPERIENCE_CHAT_SMART_FILTER_DESCRIPTION_LENGTH=600 + +# Weekly AI blog draft (app:generate-blog-post, runs Monday 08:00) +BLOG_GENERATION_RESEARCH_MODEL=gpt-4o-search-preview +BLOG_GENERATION_WRITING_MODEL=gpt-4o +BLOG_GENERATION_IMAGE_MODEL=dall-e-3 +BLOG_GENERATION_RESEARCH_MAX_TOKENS=3500 +BLOG_GENERATION_WRITING_MAX_TOKENS=4000 +BLOG_GENERATION_WRITING_TEMPERATURE=0.65 +BLOG_GENERATION_WRITING_TOP_P=0.9 +BLOG_GENERATION_LANGUAGE=Dutch +BLOG_GENERATION_LOCALE=nl diff --git a/app/Console/Commands/GenerateBlogPost.php b/app/Console/Commands/GenerateBlogPost.php new file mode 100644 index 0000000..311794a --- /dev/null +++ b/app/Console/Commands/GenerateBlogPost.php @@ -0,0 +1,40 @@ +generate(); + } catch (Throwable $e) { + $this->error('Blog generation failed: ' . $e->getMessage()); + + return self::FAILURE; + } + + $this->info("Draft blog post created: \"{$blog->title}\" (id: {$blog->id}, slug: {$blog->slug})."); + $this->info('Review and publish it from the Filament admin.'); + + return self::SUCCESS; + } +} diff --git a/app/Services/BlogGenerationService.php b/app/Services/BlogGenerationService.php new file mode 100644 index 0000000..d84a25e --- /dev/null +++ b/app/Services/BlogGenerationService.php @@ -0,0 +1,351 @@ +research(); + $parsed = $this->write($research); + + $slug = $this->ensureUniqueSlug($parsed['slug']); + $tagsString = implode(',', array_map('trim', $parsed['tags'])); + + $imagePath = null; + $imagePrompt = $parsed['image_prompt'] ?? null; + if ($imagePrompt !== null && $imagePrompt !== '') { + $imagePath = $this->generateAndStoreImage($imagePrompt, $slug); + } + + return DB::transaction(function () use ($parsed, $slug, $tagsString, $imagePath) { + $blog = Blog::query()->create([ + 'visible' => false, + 'pin_on_homepage' => false, + 'title' => $parsed['title'], + 'slug' => $slug, + 'description' => $parsed['description'], + 'image' => $imagePath, + 'author' => 'Libaro', + 'publish_date' => now()->toDateString(), + 'link' => null, + 'tags' => $tagsString, + ]); + + $sort = 0; + foreach ($parsed['blocks'] as $block) { + $type = $block['type']; + $content = $block['content']; + if ($imagePath !== null && in_array($type, [ + FilamentBlockType::Image->value, + FilamentBlockType::ImageText->value, + FilamentBlockType::LogoText->value, + ], true) && empty($content['image'])) { + $content['image'] = $imagePath; + } + + $blog->blocks()->create([ + 'type' => $type, + 'content' => $content, + 'sort' => $sort++, + ]); + } + + return $blog; + }); + } + + /** + * Step 1: Use the web-search model to find a current topic and gather research. + */ + private function research(): string + { + $language = config('blog-generation.language', 'Dutch'); + $prompt = config('blog-generation.research_prompt', ''); + $prompt = str_replace('{{ language }}', $language, $prompt); + $prompt = str_replace('{{ today }}', now()->toDateString(), $prompt); + + try { + $response = OpenAI::chat()->create([ + 'model' => config('blog-generation.research_model', 'gpt-4o-search-preview'), + 'messages' => [ + ['role' => 'system', 'content' => $prompt], + ['role' => 'user', 'content' => 'Find one current topic about custom software, integrations, or general development (not AI). Show source dates and write a detailed research summary.'], + ], + 'max_tokens' => config('blog-generation.research_max_tokens', 1800), + ]); + } catch (Throwable $e) { + throw new RuntimeException('Research step failed: ' . $e->getMessage(), 0, $e); + } + + $content = $response->choices[0]->message->content ?? null; + + if ($content === null || $content === '') { + throw new RuntimeException('Research step returned empty content.'); + } + + return $content; + } + + /** + * Step 2: Use a standard model with JSON response format to write the blog post. + * + * @return array{title: string, slug: string, description: string, tags: array, image_prompt: string|null, blocks: array}>} + */ + private function write(string $research): array + { + $language = config('blog-generation.language', 'Dutch'); + $prompt = config('blog-generation.writing_prompt', ''); + $prompt = str_replace('{{ language }}', $language, $prompt); + + try { + $response = OpenAI::chat()->create([ + 'model' => config('blog-generation.writing_model', 'gpt-4o'), + 'messages' => [ + ['role' => 'system', 'content' => $prompt], + ['role' => 'user', 'content' => "Here are the research notes:\n\n" . $research], + ], + 'max_tokens' => config('blog-generation.writing_max_tokens', 2500), + 'temperature' => config('blog-generation.writing_temperature', 0.65), + 'top_p' => config('blog-generation.writing_top_p', 0.9), + 'response_format' => ['type' => 'json_object'], + ]); + } catch (Throwable $e) { + throw new RuntimeException('Writing step failed: ' . $e->getMessage(), 0, $e); + } + + $content = $response->choices[0]->message->content ?? null; + + if ($content === null || $content === '') { + throw new RuntimeException('Writing step returned empty content.'); + } + + $parsed = json_decode($content, true); + + if (! is_array($parsed) || ! isset($parsed['title'])) { + throw new RuntimeException('Writing step returned invalid or incomplete JSON.'); + } + + $title = (string) $parsed['title']; + $slug = isset($parsed['slug']) && is_string($parsed['slug']) ? $parsed['slug'] : Str::slug($title); + $description = isset($parsed['description']) && is_string($parsed['description']) ? $parsed['description'] : ''; + $tags = is_array($parsed['tags'] ?? null) ? array_map('strval', $parsed['tags']) : []; + $blocks = $this->normalizeBlocks($parsed); + + $imagePrompt = isset($parsed['image_prompt']) && is_string($parsed['image_prompt']) ? $parsed['image_prompt'] : null; + + return [ + 'title' => $title, + 'slug' => $slug, + 'description' => $description, + 'tags' => $tags, + 'image_prompt' => $imagePrompt, + 'blocks' => $blocks, + ]; + } + + /** + * @param array $parsed + * @return array}> + */ + private function normalizeBlocks(array $parsed): array + { + $allowedTypes = [ + FilamentBlockType::Text->value, + FilamentBlockType::NumberText->value, + FilamentBlockType::CtaBlock->value, + FilamentBlockType::Image->value, + FilamentBlockType::ImageText->value, + FilamentBlockType::LogoText->value, + ]; + $normalized = []; + + if (isset($parsed['blocks']) && is_array($parsed['blocks'])) { + foreach ($parsed['blocks'] as $block) { + if (! is_array($block)) { + continue; + } + $type = isset($block['type']) && is_string($block['type']) ? $block['type'] : FilamentBlockType::Text->value; + if (! in_array($type, $allowedTypes, true)) { + continue; + } + $content = isset($block['content']) && is_array($block['content']) ? $block['content'] : []; + $normalized[] = [ + 'type' => $type, + 'content' => $this->normalizeBlockContent($type, $content), + ]; + } + } + + if ($normalized === []) { + throw new RuntimeException('Writing step returned no usable blocks.'); + } + + return $normalized; + } + + /** + * @param array $content + * @return array + */ + private function normalizeBlockContent(string $type, array $content): array + { + return match ($type) { + FilamentBlockType::NumberText->value => [ + 'number' => min(9, max(1, isset($content['number']) ? (int) $content['number'] : 1)), + 'title' => (string) ($content['title'] ?? ''), + 'text' => (string) ($content['text'] ?? ''), + ], + FilamentBlockType::CtaBlock->value => [ + 'title' => (string) ($content['title'] ?? ''), + 'text' => (string) ($content['text'] ?? ''), + 'button_text' => (string) ($content['button_text'] ?? 'Contacteer ons'), + 'button_url' => (string) ($content['button_url'] ?? $this->defaultCtaUrl()), + ], + FilamentBlockType::Image->value => [ + 'image' => (string) ($content['image'] ?? ''), + ], + FilamentBlockType::ImageText->value => [ + 'image' => (string) ($content['image'] ?? ''), + 'text' => (string) ($content['text'] ?? ''), + 'layout' => ($content['layout'] ?? 'image_text') === 'text_image' ? 'text_image' : 'image_text', + ], + FilamentBlockType::LogoText->value => [ + 'image' => (string) ($content['image'] ?? ''), + 'text' => (string) ($content['text'] ?? ''), + ], + default => [ + 'text' => (string) ($content['text'] ?? ''), + ], + }; + } + + /** + * Step 3: Generate a hero image with DALL-E and upload to S3. + * Uses WebP conversion when available, otherwise stores original format. + */ + private function generateAndStoreImage(string $prompt, string $slug): ?string + { + try { + $response = OpenAI::images()->create([ + 'model' => config('blog-generation.image_model', 'dall-e-3'), + 'prompt' => $prompt, + 'n' => 1, + 'size' => '1792x1024', + 'quality' => 'hd', + ]); + } catch (Throwable $e) { + return null; + } + + $url = $response->data[0]->url ?? null; + if ($url === null) { + return null; + } + + try { + $download = Http::timeout(30)->get($url); + if (! $download->successful()) { + return null; + } + $imageData = $download->body(); + } catch (Throwable $e) { + return null; + } + + if ($imageData === '') { + return null; + } + + $webp = $this->convertToWebp($imageData, 1000); + if ($webp !== null) { + $path = 'blogs/' . Str::slug($slug) . '-' . Str::random(8) . '.webp'; + if (Storage::disk('s3')->put($path, $webp, 'public')) { + return $path; + } + } + + $path = 'blogs/' . Str::slug($slug) . '-' . Str::random(8) . '.png'; + + return Storage::disk('s3')->put($path, $imageData, 'public') ? $path : null; + } + + /** + * Resize image data to a target height and convert to WebP. + * Returns the WebP binary string, or null if GD/WebP is unavailable. + */ + /** + * @param int<1, max> $targetHeight + */ + private function convertToWebp(string $imageData, int $targetHeight, int $quality = 85): ?string + { + if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) { + return null; + } + + $source = @imagecreatefromstring($imageData); + if ($source === false) { + return null; + } + + $origW = imagesx($source); + $origH = imagesy($source); + $newW = max(1, (int) round($origW * ($targetHeight / max(1, $origH)))); + + $resized = imagecreatetruecolor($newW, $targetHeight); + if ($resized === false) { + imagedestroy($source); + + return null; + } + + imagecopyresampled($resized, $source, 0, 0, 0, 0, $newW, $targetHeight, $origW, $origH); + imagedestroy($source); + + ob_start(); + imagewebp($resized, null, $quality); + $result = ob_get_clean(); + imagedestroy($resized); + + return is_string($result) && $result !== '' ? $result : null; + } + + private function defaultCtaUrl(): string + { + $locale = config('blog-generation.locale', 'nl'); + + return 'https://libaro.be/' . $locale . '/contact'; + } + + private function ensureUniqueSlug(string $slug): string + { + $base = $slug; + $candidate = $base; + $n = 0; + + while (Blog::query()->where('slug', $candidate)->exists()) { + $candidate = $base . '-' . now()->format('Y-\WW') . ($n > 0 ? '-' . $n : ''); + $n++; + } + + return $candidate; + } +} diff --git a/config/blog-generation.php b/config/blog-generation.php new file mode 100644 index 0000000..2e51a8a --- /dev/null +++ b/config/blog-generation.php @@ -0,0 +1,157 @@ + env('BLOG_GENERATION_RESEARCH_MODEL', 'gpt-4o-search-preview'), + 'writing_model' => env('BLOG_GENERATION_WRITING_MODEL', 'gpt-4o'), + 'image_model' => env('BLOG_GENERATION_IMAGE_MODEL', 'dall-e-3'), + 'research_max_tokens' => (int) env('BLOG_GENERATION_RESEARCH_MAX_TOKENS', 3500), + 'writing_max_tokens' => (int) env('BLOG_GENERATION_WRITING_MAX_TOKENS', env('BLOG_GENERATION_MAX_TOKENS', 4000)), + 'writing_temperature' => (float) env('BLOG_GENERATION_WRITING_TEMPERATURE', 0.65), + 'writing_top_p' => (float) env('BLOG_GENERATION_WRITING_TOP_P', 0.9), + + /* + |-------------------------------------------------------------------------- + | Language + |-------------------------------------------------------------------------- + */ + 'language' => env('BLOG_GENERATION_LANGUAGE', 'Dutch'), + 'locale' => env('BLOG_GENERATION_LOCALE', 'nl'), + + /* + |-------------------------------------------------------------------------- + | Research prompt (web-search step) + |-------------------------------------------------------------------------- + | + | Placeholders: {{ language }}, {{ today }} + */ + 'research_prompt' => <<<'PROMPT' + You work for Libaro, a Belgian software company that builds custom software, integrations (API, ERP, CRM, e-commerce), and digital solutions for businesses. The blog must attract traffic and leads: readers who might need custom development or integrations. + + Today is {{ today }}. + Take your time to search the web thoroughly before deciding on a topic. Explore multiple candidates, compare their recency and relevance, and only then pick the single best one. + + Pick ONE topic from the categories below. Give strong priority to fresh releases, version updates, or newly announced tools that developers and businesses are actively searching for right now — these "what is X and why should I care?" topics generate high organic traffic. + + PREFERRED CATEGORIES (pick what is most timely today): + - New framework or library releases (e.g. Laravel 12, Inertia 3, Vue 4, Tailwind 4, Node 22, PHP 8.4, etc.) + - New versions of tools, platforms, or services developers actually use + - Emerging IT solutions or patterns gaining adoption right now + - Custom software and bespoke development (build vs buy, ROI, delivery practices) + - Integrations: APIs, ERP (Odoo, Robaws), CRM, e-commerce, legacy connectivity, middleware + - Web and mobile development: performance, accessibility, PWA, new browser APIs + - Process automation and workflow digitisation + - Cloud, hosting, and platform choices for business software + - Security, compliance, and data protection + - Digital transformation and modernising legacy systems + + AVOID: Do not choose a generic AI/ML topic. We already have many AI posts; prefer development, integrations, releases, or business software topics. + + Recency rule: prefer announcements or articles from the last 30 days; only go older if it is a major release still actively being adopted. + + Research depth: search multiple sources. Collect: + - What exactly is new or changed (changelog highlights, key features) + - Why it matters to developers and/or businesses + - Who it affects and how they should respond + - Key facts, exact version numbers, release dates, and direct quotes + - Links to official sources, release notes, or authoritative articles + + Write the full summary in {{ language }}. Be thorough — the writer will rely solely on this to produce an accurate, beginner-friendly blog post that helps readers understand the topic from scratch. + PROMPT, + + /* + |-------------------------------------------------------------------------- + | Writing prompt (structured JSON step) + |-------------------------------------------------------------------------- + | + | Placeholder: {{ language }} + | The research output is injected as a user message by the service. + */ + 'writing_prompt' => <<<'PROMPT' + You are a skilled blog writer for Libaro, a Belgian software company that builds custom software, integrations (API, ERP, CRM, e-commerce), and digital solutions for businesses. The blog doubles as a content-marketing channel — every post should subtly position Libaro as a knowledgeable, forward-thinking partner. + + You will receive research notes about a current topic. Use them to write an engaging, accurate blog post. Cover the topic on its merits; do not favour any specific vendor or brand. + + CONTEXT: + - Write in {{ language }}. + - Tone: confident, expert, yet approachable. Use first-person plural ("wij bij Libaro", "ons team") naturally. + - Assume some readers are not technical — explain new concepts clearly in plain language before going deeper. + - Show how the topic affects real projects, teams, or business outcomes. + - Where relevant, briefly highlight how Libaro helps clients navigate this (e.g. "Bij Libaro helpen we bedrijven om …"). Keep it natural, not salesy. Keep Libaro references generic — do not mention specific technologies, versions, or frameworks; focus on the outcome or challenge instead. + - End with a clear call to action: invite the reader to get in touch with Libaro for advice or collaboration. Keep the CTA generic and outcome-focused (e.g. "Wil je weten hoe dit jouw bedrijf kan helpen?" or "Neem contact op voor een vrijblijvend gesprek."). Never name a specific tech stack, tool, or version in the CTA. + - Do not invent client names, project names, or metrics. + - Aim for 700–1000 words in 3–6 blocks with a natural flow. + - Avoid template-like writing patterns. Block lengths should vary naturally (some short, some longer). + - Use varied paragraph lengths and rich formatting (tables for comparisons, code snippets for examples, bullet lists for feature overviews). + - Write like a human editorial article, not a fixed AI outline. + - SEO goal: optimize for discoverability on Google while preserving readability and credibility. + - Match likely search intent (informational + practical) and answer the core question early in the article. + - Include relevant primary/secondary keywords naturally (no keyword stuffing), with semantic variations. + - Add at least one meaningful external link to an authoritative source when relevant. + + Also return one English prompt for the hero image (DALL·E). The image must fit this specific blog post and feel real, not generated. + + IMAGE PROMPT RULES: + - Fit the article: the image must directly reflect this post's topic (e.g. integrations → connected objects or hands at a desk; APIs → simple tech or documentation; security → lock, key, safe; no generic "office"). + - Keep it simple: prefer minimal, everyday subjects (objects, hands, a desk corner, cables, a plant, paper, real tools). It does not need people or a full scene. + - Realistic only: documentary or editorial photo style, natural lighting, real-world textures. Nothing that looks futuristic, sci‑fi, or AI‑generated. + - Never use: fake or stock-looking people in offices, holograms, neon lights, glowing screens, cyberpunk or "tech" aesthetic, floating UIs, or anything that looks like typical AI imagery. + - Allowed: simple still life, detail shots, real workspace close-ups, nature or materials that metaphorically match the topic. Wide horizontal (16:9) composition when relevant. + - Hard constraints: no text, no logos, no watermarks in the image. + + Write one concise prompt (max 80 words) in English. Output only that prompt in the `image_prompt` field. + + OUTPUT: + Return only valid JSON matching this schema: + { + "title": "string (catchy, clear, under 80 chars)", + "slug": "string (URL-safe, lowercase, hyphens, no spaces)", + "description": "string (1–2 sentences for meta/summary, under 200 chars)", + "tags": ["string", "string", ...], + "image_prompt": "string (English, max 80 words; simple, realistic photo that fits this article; no people in offices, no holograms or futuristic look)", + "blocks": [ + { + "type": "text | number_text | ctablock | image | image_text | logo_text", + "content": { "...": "fields for that block type" } + } + ] + } + + RULES: + - 3–5 tags that fit the topic and likely search queries (e.g. Security, Cloud, AI, Web Development, DevOps). + - title should be compelling and SEO-friendly (clear topic + benefit, avoid clickbait). + - description should work as a strong meta description: clear value, natural keywords, under 200 chars. + - slug: unique-looking, include the primary keyword of the topic. + - For text-like content, HTML may use rich formatting tags, including: +

,

,

,

,