feat(ai-templates): task 3 - template-driven generator + job

- Add AiTemplateRegistry (resolves, keys, default)
- Add template/templateContext params to PostContentGenerator; schema() delegates to template when both are set; instructions() uses template.promptView()
- Refactor StreamPostCreation.handle() to resolve template via registry, build TemplateContext, delegate assemble() to template; add createPostFromGenerated()
- Remove handleSingle(), handleCarousel(), resolvedContentType() (no other callers)
- PostAiCreateController passes template param from request (default image_card)
- All existing AI tests green; registry test added
This commit is contained in:
Paulo Castellano 2026-06-17 17:23:33 -03:00
parent 97798c010a
commit 9dba90129f
5 changed files with 95 additions and 90 deletions

View file

@ -5,6 +5,8 @@
namespace App\Ai\Agents;
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\TemplateContext;
use App\Models\Workspace;
use App\Services\Ai\TemplateContextResolver;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@ -27,6 +29,8 @@ public function __construct(
public int $slideCount = 1,
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
public ?AiContentTemplate $template = null,
public ?TemplateContext $templateContext = null,
) {}
public function instructions(): string
@ -45,12 +49,11 @@ public function instructions(): string
->all();
}
// Two budgets: the platform's HARD cap (must never exceed — would break
// publishing) and the recommended SWEET SPOT length (what well-performing
// posts actually look like, way below the hard cap on most platforms).
$budget = $this->platformCopyBudget($this->platformContext);
return view('prompts.post_content.generator', [
$view = $this->template?->promptView() ?? 'prompts.post_content.generator';
return view($view, [
'brand_name' => $this->workspace->name ?? '',
'brand_description' => $this->applyBrandVoice ? ($this->workspace->brand_description ?? '') : '',
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
@ -67,6 +70,10 @@ public function instructions(): string
public function schema(JsonSchema $schema): array
{
if ($this->template !== null && $this->templateContext !== null) {
return $this->template->schema($schema, $this->templateContext);
}
if ($this->format === 'carousel') {
return [
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
@ -77,7 +84,7 @@ public function schema(JsonSchema $schema): array
->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(),
'body' => $s->string()->description('Supporting body below the headline. 1-3 sentences.')->required(),
'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(),
]))
->min($this->slideCount)

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use InvalidArgumentException;
class AiTemplateRegistry
{
/** @var array<int, class-string<AiContentTemplate>> */
private const TEMPLATES = [
ImageCardTemplate::class,
CarouselTemplate::class,
];
/** @return array<int, AiContentTemplate> */
public function all(): array
{
return array_map(fn (string $class) => app($class), self::TEMPLATES);
}
/** @return array<int, string> */
public function keys(): array
{
return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all());
}
public function find(string $key): AiContentTemplate
{
foreach ($this->all() as $template) {
if ($template->key() === $key) {
return $template;
}
}
throw new InvalidArgumentException("Unknown AI content template: {$key}");
}
public function default(): AiContentTemplate
{
return app(ImageCardTemplate::class);
}
}

View file

@ -51,6 +51,7 @@ public function start(StartPostCreationRequest $request): JsonResponse
imageCount: (int) $request->input('image_count', 0),
prompt: $request->string('prompt')->toString(),
date: $request->input('date'),
template: $request->input('template', 'image_card'),
);
return response()->json([

View file

@ -7,6 +7,9 @@
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\GeneratedPost;
use App\Ai\Templates\TemplateContext;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\PostPlatform\ContentType;
@ -17,7 +20,6 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use App\Services\Image\PostImagePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -38,6 +40,7 @@ public function __construct(
public int $imageCount,
public string $prompt,
public ?string $date = null,
public string $template = 'image_card',
) {
$this->onQueue('ai');
}
@ -47,16 +50,19 @@ public function handle(): void
$workspace = Workspace::findOrFail($this->workspaceId);
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
$agentFormat = $isCarousel ? 'carousel' : 'single';
$template = app(AiTemplateRegistry::class)->find($this->template);
$context = new TemplateContext($workspace, $socialAccount, $this->format, $this->imageCount);
$isCarousel = $template->generatorFormat() === 'carousel';
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
$agent = new PostContentGenerator(
workspace: $workspace,
format: $agentFormat,
format: $template->generatorFormat(),
slideCount: $slideCount,
platformContext: $this->format,
template: $template,
templateContext: $context,
);
try {
@ -72,18 +78,13 @@ public function handle(): void
metadata: ['agent' => 'post_generator', 'format' => $this->format],
);
// StructuredAgentResponse implements ArrayAccess: access via $response['key']
$structured = $response->structured ?? [];
// Second pass: rewrite human-readable text to remove AI-tells. Image
// keywords pass through untouched (they need to stay in English).
$structured = $this->humanize($workspace, $structured, $isCarousel ? 'carousel' : 'single');
$structured = $this->humanize($workspace, $structured, $template->generatorFormat());
if ($isCarousel) {
$this->handleCarousel($workspace, $socialAccount, $structured);
} else {
$this->handleSingle($workspace, $socialAccount, $structured);
}
$generated = $template->assemble($structured, $context);
$post = $this->createPostFromGenerated($workspace, $generated, $socialAccount);
$this->notifyReady($workspace, $post);
} catch (\Throwable $e) {
Log::error('StreamPostCreation failed', [
'creation_id' => $this->creationId,
@ -96,19 +97,6 @@ public function handle(): void
}
}
/**
* The stored content type for the requested generation format. The carousel
* generation format is persisted as an Instagram feed post.
*/
private function resolvedContentType(): ?ContentType
{
if ($this->format === ContentType::CAROUSEL_FORMAT) {
return ContentType::InstagramFeed;
}
return ContentType::tryFrom($this->format);
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
@ -183,80 +171,28 @@ private function humanize(Workspace $workspace, array $structured, string $forma
return $structured;
}
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$caption = (string) data_get($structured, 'caption', '');
$media = [];
if ($socialAccount) {
$media = app(PostImagePipeline::class)->forCarousel(
workspace: $workspace,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
}
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
$this->notifyReady($workspace, $post);
}
/**
* @param array<string, mixed> $structured
*/
private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$contentType = ContentType::tryFrom($this->format);
$supportsCaption = $contentType?->supportsCaption() ?? true;
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
$media = [];
if ($this->imageCount > 0 && $socialAccount) {
$media = app(PostImagePipeline::class)->forSingle(
workspace: $workspace,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
}
$caption = $supportsCaption ? $rawContent : '';
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
$this->notifyReady($workspace, $post);
}
/**
* @param array<int, array<string, mixed>> $media
*/
private function createPost(Workspace $workspace, string $content, array $media, ?SocialAccount $socialAccount): Post
private function createPostFromGenerated(Workspace $workspace, GeneratedPost $generated, ?SocialAccount $socialAccount): Post
{
$user = User::findOrFail($this->userId);
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => $media,
'content' => $generated->content,
'media' => $generated->media,
'date' => $this->date,
]);
$contentType = $this->resolvedContentType();
if ($contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($contentType);
if ($generated->contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($generated->contentType);
$post->postPlatforms()
->where('social_account_id', $socialAccount->id)
->each(function ($platform) use ($aspectRatio, $contentType): void {
->each(function ($platform) use ($aspectRatio, $generated): void {
$meta = $platform->meta ?? [];
if ($aspectRatio !== null) {
$meta['aspect_ratio'] = $aspectRatio;
}
$platform->meta = $meta;
$platform->content_type = $contentType->value;
$platform->content_type = $generated->contentType->value;
$platform->enabled = true;
$platform->save();
});

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\ImageCardTemplate;
test('registry resolves keys and defaults to image_card', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->keys())->toContain('image_card', 'carousel')
->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class)
->and($registry->default()->key())->toBe('image_card');
});
test('registry throws on unknown key', function () {
expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class);
});