diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index cb9c09b2..09e1751f 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -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) diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php new file mode 100644 index 00000000..b01e4691 --- /dev/null +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -0,0 +1,44 @@ +> */ + private const TEMPLATES = [ + ImageCardTemplate::class, + CarouselTemplate::class, + ]; + + /** @return array */ + public function all(): array + { + return array_map(fn (string $class) => app($class), self::TEMPLATES); + } + + /** @return array */ + 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); + } +} diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php index 93f39d7b..1d40db64 100644 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ b/app/Http/Controllers/App/PostAiCreateController.php @@ -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([ diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 7d45f776..e02a09c5 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -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 $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> $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(); }); diff --git a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php new file mode 100644 index 00000000..225ccb54 --- /dev/null +++ b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php @@ -0,0 +1,17 @@ +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); +});