diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index 14c0f56..8503685 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -3,8 +3,7 @@ * LLM integration via OpenRouter for article analysis. * * Provides keyword extraction and article summarization - - aO FKGetis configurable models through the OpenRouter API. + * using configurable models through the OpenRouter API. * * @since 1.1.0 */ @@ -13,6 +12,12 @@ class Peptide_News_LLM { /** @var string OpenRouter API endpoint. */ const API_URL = 'https://openrouter.ai/api/v1/chat/completions'; + /** @var int Maximum retries for rate-limited requests. */ + const MAX_RETRIES = 3; + + /** @var int Maximum elapsed seconds before stopping a batch. */ + const BATCH_TIMEOUT = 120; + /** * Check whether LLM processing is enabled and configured. * @@ -20,7 +25,19 @@ class Peptide_News_LLM { */ public static function is_enabled() { $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); - return ! empty( $api_key ) && get_option( 'peptide_news_llm_enabled', 0 ); + return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 ); + } + + /** + * Validate an OpenRouter model ID format. + * + * Accepts patterns like: provider/model-name, provider/model-name:version + * + * @param string $model + * @return bool + */ + public static function is_valid_model( $model ) { + return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model ); } /** @@ -30,50 +47,61 @@ class Peptide_News_LLM { * * @param object $article Database row with id, title, excerpt, content, categories. * @param bool $force Re-process even if already analyzed. - * @return array Results with 'keywords' and 'summary' keys. + * @return array Results with 'keywords', 'summary', and 'success' keys. */ public static function process_article( $article, $force = false ) { if ( ! self::is_enabled() ) { - return array( 'keywords' => '', 'summary' => '' ); + return array( 'keywords' => '', 'summary' => '', 'success' => false ); } $results = array( 'keywords' => '', 'summary' => '', + 'success' => false, ); $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + $any_success = false; // --- 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 ) { + if ( $need_keywords && self::is_valid_model( $keywords_model ) ) { $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 ); + $any_success = true; } else { self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() ); } + } elseif ( $need_keywords ) { + self::log_error( 'Invalid keywords model ID: ' . $keywords_model ); } // --- 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 ) { + if ( $need_summary && self::is_valid_model( $summary_model ) ) { $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 ); + // Strip all HTML tags from LLM output to prevent XSS. + $results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) ); + $any_success = true; } else { self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() ); } + } elseif ( $need_summary ) { + self::log_error( 'Invalid summary model ID: ' . $summary_model ); } + $results['success'] = $any_success; + // --- Persist results --- if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { self::save_results( $article->id, $results, $force ); @@ -85,16 +113,26 @@ class Peptide_News_LLM { /** * 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. + * Includes a time-based guard to prevent cron timeouts and + * respects the admin-configured max articles per cycle. + * + * @param int $batch_size Max articles to process per run. + * @return int Number of articles successfully processed. */ public static function process_unanalyzed( $batch_size = 10 ) { if ( ! self::is_enabled() ) { return 0; } + // Respect admin cost-control setting. + $max_per_cycle = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); + if ( $max_per_cycle < 1 ) { + $max_per_cycle = 10; + } + $batch_size = min( $batch_size, $max_per_cycle ); + global $wpdb; - $table = $wpdb->prefix / 'peptide_news_articles'; + $table = $wpdb->prefix . 'peptide_news_articles'; // Find articles missing tags OR ai_summary. $articles = $wpdb->get_results( $wpdb->prepare( @@ -108,20 +146,38 @@ class Peptide_News_LLM { ) ); $processed = 0; + $start_time = time(); + foreach ( $articles as $article ) { - self::process_article( $article ); - $processed++; + // Timeout guard: stop if approaching the limit. + if ( ( time() - $start_time ) >= self::BATCH_TIMEOUT ) { + self::log_error( 'Batch timeout reached after ' . $processed . ' articles. Remaining will process next cycle.' ); + break; + } + + $result = self::process_article( $article ); + + // Only count successful processing. + if ( ! empty( $result['success'] ) ) { + $processed++; + } // Small delay to avoid rate limiting. - if ( $processed < count( $articles ) ) { - usleep( 500000 ); // 0.5s between calls. + if ( next( $articles ) !== false ) { + usleep( 500000 ); // 0.5s between articles. } } + // Clear article cache once per batch (not per article). + if ( $processed > 0 ) { + self::clear_article_cache(); + } + // Log the processing result. update_option( 'peptide_news_last_llm_process', array( 'time' => current_time( 'mysql' ), 'processed' => $processed, + 'attempted' => count( $articles ), ) ); return $processed; @@ -170,7 +226,7 @@ class Peptide_News_LLM { } /** - * Call the OpenRouter API. + * Call the OpenRouter API with retry logic for 429 rate limits. * * @param string $api_key * @param string $model @@ -190,46 +246,80 @@ class Peptide_News_LLM { '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 ), - ) ); + $retries = 0; + $backoff = 2; // Initial backoff in seconds. - if ( is_wp_error( $response ) ) { - return $response; + while ( $retries <= self::MAX_RETRIES ) { + $response = wp_remote_post( self::API_URL, array( + 'timeout' => 30, + '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 ); + + // Handle rate limiting with exponential backoff. + if ( 429 === $status && $retries < self::MAX_RETRIES ) { + $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); + $wait = $retry_after ? min( (int) $retry_after, 30 ) : $backoff; + sleep( $wait ); + $retries++; + $backoff *= 2; + continue; + } + + break; } - $status = wp_remote_retrieve_response_code( $response ); - $data = json_decode( wp_remote_retrieve_body( $response ), true ); + $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; + $error_msg = 'HTTP ' . $status; + if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) { + $error_msg = $data['error']['message']; + } 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' ); + // Validate the response structure thoroughly. + if ( ! is_array( $data ) ) { + return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); + } + if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) { + return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); + } + if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) { + return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); + } + if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) { + return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); } - return trim( $data['choices'[0]['message']['content'] ); + return trim( $data['choices'][0]['message']['content'] ); } /** * Sanitize and normalize keyword output from the LLM. * - * @param string $raw Raw comma-separated keywords. + * @param string $raw Raw comma-separated keywords. * @return string Cleaned, deduplicated, comma-separated list. */ private static function sanitize_keywords( $raw ) { + // Strip all HTML first to prevent XSS. + $raw = wp_strip_all_tags( $raw ); + // Remove any numbering, bullets, or extra formatting. $raw = preg_replace( '/^\d+[\.\)]\s*/m', '', $raw ); - $raw = preg_replace( '/^[-*•]\s*/m', '', $raw ); + $raw = preg_replace( '/^[-*\x{2022}]\s*/mu', '', $raw ); $keywords = array_map( 'trim', explode( ',', $raw ) ); $keywords = array_map( 'sanitize_text_field', $keywords ); @@ -245,6 +335,9 @@ class Peptide_News_LLM { /** * Save LLM results to the database. * + * Note: Cache is NOT cleared here — the caller (process_unanalyzed) + * clears cache once per batch for efficiency. + * * @param int $article_id * @param array $results Array with 'keywords' and 'summary'. * @param bool $force Overwrite existing values. @@ -274,9 +367,6 @@ class Peptide_News_LLM { $format, array( '%d' ) ); - - // Clear the article cache so fresh data shows on the front end. - self::clear_article_cache(); } }