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; } }