https://gehri.dev/images/blog/pestdocs-header.png
In this tutorial we are going to build a Laravel application using the new OpenAI Assistants API.
The goal of the application is to build a tool which answers questions about the Pest Testing framework based on the current state of the Pest documentation.
One of the benefits of using the Assistants API is that you can provide data which is then used by the AI. This way we can provide the latest version of the documentation instead of only using the outdated knowledge of the base model.
To achieve our goal we are going to use the OpenAI PHP Client for Laravel developed by me and Nuno Maduro.
If you are using any other PHP framework you can use the underlying OpenAI PHP Client directly.
All the source for this tutorial is available at GitHub.
API overview / what to do
If you are not familiar with the Assistants API yet, here is a brief overview of the API and what we need to do in order to achieve our goal.
The basic structur of the Assistants API is as follows:
- Assistants are the main resource. There we can attach files and tools the AI can use to answer questions. In our case we are going to attach the Pest docs.
- Threads contain the actual questions and answers. We can create multiple threads for the same assistant. You can think of a thread as a conversation between a user and the AI.
- Messages are either a user input / question or an answer from the AI.
- Runs are the requests to start the AI work after a thread has been created and optionally a user message has been added. Unlike the Chat API, we do not get the answer directly. Instead, we get a run id which we can use to get the result later on.
- Steps represent the progress of the AI. We can use them to get the current state of the AI. This is useful if we want to show the progress to the user or for debugging purposes. We are not going to use them in this tutorial.
For more in-depth information visit the OpenAI documentation.
Prerequisites
If you want to follow along you need to have an OpenAI account and an API key. You can create an account at https://openai.com/.
Create a new Laravel application and install the OpenAI PHP Client
We start by creating a new application using the Laravel installer.
laravel new pest-docs-bot
Next we install the OpenAI PHP Client for Laravel.
Note: You need to install the latest (beta) version of the package.
composer require openai-php/laravel:^0.8.0-beta.2`
Prepare the Pest docs
Before we can upload the documents to OpenAI, we have to prepare them, as the documentation consists of several files and OpenAI has a limit of 20 files per assistant.
Therefore, we will merge all doc files into a single file. Furthermore, we are going to exclude some files as they do not contain any useful information for our goal.
First we create a new command.
php artisan make:command PrepareDocs
We are fetching a list of all the files in the PestDoc repository using the GitHub REST API.
Then we are looping through all the files and filter out files which do not have a download url or the ones we want to exclude.
Finally, we retrieve the contents of the individual files and merge them into a single file. Then we save the file locally.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class PrepareDocs extends Command
{
private static array $FILES_TO_IGNORE = [
'LICENSE.md',
'README.md',
'announcing-pest2.md',
'documentation.md',
'pest-spicy-summer-release.md',
'video-resources.md',
'why-pest.md',
];
protected $signature = 'app:prepare-docs';
protected $description = 'Create one file containing the full Pest documentation';
public function handle()
{
$files = Http::get('https://api.github.com/repos/pestphp/docs/contents')->collect();
$fullDocs = $files->filter(fn (array $file) => $file['download_url'] !== null)
->filter(fn (array $file) => ! in_array($file['name'], self::$FILES_TO_IGNORE))
->map(fn (array $file) => Http::get($file['download_url'])->body())
->implode(PHP_EOL.PHP_EOL);
Storage::disk('local')->put('full-pest-docs.md', $fullDocs);
}
}
Upload the docs to OpenAI
Now we are ready to upload the docs to OpenAI. We are going to use the upload()
method of the Files
resource in a new UploadDocs
command.
The file to upload has to be a resource stream. Therefore, we are using the readStream()
method of the Storage
facade.
As purpose
we are using assistants
as we want to use the file for an assistant.
As a result we get a File
object which contains the id
of the uploaded file. We are going to use this id later on when we create the assistant.
I would suggest to store this id
somewhere in your application, for example in a AssistantFile
model to keep track of your uploaded files.
For simplicity we are here just going to output the id
to the console.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use OpenAI\Laravel\Facades\OpenAI;
class UploadDocs extends Command
{
protected $signature = 'upload-docs';
protected $description = 'Uploads the docs to OpenAI';
public function handle()
{
$uploadedFile = OpenAI::files()->upload([
'file' => Storage::disk('local')->readStream('full-pest-docs.md'),
'purpose' => 'assistants',
]);
$this->info('File ID: '.$uploadedFile->id);
}
}
Then we run the command:
Create the assistant
Now we need to create the assistant. We are going to use the create()
method of the Assistants
resource in a new CreateAssistant
command.
We are giving the assistant a name
, pass the id of the uploaded
file from before and add the retrieval
tool.
The Knowledge Retrieval tool is used to extract information from the uploaded file. For more information about the tool visit the OpenAI documentation.
Next we instruct
the AI to use the uploaded file to answer questions. And set the model to gpt-4-1106-preview
.
Note: The regular gpt-4
model does not work with the retrieval
tool.
Finally, we output the id
of the created assistant
to use it later.
namespace App\Console\Commands;
use Illuminate\Console\Command;
use OpenAI\Laravel\Facades\OpenAI;
class CreateAssistant extends Command
{
protected $signature = 'create-assistant {file_id}';
protected $description = 'Creates the PestDocs assistant.';
public function handle()
{
$assistant = OpenAI::assistants()->create([
'name' => 'Pest Chat Bot',
'file_ids' => [
$this->argument('file_id'),
],
'tools' => [
[
'type' => 'retrieval',
],
],
'instructions' => 'Your are a helpful bot supporting developers using the Pest Testing Framework.
You can answer questions about the framework, and help them find the right documentation.
Use the uploaded files to answer questions.',
'model' => 'gpt-4-1106-preview',
]);
$this->info('Assistant ID: '.$assistant->id);
}
}
Then we run the command:
Create the interface
Now we are ready to create the interface. We are going to use the Livewire package to create a simple interface.
First we install livewire.
composer require livewire/livewire
Then we create a new Livewire component and a layout file.
php artisan make:livewire DocBot
php artisan livewire:layout
In our routes file routes/web.php
we are registering the home route to the DocBot
component.
Route::get('/', \App\Livewire\DocBot::class);
To style the component we are using Tailwind CSS with the Forms and Typography plugins.
All installation instructions can be found in the docs.
We are going to create a simple interface where the user can enter a question and the AI will answer it.
Because this tutorial isn’t about Livewire, here a brief list of the relevant parts:
-
wire:model
directive to bind the input field to the$question
property. -
wire:submit.prevent
directive to call theask()
method on form submit. -
wire:loading
directive to show a spinner while the AI is working. - When the AI has finished its work, we render the answer which comes as markdown with Spatie’s
spatie/laravel-markdown
library.
This is what the blade part of the component does look like. All the classes have been removed to keep it simple. You can find the full code at GitHub.
<div>
<div>
<h3>PEST Documentation Assistant</h3>
<div>
<p>Enter you question, and I will try to find an answer in the current Pest documentation.</p>
</div>
<form wire:submit.prevent="ask">
<div>
<label for="question">Question</label>
<input type="text"
name="question"
wire:model="question"
placeholder="How to run a single test?"
>
</div>
<button type="submit">
<span wire:loading.class="invisible">Ask</span>
<x-spinner class="absolute invisible" wire:loading.class.remove="invisible" />
</button>
</form>
@if($answer)
<h3 class="mt-8 mb-1 text-base font-semibold leading-6 text-gray-900">My answer</h3>
<div class="mb-2 prose">
<x-markdown>{!! $answer !!}</x-markdown>
</div>
@endif
</div>
</div>
This gives us the following interface:
Ask the AI
In the class part of the Livewire component we have two properties to store the user question
and the AI answer
and one more to store the error
if necessary.
The ask()
function is executed when the user submits the form. For now, it is empty. We are going to fill it in the next step.
<?php
namespace App\Livewire;
use Livewire\Component;
use OpenAI\Laravel\Facades\OpenAI;
use OpenAI\Responses\Threads\Runs\ThreadRunResponse;
class DocBot extends Component
{
public string $question = '';
public ?string $answer = null;
public ?string $erro = null;
public function ask()
{
// ...
}
public function render()
{
return view('livewire.doc-bot');
}
}
When the user submits a question the ask()
function does two things.
First it creates a new Thread and runs it, and then it loads the answer from the given run.
public function ask()
{
$threadRun = $this->createAndRunThread();
$this->loadAnswer($threadRun);
}
Create a new Thread and run it
Let’s start with the createAndRunThread()
function. We are going to use the createAndRun()
method of the Threads
resource.
This endpoint allows us to create a new thread and run it in a single request. As an alternative we could also create a thread first and then run it in a next step. This is handy you want to prepare the thread synchronously but execute the run asynchronously.
We are passing the id
of the assistant we created before and the question
the user entered as the only message
in the thread.
private function createAndRunThread(): ThreadRunResponse
{
return OpenAI::threads()->createAndRun([
'assistant_id' => 'asst_6VzQhp0uC0ZouIRJlxhfxI0a',
'thread' => [
'messages' => [
[
'role' => 'user',
'content' => $this->question,
],
],
],
]);
}
As a result we get a ThreadRunResponse
object which contains the ids
of the thread
and the run
. Besides that it has other properties like the status
of the run
.
At this point the status
is always queued
as the AI has not processed the thread
yet.
Currently, there is no way yet to get the answer directly. Additionally, it is not possible to stream the request unlike the Chat API.
The only thing we can do is to periodically check the status of the run and wait until it is done.
OpenAI already announced that they are working on a streaming and notification options. So, this will change in the future.
Load the answer
Now we are going to implement the loadAnswer()
function. We are going to use the retrieve()
method of the ThreadsRuns
resource to get the updated status of the run
.
In a while loop we are fetching the run
until the status
is not queued
or in_progress
anymore.
Then we check if the run
has terminated successfully. If not, we set the error
message, which by the way is not handled in the GUI for simplicity.
When the run
has completed successfully we are going to fetch the messages
of the thread
. The AI has added the result of the run
as a new message.
Because new messages are added at the beginning of the list, we can access the answer
at index 0
.
Each message
contains an array of content
objects. These are either text
or image
objects. In our case there is always only one text object
.
This text object
has a text
property which has a property value
containing the actual answer
we are interested in.
So, we are going to store this value in the $answer
property of the component.
private function loadAnswer(ThreadRunResponse $threadRun)
{
while(in_array($threadRun->status, ['queued', 'in_progress'])) {
$threadRun = OpenAI::threads()->runs()->retrieve(
threadId: $threadRun->threadId,
runId: $threadRun->id,
);
}
if ($threadRun->status !== 'completed') {
$this->error = 'Request failed, please try again';
}
$messageList = OpenAI::threads()->messages()->list(
threadId: $threadRun->threadId,
);
$this->answer = $messageList->data[0]->content[0]->text->value;
}
Using the bot
That’s all we have to do. Now we can ask the AI questions about the Pest documentation and see the result.
Regular ChatGTP models are currently not able to answer this question correctly because throwsUnless
has been added to Pest after the models have been trained.
Conclusion
In this tutorial we have seen how to use the new OpenAI Assistants API in a Laravel application.
We have seen how to upload files, create an assistant, create a thread and run it, and how to get the result.
Of course there is a lot more you can do with the Assistants API. For example, you can use the AI to analyse your data and draw a graph or extend the capabilities of the AI by giving it access to your software through tools
.
If you want to learn more about the Assistants API visit the OpenAI documentation.
Additionally, we did not cover topics like error handling or testing. You can learn more about this in the PHP Client documentation.
I would love to hear your feedback. You can reach me on X / Twitter or by using the contact form.
If you like this tutorial I wouldn’t mind if you share it with your friends and colleagues. Thank you!
Laravel News Links