From 7fd84507b37ff0a6c1548da74dabc515a55f84d8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 20:03:00 -0300 Subject: [PATCH] feat(automations): generate node supports the 3 content styles --- .../Automation/Node/RunGenerateNode.php | 146 +++++++----- app/Ai/Templates/ImageCardTemplate.php | 2 + app/Ai/Templates/TemplateContext.php | 1 + .../Automation/Node/GenerateNodeTest.php | 221 ++++++++++++++++++ 4 files changed, 306 insertions(+), 64 deletions(-) diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index 05cf59cb..4daa1f06 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -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 $structured * @return array */ - 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 $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 $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', []); diff --git a/app/Ai/Templates/ImageCardTemplate.php b/app/Ai/Templates/ImageCardTemplate.php index 55692658..1c05d5d2 100644 --- a/app/Ai/Templates/ImageCardTemplate.php +++ b/app/Ai/Templates/ImageCardTemplate.php @@ -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, ); } diff --git a/app/Ai/Templates/TemplateContext.php b/app/Ai/Templates/TemplateContext.php index 474a8a51..dc2009e3 100644 --- a/app/Ai/Templates/TemplateContext.php +++ b/app/Ai/Templates/TemplateContext.php @@ -18,5 +18,6 @@ public function __construct( public string $format, public int $imageCount, public bool $isCarousel = false, + public bool $applyBrandVisuals = true, ) {} } diff --git a/tests/Feature/Automation/Node/GenerateNodeTest.php b/tests/Feature/Automation/Node/GenerateNodeTest.php index 813e65fb..325b86b3 100644 --- a/tests/Feature/Automation/Node/GenerateNodeTest.php +++ b/tests/Feature/Automation/Node/GenerateNodeTest.php @@ -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); +});