diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index 09e1751f..08a95efd 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -51,6 +51,11 @@ public function instructions(): string $budget = $this->platformCopyBudget($this->platformContext); + if ($this->template?->generatorFormat() === 'tweet_card') { + $budget['hard_max_chars'] = 560; + $budget['target_chars'] = 280; + } + $view = $this->template?->promptView() ?? 'prompts.post_content.generator'; return view($view, [ diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php index b01e4691..f1be810a 100644 --- a/app/Ai/Templates/AiTemplateRegistry.php +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -12,6 +12,7 @@ class AiTemplateRegistry private const TEMPLATES = [ ImageCardTemplate::class, CarouselTemplate::class, + TweetCardTemplate::class, ]; /** @return array */ diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php new file mode 100644 index 00000000..bcd08568 --- /dev/null +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -0,0 +1,95 @@ + */ + public function supportedFormats(): array + { + return []; + } + + public function generatorFormat(): string + { + return 'tweet_card'; + } + + public function promptView(): string + { + return 'prompts.post_content.tweet_card'; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array + { + 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.') + ->required(), + ]; + } + + /** @return array */ + public function humanizableFields(): array + { + return ['tweet_text' => 'tweet_text']; + } + + /** + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + $text = (string) data_get($structured, 'tweet_text', ''); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCard( + workspace: $context->workspace, + account: $context->socialAccount, + tweetText: $text, + ); + } + + return new GeneratedPost( + content: $text, + media: $media, + contentType: ContentType::tryFrom($context->format), + ); + } +} diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index e02a09c5..203a6f76 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -109,6 +109,10 @@ public function handle(): void */ private function humanize(Workspace $workspace, array $structured, string $format): array { + if ($format === 'tweet_card') { + return $structured; + } + try { $input = $format === 'carousel' ? [ diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php index 78343bcc..8cd73f2e 100644 --- a/tests/Feature/Ai/StreamPostCreationTest.php +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -90,6 +90,31 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag expect($platform->content_type)->toBe(ContentType::InstagramFeed); }); +test('tweet_card template stores the tweet_text as post content and attaches a media item', function () { + PostContentGenerator::fake([[ + 'tweet_text' => 'Hello world\n\nSecond para.', + ]]); + + $minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + Image::fake([base64_encode($minimalPng)]); + + (new StreamPostCreation( + userId: $this->user->id, + creationId: (string) Str::uuid(), + workspaceId: $this->workspace->id, + format: 'x_twitter', + socialAccountId: $this->account->id, + imageCount: 1, + prompt: 'A punchy take on productivity', + template: 'tweet_card', + ))->handle(); + + $post = $this->user->currentWorkspace->posts()->latest()->first(); + + expect($post->content)->toBe('Hello world\n\nSecond para.') + ->and($post->media)->toHaveCount(1); +}); + 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', diff --git a/tests/Feature/Ai/Templates/TweetCardTemplateTest.php b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php new file mode 100644 index 00000000..8894c1dd --- /dev/null +++ b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php @@ -0,0 +1,13 @@ +key())->toBe('tweet_card') + ->and($t->needsAccount())->toBeTrue() + ->and($t->generatorFormat())->toBe('tweet_card') + ->and($t->promptView())->toBe('prompts.post_content.tweet_card'); +});