refactor(ai): introduce GeneratorFormat enum and drop stringly-typed format

Replace the 'single'/'carousel' string format threaded through the AI
generation path with a typed GeneratorFormat enum (PostContentGenerator,
PostContentHumanizer, PostContentStreamer, StreamPostCreation,
RunGenerateNode, deriveFormat).

Remove generatorFormat() from AiContentTemplate and the three templates —
it conflated style and format (returning 'tweet_card'). The tweet-card
check now lives on ContentStyle via isTweetCard(), and the humanizer skip
on ContentStyle::humanizes(), so the style declares its own behaviour
instead of callers comparing magic strings.
This commit is contained in:
Paulo Castellano 2026-06-18 11:11:23 -03:00
parent 773d2c729f
commit 9f5bacc03f
18 changed files with 104 additions and 75 deletions

View file

@ -11,6 +11,7 @@
use App\Ai\Templates\TemplateContext;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\PostPlatform\ContentType;
use App\Models\AutomationRun;
use App\Models\SocialAccount;
@ -84,13 +85,13 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
: null;
$isCarousel = $format === 'carousel';
$isCarousel = $format->isCarousel();
$imageCount = $isCarousel ? $slideCount : ($wantsImage ? 1 : 0);
$templateContext = new TemplateContext(
workspace: $workspace,
socialAccount: $brandAccount,
format: $platformContext ?? $format,
format: $platformContext ?? $format->value,
imageCount: $imageCount,
isCarousel: $isCarousel,
applyBrandVisuals: (bool) data_get($config, 'use_brand_visuals', true),
@ -114,12 +115,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
metadata: ['agent' => 'post_generator', 'format' => $format->value, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format, $style->value, $applyBrandVoice, $platformContext);
$structured = $this->humanize($workspace, $structured, $format, $style, $applyBrandVoice, $platformContext);
$intendedImageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount, $style);
@ -161,14 +162,14 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format, string $styleKey = 'image_card', bool $applyBrandVoice = true, ?string $platformContext = null): array
private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style, bool $applyBrandVoice = true, ?string $platformContext = null): array
{
if ($styleKey === 'tweet_card' || $styleKey === 'tweet_card_image') {
if (! $style->humanizes()) {
return $structured;
}
try {
$input = $format === 'carousel'
$input = $format->isCarousel()
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
@ -195,10 +196,10 @@ private function humanize(Workspace $workspace, array $structured, string $forma
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'],
metadata: ['agent' => 'post_humanizer', 'format' => $format->value, 'source' => 'automation'],
);
if ($format === 'carousel') {
if ($format->isCarousel()) {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);
@ -232,15 +233,15 @@ private function humanize(Workspace $workspace, array $structured, string $forma
*
* @param array<string, mixed> $structured
*/
private function extractContent(array $structured, string $format, ContentStyle $style): string
private function extractContent(array $structured, GeneratorFormat $format, ContentStyle $style): string
{
if ($style === ContentStyle::TweetCard || $style === ContentStyle::TweetCardImage) {
return $format === 'carousel'
if ($style->isTweetCard()) {
return $format->isCarousel()
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'tweet_text', '');
}
return $format === 'carousel'
return $format->isCarousel()
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
}
@ -261,7 +262,7 @@ private function extractContent(array $structured, string $format, ContentStyle
*
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
* @param array<string, mixed> $config
* @return array{format: string, slide_count: int}
* @return array{format: GeneratorFormat, slide_count: int}
*/
public function deriveFormat(array $accountsConfig, array $config): array
{
@ -278,10 +279,10 @@ public function deriveFormat(array $accountsConfig, array $config): array
if ($maxImagesAcross > 1 && $targetSlideCount > 1) {
$cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross);
return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)];
return ['format' => GeneratorFormat::Carousel, 'slide_count' => min($targetSlideCount, $cap)];
}
return ['format' => 'single', 'slide_count' => 1];
return ['format' => GeneratorFormat::Single, 'slide_count' => 1];
}
/**
@ -311,17 +312,17 @@ private function resolvePlatformContext(array $accountsConfig): ?string
*
* @param array<string, mixed> $structured
*/
private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount, ContentStyle $style): int
private function intendedImageCount(GeneratorFormat $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount, ContentStyle $style): int
{
if (! $brandAccount) {
return 0;
}
if ($style === ContentStyle::TweetCard || $style === ContentStyle::TweetCardImage) {
return $format === 'carousel' ? $slideCount : 1;
if ($style->isTweetCard()) {
return $format->isCarousel() ? $slideCount : 1;
}
if ($format === 'carousel') {
if ($format->isCarousel()) {
$slides = data_get($structured, 'slides', []);
return is_array($slides) ? count($slides) : $slideCount;

View file

@ -7,6 +7,7 @@
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\TemplateContext;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use App\Services\Ai\TemplateContextResolver;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@ -25,7 +26,7 @@ class PostContentGenerator implements Agent, HasStructuredOutput
public function __construct(
public Workspace $workspace,
public ?string $currentContent = null,
public string $format = 'single',
public GeneratorFormat $format = GeneratorFormat::Single,
public int $slideCount = 1,
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
@ -51,7 +52,7 @@ public function instructions(): string
$budget = $this->platformCopyBudget($this->platformContext);
if ($this->template?->generatorFormat() === 'tweet_card') {
if ($this->template?->style()->isTweetCard()) {
$budget['hard_max_chars'] = 560;
$budget['target_chars'] = 280;
}
@ -66,7 +67,7 @@ public function instructions(): string
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => $this->format,
'format' => $this->format->value,
'slide_count' => $this->slideCount,
'examples' => $examples,
'hard_max_chars' => $budget['hard_max_chars'],
@ -81,7 +82,7 @@ public function schema(JsonSchema $schema): array
return $this->template->schema($schema, $this->templateContext);
}
if ($this->format === 'carousel') {
if ($this->format->isCarousel()) {
return [
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
'slides' => $schema->array()

View file

@ -5,6 +5,7 @@
namespace App\Ai\Agents;
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Temperature;
@ -27,7 +28,7 @@ class PostContentHumanizer implements Agent, HasStructuredOutput
public function __construct(
public Workspace $workspace,
public string $format = 'single',
public GeneratorFormat $format = GeneratorFormat::Single,
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
) {}
@ -43,7 +44,7 @@ public function instructions(): string
'brand_name' => $this->workspace->name ?? '',
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
'content_language' => $this->workspace->content_language,
'format' => $this->format,
'format' => $this->format->value,
'hard_max_chars' => $budget['hard_max_chars'],
'target_chars' => $budget['target_chars'],
'platform_label' => $budget['platform_label'],
@ -52,7 +53,7 @@ public function instructions(): string
public function schema(JsonSchema $schema): array
{
if ($this->format === 'carousel') {
if ($this->format->isCarousel()) {
return [
'caption' => $schema->string()->description('The humanized Instagram caption.')->required(),
'slides' => $schema->array()

View file

@ -4,6 +4,7 @@
namespace App\Ai\Agents;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Contracts\Agent;
@ -36,7 +37,7 @@ public function instructions(): string
'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [],
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => 'single',
'format' => GeneratorFormat::Single->value,
'slide_count' => 1,
])->render();
}

View file

@ -34,9 +34,6 @@ public function needsAccount(): bool;
*/
public function supportedFormats(): array;
/** The 'format' string passed to the generator agent (e.g. 'single'). */
public function generatorFormat(): string;
/** The Blade view path for the generator prompt. */
public function promptView(TemplateContext $context): string;

View file

@ -49,11 +49,6 @@ public function supportedFormats(): array
return [];
}
public function generatorFormat(): string
{
return 'single';
}
public function promptView(TemplateContext $context): string
{
return 'prompts.post_content.generator';

View file

@ -47,11 +47,6 @@ public function supportedFormats(): array
return [];
}
public function generatorFormat(): string
{
return 'tweet_card';
}
public function promptView(TemplateContext $context): string
{
return $context->isCarousel

View file

@ -47,11 +47,6 @@ public function supportedFormats(): array
return [];
}
public function generatorFormat(): string
{
return 'tweet_card';
}
public function promptView(TemplateContext $context): string
{
return $context->isCarousel

View file

@ -31,6 +31,24 @@ public function needsAccount(): bool
};
}
/** Whether this style's generated text is run through the humanizer pass. */
public function humanizes(): bool
{
return match ($this) {
self::ImageCard => true,
self::TweetCard, self::TweetCardImage => false,
};
}
/** Whether this style renders the post as a tweet-style card. */
public function isTweetCard(): bool
{
return match ($this) {
self::TweetCard, self::TweetCardImage => true,
self::ImageCard => false,
};
}
/** Public path to the picker preview thumbnail. */
public function previewAsset(): string
{

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums\Ai;
enum GeneratorFormat: string
{
case Single = 'single';
case Carousel = 'carousel';
public function isCarousel(): bool
{
return $this === self::Carousel;
}
}

View file

@ -10,6 +10,8 @@
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\GeneratedPost;
use App\Ai\Templates\TemplateContext;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\PostPlatform\ContentType;
@ -53,7 +55,7 @@ public function handle(): void
$style = app(AiTemplateRegistry::class)->find($this->template);
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
$agentFormat = $isCarousel ? 'carousel' : 'single';
$agentFormat = $isCarousel ? GeneratorFormat::Carousel : GeneratorFormat::Single;
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
$context = new TemplateContext(
@ -88,8 +90,7 @@ public function handle(): void
$structured = $response->structured ?? [];
$humanizeFormat = $isCarousel ? 'carousel' : $agentFormat;
$structured = $this->humanize($workspace, $structured, $humanizeFormat, $this->template);
$structured = $this->humanize($workspace, $structured, $agentFormat, $style->style());
$generated = $style->assemble($structured, $context);
$post = $this->createPostFromGenerated($workspace, $generated, $socialAccount);
@ -116,14 +117,14 @@ public function handle(): void
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format, string $styleKey = 'image_card'): array
private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style): array
{
if ($styleKey === 'tweet_card' || $styleKey === 'tweet_card_image') {
if (! $style->humanizes()) {
return $structured;
}
try {
$input = $format === 'carousel'
$input = $format->isCarousel()
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
@ -151,7 +152,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_humanizer', 'format' => $format],
metadata: ['agent' => 'post_humanizer', 'format' => $format->value],
);
} catch (\Throwable $e) {
Log::warning('PostContentHumanizer failed, using generator output as-is', [
@ -162,7 +163,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
if ($format === 'carousel') {
if ($format->isCarousel()) {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);

View file

@ -16,7 +16,6 @@
expect($t->style())->toBe(ContentStyle::ImageCard)
->and($t->key())->toBe('image_card')
->and($t->needsAccount())->toBeFalse()
->and($t->generatorFormat())->toBe('single')
->and($t->promptView($context))->toBe('prompts.post_content.generator')
->and($t->supportedFormats())->toBe([]);
});

View file

@ -17,7 +17,7 @@
expect($t->style())->toBe(ContentStyle::TweetCardImage)
->and($t->key())->toBe('tweet_card_image')
->and($t->needsAccount())->toBeTrue()
->and($t->generatorFormat())->toBe('tweet_card')
->and($t->style()->isTweetCard())->toBeTrue()
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card_image')
->and($t->promptView($carouselContext))->toBe('prompts.post_content.tweet_card_image_carousel');
});

View file

@ -17,7 +17,7 @@
expect($t->style())->toBe(ContentStyle::TweetCard)
->and($t->key())->toBe('tweet_card')
->and($t->needsAccount())->toBeTrue()
->and($t->generatorFormat())->toBe('tweet_card')
->and($t->style()->isTweetCard())->toBeTrue()
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card')
->and($t->promptView($carouselContext))->toBe('prompts.post_content.tweet_card_carousel');
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Actions\Automation\Node\RunGenerateNode;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\User;
@ -128,20 +129,20 @@
// tiktok_photo allows 35, but AI generation is capped at 10.
expect($node->deriveFormat([['content_type' => 'tiktok_photo']], ['target_slide_count' => 15]))
->toBe(['format' => 'carousel', 'slide_count' => 10]);
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 10]);
// pinterest_carousel allows only 5.
expect($node->deriveFormat([['content_type' => 'pinterest_carousel']], ['target_slide_count' => 8]))
->toBe(['format' => 'carousel', 'slide_count' => 5]);
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 5]);
});
it('recognises facebook_post as multi-image capable (rules-derived, not a hardcoded list)', function () {
$node = app(RunGenerateNode::class);
expect($node->deriveFormat([['content_type' => 'facebook_post']], ['target_slide_count' => 6]))
->toBe(['format' => 'carousel', 'slide_count' => 6]);
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 6]);
// A single-image (video) format never becomes a carousel.
expect($node->deriveFormat([['content_type' => 'tiktok_video']], ['target_slide_count' => 5]))
->toBe(['format' => 'single', 'slide_count' => 1]);
->toBe(['format' => GeneratorFormat::Single, 'slide_count' => 1]);
});

View file

@ -6,6 +6,7 @@
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Models\Automation;
@ -180,7 +181,7 @@
'target_slide_count' => 2,
]);
PostContentGenerator::assertPrompted(fn ($prompt) => $prompt->agent->format === 'carousel'
PostContentGenerator::assertPrompted(fn ($prompt) => $prompt->agent->format === GeneratorFormat::Carousel
&& $prompt->agent->platformContext === ContentType::XPost->value);
});
@ -260,7 +261,7 @@
['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 5]);
expect($format)->toBe('carousel');
expect($format)->toBe(GeneratorFormat::Carousel);
expect($slideCount)->toBe(5);
});
@ -276,7 +277,7 @@
['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 1]);
expect($format)->toBe('single');
expect($format)->toBe(GeneratorFormat::Single);
expect($slideCount)->toBe(1);
});
@ -290,7 +291,7 @@
['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 5]);
expect($format)->toBe('single');
expect($format)->toBe(GeneratorFormat::Single);
expect($slideCount)->toBe(1);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Ai\Agents\PostContentGenerator;
use App\Enums\Ai\GeneratorFormat;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
@ -60,7 +61,7 @@
test('single format schema returns content and image_keywords', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Single);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
@ -73,7 +74,7 @@
test('carousel format schema returns caption and slides', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 5);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 5);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
@ -85,7 +86,7 @@
test('carousel slide schema includes role enum', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 4);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 4);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
@ -100,7 +101,7 @@
test('carousel instructions mention slide count', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 5);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 5);
$instructions = $agent->instructions();
@ -110,7 +111,7 @@
test('carousel instructions describe the roteiro arc with hook, development, proof and cta', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 4);
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Carousel, slideCount: 4);
$instructions = $agent->instructions();
@ -123,7 +124,7 @@
test('single format instructions do not include carousel roteiro section', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Single);
$instructions = $agent->instructions();
@ -133,7 +134,7 @@
test('single instructions mention image_keywords', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$agent = new PostContentGenerator(workspace: $workspace, format: GeneratorFormat::Single);
$instructions = $agent->instructions();
@ -143,7 +144,7 @@
test('instructions include examples when platform context provided and templates exist', function () {
$agent = new PostContentGenerator(
workspace: Workspace::factory()->make(),
format: 'carousel',
format: GeneratorFormat::Carousel,
slideCount: 3,
platformContext: 'instagram_carousel',
);
@ -161,7 +162,7 @@
test('instructions do not include examples section when platform context is null', function () {
$agent = new PostContentGenerator(
workspace: Workspace::factory()->make(),
format: 'single',
format: GeneratorFormat::Single,
);
expect($agent->instructions())->not->toContain('curated library');
@ -170,7 +171,7 @@
test('instructions inject the platform character cap from the shared budget', function () {
$agent = new PostContentGenerator(
workspace: Workspace::factory()->make(),
format: 'single',
format: GeneratorFormat::Single,
platformContext: 'x_post',
);

View file

@ -21,6 +21,12 @@
expect(ContentStyle::TweetCardImage->needsAccount())->toBeTrue();
});
test('humanizes is true only for ImageCard', function () {
expect(ContentStyle::ImageCard->humanizes())->toBeTrue();
expect(ContentStyle::TweetCard->humanizes())->toBeFalse();
expect(ContentStyle::TweetCardImage->humanizes())->toBeFalse();
});
test('previewAsset returns the correct public path for each case', function () {
expect(ContentStyle::ImageCard->previewAsset())->toBe('/images/ai-templates/image-card.png');
expect(ContentStyle::TweetCard->previewAsset())->toBe('/images/ai-templates/tweet-card.png');