skip_dedup = $skip; return $this; } /** * Score, deduplicate, and rank article ideas. * * @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output. * @param int $target_count Number of ideas to return. * * @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score descending. */ public function score_and_rank( array $analysis_results, int $target_count ): array { $exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: []; $ideas = []; $excluded_count = 0; $deduped_count = 0; // Track keywords of already-accepted ideas for intra-batch dedup. $accepted_keyword_sets = []; foreach ( $analysis_results as $result ) { if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) { $excluded_count++; continue; } if ( ! $this->skip_dedup ) { $topic_keywords = $this->extract_meaningful_keywords( $result->get_topic() ); // Intra-batch: skip if too similar to an already-accepted idea. if ( $this->overlaps_any( $topic_keywords, $accepted_keyword_sets ) ) { $deduped_count++; PRAutoBlogger_Logger::instance()->info( sprintf( 'Dedup(batch): skipping "%s" — overlaps accepted idea.', substr( $result->get_topic(), 0, 80 ) ), 'scorer' ); continue; } // Recent posts: skip if ≥60% overlap with a post from the last 7 days. if ( $this->has_recent_similar_post( $topic_keywords ) ) { $deduped_count++; PRAutoBlogger_Logger::instance()->info( sprintf( 'Dedup(recent): skipping "%s" — similar post in last 7 days.', substr( $result->get_topic(), 0, 80 ) ), 'scorer' ); continue; } $accepted_keyword_sets[] = $topic_keywords; } $metadata = $result->get_metadata() ?? []; $ideas[] = new PRAutoBlogger_Article_Idea( [ 'topic' => $result->get_topic(), 'article_type' => $this->map_type( $result->get_analysis_type() ), 'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(), 'summary' => $result->get_summary() ?? '', 'score' => $this->compute_score( $result ), 'analysis_id' => $result->get_id(), 'source_ids' => $result->get_source_ids(), 'key_points' => $metadata['key_points'] ?? [], 'target_keywords' => $metadata['target_keywords'] ?? [], ] ); } PRAutoBlogger_Logger::instance()->info( sprintf( 'Scorer: %d input → %d viable, %d deduped, %d excluded. Returning top %d.', count( $analysis_results ), count( $ideas ), $deduped_count, $excluded_count, min( $target_count, count( $ideas ) ) ), 'scorer' ); usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int { return $b->get_score() <=> $a->get_score(); } ); return array_slice( $ideas, 0, $target_count ); } /** * Compute a composite score for an analysis result. * * Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%). * * @param PRAutoBlogger_Analysis_Result $result * * @return float Score between 0 and 1. */ private function compute_score( PRAutoBlogger_Analysis_Result $result ): float { $relevance = min( $result->get_relevance_score(), 1.0 ); $frequency = min( $result->get_frequency() / 10.0, 1.0 ); // Recency: analyzed within last 24h gets full bonus. $hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0; $recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) ); return ( $relevance * 0.5 ) + ( $frequency * 0.3 ) + ( $recency * 0.2 ); } /** * Check if a topic should be excluded based on user-configured exclusions. * * @param string $topic Topic text. * @param string[] $exclusions List of excluded terms/phrases. * * @return bool */ private function is_excluded( string $topic, array $exclusions ): bool { $topic_lower = strtolower( $topic ); foreach ( $exclusions as $exclusion ) { if ( false !== strpos( $topic_lower, strtolower( trim( $exclusion ) ) ) ) { return true; } } return false; } /** * Check if a keyword set overlaps ≥60% with any set in the given list. * * @param string[] $keywords Keywords to check. * @param array $keyword_sets List of previously accepted keyword arrays. * @return bool */ private function overlaps_any( array $keywords, array $keyword_sets ): bool { if ( count( $keywords ) < 2 ) { return false; } foreach ( $keyword_sets as $existing ) { $overlap = count( array_intersect( $keywords, $existing ) ); if ( $overlap / count( $keywords ) >= 0.6 ) { return true; } } return false; } /** * Check if a recently published post (last 7 days) is too similar. * * Only deduplicates against recent content — revisiting a topic * weeks later is intentionally allowed. The recency window keeps * the dedup scope narrow so the blog can grow without increasingly * rejecting all topics. * * @param string[] $topic_keywords Pre-extracted keywords from the topic. * @return bool True if a recent post has ≥60% keyword overlap. */ private function has_recent_similar_post( array $topic_keywords ): bool { if ( count( $topic_keywords ) < 2 ) { return false; } static $recent_titles = null; if ( null === $recent_titles ) { $recent_titles = $this->fetch_recent_post_titles(); } foreach ( $recent_titles as $title ) { $title_keywords = $this->extract_meaningful_keywords( $title ); $overlap = count( array_intersect( $topic_keywords, $title_keywords ) ); if ( $overlap / count( $topic_keywords ) >= 0.6 ) { return true; } } return false; } /** * Fetch titles of generated posts from the last 7 days only. * * @return string[] */ private function fetch_recent_post_titles(): array { $query = new \WP_Query( [ 'post_type' => 'post', 'post_status' => [ 'publish', 'draft', 'pending' ], 'meta_key' => '_prautoblogger_generated', 'meta_value' => '1', 'date_query' => [ [ 'after' => '7 days ago' ], ], 'posts_per_page' => 50, 'fields' => 'ids', 'no_found_rows' => true, ] ); $titles = []; foreach ( $query->posts as $post_id ) { $titles[] = get_the_title( $post_id ); } return $titles; } /** * Extract meaningful keywords from a text string. * * Strips stopwords and short filler words so dedup focuses on * substantive terms. Returns lowercase unique keywords. * * @param string $text Input text (topic or title). * @return string[] Unique lowercase keywords. */ private function extract_meaningful_keywords( string $text ): array { // Common stopwords that add no topical signal. static $stopwords = [ 'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as', 'it', 'its', 'this', 'that', 'your', 'you', 'how', 'what', 'when', 'why', 'where', 'which', 'who', 'do', 'does', 'did', 'not', 'no', 'can', 'will', 'should', 'could', 'would', 'may', 'might', 'have', 'has', 'had', 'be', 'been', 'being', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'same', 'up', 'down', 'out', 'off', 'over', 'under', 'again', 'then', 'here', 'there', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'than', 'too', 'very', 'just', 'also', 'only', 'own', 'so', 'if', 'while', 'because', 'until', 'vs', 'versus', 'guide', 'complete', 'ultimate', 'best', 'top', 'new', 'first', 'need', 'know', 'everything', ]; $text = strtolower( $text ); // Preserve compound identifiers like "BPC-157" by treating hyphens // between alphanumerics as part of the word. $words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY ); $words = array_filter( $words, static function ( string $w ) use ( $stopwords ): bool { return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true ); } ); return array_values( array_unique( $words ) ); } /** * Map analysis type to a reader-friendly article type. * * @param string $analysis_type 'question', 'complaint', 'comparison'. * * @return string Article type label. */ private function map_type( string $analysis_type ): string { $map = [ 'question' => 'guide', 'complaint' => 'solution', 'comparison' => 'comparison', 'news' => 'news', 'guide' => 'guide', ]; return $map[ $analysis_type ] ?? 'article'; } }