feat(ai-templates): styles orthogonal to single/carousel; tweet-card carousel

- TemplateContext gains bool $isCarousel (set from format === CAROUSEL_FORMAT in StreamPostCreation)
- AiContentTemplate interface: promptView() now takes TemplateContext
- ImageCardTemplate handles both single and carousel via context.isCarousel; schema() returns the correct shape per mode; assemble() routes to assembleSingle/assembleCarousel
- TweetCardTemplate handles both single and carousel; promptView() returns tweet_card_carousel view when isCarousel; schema() returns {tweet_text} or {caption, slides[]}; assemble() calls forTweetCard (single) or forTweetCardCarousel (carousel)
- CarouselTemplate deleted — carousel is now a format dimension each style handles, not a standalone style
- AiTemplateRegistry: [ImageCardTemplate, TweetCardTemplate] only
- PostImagePipeline: adds forTweetCardCarousel — renders one renderTweetCard per slide text, returns N media items
- StreamPostCreation: carousel detection restored to $format === CAROUSEL_FORMAT (like main); style dispatched via AiTemplateRegistry::find($this->template); humanize skips tweet_card (single and carousel)
- PostContentGenerator: promptView() call updated to pass templateContext
- New prompt: resources/views/prompts/post_content/tweet_card_carousel.blade.php
- Tests: TemplateContractTest and TweetCardTemplateTest updated for promptView(context) signature; AiTemplateRegistryTest asserts [image_card, tweet_card]; StreamPostCreationTest adds tweet_card carousel test; TweetCardTemplateTest covers both schema shapes and both promptView return values
This commit is contained in:
Paulo Castellano 2026-06-17 18:59:41 -03:00
parent e886db5621
commit 58ac2714d0
14 changed files with 311 additions and 123 deletions

View file

@ -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 ?? '',

View file

@ -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.

View file

@ -11,7 +11,6 @@ class AiTemplateRegistry
/** @var array<int, class-string<AiContentTemplate>> */
private const TEMPLATES = [
ImageCardTemplate::class,
CarouselTemplate::class,
TweetCardTemplate::class,
];

View file

@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class CarouselTemplate implements AiContentTemplate
{
public function key(): string
{
return 'carousel';
}
public function name(): string
{
return 'posts.ai.templates.carousel.name';
}
public function description(): string
{
return 'posts.ai.templates.carousel.description';
}
public function previewAsset(): string
{
return '/images/ai-templates/carousel.png';
}
public function needsAccount(): bool
{
return false;
}
public function supportedFormats(): array
{
return [ContentType::CAROUSEL_FORMAT];
}
public function generatorFormat(): string
{
return 'carousel';
}
public function promptView(): string
{
return 'prompts.post_content.generator';
}
public function schema(JsonSchema $schema, TemplateContext $context): array
{
$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(),
];
}
/**
* @param array<string, mixed> $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);
}
}

View file

@ -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<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost
{
if ($context->isCarousel) {
return $this->assembleCarousel($structured, $context);
}
return $this->assembleSingle($structured, $context);
}
/**
* @param array<string, mixed> $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<string, mixed> $structured
*/
private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost
{
$contentType = self::resolveContentType($context->format);
$supportsCaption = $contentType?->supportsCaption() ?? true;

View file

@ -17,5 +17,6 @@ public function __construct(
public ?SocialAccount $socialAccount,
public string $format,
public int $imageCount,
public bool $isCarousel = false,
) {}
}

View file

@ -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<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost
{
if ($context->isCarousel) {
return $this->assembleCarousel($structured, $context);
}
return $this->assembleSingle($structured, $context);
}
/**
* @param array<string, mixed> $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<string, mixed> $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,
);
}
}

View file

@ -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) {

View file

@ -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<int, string> $slideTexts
* @return array<int, array<string, mixed>>
*/
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.

View file

@ -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.

View file

@ -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',

View file

@ -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');
});

View file

@ -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']);
});

View file

@ -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']);
});