From 281c8cd89c2d03d03305e547e8c3a2f7fd96ca76 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Sun, 19 Apr 2026 13:35:26 +0700 Subject: [PATCH] feat: semantic dedup + LLM-aware topic avoidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 6 +- includes/core/class-analysis-prompts.php | 44 +++ includes/core/class-idea-scorer.php | 142 ++------- includes/core/class-semantic-dedup.php | 272 ++++++++++++++++++ .../class-open-router-embedding-provider.php | 179 ++++++++++++ 5 files changed, 530 insertions(+), 113 deletions(-) create mode 100644 includes/core/class-semantic-dedup.php create mode 100644 includes/providers/class-open-router-embedding-provider.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7cf2cd6..05fc9b4 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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) diff --git a/includes/core/class-analysis-prompts.php b/includes/core/class-analysis-prompts.php index b7f5cba..c0ef94f 100644 --- a/includes/core/class-analysis-prompts.php +++ b/includes/core/class-analysis-prompts.php @@ -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; + } } diff --git a/includes/core/class-idea-scorer.php b/includes/core/class-idea-scorer.php index 48b71f6..62dd62e 100755 --- a/includes/core/class-idea-scorer.php +++ b/includes/core/class-idea-scorer.php @@ -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 { diff --git a/includes/core/class-semantic-dedup.php b/includes/core/class-semantic-dedup.php new file mode 100644 index 0000000..3167fb3 --- /dev/null +++ b/includes/core/class-semantic-dedup.php @@ -0,0 +1,272 @@ +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 ) ); + } +} diff --git a/includes/providers/class-open-router-embedding-provider.php b/includes/providers/class-open-router-embedding-provider.php new file mode 100644 index 0000000..57d6b0f --- /dev/null +++ b/includes/providers/class-open-router-embedding-provider.php @@ -0,0 +1,179 @@ +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 ); + } +}