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>
This commit is contained in:
peptiderepo
2026-04-19 13:35:26 +07:00
parent 00cfb39253
commit 281c8cd89c
5 changed files with 530 additions and 113 deletions

View File

@@ -128,7 +128,8 @@ prautoblogger/
│ │ ├── class-source-collector.php # Orchestrates data collection from all sources
│ │ ├── class-content-analyzer.php # LLM-powered analysis of collected social data
│ │ ├── class-analysis-prompts.php # System/user prompt builders + performance context for analyzer
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas (delegates to Semantic_Dedup)
│ │ ├── class-semantic-dedup.php # Embedding-based cosine similarity dedup with keyword fallback
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
@@ -145,7 +146,8 @@ prautoblogger/
│ │
│ ├── providers/
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
│ │ ├── class-open-router-provider.php # OpenRouter API implementation
│ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
│ │ ├── interface-source-provider.php # Contract for any social media source
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)

View File

@@ -56,6 +56,11 @@ class PRAutoBlogger_Analysis_Prompts {
$prompt .= $performance_context . "\n\n";
}
$recent_context = self::get_recent_articles_context();
if ( '' !== $recent_context ) {
$prompt .= $recent_context . "\n\n";
}
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
@@ -112,4 +117,43 @@ class PRAutoBlogger_Analysis_Prompts {
return implode( "\n", $lines );
}
/**
* Get titles of recently published articles so the LLM avoids repeating them.
*
* Fetches generated posts from the last 30 days. A wider window than the
* scorer's 7-day dedup because the LLM is smart enough to find genuinely
* new angles even on adjacent topics — we just need it to know what exists.
*
* Side effects: database read via WP_Query.
*
* @return string Formatted context block, or empty string if no recent articles.
*/
public static function get_recent_articles_context(): string {
$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,
] );
if ( empty( $query->posts ) ) {
return '';
}
$titles = [];
foreach ( $query->posts as $post_id ) {
$titles[] = '- "' . get_the_title( $post_id ) . '"';
}
$block = "IMPORTANT — Topics already covered (last 30 days). Do NOT suggest topics that overlap with these. ";
$block .= "Find genuinely NEW angles, questions, or subjects that are NOT already covered:\n";
$block .= implode( "\n", $titles );
return $block;
}
}

View File

