feat(automations): generate node supports the 3 content styles

This commit is contained in:
Paulo Castellano 2026-06-17 20:03:00 -03:00
parent 8d608554cd
commit 7fd84507b3
4 changed files with 306 additions and 64 deletions

View file

@ -7,7 +7,10 @@
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\TemplateContext;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Models\AutomationRun;
use App\Models\SocialAccount;
@ -16,7 +19,6 @@
use App\Services\Ai\RecordAiUsage;
use App\Services\Automation\ExpressionResolver;
use App\Services\Automation\GenerateNodeValidator;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Facades\Log;
use Throwable;
@ -48,41 +50,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
->get()
->keyBy('id');
// Per-automation toggle: when off, the brand persona/voice is NOT
// injected, so the post stays faithful to a third-party source (news /
// RSS curation) instead of being rewritten in the brand's voice.
$applyBrandVoice = (bool) data_get($config, 'use_brand_voice', true);
$platformContext = $this->resolvePlatformContext($accountsConfig);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $format,
slideCount: $slideCount,
platformContext: $platformContext,
applyBrandVoice: $applyBrandVoice,
);
$generatorResponse = $agent->prompt($prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $generatorResponse->usage->promptTokens,
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format, $applyBrandVoice, $platformContext);
$content = $format === 'carousel'
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
$user = $this->resolveUser($run);
$style = ContentStyle::tryFrom((string) data_get($config, 'style', ContentStyle::default()->value)) ?? ContentStyle::default();
$styleTemplate = app(AiTemplateRegistry::class)->find($style);
$platforms = [];
foreach ($accountsConfig as $entry) {
@ -105,50 +78,71 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
];
}
// A single source of truth for image count: 0 = text-only (no image),
// 1 = single image, 2+ = carousel. For the single-image branch we only
// care whether at least one image was requested.
$wantsImage = (int) data_get($config, 'target_slide_count', 1) >= 1;
$brandAccount = $platforms !== []
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
: null;
$contentType = $platforms !== []
? ContentType::tryFrom((string) data_get($platforms[0], 'content_type'))
: null;
$isCarousel = $format === 'carousel';
$imageCount = $isCarousel ? $slideCount : ($wantsImage ? 1 : 0);
$imageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount);
$templateContext = new TemplateContext(
workspace: $workspace,
socialAccount: $brandAccount,
format: $platformContext ?? $format,
imageCount: $imageCount,
isCarousel: $isCarousel,
applyBrandVisuals: (bool) data_get($config, 'use_brand_visuals', true),
);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $format,
slideCount: $slideCount,
platformContext: $platformContext,
applyBrandVoice: $applyBrandVoice,
template: $styleTemplate,
templateContext: $templateContext,
);
$generatorResponse = $agent->prompt($prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $generatorResponse->usage->promptTokens,
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format, $style->value, $applyBrandVoice, $platformContext);
$intendedImageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount, $style);
// Dry runs do the AI work (so the user sees a real generation) but
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
// and skip their persistence too.
if ($run->is_dry_run) {
$dryContent = $this->extractContent($structured, $format, $style);
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => null,
'content' => $content,
'content' => $dryContent,
'dry_run' => true,
'image_count' => $imageCount,
'image_count' => $intendedImageCount,
],
]);
}
$media = [];
$generated = $styleTemplate->assemble($structured, $templateContext);
$applyBrandVisuals = (bool) data_get($config, 'use_brand_visuals', true);
if ($brandAccount) {
if ($format === 'carousel') {
$media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
} elseif ($wantsImage) {
$media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
}
}
$user = $this->resolveUser($run);
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => $media,
'content' => $generated->content,
'media' => $generated->media,
'platforms' => $platforms,
]);
@ -157,7 +151,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => $post->id,
'content' => $content,
'content' => $generated->content,
'post_url' => route('app.posts.show', $post->id),
],
]);
@ -167,8 +161,12 @@ 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, bool $applyBrandVoice = true, ?string $platformContext = null): array
private function humanize(Workspace $workspace, array $structured, string $format, string $styleKey = 'image_card', bool $applyBrandVoice = true, ?string $platformContext = null): array
{
if ($styleKey === 'tweet_card' || $styleKey === 'tweet_card_image') {
return $structured;
}
try {
$input = $format === 'carousel'
? [
@ -227,6 +225,26 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
/**
* Extract the post caption from the raw structured output without calling
* assemble() (which triggers image generation). Used for dry-run responses
* so no pipeline work happens during test runs.
*
* @param array<string, mixed> $structured
*/
private function extractContent(array $structured, string $format, ContentStyle $style): string
{
if ($style === ContentStyle::TweetCard || $style === ContentStyle::TweetCardImage) {
return $format === 'carousel'
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'tweet_text', '');
}
return $format === 'carousel'
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
}
/**
* Derive the generator format and slide count from per-account content types.
*
@ -247,11 +265,6 @@ private function humanize(Workspace $workspace, array $structured, string $forma
*/
public function deriveFormat(array $accountsConfig, array $config): array
{
// Multi-image capability comes from the same ContentType rules the
// publish flow uses — never a hardcoded list — so facebook_post,
// tiktok_photo, linkedin_carousel etc. are all recognised. We cap the
// slide count at MAX_GENERATED_IMAGES and at the tightest selected
// account's limit.
$maxImagesAcross = 0;
foreach ($accountsConfig as $entry) {
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
@ -294,15 +307,20 @@ private function resolvePlatformContext(array $accountsConfig): ?string
* Number of images that would be attached for the resolved format. Used as
* the dry-run indicator and mirrors the non-dry image generation branches:
* one per slide for carousels, one for single posts when images are enabled.
* Tweet styles always produce one image per slide/post when an account is set.
*
* @param array<string, mixed> $structured
*/
private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount): int
private function intendedImageCount(string $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 ($format === 'carousel') {
$slides = data_get($structured, 'slides', []);

View file

@ -118,6 +118,7 @@ private function assembleCarousel(array $structured, TemplateContext $context):
account: $context->socialAccount,
structured: $structured,
contentType: ContentType::InstagramFeed,
applyBrandVisuals: $context->applyBrandVisuals,
);
}
@ -142,6 +143,7 @@ private function assembleSingle(array $structured, TemplateContext $context): Ge
account: $context->socialAccount,
structured: $structured,
contentType: $contentType,
applyBrandVisuals: $context->applyBrandVisuals,
);
}

View file

@ -18,5 +18,6 @@ public function __construct(
public string $format,
public int $imageCount,
public bool $isCarousel = false,
public bool $applyBrandVisuals = true,
) {}
}

