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:
parent
e886db5621
commit
58ac2714d0
14 changed files with 311 additions and 123 deletions
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ class AiTemplateRegistry
|
|||
/** @var array<int, class-string<AiContentTemplate>> */
|
||||
private const TEMPLATES = [
|
||||
ImageCardTemplate::class,
|
||||
CarouselTemplate::class,
|
||||
TweetCardTemplate::class,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ public function __construct(
|
|||
public ?SocialAccount $socialAccount,
|
||||
public string $format,
|
||||
public int $imageCount,
|
||||
public bool $isCarousel = false,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue