From ab56ec7e97e29c273840975d300429a28c1e908e Mon Sep 17 00:00:00 2001 From: Terence Ong Date: Tue, 7 Apr 2026 14:59:18 +0700 Subject: [PATCH] feat: add bulk AI summary generation button to admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Generate AI Summaries" button to the Peptide News settings page that processes all articles missing summaries in batches of 50. The UI loops automatically until every article has been summarized, showing real-time progress. The admin batch overrides the per-cycle limit so it isn't capped at 10 — the existing 120-second timeout guard still applies. Co-Authored-By: Claude Opus 4.6 --- admin/class-peptide-news-admin.php | 51 ++ includes/class-peptide-news-llm.php | 79 +- includes/class-peptide-news.php | 1 + .../admin/class-peptide-news-admin.php | 688 +++++++++++++++++- .../includes/class-peptide-news-llm.php | 460 ++++++++++++ .../includes/class-peptide-news.php | 133 +++- 6 files changed, 1354 insertions(+), 58 deletions(-) create mode 100644 peptide-news-plugin/includes/class-peptide-news-llm.php diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php index 177dbd8..7ccffa8 100644 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -568,6 +568,13 @@ class Peptide_News_Admin { echo ''; echo ''; + // Generate AI Summaries button. + echo '
'; + echo ''; + echo ''; + ?> prefix . 'peptide_news_articles'; @@ -370,6 +374,63 @@ class Peptide_News_LLM { } } + /** + * AJAX handler: bulk-generate AI summaries for articles that are missing them. + * + * Processes up to 50 articles per request. Returns the count of articles + * processed and remaining so the admin UI can loop until done. + * + * @since 2.0.1 + */ + public static function ajax_generate_summaries() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + if ( ! self::is_enabled() ) { + wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' ); + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + // Count how many still need processing. + $remaining = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} + WHERE is_active = 1 + AND ( ai_summary = '' OR ai_summary IS NULL )" + ); + + if ( $remaining < 1 ) { + wp_send_json_success( array( + 'processed' => 0, + 'remaining' => 0, + 'message' => 'All articles already have AI summaries.', + ) ); + } + + // Process a generous batch — the 120-second timeout guard inside + // process_unanalyzed() will stop early if needed. Override the + // per-cycle limit since this is a manual admin action. + $batch_size = min( 50, $remaining ); + $processed = self::process_unanalyzed( $batch_size, true ); + + // Recount remaining after this batch. + $still_remaining = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} + WHERE is_active = 1 + AND ( ai_summary = '' OR ai_summary IS NULL )" + ); + + wp_send_json_success( array( + 'processed' => $processed, + 'remaining' => $still_remaining, + 'message' => sprintf( '%d article(s) summarized, %d remaining.', $processed, $still_remaining ), + ) ); + } + /** * Clear all article transient caches. */ diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 7dee51b..25a0cc3 100644 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -81,6 +81,7 @@ class Peptide_News { $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); $this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' ); $this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' ); + $this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' ); } /** diff --git a/peptide-news-plugin/admin/class-peptide-news-admin.php b/peptide-news-plugin/admin/class-peptide-news-admin.php index 5fb863e..7ccffa8 100644 --- a/peptide-news-plugin/admin/class-peptide-news-admin.php +++ b/peptide-news-plugin/admin/class-peptide-news-admin.php @@ -55,36 +55,698 @@ class Peptide_News_Admin { wp_enqueue_script( $this->plugin_name . '-admin', PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js', - array( 'jquery', 'dashboard' ), + array( 'jquery', 'chartjs' ), $this->version, true ); + + wp_localize_script( $this->plugin_name . '-admin', 'peptideNewsAdmin', array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'rest_url' => rest_url( 'peptide-news/v1/' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'admin_nonce' => wp_create_nonce( 'peptide_news_admin' ), + ) ); } /** - * Register the settings page. + * Add admin menu pages. */ - public function register_settings_page() { - add_management_page( - 'manage', - 'Peptide News', - 'Peptide News', + public function add_admin_menu() { + // Main menu — Analytics Dashboard. + add_menu_page( + __( 'Peptide News', 'peptide-news' ), + __( 'Peptide News', 'peptide-news' ), 'manage_options', + 'peptide-news-dashboard', + array( $this, 'render_dashboard_page' ), + 'dashicons-rss', + 30 + ); + + // Sub-menu — Settings. + add_submenu_page( + 'peptide-news-dashboard', + __( 'Settings', 'peptide-news' ), + __( 'Settings', 'peptide-news' ), + 'manage_options', + 'peptide-news-settings', array( $this, 'render_settings_page' ) ); + + // Sub-menu — Articles. + add_submenu_page( + 'peptide-news-dashboard', + __( 'Articles', 'peptide-news' ), + __( 'Articles', 'peptide-news' ), + 'manage_options', + 'peptide-news-articles', + array( $this, 'render_articles_page' ) + ); } /** - * Render the settings page. + * Register all settings. */ - public function render_settings_page() { - require PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/settings-page.php'; + public function register_settings() { + + // --- General section --- + add_settings_section( + 'peptide_news_general', + __( 'General Settings', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'fetch_interval', __( 'Fetch Interval', 'peptide-news' ), 'render_fetch_interval_field', 'peptide_news_general' ); + $this->add_setting( 'articles_count', __( 'Articles to Display', 'peptide-news' ), 'render_articles_count_field', 'peptide_news_general' ); + $this->add_setting( 'search_keywords', __( 'Search Keywords', 'peptide-news' ), 'render_search_keywords_field', 'peptide_news_general' ); + + // --- RSS section --- + add_settings_section( + 'peptide_news_rss', + __( 'RSS Feed Sources', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'rss_enabled', __( 'Enable RSS Feeds', 'peptide-news' ), 'render_rss_enabled_field', 'peptide_news_rss' ); + $this->add_setting( 'rss_feeds', __( 'RSS Feed URLs', 'peptide-news' ), 'render_rss_feeds_field', 'peptide_news_rss' ); + + // --- NewsAPI section --- + add_settings_section( + 'peptide_news_newsapi', + __( 'NewsAPI.org', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'newsapi_enabled', __( 'Enable NewsAPI', 'peptide-news' ), 'render_newsapi_enabled_field', 'peptide_news_newsapi' ); + $this->add_setting( 'newsapi_key', __( 'API Key', 'peptide-news' ), 'render_newsapi_key_field', 'peptide_news_newsapi' ); + + // --- Analytics section --- + add_settings_section( + 'peptide_news_analytics_section', + __( 'Analytics', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'article_retention', __( 'Article Retention (days)', 'peptide-news' ), 'render_article_retention_field', 'peptide_news_analytics_section' ); + $this->add_setting( 'analytics_retention', __( 'Analytics Data Retention (days)', 'peptide-news' ), 'render_retention_field', 'peptide_news_analytics_section' ); + $this->add_setting( 'anonymize_ip', __( 'Anonymize IPs', 'peptide-news' ), 'render_anonymize_ip_field', 'peptide_news_analytics_section' ); + + // --- Content Filter section --- + add_settings_section( + 'peptide_news_filter_section', + __( 'Content Filter', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '

'; + $last_run = get_option( 'peptide_news_filter_last_run' ); + if ( $last_run && is_array( $last_run ) ) { + printf( + '

%s: %s | %s: %d | %s: %d | %s: %d | %s: %d

', + esc_html__( 'Last filter run', 'peptide-news' ), + esc_html( $last_run['time'] ), + esc_html__( 'Evaluated', 'peptide-news' ), + absint( $last_run['total'] ), + esc_html__( 'Removed', 'peptide-news' ), + absint( $last_run['removed'] ), + esc_html__( 'LLM checked', 'peptide-news' ), + absint( $last_run['llm_checked'] ), + esc_html__( 'Passed', 'peptide-news' ), + absint( $last_run['passed'] ) + ); + } + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'filter_enabled', __( 'Enable Content Filter', 'peptide-news' ), 'render_filter_enabled_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_sensitivity', __( 'Filter Sensitivity', 'peptide-news' ), 'render_filter_sensitivity_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_llm_enabled', __( 'LLM Classification', 'peptide-news' ), 'render_filter_llm_enabled_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_llm_model', __( 'Filter LLM Model', 'peptide-news' ), 'render_filter_llm_model_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_title_keywords', __( 'Title Keywords', 'peptide-news' ), 'render_filter_title_keywords_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_body_keywords', __( 'Body Keywords', 'peptide-news' ), 'render_filter_body_keywords_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_blocked_domains', __( 'Blocked Domains', 'peptide-news' ), 'render_filter_blocked_domains_field', 'peptide_news_filter_section' ); + + // --- AI / LLM section --- + add_settings_section( + 'peptide_news_llm_section', + __( 'AI Analysis (OpenRouter)', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'llm_enabled', __( 'Enable AI Analysis', 'peptide-news' ), 'render_llm_enabled_field', 'peptide_news_llm_section' ); + $this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); + + $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); } /** - * Render the analytics dashboard. + * Helper: register a single setting with a field callback. */ - public function render_dashboard() { - require PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php'; + private function add_setting( $key, $label, $callback, $section ) { + $option_name = "peptide_news_{$key}"; + + $sanitize_cb = $this->get_sanitize_callback( $key ); + + register_setting( 'peptide_news_settings_group', $option_name, array( + 'sanitize_callback' => $sanitize_cb, + ) ); + + add_settings_field( + $option_name, + $label, + array( $this, $callback ), + 'peptide-news-settings', + $section + ); + } + + /** + * Return the appropriate sanitize callback for a given setting key. + * + * @param string $key Setting key (without prefix). + * @return callable + */ + private function get_sanitize_callback( $key ) { + $callbacks = array( + 'fetch_interval' => 'sanitize_text_field', + 'articles_count' => 'absint', + 'search_keywords' => 'sanitize_text_field', + 'rss_enabled' => 'absint', + 'rss_feeds' => 'sanitize_textarea_field', + 'newsapi_enabled' => 'absint', + 'newsapi_key' => 'sanitize_text_field', + 'article_retention' => 'absint', + 'analytics_retention' => 'absint', + 'anonymize_ip' => 'absint', + 'filter_enabled' => 'absint', + 'filter_sensitivity' => 'sanitize_text_field', + 'filter_llm_enabled' => 'absint', + 'filter_llm_model' => array( $this, 'sanitize_model_id' ), + 'filter_title_keywords' => 'sanitize_textarea_field', + 'filter_body_keywords' => 'sanitize_textarea_field', + 'filter_blocked_domains' => 'sanitize_textarea_field', + 'llm_enabled' => 'absint', + 'openrouter_api_key' => 'sanitize_text_field', + 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), + 'llm_summary_model' => array( $this, 'sanitize_model_id' ), + 'llm_max_articles' => 'absint', + ); + + return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; + } + + /** + * Sanitize and validate an OpenRouter model ID. + * + * @param string $value Raw input. + * @return string Sanitized model ID or default. + */ + public function sanitize_model_id( $value ) { + $value = sanitize_text_field( $value ); + if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { + add_settings_error( + 'peptide_news_settings_group', + 'invalid_model', + sprintf( + /* translators: %s: model ID */ + __( 'Invalid model ID format: %s. Expected format: provider/model-name', 'peptide-news' ), + esc_html( $value ) + ), + 'error' + ); + return 'google/gemini-2.0-flash-001'; + } + return $value; + } + + // --- Field renderers --- + + public function render_fetch_interval_field() { + $value = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); + $options = array( + 'every_fifteen_minutes' => __( 'Every 15 minutes', 'peptide-news' ), + 'every_thirty_minutes' => __( 'Every 30 minutes', 'peptide-news' ), + 'hourly' => __( 'Hourly', 'peptide-news' ), + 'every_four_hours' => __( 'Every 4 hours', 'peptide-news' ), + 'every_six_hours' => __( 'Every 6 hours', 'peptide-news' ), + 'twicedaily' => __( 'Twice daily', 'peptide-news' ), + 'daily' => __( 'Daily', 'peptide-news' ), + ); + + echo ''; + echo '

' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '

'; + } + + public function render_articles_count_field() { + $value = get_option( 'peptide_news_articles_count', 10 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '

'; + } + + public function render_search_keywords_field() { + $value = get_option( 'peptide_news_search_keywords', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '

'; + } + + public function render_rss_enabled_field() { + $value = get_option( 'peptide_news_rss_enabled', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Fetch articles from RSS feeds', 'peptide-news' ) + ); + } + + public function render_rss_feeds_field() { + $value = get_option( 'peptide_news_rss_feeds', '' ); + printf( + '', + esc_textarea( $value ) + ); + } + + public function render_newsapi_enabled_field() { + $value = get_option( 'peptide_news_newsapi_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Fetch articles from NewsAPI.org', 'peptide-news' ) + ); + } + + public function render_newsapi_key_field() { + $value = get_option( 'peptide_news_newsapi_key', '' ); + printf( + '', + esc_attr( $value ) + ); + } + + public function render_article_retention_field() { + $value = get_option( 'peptide_news_article_retention', 90 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '

'; + } + + public function render_retention_field() { + $value = get_option( 'peptide_news_analytics_retention', 365 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '

'; + } + + public function render_anonymize_ip_field() { + $value = get_option( 'peptide_news_anonymize_ip', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Anonymize IP addresses for GDPR compliance', 'peptide-news' ) + ); + } + + // --- Content Filter field renderers --- + + public function render_filter_enabled_field() { + $value = get_option( 'peptide_news_filter_enabled', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Filter out ads, press releases, and promotional content during fetch', 'peptide-news' ) + ); + } + + public function render_filter_sensitivity_field() { + $value = get_option( 'peptide_news_filter_sensitivity', 'moderate' ); + $options = array( + 'strict' => __( 'Strict — block aggressively (may catch some legitimate articles)', 'peptide-news' ), + 'moderate' => __( 'Moderate — balanced filtering (recommended)', 'peptide-news' ), + 'lenient' => __( 'Lenient — only block obvious promotional content', 'peptide-news' ), + ); + + echo ''; + } + + public function render_filter_llm_enabled_field() { + $value = get_option( 'peptide_news_filter_llm_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Use LLM to classify borderline articles (requires AI Analysis to be enabled)', 'peptide-news' ) + ); + } + + public function render_filter_llm_model_field() { + $value = get_option( 'peptide_news_filter_llm_model', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '

