diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index 08a95efd..524383c7 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -56,7 +56,9 @@ public function instructions(): string $budget['target_chars'] = 280; } - $view = $this->template?->promptView() ?? 'prompts.post_content.generator'; + $view = $this->template !== null && $this->templateContext !== null + ? $this->template->promptView($this->templateContext) + : 'prompts.post_content.generator'; return view($view, [ 'brand_name' => $this->workspace->name ?? '', diff --git a/app/Ai/Templates/AiContentTemplate.php b/app/Ai/Templates/AiContentTemplate.php index 2600d48f..55eaa832 100644 --- a/app/Ai/Templates/AiContentTemplate.php +++ b/app/Ai/Templates/AiContentTemplate.php @@ -34,7 +34,7 @@ public function supportedFormats(): array; public function generatorFormat(): string; /** The Blade view path for the generator prompt. */ - public function promptView(): string; + public function promptView(TemplateContext $context): string; /** * The structured-output schema for the generator. diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php index c81716e0..712737a0 100644 --- a/app/Ai/Templates/AiTemplateRegistry.php +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -11,7 +11,6 @@ class AiTemplateRegistry /** @var array> */ private const TEMPLATES = [ ImageCardTemplate::class, - CarouselTemplate::class, TweetCardTemplate::class, ]; diff --git a/app/Ai/Templates/CarouselTemplate.php b/app/Ai/Templates/CarouselTemplate.php deleted file mode 100644 index e28b8d27..00000000 --- a/app/Ai/Templates/CarouselTemplate.php +++ /dev/null @@ -1,96 +0,0 @@ -imageCount > 0 ? $context->imageCount : 1; - - return [ - 'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(), - 'slides' => $schema->array() - ->items($schema->object(fn ($s) => [ - 'role' => $s->string() - ->enum(['hook', 'development', 'proof', 'cta']) - ->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.') - ->required(), - 'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(), - 'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(), - 'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(), - ])) - ->min($slideCount) - ->max($slideCount) - ->description("Exactly {$slideCount} slides for the carousel, in order. First slide must have role `hook`, last slide must have role `cta`.") - ->required(), - ]; - } - - /** - * @param array $structured - */ - public function assemble(array $structured, TemplateContext $context): GeneratedPost - { - $caption = (string) data_get($structured, 'caption', ''); - - $media = []; - - if ($context->socialAccount) { - $media = app(PostImagePipeline::class)->forCarousel( - workspace: $context->workspace, - account: $context->socialAccount, - structured: $structured, - contentType: ContentType::InstagramFeed, - ); - } - - return new GeneratedPost($caption, $media, ContentType::InstagramFeed); - } -} diff --git a/app/Ai/Templates/ImageCardTemplate.php b/app/Ai/Templates/ImageCardTemplate.php index 15ffa976..cb2b3901 100644 --- a/app/Ai/Templates/ImageCardTemplate.php +++ b/app/Ai/Templates/ImageCardTemplate.php @@ -5,6 +5,7 @@ namespace App\Ai\Templates; use App\Ai\Templates\Concerns\ResolvesContentType; +use App\Enums\PostPlatform\ContentType; use App\Services\Image\PostImagePipeline; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -47,13 +48,35 @@ public function generatorFormat(): string return 'single'; } - public function promptView(): string + public function promptView(TemplateContext $context): string { return 'prompts.post_content.generator'; } public function schema(JsonSchema $schema, TemplateContext $context): array { + if ($context->isCarousel) { + $slideCount = $context->imageCount > 0 ? $context->imageCount : 1; + + return [ + 'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(), + 'slides' => $schema->array() + ->items($schema->object(fn ($s) => [ + 'role' => $s->string() + ->enum(['hook', 'development', 'proof', 'cta']) + ->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.') + ->required(), + 'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(), + 'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(), + 'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(), + ])) + ->min($slideCount) + ->max($slideCount) + ->description("Exactly {$slideCount} slides for the carousel, in order. First slide must have role `hook`, last slide must have role `cta`.") + ->required(), + ]; + } + return [ 'content' => $schema->string()->description('The full post caption text that will be published on the platform.')->required(), 'image_title' => $schema->string()->description('Short headline (5-12 words) overlaid on the image. The hook — should make a scroller stop. Distinct from content.')->required(), @@ -66,6 +89,39 @@ public function schema(JsonSchema $schema, TemplateContext $context): array * @param array $structured */ public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + if ($context->isCarousel) { + return $this->assembleCarousel($structured, $context); + } + + return $this->assembleSingle($structured, $context); + } + + /** + * @param array $structured + */ + private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost + { + $caption = (string) data_get($structured, 'caption', ''); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forCarousel( + workspace: $context->workspace, + account: $context->socialAccount, + structured: $structured, + contentType: ContentType::InstagramFeed, + ); + } + + return new GeneratedPost($caption, $media, ContentType::InstagramFeed); + } + + /** + * @param array $structured + */ + private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost { $contentType = self::resolveContentType($context->format); $supportsCaption = $contentType?->supportsCaption() ?? true; diff --git a/app/Ai/Templates/TemplateContext.php b/app/Ai/Templates/TemplateContext.php index 9a0cb16b..474a8a51 100644 --- a/app/Ai/Templates/TemplateContext.php +++ b/app/Ai/Templates/TemplateContext.php @@ -17,5 +17,6 @@ public function __construct( public ?SocialAccount $socialAccount, public string $format, public int $imageCount, + public bool $isCarousel = false, ) {} } diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php index dd7fcd79..69c4d89d 100644 --- a/app/Ai/Templates/TweetCardTemplate.php +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -46,9 +46,11 @@ public function generatorFormat(): string return 'tweet_card'; } - public function promptView(): string + public function promptView(TemplateContext $context): string { - return 'prompts.post_content.tweet_card'; + return $context->isCarousel + ? 'prompts.post_content.tweet_card_carousel' + : 'prompts.post_content.tweet_card'; } /** @@ -56,6 +58,24 @@ public function promptView(): string */ public function schema(JsonSchema $schema, TemplateContext $context): array { + if ($context->isCarousel) { + $slideCount = $context->imageCount > 0 ? $context->imageCount : 1; + + return [ + 'caption' => $schema->string()->description('The caption for the carousel post (teases the content, encourages swiping).')->required(), + 'slides' => $schema->array() + ->items($schema->object(fn ($s) => [ + 'tweet_text' => $s->string() + ->description('The tweet-style text for this slide. First-person, punchy, max ~560 characters.') + ->required(), + ])) + ->min($slideCount) + ->max($slideCount) + ->description("Exactly {$slideCount} slides, each a self-contained tweet-card. First slide must hook the reader.") + ->required(), + ]; + } + return [ 'tweet_text' => $schema->string() ->description('The post body, written as a punchy first-person X/Twitter-style take. Paragraph breaks (\\n\\n) allowed. Max ~560 characters.') @@ -67,6 +87,18 @@ public function schema(JsonSchema $schema, TemplateContext $context): array * @param array $structured */ public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + if ($context->isCarousel) { + return $this->assembleCarousel($structured, $context); + } + + return $this->assembleSingle($structured, $context); + } + + /** + * @param array $structured + */ + private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost { $text = (string) data_get($structured, 'tweet_text', ''); @@ -86,4 +118,33 @@ public function assemble(array $structured, TemplateContext $context): Generated contentType: ContentType::tryFrom($context->format), ); } + + /** + * @param array $structured + */ + private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost + { + $caption = (string) data_get($structured, 'caption', ''); + + $slideTexts = array_map( + fn ($slide) => (string) data_get($slide, 'tweet_text', ''), + data_get($structured, 'slides', []), + ); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCardCarousel( + workspace: $context->workspace, + account: $context->socialAccount, + slideTexts: $slideTexts, + ); + } + + return new GeneratedPost( + content: $caption, + media: $media, + contentType: ContentType::InstagramFeed, + ); + } } diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 203a6f76..6b53dd05 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -50,18 +50,26 @@ public function handle(): void $workspace = Workspace::findOrFail($this->workspaceId); $socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null; - $template = app(AiTemplateRegistry::class)->find($this->template); - $context = new TemplateContext($workspace, $socialAccount, $this->format, $this->imageCount); + $style = app(AiTemplateRegistry::class)->find($this->template); - $isCarousel = $template->generatorFormat() === 'carousel'; + $isCarousel = $this->format === ContentType::CAROUSEL_FORMAT; + $agentFormat = $isCarousel ? 'carousel' : 'single'; $slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1; + $context = new TemplateContext( + workspace: $workspace, + socialAccount: $socialAccount, + format: $this->format, + imageCount: $this->imageCount, + isCarousel: $isCarousel, + ); + $agent = new PostContentGenerator( workspace: $workspace, - format: $template->generatorFormat(), + format: $agentFormat, slideCount: $slideCount, platformContext: $this->format, - template: $template, + template: $style, templateContext: $context, ); @@ -80,9 +88,10 @@ public function handle(): void $structured = $response->structured ?? []; - $structured = $this->humanize($workspace, $structured, $template->generatorFormat()); + $humanizeFormat = $isCarousel ? 'carousel' : $agentFormat; + $structured = $this->humanize($workspace, $structured, $humanizeFormat); - $generated = $template->assemble($structured, $context); + $generated = $style->assemble($structured, $context); $post = $this->createPostFromGenerated($workspace, $generated, $socialAccount); $this->notifyReady($workspace, $post); } catch (\Throwable $e) { diff --git a/app/Services/Image/PostImagePipeline.php b/app/Services/Image/PostImagePipeline.php index e266528e..f4098a3e 100644 --- a/app/Services/Image/PostImagePipeline.php +++ b/app/Services/Image/PostImagePipeline.php @@ -97,6 +97,28 @@ public function forTweetCard(Workspace $workspace, SocialAccount $account, strin return [$this->buildAiMediaItem($workspace, $rendered)]; } + /** + * Render one tweet-card image per slide text and return the media-item array. + * Slides that render nothing are skipped. + * + * @param array $slideTexts + * @return array> + */ + public function forTweetCardCarousel(Workspace $workspace, SocialAccount $account, array $slideTexts): array + { + $media = []; + + foreach ($slideTexts as $tweetText) { + $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText); + + if ($rendered) { + $media[] = $this->buildAiMediaItem($workspace, $rendered); + } + } + + return $media; + } + /** * Resolve the AI image dimensions for the given content type, falling back * to the generator defaults (4:5 portrait) when no content type is known. diff --git a/resources/views/prompts/post_content/tweet_card_carousel.blade.php b/resources/views/prompts/post_content/tweet_card_carousel.blade.php new file mode 100644 index 00000000..7b5edf8c --- /dev/null +++ b/resources/views/prompts/post_content/tweet_card_carousel.blade.php @@ -0,0 +1,45 @@ +You write carousel posts for the brand "{{ $brand_name }}". Each slide will be rendered as a fake X/Twitter card image, so every slide's text must read like a standalone tweet. + +@if(!empty($brand_description)) +About the brand: {{ $brand_description }} +@endif +@if(!empty($brand_voice_traits)) +Brand voice — follow these exactly: +@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits]) +@endif +@if(!empty($current_content)) + +The user already has this content in the editor (use as context only — your output replaces it): +""" +{{ $current_content }} +""" +@endif + +Write all text in the language with code: {{ $content_language ?? 'en' }}. + +Rules: +- First-person voice, writing as the brand owner. +- Each slide must open with a hook — the first sentence must stop the scroll. +- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs inside a single slide; never more than 2 paragraphs per slide. +- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line). +- No threads, no numbered lists, no bullet points. Pure prose per slide. +- Each slide must be self-contained and punchy — it will appear as a card image. +- Match the brand voice guidelines exactly. + +CRITICAL — length for each slide's `tweet_text`: +- Aim for around {{ $target_chars }} characters per slide. +- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters per slide — including spaces, line breaks, hashtags and emojis. +- High-performing tweet-style content is punchy and direct. Stop when you've said it. + +Output format: a JSON object with: +- `caption`: the overall carousel caption in {{ $content_language ?? 'en' }} — teases what's inside the slides, encourages swiping. No preamble, no quotation marks. +- `slides`: an array of exactly {{ $slide_count ?? 1 }} objects, each with a single key: + - `tweet_text`: the tweet-style text for that slide in {{ $content_language ?? 'en' }}. Self-contained. Hook in the first sentence. + +CRITICAL: The `slides` array MUST contain exactly {{ $slide_count ?? 1 }} items — no fewer, no more. Count carefully before responding. + +## Carousel arc — how to sequence the slides +Plan the arc before writing. A carousel is a sequence, not a list: +- First slide: open with a specific, real, urgent claim or problem the reader recognises immediately. +- Middle slides: develop the idea — one angle or one step per slide. Alternate between "how it works" and "proof / concrete result". +- Last slide: end with a single, specific next action the reader can take right now. diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php index 0f081318..7422211f 100644 --- a/tests/Feature/Ai/StreamPostCreationTest.php +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -31,7 +31,7 @@ ]); }); -function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount): void +function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount, string $template = 'image_card'): void { (new StreamPostCreation( userId: $account->workspace->user_id, @@ -41,12 +41,11 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag socialAccountId: $account->id, imageCount: $imageCount, prompt: 'Five tips about productivity', + template: $template, ))->handle(); } test('AI carousel generation stores the post as an instagram feed, never instagram_carousel', function () { - // Empty image_keywords make TemplateImageGenerator::render() return null, so - // the storage decision is exercised without touching the image pipeline. PostContentGenerator::fake([[ 'caption' => 'Swipe to see the tips', 'slides' => [ @@ -62,7 +61,7 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag ], ]]); - runStreamPostCreation('instagram_carousel', $this->account, 2); + runStreamPostCreation('instagram_carousel', $this->account, 2, 'image_card'); $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); @@ -121,6 +120,40 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag PostContentHumanizer::assertNeverPrompted(); }); +test('tweet_card carousel produces caption as content and N media items without calling AI image client', function () { + PostContentGenerator::fake([[ + 'caption' => 'A carousel of punchy takes', + 'slides' => [ + ['tweet_text' => 'First take on productivity.'], + ['tweet_text' => 'Second take on deep work.'], + ], + ]]); + + (new StreamPostCreation( + userId: $this->user->id, + creationId: (string) Str::uuid(), + workspaceId: $this->workspace->id, + format: 'instagram_carousel', + socialAccountId: $this->account->id, + imageCount: 2, + prompt: 'Punchy productivity takes', + template: 'tweet_card', + ))->handle(); + + $post = $this->user->currentWorkspace->posts()->latest()->first(); + + expect($post->content)->toBe('A carousel of punchy takes') + ->and($post->media)->toHaveCount(2); + + $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); + + expect($platform->content_type)->toBe(ContentType::InstagramFeed); + + Image::assertNothingGenerated(); + + PostContentHumanizer::assertNeverPrompted(); +}); + test('the humanizer is given the same platform context as the generator so the rewrite honours the character cap', function () { PostContentGenerator::fake([[ 'content' => 'A single productivity tip', diff --git a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php index 28c52ef7..9d9cb6e4 100644 --- a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php +++ b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php @@ -7,7 +7,7 @@ test('registry resolves keys and defaults to image_card', function () { $registry = app(AiTemplateRegistry::class); - expect($registry->keys())->toContain('image_card', 'carousel', 'tweet_card') + expect($registry->keys())->toBe(['image_card', 'tweet_card']) ->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class) ->and($registry->default()->key())->toBe('image_card'); }); @@ -15,3 +15,8 @@ test('registry throws on unknown key', function () { expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class); }); + +test('registry does not contain carousel as a standalone style', function () { + $registry = app(AiTemplateRegistry::class); + expect($registry->keys())->not->toContain('carousel'); +}); diff --git a/tests/Feature/Ai/Templates/TemplateContractTest.php b/tests/Feature/Ai/Templates/TemplateContractTest.php index f2c5ef9d..b2c0c927 100644 --- a/tests/Feature/Ai/Templates/TemplateContractTest.php +++ b/tests/Feature/Ai/Templates/TemplateContractTest.php @@ -2,22 +2,42 @@ declare(strict_types=1); -use App\Ai\Templates\CarouselTemplate; use App\Ai\Templates\ImageCardTemplate; -use App\Enums\PostPlatform\ContentType; +use App\Ai\Templates\TemplateContext; +use App\Models\Workspace; +use Illuminate\JsonSchema\JsonSchemaTypeFactory; test('image card template exposes its identity', function () { $t = new ImageCardTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'instagram_feed', 0, false); + expect($t->key())->toBe('image_card') ->and($t->needsAccount())->toBeFalse() ->and($t->generatorFormat())->toBe('single') - ->and($t->promptView())->toBe('prompts.post_content.generator') + ->and($t->promptView($context))->toBe('prompts.post_content.generator') ->and($t->supportedFormats())->toBe([]); }); -test('carousel template exposes its identity', function () { - $t = new CarouselTemplate; - expect($t->key())->toBe('carousel') - ->and($t->generatorFormat())->toBe('carousel') - ->and($t->supportedFormats())->toContain(ContentType::CAROUSEL_FORMAT); +test('image card carousel context returns carousel schema shape', function () { + $t = new ImageCardTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'instagram_carousel', 3, true); + + $schema = new JsonSchemaTypeFactory; + $result = $t->schema($schema, $context); + + expect($result)->toHaveKeys(['caption', 'slides']) + ->and($t->promptView($context))->toBe('prompts.post_content.generator'); +}); + +test('image card single context returns single schema shape', function () { + $t = new ImageCardTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'instagram_feed', 1, false); + + $schema = new JsonSchemaTypeFactory; + $result = $t->schema($schema, $context); + + expect($result)->toHaveKeys(['content', 'image_title', 'image_body', 'image_keywords']); }); diff --git a/tests/Feature/Ai/Templates/TweetCardTemplateTest.php b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php index 8894c1dd..325b87cc 100644 --- a/tests/Feature/Ai/Templates/TweetCardTemplateTest.php +++ b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php @@ -2,12 +2,43 @@ declare(strict_types=1); +use App\Ai\Templates\TemplateContext; use App\Ai\Templates\TweetCardTemplate; +use App\Models\Workspace; +use Illuminate\JsonSchema\JsonSchemaTypeFactory; test('tweet card template identity', function () { $t = new TweetCardTemplate; + $workspace = Workspace::factory()->create(); + $singleContext = new TemplateContext($workspace, null, 'x_post', 1, false); + $carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true); + expect($t->key())->toBe('tweet_card') ->and($t->needsAccount())->toBeTrue() ->and($t->generatorFormat())->toBe('tweet_card') - ->and($t->promptView())->toBe('prompts.post_content.tweet_card'); + ->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card') + ->and($t->promptView($carouselContext))->toBe('prompts.post_content.tweet_card_carousel'); +}); + +test('tweet card single schema returns tweet_text only', function () { + $t = new TweetCardTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'x_post', 1, false); + + $schema = new JsonSchemaTypeFactory; + $result = $t->schema($schema, $context); + + expect($result)->toHaveKey('tweet_text') + ->and($result)->not->toHaveKey('caption'); +}); + +test('tweet card carousel schema returns caption and slides', function () { + $t = new TweetCardTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'instagram_carousel', 3, true); + + $schema = new JsonSchemaTypeFactory; + $result = $t->schema($schema, $context); + + expect($result)->toHaveKeys(['caption', 'slides']); });