Building an Artificial Intelligence Automated Blog
#PHP #Laravel #API #AI
Artificial intelligence (AI) has been a hot topic recently with tools like ChatGPT and Dall-E.
In this article, we will play with OpenAI API to automate post creations for a simple blog application built with Laravel.
Introduction and Project Setup
In this article we will use a simple blog demo application built with Laravel.
If you want to follow along you can find the code in this Github repository.
We will start on the "start" branch. To install the application, follow the steps in the README file.
We are using Laravel Sail, so you will need to have Docker installed to run the containers.
The application is quite simple, there is a home page with the last four posts, a page with the list of posts, a page for post details and an "about" page.
Once you have run the application's fixtures, you can log into the admin with the following credentials:
Email: admin@admin.com
Password: password
In the admin you can perform the basic CRUD operations for posts. The Post model has a title, an image and a content property.
We will use OpenAI API, so we will first need to create an API key.
We can generate one in our OpenAI account.
Once created, we set this API key to an OPENAI_API_KEY environment variable in our .env file.
Automating the Post Creation Form
What we want to do for the first step, is simplify the post creation form in the admin.
We want to be able to create a new post by submitting only a title, we will have the AI take care of creating both the image and the content of the post.
For the image, we will use the OpenAI API Images endpoint.
For the text content, we will use the OpenAI API Chat endpoint.
We will create two separates services dedicated to each of these tasks (trying to follow Single Responsibility Principle 😉). We will start by creating two interfaces that these services will have to implement, we do not need them right now but it can be helpful in the futur if we want to use some different APIs without having to change the rest of our code (again trying to follow some SOLID principles).
The AI API Images endpoint can return us the generated image either as an url or base64, we will work with the url.
The AiImageApi interface will define the signature of a "getImageContentUrl". This method will take as arguments the prompt to send to the AI API and the size of the requested image (right now it must be one of 256x256, 512x512, or 1024x1024) and return us the url of the generated image.
App\Contract\AiImageApi
namespace App\Contract;
interface AiImageApi
{
public function getImageContentUrl(string $prompt, string $size): string;
}
The OpenAiImageApi class will implement this interface.
App\Service\OpenAiImageApi
namespace App\Service;
use App\Contract\AiImageApi;
use App\Exceptions\AiApiException;
use Illuminate\Support\Facades\Http;
class OpenAiImageApi implements AiImageApi
{
public function __construct(
private readonly string $apiKey,
private readonly string $apiImageUrl
)
{
}
public function getImageContentUrl(string $prompt, string $size): string
{
$response = Http::withToken($this->apiKey)->post($this->apiImageUrl, [
'prompt' => $prompt,
'n' => 1,
'size' => $size,
]);
$decodedResponse = json_decode($response->getBody());
if ($decodedResponse?->error ?? false) {
throw new AiApiException($decodedResponse?->error?->message);
}
return $decodedResponse->data[0]->url;
}
}
It makes a POST request to the API endpoint, with Bearer Authentication and json content with the expected parameters in the following format (n being the number of images requested):
{
"prompt": "A cute baby sea otter",
"n": 1,
"size": "1024x1024"
}
We will receive a response with some json in this format:
{
"created": 1589478378,
"data": [
{
"url": "https://..."
}
]
}
We need to decode it, then access the "url" property which is in the first element of the "data" array.
We also created a custom AiApiException Exception in case something went wrong (e.g. wrong API key).
That will take care of getting the image, then for the text content, the AiTextApi interface will define the signature of a "getPostContent" method. This method will take as arguments the topic of the post we want to request from the AI API and the number of words we want for our content. It will return the text content.
App\Contract\AiTextApi
interface AiTextApi
{
public function getPostContent(string $topic, int $nbWords): string;
}
The OpenAiTextApi class will implement this interface.
App\Service\OpenAiTextApi
namespace App\Service;
use App\Contract\AiTextApi;
use App\Exceptions\AiApiException;
use Illuminate\Support\Facades\Http;
class OpenAiTextApi implements AiTextApi
{
public function __construct(
private readonly string $apiKey,
private readonly string $apiTextUrl
)
{
}
public function getPostContent(string $topic, int $nbWords): string
{
return $this->getTextContent('Write a ' . $nbWords . ' words post about this topic: ' . $topic);
}
private function getTextContent(string $prompt): string
{
$response = Http::withToken($this->apiKey)->post($this->apiTextUrl, [
'model' => 'gpt-3.5-turbo',
'messages' => [
[
'role' => 'user',
'content' => $prompt,
]
],
]);
$decodedResponse = json_decode($response->getBody());
if ($decodedResponse?->error ?? false) {
throw new AiApiException($decodedResponse?->error?->message);
}
return $decodedResponse->choices[0]->message->content;
}
}
The "getPostContent" method calls the "getTextContent" method and pass it a custom prompt to ask for a post of $nbWords words about the $topic topic.
The "getTextContent" method sends a POST request to the API endpoint, with Bearer Authentication and json content with the expected parameters in the following format:
{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hello!"}]
}
We will receive a response with json content in this format:
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "\n\nHello there, how may I assist you today?",
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 12,
"total_tokens": 21
}
}
We need to decode it and access the "content" property in the "message" object, which is in the first element of the "choices" array.
Once we have created both of these new services, we need to bind the interfaces to their concrete implementations in our AppServiceProvider and give their constructor their requested arguments (the API key and the endpoint url).
App\Providers\AppServiceProvider
public function register(): void
{
$this->app->singleton(
AiTextApi::class, fn () => new OpenAiTextApi(
env('OPENAI_API_KEY'),
'https://api.openai.com/v1/chat/completions'
)
);
$this->app->singleton(
AiImageApi::class, fn () => new OpenAiImageApi(
env('OPENAI_API_KEY'),
'https://api.openai.com/v1/images/generations'
)
);
}
Then we will inject our new services in the "store" method of our Admin\PostController that is in charge of receiving the POST requests from the post creation route (i.e. the route to which the post creation form is submitted).
We put the API calls into a try catch as there could be many reasons for them to fail (request timeout, wrong API key ...).
If something goes wrong we return back to the creation form with some error, otherwise we copy the image content from the url received from the Images endpoint into some randomly named file and create the new Post with the corresponding filename as well as the text content received from the Chat endpoint.
App\Http\Controllers\Admin\PostController
public function store(AiImageApi $imageApi, AiTextApi $textApi, CreatePostRequest $request): RedirectResponse
{
try {
$imgUrl = $imageApi->getImageContentUrl($request->title, '512x512');
$content = $textApi->getPostContent($request->title, 300);
} catch (\Exception $exception) {
return back()->withErrors([
'error' => sprintf('Could not create post: %s', $exception->getMessage())
]);
}
$filename = uniqid() . '_' . time() . '.png';
Storage::disk('public')->put('uploads/' . $filename, file_get_contents($imgUrl));
$post = Post::create([
'title' => $request->title,
'image' => 'storage/uploads/' . $filename,
'content' => $content,
]);
return redirect()->route('admin.posts.index');
}
In order for this to work we must remove the rules about the content and the image in the CreatePostRequest, leaving only the title as required.
App\Http\Requests\Admin\CreatePostRequest.php
public function rules(): array
{
return [
'title' => 'required',
];
}
We will also remove the file input and textarea that are no longer needed in our blade template for the post creation form, leaving only the text input to submit the post title. And we should display the errors if something goes wrong.
resources/views/admin/posts/create.blade.php
<x-app-layout>
<x-slot name="header">
<div class="text-center">
<h1 class="text-6xl font-bold text-orange-600">{{ __('Create Post') }}</h1>
</div>
</x-slot>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 bg-white border-b border-gray-200">
<form method="POST" action="{{ route('admin.posts.store') }}" enctype="multipart/form-data">
@csrf
<div class="mb-4">
<x-label for="title">Title</x-label>
<x-input id="title" class="block w-full mt-1" name="title" required value="{{ old('title') }}" type="text"/>
@error('title')
<span class="font-medium text-red-600" role="alert">{{ $message }}</span>
@enderror
</div>
<div class="mt-6">
<x-button>Submit</x-button>
</div>
</form>
@if ($errors->any())
<ul class="mt-3 list-disc list-inside text-sm text-red-600">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
</div>
</x-app-layout>
You can find the code for this first step in the ai_post_completion branch.
If we test our new AI powered post creation form with some title, for example "Cats Are The Best", we get a shining new post with AI generated image and text content.
A Fully Automated Post Creation Command
Our new post creation form is great but having to manually input a title is still too much work for us 🦥. To go a step further, we now want to fully automate our post creation process and have a scheduled task that runs for example every week to create a new random post.
We will start by adding a new "getRandomTitle" method to our AiTextApi interface and OpenAiTextApi class.
App\Contract\AiTextApi
namespace App\Contract;
interface AiTextApi
{
public function getRandomTitle(int $nbCharacters): string;
public function getPostContent(string $topic, int $nbWords): string;
}
This new method will be responsible to build a custom prompt to ask the API for a random post title. it will call the already existing "getTextContent" method with this prompt.
App\Service\OpenAiTextApi
namespace App\Service;
use App\Contract\AiTextApi;
use App\Exceptions\AiApiException;
use Illuminate\Support\Facades\Http;
class OpenAiTextApi implements AiTextApi
{
public function __construct(
private readonly string $apiKey,
private readonly string $apiTextUrl
)
{
}
public function getRandomTitle(int $nbCharacters): string
{
return $this->getTextContent('Give me a ' . $nbCharacters . ' characters long random post title');
}
public function getPostContent(string $topic, int $nbWords): string
{
return $this->getTextContent('Write a ' . $nbWords . ' words post about this topic: ' . $topic);
}
private function getTextContent(string $prompt): string
{
$response = Http::withToken($this->apiKey)->post($this->apiTextUrl, [
'model' => 'gpt-3.5-turbo',
'messages' => [
[
'role' => 'user',
'content' => $prompt,
]
],
]);
$decodedResponse = json_decode($response->getBody());
if ($decodedResponse?->error ?? false) {
throw new AiApiException($decodedResponse?->error?->message);
}
return $decodedResponse->choices[0]->message->content;
}
}
We will the create a new AutomaticPostCreator service.
App\Contract\AutomaticPostCreator
namespace App\Contract;
use App\Models\Post;
interface AutomaticPostCreator
{
public function createPost(): Post;
}
The "createPost" method makes a first request to the text API to ask for a random article title. Then it asks for an image and a text content according to this title and creates the new Post, like what we did in the PostController.
App\Service\AutomaticPostCreator
namespace App\Service;
use App\Models\Post;
use App\Contract\AiTextApi;
use App\Contract\AiImageApi;
use App\Contract\AutomaticPostCreator as AutomaticPostCreatorInterface;
use Illuminate\Support\Facades\Storage;
class AutomaticPostCreator implements AutomaticPostCreatorInterface
{
public function __construct(
private readonly AiImageApi $imageApi,
private readonly AiTextApi $textApi,
)
{
}
public function createPost(): Post
{
try {
$title = $this->textApi->getRandomTitle(80);
echo "$title\n";
$imgUrl = $this->imageApi->getImageContentUrl($title, '512x512');
$content = $this->textApi->getPostContent($title, 300);
} catch (\Exception $exception) {
throw new \Exception($exception->getMessage());
}
$filename = uniqid() . '_' . time() . '.png';
Storage::disk('public')->put('uploads/' . $filename, file_get_contents($imgUrl));
return Post::create([
'title' => $title,
'image' => 'storage/uploads/' . $filename,
'content' => $content,
]);
}
}
We have to bind the interface to its implementation.
App\Providers\AppServiceProvider
...
use App\Service\AutomaticPostCreator;
use App\Contract\AutomaticPostCreator as AutomaticPostCreatorInterface;
...
public function register(): void
{
...
$this->app->singleton(AutomaticPostCreatorInterface::class, AutomaticPostCreator::class);
}
We will use this new service in a Laravel Command. To create a new command, we can use the "make:command" Artisan command.
./vendor/bin/sail artisan make:command CreatePost
This command takes our AutomaticPostCreator service as a dependency. In its "handle" method it calls this service "createPost" method to create a new post.
App\Console\Commands\CreatePost
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Contract\AutomaticPostCreator as AutomaticPostCreatorInterface;
class CreatePost extends Command
{
public function __construct(
private readonly AutomaticPostCreatorInterface $postCreator
){
parent::__construct();
}
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:create-post';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Automatically create a random Post by calling some AI API';
/**
* Execute the console command.
*/
public function handle(): void
{
$this->info('Creating new post...');
try {
$post = $this->postCreator->createPost();
$this->info('New post created!');
} catch (\Exception $exception) {
$this->error(sprintf('Something went wrong: %s', $exception->getMessage()));
}
}
}
We can now test our command.
./vendor/bin/sail artisan app:create-post
And we should get something like that:
Creating new post...
"Chasing the Wind: A Journey of Exploration and Adventure"
New post created!
Or an error if something went wrong.
The last step of automating our post creation process is to have a scheduled task run this command. We could simply create a cron job for this but we will use Laravel Scheduler which will be a bit nicer. We will define our scheduled task in the schedule method of our application's App\Console\Kernel class. Let's say that we want it to run every tuesday at 08:12.
App\Console\Kernel.php
protected function schedule(Schedule $schedule): void
{
$schedule->command('app:create-post')->weekly()->tuesdays()->at('08:12');
}
And we are done with automating our blog posts creations! 🤖
Of course there are some chances that our task will fail due to a request timeout or something and we should probably add some kind of notification for this but we will leave that to the reader.
You can find the code for this version in the post_creation_schedule branch.
You can see the result online at https://ai-blog.jycurien.fr/posts.
Final Thoughts
We will let the reader judge the quality of the articles produced using AI.
If you are a content creator, there is no harm in seeking a little assistance from AI. However, it should not replace human creativity and critical thinking. Otherwise, we might be inundated with a plethora of uninteresting, repetitive and fabricated content.
As AI technology advances, it is probable that it will become even more integrated into our daily lives and applications. Developers will need to adapt and learn how to use these tools effectively.
Last updated 1 year ago