feat(ai-templates): extract image-card + carousel templates
This commit is contained in:
parent
ebd9a2ac0f
commit
97798c010a
6 changed files with 245 additions and 0 deletions
104
app/Ai/Templates/CarouselTemplate.php
Normal file
104
app/Ai/Templates/CarouselTemplate.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
|
||||
class CarouselTemplate implements AiContentTemplate
|
||||
{
|
||||
use ResolvesContentType;
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
||||
public function humanizableFields(): array
|
||||
{
|
||||
return ['caption' => 'caption'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
19
app/Ai/Templates/Concerns/ResolvesContentType.php
Normal file
19
app/Ai/Templates/Concerns/ResolvesContentType.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Templates\Concerns;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
|
||||
trait ResolvesContentType
|
||||
{
|
||||
private static function resolveContentType(string $format): ?ContentType
|
||||
{
|
||||
if ($format === ContentType::CAROUSEL_FORMAT) {
|
||||
return ContentType::InstagramFeed;
|
||||
}
|
||||
|
||||
return ContentType::tryFrom($format);
|
||||
}
|
||||
}
|
||||
99
app/Ai/Templates/ImageCardTemplate.php
Normal file
99
app/Ai/Templates/ImageCardTemplate.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Templates;
|
||||
|
||||
use App\Ai\Templates\Concerns\ResolvesContentType;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
|
||||
class ImageCardTemplate implements AiContentTemplate
|
||||
{
|
||||
use ResolvesContentType;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'image_card';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'posts.ai.templates.image_card.name';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'posts.ai.templates.image_card.description';
|
||||
}
|
||||
|
||||
public function previewAsset(): string
|
||||
{
|
||||
return '/images/ai-templates/image-card.png';
|
||||
}
|
||||
|
||||
public function needsAccount(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function generatorFormat(): string
|
||||
{
|
||||
return 'single';
|
||||
}
|
||||
|
||||
public function promptView(): string
|
||||
{
|
||||
return 'prompts.post_content.generator';
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema, TemplateContext $context): array
|
||||
{
|
||||
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(),
|
||||
'image_body' => $schema->string()->description('1-2 short sentences (max 25 words) overlaid below the image_title. Expands the hook just enough to compel reading the caption.')->required(),
|
||||
'image_keywords' => $schema->array()->items($schema->string())->description('2-4 search keywords for Unsplash for the single image.')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function humanizableFields(): array
|
||||
{
|
||||
return [
|
||||
'content' => 'content',
|
||||
'image_title' => 'image_title',
|
||||
'image_body' => 'image_body',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
public function assemble(array $structured, TemplateContext $context): GeneratedPost
|
||||
{
|
||||
$contentType = self::resolveContentType($context->format);
|
||||
$supportsCaption = $contentType?->supportsCaption() ?? true;
|
||||
|
||||
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($context->imageCount > 0 && $context->socialAccount) {
|
||||
$media = app(PostImagePipeline::class)->forSingle(
|
||||
workspace: $context->workspace,
|
||||
account: $context->socialAccount,
|
||||
structured: $structured,
|
||||
contentType: $contentType,
|
||||
);
|
||||
}
|
||||
|
||||
$caption = $supportsCaption ? $rawContent : '';
|
||||
|
||||
return new GeneratedPost($caption, $media, $contentType);
|
||||
}
|
||||
}
|
||||
BIN
public/images/ai-templates/carousel.png
Normal file
BIN
public/images/ai-templates/carousel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/ai-templates/image-card.png
Normal file
BIN
public/images/ai-templates/image-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
23
tests/Feature/Ai/Templates/TemplateContractTest.php
Normal file
23
tests/Feature/Ai/Templates/TemplateContractTest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Templates\CarouselTemplate;
|
||||
use App\Ai\Templates\ImageCardTemplate;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
|
||||
test('image card template exposes its identity', function () {
|
||||
$t = new ImageCardTemplate;
|
||||
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->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);
|
||||
});
|
||||
Loading…
Reference in a new issue