is_configured() ) { ai_content_writer()->create_log( esc_html__( 'Autopilot campaign skipped: the selected AI provider is not configured. Add its API key on the settings page.', 'ai-content-writer' ), 'error', $campaign_id ); return false; } $recent_titles = self::get_recent_titles( $campaign_id ); // Guard: without enough existing content there is no niche to match — skip instead of generating generic filler. if ( count( $recent_titles ) < self::MIN_PUBLISHED_POSTS ) { ai_content_writer()->create_log( sprintf( /* translators: %d: Minimum number of published posts. */ esc_html__( 'Autopilot campaign skipped: at least %d published posts are needed so the AI can learn what your site is about. Publish more content or use a keyword campaign instead.', 'ai-content-writer' ), self::MIN_PUBLISHED_POSTS ), 'warning', $campaign_id ); return false; } $profile = self::get_site_profile( $campaign_id, $provider, $recent_titles ); $language = aicw_get_campaign_language( $campaign_id ); $tone = aicw_get_campaign_tone( $campaign_id ); // Ask for a few extra titles so duplicates can be discarded. $count = absint( $posts_needed ) + 10; $prompt = self::build_titles_prompt( $profile, $recent_titles, $count, $language, $tone ); $text = $provider->generate( $prompt, array( 'max_tokens' => 1000 ) ); if ( is_wp_error( $text ) || empty( $text ) ) { $reason = is_wp_error( $text ) ? $text->get_error_message() : esc_html__( 'The provider replied with an empty message.', 'ai-content-writer' ); ai_content_writer()->create_log( sprintf( /* translators: 1: Provider label, 2: Model ID, 3: Error reported by the provider. */ esc_html__( 'Autopilot title generation failed. Provider: %1$s. Model: %2$s. Reason: %3$s', 'ai-content-writer' ), esc_html( $provider->get_label() ), esc_html( $provider->get_default_model() ), esc_html( $reason ) ), 'error', $campaign_id ); return false; } $counter = 0; foreach ( explode( "\n", trim( $text ) ) as $title ) { $title = trim( $title ); if ( empty( $title ) || ! preg_match( '/^(\d+\.\s|- )/', $title ) ) { continue; } $title = preg_replace( '/^(\d+\.\s*|- )/', '', $title ); $title = trim( $title, '" ' ); if ( empty( $title ) || aicw_get_post_by_title( $title ) ) { continue; } $post_id = wp_insert_post( array( 'post_type' => 'aicw_post', 'post_title' => wp_strip_all_tags( $title ), 'post_content' => '', 'post_status' => 'pending', 'post_author' => get_post_field( 'post_author', $campaign_id ), ) ); if ( is_wp_error( $post_id ) || ! $post_id ) { continue; } update_post_meta( $post_id, '_aicw_campaign_id', $campaign_id ); ++$counter; if ( $counter >= absint( $posts_needed ) ) { break; } } if ( $counter > 0 ) { ai_content_writer()->create_log( sprintf( /* translators: %d: Number of titles generated. */ esc_html__( 'Autopilot generated %d article titles matched to your existing site content.', 'ai-content-writer' ), $counter ), 'success', $campaign_id ); } aicw_update_last_run_time( $campaign_id ); return $counter > 0; } /** * Build the title-generation prompt from the site profile and existing titles. * * @param string $profile The distilled site profile. * @param array $recent_titles Recent published post titles. * @param int $count Number of titles to request. * @param string $language Output language. * @param string $tone Writing tone ('' for default). * * @since 2.4.5 * @return string */ public static function build_titles_prompt( $profile, $recent_titles, $count, $language, $tone = '' ) { $titles_list = implode( "\n", array_map( static function ( $title ) { return '- ' . $title; }, $recent_titles ) ); $tone_line = empty( $tone ) ? '' : sprintf( ' Match a %s tone.', $tone ); return sprintf( "You are the content strategist for a website with this profile:\n%1\$s\n\nThese articles are already published on the site:\n%2\$s\n\nPropose %3\$d NEW article titles in %4\$s that fit the site's niche and audience, extend its existing topics, and do NOT duplicate or closely overlap any of the published articles above.%5\$s Return them as a plain numbered list (\"1. Title\"), one title per line, with no extra commentary.", $profile, $titles_list, absint( $count ), $language, $tone_line ); } /** * Get (and cache) the AI-distilled site profile for a campaign. * * The profile is stored in campaign meta and refreshed after PROFILE_TTL so * it keeps tracking the site as new content is published. When the AI call * fails, an uncached deterministic summary is used for the current run. * * @param int $campaign_id The campaign ID. * @param \AIContentWriter\Services\ProviderInterface $provider The AI provider. * @param array $recent_titles Recent published post titles. * * @since 2.4.5 * @return string */ protected static function get_site_profile( $campaign_id, $provider, $recent_titles ) { $profile = get_post_meta( $campaign_id, '_aicw_autopilot_profile', true ); $timestamp = absint( get_post_meta( $campaign_id, '_aicw_autopilot_profile_time', true ) ); if ( ! empty( $profile ) && ( time() - $timestamp ) < self::PROFILE_TTL ) { return $profile; } $summary = self::build_site_summary( $campaign_id, $recent_titles ); $prompt = sprintf( "Analyze this website's published content and describe the site in 3-4 sentences: its niche, main topics, target audience, and overall writing style. Respond with the description only, no headings or commentary.\n\n%s", $summary ); $profile = $provider->generate( $prompt, array( 'max_tokens' => 500 ) ); if ( is_wp_error( $profile ) || empty( $profile ) ) { // Fall back to the raw summary for this run; don't cache it so the // AI profile is retried next time. return $summary; } $profile = sanitize_textarea_field( $profile ); update_post_meta( $campaign_id, '_aicw_autopilot_profile', $profile ); update_post_meta( $campaign_id, '_aicw_autopilot_profile_time', time() ); return $profile; } /** * Build a deterministic summary of the site's existing content. * * @param int $campaign_id The campaign ID. * @param array $recent_titles Recent published post titles. * * @since 2.4.5 * @return string */ protected static function build_site_summary( $campaign_id, $recent_titles ) { $parts = array(); $parts[] = sprintf( 'Site name: %s.', get_bloginfo( 'name' ) ); $tagline = get_bloginfo( 'description' ); if ( ! empty( $tagline ) ) { $parts[] = sprintf( 'Tagline: %s.', $tagline ); } $categories = self::get_top_terms( 'category' ); if ( ! empty( $categories ) ) { $parts[] = sprintf( 'Main categories: %s.', implode( ', ', $categories ) ); } $tags = self::get_top_terms( 'post_tag' ); if ( ! empty( $tags ) ) { $parts[] = sprintf( 'Popular tags: %s.', implode( ', ', $tags ) ); } $parts[] = "Recent article titles:\n- " . implode( "\n- ", $recent_titles ); return implode( "\n", $parts ); } /** * Get the titles of the most recent published posts of the campaign's * target post type. * * @param int $campaign_id The campaign ID. * * @since 2.4.5 * @return array Post titles, newest first. */ protected static function get_recent_titles( $campaign_id ) { $post_type = get_post_meta( $campaign_id, '_aicw_post_type', true ); $post_type = ! empty( $post_type ) ? $post_type : 'post'; $posts = get_posts( array( 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => self::SAMPLE_SIZE, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, ) ); $titles = array(); foreach ( $posts as $post ) { $title = trim( wp_strip_all_tags( $post->post_title ) ); if ( '' !== $title ) { $titles[] = $title; } } return $titles; } /** * Get the names of the most-used terms in a taxonomy. * * @param string $taxonomy The taxonomy name. * @param int $limit Maximum number of terms. * * @since 2.4.5 * @return array Term names. */ protected static function get_top_terms( $taxonomy, $limit = 10 ) { $terms = get_terms( array( 'taxonomy' => $taxonomy, 'orderby' => 'count', 'order' => 'DESC', 'number' => $limit, 'hide_empty' => true, ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array(); } return wp_list_pluck( $terms, 'name' ); } }