trypost/app/Jobs/Ai/StreamPostCreation.php
Paulo Castellano 06f83a6571 Track post creation origin via created_via.
Persist whether a post was created through web, MCP, API, or automation so we can attribute entry points without guessing from request context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:19:01 -03:00

255 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs\Ai;
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\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\Post\CreatedVia;
use App\Enums\PostPlatform\ContentType;
use App\Events\Ai\PostCreationReady;
use App\Jobs\SendNotification;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class StreamPostCreation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $userId,
public string $creationId,
public string $workspaceId,
public string $format,
public ?string $socialAccountId,
public int $imageCount,
public string $prompt,
public ?string $date = null,
public string $template = 'image_card',
public bool $applyBrandVisuals = true,
) {
$this->onQueue('ai');
}
public function handle(): void
{
$workspace = Workspace::findOrFail($this->workspaceId);
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
$style = app(AiTemplateRegistry::class)->find($this->template);
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
$agentFormat = $isCarousel ? GeneratorFormat::Carousel : GeneratorFormat::Single;
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
$context = new TemplateContext(
workspace: $workspace,
socialAccount: $socialAccount,
format: $this->format,
imageCount: $this->imageCount,
isCarousel: $isCarousel,
applyBrandVisuals: $this->applyBrandVisuals,
);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $agentFormat,
slideCount: $slideCount,
platformContext: $this->format,
template: $style,
templateContext: $context,
);
try {
$response = $agent->prompt($this->prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_generator', 'format' => $this->format],
);
$structured = $response->structured ?? [];
$structured = $this->humanize($workspace, $structured, $agentFormat, $style->style());
$generated = $style->assemble($structured, $context);
$post = $this->createPostFromGenerated($workspace, $generated, $socialAccount);
$this->notifyReady($workspace, $post);
} catch (\Throwable $e) {
Log::error('StreamPostCreation failed', [
'creation_id' => $this->creationId,
'error' => $e->getMessage(),
]);
PostCreationReady::dispatch($this->userId, $this->creationId, null, $e->getMessage());
throw $e;
}
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
* image_keywords and slide order/count). Failures are logged and the
* original structure is returned so generation never breaks because of the
* polish step.
*
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style): array
{
if (! $style->humanizes()) {
return $structured;
}
try {
$input = $format->isCarousel()
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
fn ($s) => [
'title' => data_get($s, 'title', ''),
'body' => data_get($s, 'body', ''),
],
data_get($structured, 'slides', []),
),
]
: [
'content' => data_get($structured, 'content', ''),
'image_title' => data_get($structured, 'image_title', ''),
'image_body' => data_get($structured, 'image_body', ''),
];
$humanizer = new PostContentHumanizer($workspace, $format, platformContext: $this->format);
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
$humanized = $response->structured ?? [];
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_humanizer', 'format' => $format->value],
);
} catch (\Throwable $e) {
Log::warning('PostContentHumanizer failed, using generator output as-is', [
'creation_id' => $this->creationId,
'error' => $e->getMessage(),
]);
return $structured;
}
if ($format->isCarousel()) {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);
foreach ($originalSlides as $i => $slide) {
if (isset($humanizedSlides[$i])) {
$originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? '');
$originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? '');
}
}
$structured['slides'] = $originalSlides;
} else {
$structured['content'] = data_get($humanized, 'content', $structured['content'] ?? '');
$structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? '');
$structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? '');
}
return $structured;
}
private function createPostFromGenerated(Workspace $workspace, GeneratedPost $generated, ?SocialAccount $socialAccount): Post
{
$user = User::findOrFail($this->userId);
$post = CreatePost::execute($workspace, $user, [
'content' => $generated->content,
'media' => $generated->media,
'date' => $this->date,
'created_via' => CreatedVia::Web,
]);
if ($generated->contentType && $socialAccount) {
$aspectRatio = $this->aspectRatioFor($generated->contentType);
$post->postPlatforms()
->where('social_account_id', $socialAccount->id)
->each(function ($platform) use ($aspectRatio, $generated): void {
$meta = $platform->meta ?? [];
if ($aspectRatio !== null) {
$meta['aspect_ratio'] = $aspectRatio;
}
$platform->meta = $meta;
$platform->content_type = $generated->contentType->value;
$platform->enabled = true;
$platform->save();
});
}
return $post;
}
private function notifyReady(Workspace $workspace, Post $post): void
{
PostCreationReady::dispatch(
userId: $this->userId,
creationId: $this->creationId,
postId: $post->id,
);
$user = User::findOrFail($this->userId);
SendNotification::dispatch(
user: $user,
workspaceId: $workspace->id,
type: NotificationType::PostReady,
channel: NotificationChannel::InApp,
title: trans('notifications.post_ready.title', [], $workspace->content_language),
body: trans('notifications.post_ready.body', [], $workspace->content_language),
data: ['post_id' => $post->id],
);
}
private function aspectRatioFor(ContentType $type): ?string
{
$dims = $type->aiImageDimensions();
$ratio = $dims['width'] / $dims['height'];
return match (true) {
abs($ratio - 1.0) < 0.01 => '1:1',
abs($ratio - 4 / 5) < 0.01 => '4:5',
abs($ratio - 16 / 9) < 0.01 => '16:9',
default => null,
};
}
}