'; + } + + public function render_filter_title_keywords_field() { + $value = get_option( 'peptide_news_filter_title_keywords', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_title_keywords_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '

' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '

'; + } + + public function render_filter_body_keywords_field() { + $value = get_option( 'peptide_news_filter_body_keywords', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_body_keywords_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '

' . esc_html__( 'One keyword/phrase per line. Multiple matches in article body trigger filtering (threshold depends on sensitivity). Leave empty to use built-in defaults.', 'peptide-news' ) . '

'; + } + + public function render_filter_blocked_domains_field() { + $value = get_option( 'peptide_news_filter_blocked_domains', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_blocked_domains_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '

' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '

'; + } + + // --- AI / LLM field renderers --- + + public function render_llm_enabled_field() { + $value = get_option( 'peptide_news_llm_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Analyze articles with AI during each fetch cycle', 'peptide-news' ) + ); + } + + public function render_openrouter_key_field() { + $value = get_option( 'peptide_news_openrouter_api_key', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . wp_kses( + __( 'Get an API key at openrouter.ai/keys.', 'peptide-news' ), + array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) ) + ) . '

'; + } + + public function render_llm_keywords_model_field() { + $value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '

'; + } + + public function render_llm_summary_model_field() { + $value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '

'; + } + + public function render_llm_max_articles_field() { + $value = get_option( 'peptide_news_llm_max_articles', 10 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '

'; + } + + // --- Page renderers --- + + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle manual fetch trigger. + if ( isset( $_POST['peptide_news_fetch_now'] ) && check_admin_referer( 'peptide_news_fetch_now_action' ) ) { + $fetcher = new Peptide_News_Fetcher(); + $fetcher->fetch_all_sources(); + echo '

' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '

'; + } + + echo '
'; + echo '

' . esc_html( get_admin_page_title() ) . '