View file

@ -5,6 +5,7 @@
use App\Actions\Automation\Node\RunGenerateNode;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Ai\ContentStyle;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Models\Automation;
@ -457,3 +458,223 @@
$run->refresh();
expect($run->generated_post_id)->toBeNull();
});
it('defaults to image_card style when no style is set, behaving identically to before', function () {
PostContentGenerator::fake([
['content' => 'Legacy post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => 'Legacy post', 'image_title' => 'Title', 'image_body' => 'Body'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 1, 'path' => 'ai-images/x.webp', 'url' => 'http://x', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forSingle')->once()->andReturn([$mediaItem]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Legacy post');
expect($post->media)->toHaveCount(1);
});
it('generates a tweet card post when style is tweet_card', function () {
PostContentGenerator::fake([
['tweet_text' => 'Hot take on AI agents today.'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 2, 'path' => 'ai-images/tweet.webp', 'url' => 'http://tweet', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([$mediaItem]);
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCard->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Hot take on AI agents today.');
expect($post->media)->toHaveCount(1);
});
it('skips the humanizer for tweet_card style', function () {
PostContentGenerator::fake([
['tweet_text' => 'Raw tweet, no humanizer.'],
]);
PostContentHumanizer::fake([]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCard->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
PostContentHumanizer::assertNeverPrompted();
});
it('generates a tweet card with AI image background when style is tweet_card_image', function () {
PostContentGenerator::fake([
['tweet_text' => 'AI is transforming everything.', 'image_keywords' => ['artificial intelligence', 'technology']],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$mediaItem = ['id' => 3, 'path' => 'ai-images/tweet-img.webp', 'url' => 'http://tweet-img', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')
->once()
->withArgs(function ($ws, $acc, $text, $keywords) {
return $text === 'AI is transforming everything.'
&& is_array($keywords)
&& count($keywords) > 0;
})
->andReturn([$mediaItem]);
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCardImage->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
$post = Post::find($result->output['generated']['post_id']);
expect($post)->not->toBeNull();
expect($post->content)->toBe('AI is transforming everything.');
expect($post->media)->toHaveCount(1);
});
it('skips the humanizer for tweet_card_image style', function () {
PostContentGenerator::fake([
['tweet_text' => 'Raw tweet with image.', 'image_keywords' => ['tech']],
]);
PostContentHumanizer::fake([]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([]);
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['title' => 'test']],
]);
app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCardImage->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
'target_slide_count' => 1,
]);
PostContentHumanizer::assertNeverPrompted();
});
it('dry run returns sensible image_count for tweet_card style', function () {
PostContentGenerator::fake([
['tweet_text' => 'A tweet for dry run.'],
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldNotReceive('forTweetCard');
$pipeline->shouldNotReceive('forSingle');
$pipeline->shouldNotReceive('forCarousel');
app()->instance(PostImagePipeline::class, $pipeline);
$automation = Automation::factory()->for($workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
'is_dry_run' => true,
'context' => ['trigger' => ['title' => 'test']],
]);
$result = app(RunGenerateNode::class)($run, [
'style' => ContentStyle::TweetCard->value,
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
],
'prompt_template' => 'Write about {{ trigger.title }}',
'target_slide_count' => 1,
]);
expect($result->status->value)->toBe('completed');
expect($result->output['generated']['dry_run'])->toBeTrue();
expect($result->output['generated']['image_count'])->toBe(1);
expect($result->output['generated']['post_id'])->toBeNull();
expect(Post::count())->toBe(0);
});