diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php new file mode 100644 index 0000000..14c0f56 --- /dev/null +++ b/includes/class-peptide-news-llm.php @@ -0,0 +1,305 @@ + '', 'summary' => '' ); + } + + $results = array( + 'keywords' => '', + 'summary' => '', + ); + + $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + + // --- Keyword extraction --- + $keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); + $need_keywords = $force || empty( $article->tags ); + + if ( $need_keywords ) { + $keywords_prompt = self::build_keywords_prompt( $article ); + $keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt ); + + if ( ! is_wp_error( $keywords_result ) ) { + $results['keywords'] = self::sanitize_keywords( $keywords_result ); + } else { + self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() ); + } + } + + // --- Summarization --- + $summary_model = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); + $need_summary = $force || empty( $article->ai_summary ); + + if ( $need_summary ) { + $summary_prompt = self::build_summary_prompt( $article ); + $summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt ); + + if ( ! is_wp_error( $summary_result ) ) { + $results['summary'] = sanitize_textarea_field( $summary_result ); + } else { + self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() ); + } + } + + // --- Persist results --- + if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { + self::save_results( $article->id, $results, $force ); + } + + return $results; + } + + /** + * Process all unanalyzed articles (called after a fetch cycle). + * + * @param int $batch_size Max articles to process per run (avoids timeout). + * @return int Number of articles processed. + */ + public static function process_unanalyzed( $batch_size = 10 ) { + if ( ! self::is_enabled() ) { + return 0; + } + + global $wpdb; + $table = $wpdb->prefix / 'peptide_news_articles'; + + // Find articles missing tags OR ai_summary. + $articles = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title, excerpt, content, categories, tags, ai_summary + FROM {$table} + WHERE is_active = 1 + AND ( tags = '' OR ai_summary = '' OR ai_summary IS NULL ) + ORDER BY fetched_at DESC + LIMIT %d", + $batch_size + ) ); + + $processed = 0; + foreach ( $articles as $article ) { + self::process_article( $article ); + $processed++; + + // Small delay to avoid rate limiting. + if ( $processed < count( $articles ) ) { + usleep( 500000 ); // 0.5s between calls. + } + } + + // Log the processing result. + update_option( 'peptide_news_last_llm_process', array( + 'time' => current_time( 'mysql' ), + 'processed' => $processed, + ) ); + + return $processed; + } + + /** + * Build the prompt for keyword extraction. + * + * @param object $article + * @return string + */ + private static function build_keywords_prompt( $article ) { + $text = $article->title; + if ( ! empty( $article->excerpt ) ) { + $text .= "\n\n" . $article->excerpt; + } + if ( ! empty( $article->content ) ) { + // Limit content to ~2000 chars to keep token usage reasonable. + $text .= "\n\n" . mb_substr( wp_strip_all_tags( $article->content ), 0, 2000 ); + } + + return "Extract 5-10 relevant keywords or key phrases from this peptide research article. " + . "Return ONLY a comma-separated list of keywords, nothing else. No numbering, no explanations.\n\n" + . "Article:\n" . $text; + } + + /** + * Build the prompt for article summarization. + * + * @param object $article + * @return string + */ + private static function build_summary_prompt( $article ) { + $text = $article->title; + if ( ! empty( $article->excerpt ) ) { + $text .= "\n\n" . $article->excerpt; + } + if ( ! empty( $article->content ) ) { + $text .= "\n\n" . mb_substr( wp_strip_all_tags( $article->content ), 0, 3000 ); + } + + return "Summarize this peptide research article in 3-4 sentences. " + . "Be concise, factual, and accessible to a general audience interested in peptide science. " + . "Do not include any preamble or labels — just the summary text.\n\n" + . "Article:\n" . $text; + } + + /** + * Call the OpenRouter API. + * + * @param string $api_key + * @param string $model + * @param string $prompt + * @return string|WP_Error The response text or an error. + */ + private static function call_openrouter( $api_key, $model, $prompt ) { + $body = array( + 'model' => $model, + 'messages' => array( + array( + 'role' => 'user', + 'content' => $prompt, + ), + ), + 'max_tokens' => 300, + 'temperature' => 0.3, + ); + + $response = wp_remote_post( self::API_URL, array( + 'timeout' => 60, + 'headers' => array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'Peptide News Aggregator', + ), + 'body' => wp_json_encode( $body ), + ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status = wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status < 200 || $status >= 300 ) { + $error_msg = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $status; + return new WP_Error( 'openrouter_error', $error_msg ); + } + + if ( empty( $data['choices'][0]['message']['content'] ) ) { + return new WP_Error( 'openrouter_empty', 'Empty response from OpenRouter' ); + } + + return trim( $data['choices'[0]['message']['content'] ); + } + + /** + * Sanitize and normalize keyword output from the LLM. + * + * @param string $raw Raw comma-separated keywords. + * @return string Cleaned, deduplicated, comma-separated list. + */ + private static function sanitize_keywords( $raw ) { + // Remove any numbering, bullets, or extra formatting. + $raw = preg_replace( '/^\d+[\.\)]\s*/m', '', $raw ); + $raw = preg_replace( '/^[-*•]\s*/m', '', $raw ); + + $keywords = array_map( 'trim', explode( ',', $raw ) ); + $keywords = array_map( 'sanitize_text_field', $keywords ); + $keywords = array_filter( $keywords ); + $keywords = array_unique( $keywords ); + + // Limit to 10 keywords max. + $keywords = array_slice( $keywords, 0, 10 ); + + return implode( ', ', $keywords ); + } + + /** + * Save LLM results to the database. + * + * @param int $article_id + * @param array $results Array with 'keywords' and 'summary'. + * @param bool $force Overwrite existing values. + */ + private static function save_results( $article_id, $results, $force = false ) { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + $update = array(); + $format = array(); + + if ( ! empty( $results['keywords'] ) ) { + $update['tags'] = $results['keywords']; + $format[] = '%s'; + } + + if ( ! empty( $results['summary'] ) ) { + $update['ai_summary'] = $results['summary']; + $format[] = '%s'; + } + + if ( ! empty( $update ) ) { + $wpdb->update( + $table, + $update, + array( 'id' => $article_id ), + $format, + array( '%d' ) + ); + + // Clear the article cache so fresh data shows on the front end. + self::clear_article_cache(); + } + } + + /** + * Clear all article transient caches. + */ + private static function clear_article_cache() { + global $wpdb; + $wpdb->query( + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE '_transient_peptide_news_articles_%' + OR option_name LIKE '_transient_timeout_peptide_news_articles_%'" + ); + } + + /** + * Log an error to the WordPress debug log. + * + * @param string $message + */ + private static function log_error( $message ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( '[Peptide News LLM] ' . $message ); + } + } +}