feat(tweet-card): tweet_card template wired end-to-end

This commit is contained in:
Paulo Castellano 2026-06-17 17:35:18 -03:00
parent 0d4f3b90d7
commit 9d8f772a3e
6 changed files with 143 additions and 0 deletions

View file

@ -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, [

View file

@ -12,6 +12,7 @@ class AiTemplateRegistry
private const TEMPLATES = [
ImageCardTemplate::class,
CarouselTemplate::class,
TweetCardTemplate::class,
];
/** @return array<int, AiContentTemplate> */

View file

@ -0,0 +1,95 @@
<?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 TweetCardTemplate implements AiContentTemplate
{
public function key(): string
{
return 'tweet_card';
}
public function name(): string
{
return 'posts.ai.templates.tweet_card.name';
}
public function description(): string
{
return 'posts.ai.templates.tweet_card.description';
}
public function previewAsset(): string
{
return '/images/ai-templates/tweet-card.png';
}
public function needsAccount(): bool
{
return true;
}
/** @return array<int, string> */
public function supportedFormats(): array
{
return [];
}
public function generatorFormat(): string
{
return 'tweet_card';
}
public function promptView(): string
{
return 'prompts.post_content.tweet_card';
}
/**
* @return array<string, mixed>
*/
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<string, string> */
public function humanizableFields(): array
{
return ['tweet_text' => 'tweet_text'];
}
/**
* @param array<string, mixed> $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),
);
}
}

View file

@ -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'
? [

View file

@ -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',

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\TweetCardTemplate;
test('tweet card template identity', function () {
$t = new TweetCardTemplate;
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');
});