'; + + // Fetch Now button. + echo '
'; + wp_nonce_field( 'peptide_news_fetch_now_action' ); + submit_button( __( 'Fetch Articles Now', 'peptide-news' ), 'secondary', 'peptide_news_fetch_now', false ); + echo '
'; + + // Backfill Sources button (fixes "news.google.com" source labels). + echo ''; + echo ''; + + // Generate AI Summaries button. + echo '
'; + echo ''; + echo ''; + + ?> + + 0 ) { + $extra_info .= sprintf( ' | %s: %d', + esc_html__( 'Filtered out', 'peptide-news' ), + $last_fetch['filtered_out'] + ); + } + if ( isset( $last_fetch['ai_processed'] ) ) { + $extra_info .= sprintf( ' | %s: %d', + esc_html__( 'AI analyzed', 'peptide-news' ), + $last_fetch['ai_processed'] + ); + } + printf( + '

%s: %s | %s: %d | %s: %d%s

', + esc_html__( 'Last fetch', 'peptide-news' ), + esc_html( $last_fetch['time'] ), + esc_html__( 'Found', 'peptide-news' ), + $last_fetch['found'], + esc_html__( 'New stored', 'peptide-news' ), + $last_fetch['new_stored'], + $extra_info + ); + } + + // Settings form. + echo '
'; + settings_fields( 'peptide_news_settings_group' ); + do_settings_sections( 'peptide-news-settings' ); + submit_button(); + echo '
'; + + // Shortcode usage info. + echo '

' . esc_html__( 'Usage', 'peptide-news' ) . '

'; + echo '

' . esc_html__( 'Shortcode:', 'peptide-news' ) . ' [peptide_news] ' . + esc_html__( 'or', 'peptide-news' ) . ' [peptide_news count="5"]

'; + echo '

' . esc_html__( 'Widget:', 'peptide-news' ) . ' ' . + esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '

'; + + echo '
'; + } + + public function render_dashboard_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle CSV export before any HTML output. + if ( isset( $_GET['export'] ) && 'csv' === $_GET['export'] ) { + check_admin_referer( 'peptide_news_export_csv' ); + $this->export_csv(); + return; + } + + include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php'; + } + + /** + * Stream a CSV export of click analytics data. + */ + private function export_csv() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) ); + } + + $raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : ''; + $raw_end = isset( $_GET['end_date'] ) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : ''; + $start_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_start ) ? $raw_start : gmdate( 'Y-m-d', strtotime( '-30 days' ) ); + $end_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_end ) ? $raw_end : gmdate( 'Y-m-d' ); + + $data = Peptide_News_Analytics::export_clicks_csv( $start_date, $end_date ); + + $filename = 'peptide-news-clicks-' . $start_date . '-to-' . $end_date . '.csv'; + + header( 'Content-Type: text/csv; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + $output = fopen( 'php://output', 'w' ); + + if ( ! empty( $data ) ) { + fputcsv( $output, array_keys( $data[0] ) ); + foreach ( $data as $row ) { + fputcsv( $output, $row ); + } + } else { + fputcsv( $output, array( 'No data for selected period' ) ); + } + + fclose( $output ); + exit; + } + + public function render_articles_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php'; } } diff --git a/peptide-news-plugin/includes/class-peptide-news-llm.php b/peptide-news-plugin/includes/class-peptide-news-llm.php new file mode 100644 index 0000000..57a06a0 --- /dev/null +++ b/peptide-news-plugin/includes/class-peptide-news-llm.php @@ -0,0 +1,460 @@ + '', '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 && 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 && 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 ) ) { + // 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 ); + } + + return $results; + } + + /** + * Process all unanalyzed articles (called after a fetch cycle). + * + * Includes a time-based guard to prevent cron timeouts and + * respects the admin-configured max articles per cycle unless + * $override_limit is true (used by the manual bulk-generate button). + * + * @param int $batch_size Max articles to process per run. + * @param bool $override_limit When true, ignore the admin "max per cycle" cap. + * @return int Number of articles successfully processed. + */ + public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) { + if ( ! self::is_enabled() ) { + return 0; + } + + if ( ! $override_limit ) { + // Respect admin cost-control setting for automated cron runs. + $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'; + + // 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; + $start_time = time(); + + foreach ( $articles as $article ) { + // 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 ( 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; + } + + /** + * 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 with retry logic for 429 rate limits. + * + * @param string $api_key + * @param string $model + * @param string $prompt + * @return string|WP_Error The response text or an error. + */ + public 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, + ); + + $retries = 0; + $backoff = 2; // Initial backoff in seconds. + + 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; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status < 200 || $status >= 300 ) { + $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 ); + } + + // 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'] ); + } + + /** + * 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 ) { + // 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( '/^[-*\x{2022}]\s*/mu', '', $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. + * + * 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. + */ + 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' ) + ); + } + } + + /** + * AJAX handler: bulk-generate AI summaries for articles that are missing them. + * + * Processes up to 50 articles per request. Returns the count of articles + * processed and remaining so the admin UI can loop until done. + * + * @since 2.0.1 + */ + public static function ajax_generate_summaries() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + if ( ! self::is_enabled() ) { + wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' ); + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + // Count how many still need processing. + $remaining = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} + WHERE is_active = 1 + AND ( ai_summary = '' OR ai_summary IS NULL )" + ); + + if ( $remaining < 1 ) { + wp_send_json_success( array( + 'processed' => 0, + 'remaining' => 0, + 'message' => 'All articles already have AI summaries.', + ) ); + } + + // Process a generous batch — the 120-second timeout guard inside + // process_unanalyzed() will stop early if needed. Override the + // per-cycle limit since this is a manual admin action. + $batch_size = min( 50, $remaining ); + $processed = self::process_unanalyzed( $batch_size, true ); + + // Recount remaining after this batch. + $still_remaining = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} + WHERE is_active = 1 + AND ( ai_summary = '' OR ai_summary IS NULL )" + ); + + wp_send_json_success( array( + 'processed' => $processed, + 'remaining' => $still_remaining, + 'message' => sprintf( '%d article(s) summarized, %d remaining.', $processed, $still_remaining ), + ) ); + } + + /** + * Clear all article transient caches. + */ + private static function clear_article_cache() { + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE %s + OR option_name LIKE %s", + $wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%', + $wpdb->esc_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 ); + } + } +} diff --git a/peptide-news-plugin/includes/class-peptide-news.php b/peptide-news-plugin/includes/class-peptide-news.php index 3b94c29..25a0cc3 100644 --- a/peptide-news-plugin/includes/class-peptide-news.php +++ b/peptide-news-plugin/includes/class-peptide-news.php @@ -1,53 +1,114 @@ loader = new Peptide_News_Loader(); - $this -> admin = new Peptide_News_Admin('peptide-news', PEPTIDE_NEWS_VERSION); - $this -> public = new Peptide_News_Public('peptide-news', PEPTIDE_NEWS_VERSION); + $this->version = PEPTIDE_NEWS_VERSION; + $this->plugin_name = 'peptide-news'; + + $this->load_dependencies(); + $this->define_admin_hooks(); + $this->define_public_hooks(); + $this->define_cron_hooks(); + $this->define_rest_hooks(); } + /** + * Load all required files. + */ + private function load_dependencies() { + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; + + $this->loader = new Peptide_News_Loader(); + } + + /** + * Register admin-side hooks. + */ + private function define_admin_hooks() { + $admin = new Peptide_News_Admin( $this->plugin_name, $this->version ); + + $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); + $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' ); + $this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' ); + $this->loader->add_action( 'admin_init', $admin, 'register_settings' ); + } + + /** + * Register public-facing hooks. + */ + private function define_public_hooks() { + $public = new Peptide_News_Public( $this->plugin_name, $this->version ); + + $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); + $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' ); + $this->loader->add_action( 'init', $public, 'register_shortcode' ); + $this->loader->add_action( 'widgets_init', $public, 'register_widget' ); + $this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' ); + $this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' ); + } + + /** + * Register WP-Cron hooks for scheduled fetching. + */ + private function define_cron_hooks() { + $fetcher = new Peptide_News_Fetcher(); + + $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); + $this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' ); + $this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' ); + $this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' ); + } + + /** + * Register REST API hooks. + */ + private function define_rest_hooks() { + $rest = new Peptide_News_Rest_API(); + + $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); + } + + /** + * Run the loader to execute all registered hooks. + */ public function run() { $this->loader->run(); - $this->admin->run(); - $this->public->run(); + } + + public function get_plugin_name() { + return $this->plugin_name; + } + + public function get_version() { + return $this->version; + } + + public function get_loader() { + return $this->loader; } }