Files
prautoblogger/includes/core/class-semantic-dedup.php
peptiderepo 281c8cd89c feat: semantic dedup + LLM-aware topic avoidance
Two-pronged fix for repeated article topics:

1. Feed last 30 days of published article titles into the LLM analysis
   prompt with explicit "avoid these" instruction. Zero cost — just
   extra context in an existing API call.

2. Replace keyword-overlap dedup (60% word match) with embedding-based
   cosine similarity via sentence-transformers/all-MiniLM-L12-v2 on
   OpenRouter. Catches rephrasings that keyword matching misses
   (e.g. "BPC-157 dosing" vs "how much BPC-157 to take"). Falls back
   to keyword matching if the embedding API is unavailable.

   Cost: ~$0.00001 per generation run ($0.02/M tokens, ~500 tokens/run).

New files:
- class-semantic-dedup.php — Embedding cosine similarity + keyword fallback
- class-open-router-embedding-provider.php — OpenRouter /embeddings client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 13:35:26 +07:00

273 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
/**
* Semantic deduplication using embedding cosine similarity.
*
* Embeds candidate topics and recent post titles in a single batch API call,
* then compares via cosine similarity. Falls back to keyword-overlap matching
* if the embedding API is unavailable or fails.
*
* Triggered by: PRAutoBlogger_Idea_Scorer::score_and_rank().
* Dependencies: OpenRouter_Embedding_Provider, WP_Query.
*
* @see core/class-idea-scorer.php — Consumer.
* @see providers/class-open-router-embedding-provider.php — Embedding API.
*/
class PRAutoBlogger_Semantic_Dedup {
/**
* Cosine similarity threshold for "too similar". MiniLM embeddings treat
* ~0.7+ as semantically equivalent for short texts. We use 0.72 to avoid
* over-aggressive blocking while still catching rephrasings.
*/
private const SIMILARITY_THRESHOLD = 0.72;
/** @var float[][] Cached embeddings for recent post titles, keyed by index. */
private array $title_embeddings = [];
/** @var string[] Recent post titles. */
private array $recent_titles = [];
/** @var float[][] Cached embeddings for accepted ideas, accumulated per-batch. */
private array $accepted_embeddings = [];
/** @var bool Whether embeddings are available (false after API failure). */
private bool $embeddings_available = true;
/** @var PRAutoBlogger_OpenRouter_Embedding_Provider|null Reused across calls. */
private ?PRAutoBlogger_OpenRouter_Embedding_Provider $provider = null;
/**
* Initialize by fetching recent titles and pre-computing their embeddings.
*
* Called once at the start of scoring. If the embedding API fails,
* sets a flag so all subsequent checks fall back to keyword matching.
*
* Side effects: HTTP request to OpenRouter embedding API, WP_Query.
*/
public function initialize(): void {
$this->recent_titles = $this->fetch_recent_post_titles();
if ( empty( $this->recent_titles ) ) {
return;
}
try {
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
$this->title_embeddings = $this->provider->get_embeddings( $this->recent_titles );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
'Semantic dedup unavailable, falling back to keyword matching: ' . $e->getMessage(),
'scorer'
);
$this->embeddings_available = false;
}
}
/**
* Check if a topic is too similar to any already-accepted idea in this batch.
*
* @param string $topic Candidate topic text.
* @param string[] $topic_keywords Pre-extracted keywords (for fallback).
* @param array $keyword_sets Accepted keyword sets (for fallback).
*
* @return bool True if the topic should be skipped.
*/
public function is_batch_duplicate( string $topic, array $topic_keywords, array $keyword_sets ): bool {
if ( ! $this->embeddings_available || empty( $this->accepted_embeddings ) ) {
return $this->keyword_overlaps_any( $topic_keywords, $keyword_sets );
}
$embedding = $this->embed_single( $topic );
if ( null === $embedding ) {
return $this->keyword_overlaps_any( $topic_keywords, $keyword_sets );
}
foreach ( $this->accepted_embeddings as $accepted ) {
$sim = PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $embedding, $accepted );
if ( $sim >= self::SIMILARITY_THRESHOLD ) {
return true;
}
}
return false;
}
/**
* Check if a topic is too similar to any recently published post.
*
* @param string $topic Candidate topic text.
* @param string[] $topic_keywords Pre-extracted keywords (for fallback).
*
* @return bool True if a recent post is semantically too similar.
*/
public function is_recent_duplicate( string $topic, array $topic_keywords ): bool {
if ( ! $this->embeddings_available || empty( $this->title_embeddings ) ) {
return $this->keyword_matches_recent( $topic_keywords );
}
$embedding = $this->embed_single( $topic );
if ( null === $embedding ) {
return $this->keyword_matches_recent( $topic_keywords );
}
foreach ( $this->title_embeddings as $i => $title_emb ) {
$sim = PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $embedding, $title_emb );
if ( $sim >= self::SIMILARITY_THRESHOLD ) {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Semantic match (%.2f): "%s" ≈ "%s"',
$sim,
substr( $topic, 0, 60 ),
substr( $this->recent_titles[ $i ] ?? '', 0, 60 )
),
'scorer'
);
return true;
}
}
return false;
}
/**
* Record an accepted idea's embedding for intra-batch dedup.
*
* @param string $topic The accepted topic text.
*/
public function record_accepted( string $topic ): void {
if ( ! $this->embeddings_available ) {
return;
}
$embedding = $this->embed_single( $topic );
if ( null !== $embedding ) {
$this->accepted_embeddings[] = $embedding;
}
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Embed a single text, with error handling.
*
* @param string $text Text to embed.
* @return float[]|null Embedding vector, or null on failure.
*/
private function embed_single( string $text ): ?array {
try {
if ( null === $this->provider ) {
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
}
$result = $this->provider->get_embeddings( [ $text ] );
return $result[0] ?? null;
} catch ( \Throwable $e ) {
// Degrade gracefully — one failed embed shouldn't kill the pipeline.
$this->embeddings_available = false;
PRAutoBlogger_Logger::instance()->warning(
'Embedding failed, disabling semantic dedup for this run: ' . $e->getMessage(),
'scorer'
);
return null;
}
}
/**
* Keyword-overlap fallback for intra-batch dedup.
*
* @param string[] $keywords Keywords to check.
* @param array $keyword_sets List of previously accepted keyword arrays.
* @return bool
*/
private function keyword_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;
}
/**
* Keyword-overlap fallback for recent-post dedup.
*
* @param string[] $topic_keywords Pre-extracted keywords.
* @return bool
*/
private function keyword_matches_recent( array $topic_keywords ): bool {
if ( count( $topic_keywords ) < 2 ) {
return false;
}
foreach ( $this->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 30 days.
*
* Widened from 7 days (the old keyword window) because semantic similarity
* catches rephrasings that keywords miss — a wider net is safe here.
*
* @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' => '30 days ago' ] ],
'posts_per_page' => 100,
'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 text (fallback dedup).
*
* @param string $text Input text.
* @return string[] Unique lowercase keywords.
*/
private function extract_meaningful_keywords( string $text ): array {
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 );
$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 ) );
}
}