feat: add SocialMediaAssistant agent with Conversational interface
The agent implements Laravel AI SDK's Agent + Conversational contracts: - instructions() renders the existing Blade system prompt with workspace brand context (name, description, website, tone, voice notes, locale) - messages() reads directly from the AiMessage model scoped to the post, so our existing conversation persistence stays the single source of truth — no duplicate SDK-managed storage. - Assistant messages with attachments are enriched inline with a summary like [This assistant message attached: 2 image] so the model tracks progress through carousel-style multi-image generations. - provider() maps the trypost.ai.text_provider config to Lab::Gemini or Lab::OpenAI, preserving the existing provider-switching behavior.
This commit is contained in:
parent
a962124097
commit
6007aec17d
2 changed files with 188 additions and 0 deletions
80
app/Ai/Agents/SocialMediaAssistant.php
Normal file
80
app/Ai/Agents/SocialMediaAssistant.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\Conversational;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Messages\Message;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
class SocialMediaAssistant implements Agent, Conversational
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
public ?Post $post = null,
|
||||
public ?string $userId = null,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return view('prompts.assistant.system', [
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'brand_description' => $this->workspace->brand_description ?? '',
|
||||
'brand_website' => $this->workspace->brand_website ?? '',
|
||||
'tone' => $this->workspace->brand_tone ?? 'professional',
|
||||
'voice_notes' => $this->workspace->brand_voice_notes ?? '',
|
||||
'locale' => app()->getLocale(),
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<Message>
|
||||
*/
|
||||
public function messages(): iterable
|
||||
{
|
||||
if (! $this->post) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AiMessage::query()
|
||||
->where('post_id', $this->post->id)
|
||||
->whereIn('role', ['user', 'assistant'])
|
||||
->oldest()
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (AiMessage $m) => new Message($m->role, $this->enrichContent($m)))
|
||||
->all();
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('trypost.ai.text_provider')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
private function enrichContent(AiMessage $m): string
|
||||
{
|
||||
$content = $m->content;
|
||||
|
||||
if ($m->role === 'assistant' && ! empty($m->attachments)) {
|
||||
$counts = collect($m->attachments)
|
||||
->groupBy('type')
|
||||
->map(fn ($group, $type) => count($group)." {$type}")
|
||||
->implode(', ');
|
||||
|
||||
$content .= "\n\n[This assistant message attached: {$counts}]";
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
108
tests/Feature/Ai/SocialMediaAssistantTest.php
Normal file
108
tests/Feature/Ai/SocialMediaAssistantTest.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Agents\SocialMediaAssistant;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Messages\Message;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Paulo Coffee',
|
||||
'brand_tone' => 'friendly',
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('instructions include brand context from workspace', function () {
|
||||
$agent = new SocialMediaAssistant($this->workspace, $this->post);
|
||||
|
||||
$instructions = $agent->instructions();
|
||||
|
||||
expect($instructions)
|
||||
->toContain('Paulo Coffee')
|
||||
->toContain('friendly');
|
||||
});
|
||||
|
||||
test('messages returns AiMessage rows as SDK messages', function () {
|
||||
AiMessage::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'role' => 'user',
|
||||
'content' => 'Write a caption',
|
||||
]);
|
||||
AiMessage::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'role' => 'assistant',
|
||||
'content' => 'Here it is!',
|
||||
]);
|
||||
|
||||
$agent = new SocialMediaAssistant($this->workspace, $this->post);
|
||||
|
||||
$messages = collect($agent->messages())->all();
|
||||
|
||||
expect($messages)->toHaveCount(2);
|
||||
expect($messages[0])->toBeInstanceOf(Message::class);
|
||||
expect($messages[0]->role->value)->toBe('user');
|
||||
expect($messages[0]->content)->toBe('Write a caption');
|
||||
expect($messages[1]->role->value)->toBe('assistant');
|
||||
expect($messages[1]->content)->toBe('Here it is!');
|
||||
});
|
||||
|
||||
test('messages is empty when no post is provided', function () {
|
||||
$agent = new SocialMediaAssistant($this->workspace);
|
||||
|
||||
$messages = collect($agent->messages())->all();
|
||||
|
||||
expect($messages)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('messages is empty when post has no AiMessages', function () {
|
||||
$agent = new SocialMediaAssistant($this->workspace, $this->post);
|
||||
|
||||
$messages = collect($agent->messages())->all();
|
||||
|
||||
expect($messages)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('messages enriches assistant content with attachment summary', function () {
|
||||
AiMessage::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'role' => 'assistant',
|
||||
'content' => 'Here is your image',
|
||||
'attachments' => [
|
||||
['id' => 'a', 'type' => 'image'],
|
||||
['id' => 'b', 'type' => 'image'],
|
||||
],
|
||||
]);
|
||||
|
||||
$agent = new SocialMediaAssistant($this->workspace, $this->post);
|
||||
|
||||
$messages = collect($agent->messages())->all();
|
||||
|
||||
expect($messages[0]->content)->toContain('Here is your image');
|
||||
expect($messages[0]->content)->toContain('[This assistant message attached: 2 image]');
|
||||
});
|
||||
|
||||
test('provider honors trypost.ai.text_provider config', function () {
|
||||
config()->set('trypost.ai.text_provider', 'openai');
|
||||
|
||||
expect((new SocialMediaAssistant($this->workspace))->provider())->toBe(Lab::OpenAI);
|
||||
|
||||
config()->set('trypost.ai.text_provider', 'gemini');
|
||||
|
||||
expect((new SocialMediaAssistant($this->workspace))->provider())->toBe(Lab::Gemini);
|
||||
});
|
||||
Loading…
Reference in a new issue