Dedup now has two narrow scopes instead of checking all posts ever: 1. Intra-batch: prevents proposing two articles on the same topic within a single pipeline run (keyword overlap ≥60%) 2. Recent posts: checks only posts from the last 7 days to avoid back-to-back similar content Older articles are intentionally NOT checked — revisiting a topic weeks later is fine. This prevents the dedup from becoming increasingly aggressive as the blog grows, which was the root cause of "no viable article ideas found" with 125 collected items. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
9.4 KiB
PHP
Executable File
290 lines
9.4 KiB
PHP
Executable File
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Ranks and deduplicates article ideas from analysis results.
|
|
*
|
|
* Two dedup scopes: (1) intra-batch — prevents proposing two articles on
|
|
* the same topic in one run; (2) recent posts — avoids back-to-back
|
|
* similar content within a configurable recency window (default 7 days).
|
|
* Older articles are intentionally NOT deduped against — revisiting a
|
|
* topic weeks later is fine.
|
|
*
|
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
|
|
* Dependencies: WordPress WP_Query.
|
|
*
|
|
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
|
|
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
|
* @see ARCHITECTURE.md — Data flow step 3.
|
|
*/
|
|
class PRAutoBlogger_Idea_Scorer {
|
|
|
|
/** @var bool Whether to skip the has_similar_post() check. */
|
|
private bool $skip_dedup = false;
|
|
|
|
/**
|
|
* @param bool $skip True to skip deduplication.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function set_skip_dedup( bool $skip ): self {
|
|
$this->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';
|
|
}
|
|
}
|