@@ -4,27 +4,25 @@ 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.
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
* cosine similarity with automatic fallback to keyword overlap if the embedding
* API is unavailable.
*
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
* Dependencies: WordPress WP_Query.
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
* Dependencies: Semantic_Dedup, WP_Query.
*
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
* @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. */
/** @var bool Whether to skip deduplication entirely. */
private bool $skip_dedup = false;
/**
* @param bool $skip True to skip deduplication.
*
* @return $this
*/
public function set_skip_dedup( bool $skip ): self {
@@ -35,10 +33,15 @@ class PRAutoBlogger_Idea_Scorer {
/**
* Score, deduplicate, and rank article ideas.
*
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
* @param int $target_count Number of ideas to return.
* Initializes the semantic dedup engine once, then checks each candidate
* against (1) intra-batch duplicates and (2) recently published posts.
*
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score descending.
* Side effects: embedding API calls (via Semantic_Dedup), WP_Query.
*
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
* @param int $target_count Max ideas to return.
*
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
*/
public function score_and_rank( array $analysis_results, int $target_count ): array {
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
@@ -46,7 +49,12 @@ class PRAutoBlogger_Idea_Scorer {
$excluded_count = 0;
$deduped_count = 0;
// Track keywords of already-accepted ideas for intra-batch dedup.
$dedup = new PRAutoBlogger_Semantic_Dedup();
if ( ! $this->skip_dedup ) {
$dedup->initialize();
}
// Keyword sets kept for fallback (passed through to Semantic_Dedup).
$accepted_keyword_sets = [];
foreach ( $analysis_results as $result ) {
@@ -56,29 +64,29 @@ class PRAutoBlogger_Idea_Scorer {
}
if ( ! $this->skip_dedup ) {
$topic_keywords = $this->extract_meaningful_keywords( $result->get_topic() );
$topic = $result->get_topic();
$topic_keywords = $this->extract_meaningful_keywords( $topic );
// Intra-batch: skip if too similar to an already-accepted idea.
if ( $this->overlaps_any( $topic_keywords, $accepted_keyword_sets ) ) {
if ( $dedup->is_batch_duplicate( $topic, $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 ) ),
sprintf( 'Dedup(batch): skipping "%s"', substr( $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 ) ) {
if ( $dedup->is_recent_duplicate( $topic, $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 ) ),
sprintf( 'Dedup(recent): skipping "%s"', substr( $topic, 0, 80 ) ),
'scorer'
);
continue;
}
$accepted_keyword_sets[] = $topic_keywords;
$dedup->record_accepted( $topic );
}
$metadata = $result->get_metadata() ?? [];
@@ -121,14 +129,12 @@ class PRAutoBlogger_Idea_Scorer {
* 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 ) );
@@ -140,7 +146,6 @@ class PRAutoBlogger_Idea_Scorer {
*
* @param string $topic Topic text.
* @param string[] $exclusions List of excluded terms/phrases.
*
* @return bool
*/
private function is_excluded( string $topic, array $exclusions ): bool {
@@ -154,94 +159,12 @@ class PRAutoBlogger_Idea_Scorer {
}
/**
* Check if a keyword set overlaps ≥60% with any set in the given list.
* Extract meaningful keywords from a text string (for fallback dedup).
*
* @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).
* @param string $text Input text.
* @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',
@@ -259,8 +182,6 @@ class PRAutoBlogger_Idea_Scorer {
];
$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 );
@@ -272,8 +193,7 @@ class PRAutoBlogger_Idea_Scorer {
/**
* Map analysis type to a reader-friendly article type.
*
* @param string $analysis_type 'question', 'complaint', 'comparison'.
*
* @param string $analysis_type 'question', 'complaint', 'comparison', etc.
* @return string Article type label.
*/
private function map_type( string $analysis_type ): string {

View File

@@ -0,0 +1,272 @@
<?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 ) );
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* Fetches text embeddings from OpenRouter for semantic similarity comparisons.
*
* Used by the Idea Scorer to detect semantically similar topics that keyword
* matching would miss (e.g. "BPC-157 dosing" vs "how much BPC-157 to take").
* OpenRouter exposes an OpenAI-compatible /embeddings endpoint.
*
* Triggered by: PRAutoBlogger_Idea_Scorer (semantic dedup check).
* Dependencies: OpenRouter API key, OpenRouter_Config, OpenRouter_Request_Builder.
*
* @see core/class-idea-scorer.php — Consumes embeddings for dedup.
* @see providers/class-open-router-config.php — API base URL resolution.
* @see providers/class-open-router-request-builder.php — Header + cURL auth.
*/
class PRAutoBlogger_OpenRouter_Embedding_Provider {
/** Default embedding model — small, fast, nearly free ($0.02/M tokens). */
private const DEFAULT_MODEL = 'sentence-transformers/all-minilm-l12-v2';
/** Embedding dimension for MiniLM-L12-v2. Used for validation. */
private const EXPECTED_DIMENSIONS = 384;
/**
* Generate embeddings for one or more text inputs in a single API call.
*
* Batches all inputs into one request to minimize HTTP round-trips.
* Returns one embedding vector per input, in the same order.
*
* Side effects: HTTP request to OpenRouter API.
*
* @param string[] $texts Array of text strings to embed.
* @param string $model Optional model override.
*
* @return float[][] Array of embedding vectors, one per input text.
* Each vector is an array of floats (384 dimensions for MiniLM).
*
* @throws \RuntimeException On API error or malformed response.
*/
public function get_embeddings( array $texts, string $model = '' ): array {
if ( empty( $texts ) ) {
return [];
}
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
throw new \RuntimeException(
__( 'OpenRouter API key is not configured.', 'prautoblogger' )
);
}
$model = '' !== $model ? $model : self::DEFAULT_MODEL;
$config = new PRAutoBlogger_OpenRouter_Config();
$base_url = $config->get_api_base_url();
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
$request_headers = $builder->build_headers( $api_key, $config->is_via_gateway(), 0 );
$curl_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
try {
$response = wp_remote_post(
$base_url . '/embeddings',
[
'timeout' => 30,
'headers' => $request_headers,
'body' => wp_json_encode( [
'model' => $model,
'input' => array_values( $texts ),
] ),
]
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf( 'Embedding request failed: %s', $response->get_error_message() )
);
}
$status = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
$msg = $body['error']['message'] ?? wp_remote_retrieve_body( $response );
throw new \RuntimeException(
sprintf( 'Embedding API error (HTTP %d): %s', $status, $msg )
);
}
return $this->parse_response( $body, count( $texts ) );
} finally {
remove_action( 'http_api_curl', $curl_filter, 99 );
}
}
/**
* Compute cosine similarity between two embedding vectors.
*
* Returns a value between -1 and 1. Values above ~0.7 indicate
* high semantic similarity for MiniLM embeddings.
*
* @param float[] $a First embedding vector.
* @param float[] $b Second embedding vector.
*
* @return float Cosine similarity score.
*/
public static function cosine_similarity( array $a, array $b ): float {
$dot = 0.0;
$norm_a = 0.0;
$norm_b = 0.0;
$len = min( count( $a ), count( $b ) );
for ( $i = 0; $i < $len; $i++ ) {
$dot += $a[ $i ] * $b[ $i ];
$norm_a += $a[ $i ] * $a[ $i ];
$norm_b += $b[ $i ] * $b[ $i ];
}
$denom = sqrt( $norm_a ) * sqrt( $norm_b );
if ( $denom < 1e-10 ) {
return 0.0;
}
return $dot / $denom;
}
/**
* Parse the OpenAI-compatible embeddings response.
*
* @param array|null $body Decoded JSON response body.
* @param int $expected_count Number of embeddings expected.
*
* @return float[][] Embedding vectors sorted by input order.
*
* @throws \RuntimeException On malformed response.
*/
private function parse_response( ?array $body, int $expected_count ): array {
if ( ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
throw new \RuntimeException( 'Embedding response missing "data" field.' );
}
if ( count( $body['data'] ) !== $expected_count ) {
throw new \RuntimeException(
sprintf( 'Expected %d embeddings, got %d.', $expected_count, count( $body['data'] ) )
);
}
// Sort by index to match input order (API may return out of order).
usort( $body['data'], static function ( array $a, array $b ): int {
return ( $a['index'] ?? 0 ) <=> ( $b['index'] ?? 0 );
} );
$embeddings = [];
foreach ( $body['data'] as $item ) {
if ( ! isset( $item['embedding'] ) || ! is_array( $item['embedding'] ) ) {
throw new \RuntimeException( 'Embedding item missing "embedding" array.' );
}
$embeddings[] = array_map( 'floatval', $item['embedding'] );
}
return $embeddings;
}
/**
* Get the decrypted API key from options.
*
* @return string Decrypted API key, or empty string if not set.
*/
private function get_api_key(): string {
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $encrypted ) {
return '';
}
return PRAutoBlogger_Encryption::decrypt( $encrypted );
}
}