context ?? []; $prompt = $this->resolver->resolve(data_get($config, 'prompt_template', ''), $context); $accountsConfig = $this->resolveAccountsConfig($config); ['format' => $format, 'slide_count' => $slideCount] = $this->deriveFormat($accountsConfig, $config); $accountIds = array_values(array_filter(array_map( fn ($a) => data_get($a, 'social_account_id'), $accountsConfig, ))); $workspace = $run->automation->workspace; $activeAccounts = SocialAccount::query() ->whereIn('id', $accountIds) ->where('workspace_id', $workspace->id) ->active() ->get() ->keyBy('id'); if ($accountIds !== [] && $activeAccounts->isEmpty()) { Log::warning('RunGenerateNode: no active social accounts found, skipping account assignment', [ 'automation_id' => $run->automation_id, 'social_account_ids' => $accountIds, ]); } $agent = new PostContentGenerator( workspace: $workspace, format: $format, slideCount: $slideCount, ); $generatorResponse = $agent->prompt($prompt); RecordAiUsage::recordText( workspace: $workspace, promptTokens: $generatorResponse->usage->promptTokens, completionTokens: $generatorResponse->usage->completionTokens, provider: (string) config('ai.default'), model: (string) config('ai.default_text_model'), metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'], ); $structured = $generatorResponse->structured ?? []; $structured = $this->humanize($workspace, $structured, $format); $content = $format === 'carousel' ? (string) data_get($structured, 'caption', '') : (string) data_get($structured, 'content', ''); $user = $this->resolveUser($run); $platforms = []; foreach ($accountsConfig as $entry) { $accountId = data_get($entry, 'social_account_id'); if (! $accountId || ! $activeAccounts->has($accountId)) { if ($accountId) { Log::warning('RunGenerateNode: account no longer active, skipping', [ 'automation_id' => $run->automation_id, 'social_account_id' => $accountId, ]); } continue; } $platforms[] = [ 'social_account_id' => $accountId, 'content_type' => data_get($entry, 'content_type'), 'meta' => data_get($entry, 'meta', []), ]; } // Dry runs do the AI work (so the user sees a real generation) but // never persist a Post. Downstream nodes (Publish) read `is_dry_run` // and skip their persistence too. if ($run->is_dry_run) { return NodeRunResult::completed(output: [ 'generated' => [ 'post_id' => null, 'content' => $content, 'dry_run' => true, ], ]); } $post = CreatePost::execute($workspace, $user, [ 'content' => $content, 'media' => [], 'platforms' => $platforms, ]); $run->update(['generated_post_id' => $post->id]); return NodeRunResult::completed(output: [ 'generated' => [ 'post_id' => $post->id, 'content' => $content, 'post_url' => route('app.posts.show', $post->id), ], ]); } /** * @param array $structured * @return array */ private function humanize(Workspace $workspace, array $structured, string $format): array { try { $input = $format === 'carousel' ? [ '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); $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'), metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'], ); if ($format === 'carousel') { $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'] ?? ''); } } catch (\Throwable $e) { Log::warning('RunGenerateNode: PostContentHumanizer failed, using generator output as-is', [ 'error' => $e->getMessage(), ]); } return $structured; } /** * Derive the generator format and slide count from per-account content types. * * Carousel-capable content types: * - instagram_feed (Instagram feed carousel = multi-image feed post) * - linkedin_carousel (LinkedIn personal carousel PDF) * - linkedin_page_carousel (LinkedIn page carousel PDF) * - pinterest_carousel (Pinterest carousel pin) * - tiktok_photo (TikTok photo carousel) * * When at least one account has a carousel-capable content type AND * target_slide_count > 1, the generator is told to produce a carousel with * that many slides. Otherwise it falls back to a single-post format. * * @param array}> $accountsConfig * @param array $config * @return array{format: string, slide_count: int} */ public function deriveFormat(array $accountsConfig, array $config): array { $carouselCapable = [ ContentType::InstagramFeed->value, ContentType::LinkedInCarousel->value, ContentType::LinkedInPageCarousel->value, ContentType::PinterestCarousel->value, ContentType::TikTokPhoto->value, ]; $hasCarouselAccount = false; foreach ($accountsConfig as $entry) { if (in_array(data_get($entry, 'content_type'), $carouselCapable, strict: true)) { $hasCarouselAccount = true; break; } } $targetSlideCount = (int) data_get($config, 'target_slide_count', 1); if ($hasCarouselAccount && $targetSlideCount > 1) { return ['format' => 'carousel', 'slide_count' => $targetSlideCount]; } return ['format' => 'single', 'slide_count' => 1]; } private function resolveUser(AutomationRun $run): User { if ($run->automation->user_id) { return $run->automation->user; } return $run->automation->workspace->owner; } /** * Read the current `accounts` shape and fall back to the legacy * `social_account_ids` array so older automations keep running until * the user re-opens and saves the node. * * @param array $config * @return array}> */ private function resolveAccountsConfig(array $config): array { $accounts = data_get($config, 'accounts'); if (is_array($accounts)) { return array_values(array_map(fn ($entry) => [ 'social_account_id' => (string) data_get($entry, 'social_account_id', ''), 'content_type' => data_get($entry, 'content_type'), 'meta' => (array) data_get($entry, 'meta', []), ], $accounts)); } $legacy = data_get($config, 'social_account_ids', []); if (! is_array($legacy)) { return []; } return array_values(array_map(fn ($id) => [ 'social_account_id' => (string) $id, 'content_type' => null, 'meta' => [], ], $legacy)); } }