feat: add bulk AI summary generation button to admin
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 <noreply@anthropic.com>
This commit is contained in:
@@ -568,6 +568,13 @@ class Peptide_News_Admin {
|
||||
echo '</button>';
|
||||
echo '<span id="peptide-backfill-result" style="margin-left:10px;"></span>';
|
||||
|
||||
// Generate AI Summaries button.
|
||||
echo '<br style="margin-bottom:8px;">';
|
||||
echo '<button type="button" id="peptide-generate-summaries" class="button button-primary" style="margin-top:8px;">';
|
||||
echo esc_html__( 'Generate AI Summaries', 'peptide-news' );
|
||||
echo '</button>';
|
||||
echo '<span id="peptide-summary-result" style="margin-left:10px;"></span>';
|
||||
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
@@ -591,6 +598,50 @@ class Peptide_News_Admin {
|
||||
});
|
||||
});
|
||||
|
||||
// Generate AI Summaries — loops in batches until all articles are done.
|
||||
$('#peptide-generate-summaries').on('click', function() {
|
||||
var $btn = $(this), $result = $('#peptide-summary-result');
|
||||
var totalProcessed = 0;
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
$result.text('<?php echo esc_js( __( 'Starting...', 'peptide-news' ) ); ?>');
|
||||
|
||||
function runBatch() {
|
||||
$btn.text('<?php echo esc_js( __( 'Generating...', 'peptide-news' ) ); ?>');
|
||||
$.post(peptideNewsAdmin.ajax_url, {
|
||||
action: 'peptide_news_generate_summaries',
|
||||
nonce: peptideNewsAdmin.admin_nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
totalProcessed += response.data.processed;
|
||||
var remaining = response.data.remaining;
|
||||
$result.text(totalProcessed + ' summarized, ' + remaining + ' remaining...');
|
||||
|
||||
if (remaining > 0 && response.data.processed > 0) {
|
||||
// Continue with next batch.
|
||||
runBatch();
|
||||
} else {
|
||||
// Done — either all processed or no progress (error).
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
||||
if (remaining === 0) {
|
||||
$result.text('Done! ' + totalProcessed + ' article(s) summarized.');
|
||||
} else {
|
||||
$result.text(totalProcessed + ' summarized. ' + remaining + ' could not be processed — check the debug log.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
||||
$result.text('Error: ' + (response.data || 'Unknown error'));
|
||||
}
|
||||
}).fail(function() {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
||||
$result.text('Request failed. ' + totalProcessed + ' completed before failure.');
|
||||
});
|
||||
}
|
||||
|
||||
runBatch();
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
</script>
|
||||
<?php
|
||||
|
||||
@@ -114,22 +114,26 @@ class Peptide_News_LLM {
|
||||
* 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.
|
||||
* 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.
|
||||
* @return int Number of articles successfully processed.
|
||||
* @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 ) {
|
||||
public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) {
|
||||
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;
|
||||
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 );
|
||||
}
|
||||
$batch_size = min( $batch_size, $max_per_cycle );
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->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.
|
||||
*/
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 '<p>' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'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 '<p>' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'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 '<p>' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'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 '<p>' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'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 '<p>' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '</p>';
|
||||
$last_run = get_option( 'peptide_news_filter_last_run' );
|
||||
if ( $last_run && is_array( $last_run ) ) {
|
||||
printf(
|
||||
'<p class="description"><strong>%s:</strong> %s | %s: %d | %s: %d | %s: %d | %s: %d</p>',
|
||||
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 '<p>' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '</p>';
|
||||
},
|
||||
'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 '<select name="peptide_news_fetch_interval" id="peptide_news_fetch_interval">';
|
||||
foreach ( $options as $key => $label ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $key ),
|
||||
selected( $value, $key, false ),
|
||||
esc_html( $label )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<p class="description">' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_articles_count_field() {
|
||||
$value = get_option( 'peptide_news_articles_count', 10 );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_articles_count" value="%d" min="1" max="100" class="small-text" />',
|
||||
absint( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_search_keywords_field() {
|
||||
$value = get_option( 'peptide_news_search_keywords', '' );
|
||||
printf(
|
||||
'<input type="text" name="peptide_news_search_keywords" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_rss_enabled_field() {
|
||||
$value = get_option( 'peptide_news_rss_enabled', 1 );
|
||||
printf(
|
||||
'<label><input type="checkbox" name="peptide_news_rss_enabled" value="1" %s /> %s</label>',
|
||||
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(
|
||||
'<textarea name="peptide_news_rss_feeds" rows="6" class="large-text code">%s</textarea>',
|
||||
esc_textarea( $value )
|
||||
);
|
||||
}
|
||||
|
||||
public function render_newsapi_enabled_field() {
|
||||
$value = get_option( 'peptide_news_newsapi_enabled', 0 );
|
||||
printf(
|
||||
'<label><input type="checkbox" name="peptide_news_newsapi_enabled" value="1" %s /> %s</label>',
|
||||
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(
|
||||
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
);
|
||||
}
|
||||
|
||||
public function render_article_retention_field() {
|
||||
$value = get_option( 'peptide_news_article_retention', 90 );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_article_retention" value="%d" min="7" max="3650" class="small-text" />',
|
||||
absint( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_retention_field() {
|
||||
$value = get_option( 'peptide_news_analytics_retention', 365 );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_analytics_retention" value="%d" min="30" max="3650" class="small-text" />',
|
||||
absint( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_anonymize_ip_field() {
|
||||
$value = get_option( 'peptide_news_anonymize_ip', 1 );
|
||||
printf(
|
||||
'<label><input type="checkbox" name="peptide_news_anonymize_ip" value="1" %s /> %s</label>',
|
||||
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(
|
||||
'<label><input type="checkbox" name="peptide_news_filter_enabled" value="1" %s /> %s</label>',
|
||||
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 '<select name="peptide_news_filter_sensitivity" id="peptide_news_filter_sensitivity">';
|
||||
foreach ( $options as $key => $label ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $key ),
|
||||
selected( $value, $key, false ),
|
||||
esc_html( $label )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
}
|
||||
|
||||
public function render_filter_llm_enabled_field() {
|
||||
$value = get_option( 'peptide_news_filter_llm_enabled', 0 );
|
||||
printf(
|
||||
'<label><input type="checkbox" name="peptide_news_filter_llm_enabled" value="1" %s /> %s</label>',
|
||||
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(
|
||||
'<input type="text" name="peptide_news_filter_llm_model" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
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(
|
||||
'<textarea name="peptide_news_filter_title_keywords" rows="8" class="large-text code" placeholder="%s">%s</textarea>',
|
||||
esc_attr( $placeholder ),
|
||||
esc_textarea( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
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(
|
||||
'<textarea name="peptide_news_filter_body_keywords" rows="8" class="large-text code" placeholder="%s">%s</textarea>',
|
||||
esc_attr( $placeholder ),
|
||||
esc_textarea( $value )
|
||||
);
|
||||
echo '<p class="description">' . 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' ) . '</p>';
|
||||
}
|
||||
|
||||
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(
|
||||
'<textarea name="peptide_news_filter_blocked_domains" rows="6" class="large-text code" placeholder="%s">%s</textarea>',
|
||||
esc_attr( $placeholder ),
|
||||
esc_textarea( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
// --- AI / LLM field renderers ---
|
||||
|
||||
public function render_llm_enabled_field() {
|
||||
$value = get_option( 'peptide_news_llm_enabled', 0 );
|
||||
printf(
|
||||
'<label><input type="checkbox" name="peptide_news_llm_enabled" value="1" %s /> %s</label>',
|
||||
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(
|
||||
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
);
|
||||
echo '<p class="description">' . wp_kses(
|
||||
__( 'Get an API key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>.', 'peptide-news' ),
|
||||
array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) )
|
||||
) . '</p>';
|
||||
}
|
||||
|
||||
public function render_llm_keywords_model_field() {
|
||||
$value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' );
|
||||
printf(
|
||||
'<input type="text" name="peptide_news_llm_keywords_model" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_llm_summary_model_field() {
|
||||
$value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' );
|
||||
printf(
|
||||
'<input type="text" name="peptide_news_llm_summary_model" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
public function render_llm_max_articles_field() {
|
||||
$value = get_option( 'peptide_news_llm_max_articles', 10 );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_llm_max_articles" value="%d" min="1" max="50" class="small-text" />',
|
||||
absint( $value )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
// --- 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 '<div class="notice notice-success"><p>' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '</p></div>';
|
||||
}
|
||||
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
|
||||
|
||||
// Fetch Now button.
|
||||
echo '<form method="post" style="display:inline-block;margin-right:12px;">';
|
||||
wp_nonce_field( 'peptide_news_fetch_now_action' );
|
||||
submit_button( __( 'Fetch Articles Now', 'peptide-news' ), 'secondary', 'peptide_news_fetch_now', false );
|
||||
echo '</form>';
|
||||
|
||||
// Backfill Sources button (fixes "news.google.com" source labels).
|
||||
echo '<button type="button" id="peptide-backfill-sources" class="button button-secondary">';
|
||||
echo esc_html__( 'Fix Article Sources', 'peptide-news' );
|
||||
echo '</button>';
|
||||
echo '<span id="peptide-backfill-result" style="margin-left:10px;"></span>';
|
||||
|
||||
// Generate AI Summaries button.
|
||||
echo '<br style="margin-bottom:8px;">';
|
||||
echo '<button type="button" id="peptide-generate-summaries" class="button button-primary" style="margin-top:8px;">';
|
||||
echo esc_html__( 'Generate AI Summaries', 'peptide-news' );
|
||||
echo '</button>';
|
||||
echo '<span id="peptide-summary-result" style="margin-left:10px;"></span>';
|
||||
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
$('#peptide-backfill-sources').on('click', function() {
|
||||
var $btn = $(this), $result = $('#peptide-backfill-result');
|
||||
$btn.prop('disabled', true).text('<?php echo esc_js( __( 'Fixing...', 'peptide-news' ) ); ?>');
|
||||
$result.text('');
|
||||
$.post(peptideNewsAdmin.ajax_url, {
|
||||
action: 'peptide_news_backfill_sources',
|
||||
nonce: peptideNewsAdmin.admin_nonce
|
||||
}, function(response) {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Fix Article Sources', 'peptide-news' ) ); ?>');
|
||||
if (response.success) {
|
||||
$result.text(response.data.updated + ' article(s) updated.');
|
||||
} else {
|
||||
$result.text('Error: ' + (response.data || 'Unknown error'));
|
||||
}
|
||||
}).fail(function() {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Fix Article Sources', 'peptide-news' ) ); ?>');
|
||||
$result.text('Request failed.');
|
||||
});
|
||||
});
|
||||
|
||||
// Generate AI Summaries — loops in batches until all articles are done.
|
||||
$('#peptide-generate-summaries').on('click', function() {
|
||||
var $btn = $(this), $result = $('#peptide-summary-result');
|
||||
var totalProcessed = 0;
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
$result.text('<?php echo esc_js( __( 'Starting...', 'peptide-news' ) ); ?>');
|
||||
|
||||
function runBatch() {
|
||||
$btn.text('<?php echo esc_js( __( 'Generating...', 'peptide-news' ) ); ?>');
|
||||
$.post(peptideNewsAdmin.ajax_url, {
|
||||
action: 'peptide_news_generate_summaries',
|
||||
nonce: peptideNewsAdmin.admin_nonce
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
totalProcessed += response.data.processed;
|
||||
var remaining = response.data.remaining;
|
||||
$result.text(totalProcessed + ' summarized, ' + remaining + ' remaining...');
|
||||
|
||||
if (remaining > 0 && response.data.processed > 0) {
|
||||
// Continue with next batch.
|
||||
runBatch();
|
||||
} else {
|
||||
// Done — either all processed or no progress (error).
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
||||
if (remaining === 0) {
|
||||
$result.text('Done! ' + totalProcessed + ' article(s) summarized.');
|
||||
} else {
|
||||
$result.text(totalProcessed + ' summarized. ' + remaining + ' could not be processed — check the debug log.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
||||
$result.text('Error: ' + (response.data || 'Unknown error'));
|
||||
}
|
||||
}).fail(function() {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js( __( 'Generate AI Summaries', 'peptide-news' ) ); ?>');
|
||||
$result.text('Request failed. ' + totalProcessed + ' completed before failure.');
|
||||
});
|
||||
}
|
||||
|
||||
runBatch();
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
</script>
|
||||
<?php
|
||||
|
||||
// Last fetch info.
|
||||
$last_fetch = get_option( 'peptide_news_last_fetch' );
|
||||
if ( $last_fetch ) {
|
||||
$extra_info = '';
|
||||
if ( isset( $last_fetch['filtered_out'] ) && $last_fetch['filtered_out'] > 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(
|
||||
'<p class="description">%s: %s | %s: %d | %s: %d%s</p>',
|
||||
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 '<form method="post" action="options.php">';
|
||||
settings_fields( 'peptide_news_settings_group' );
|
||||
do_settings_sections( 'peptide-news-settings' );
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
|
||||
// Shortcode usage info.
|
||||
echo '<h2>' . esc_html__( 'Usage', 'peptide-news' ) . '</h2>';
|
||||
echo '<p><strong>' . esc_html__( 'Shortcode:', 'peptide-news' ) . '</strong> <code>[peptide_news]</code> ' .
|
||||
esc_html__( 'or', 'peptide-news' ) . ' <code>[peptide_news count="5"]</code></p>';
|
||||
echo '<p><strong>' . esc_html__( 'Widget:', 'peptide-news' ) . '</strong> ' .
|
||||
esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '</p>';
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
460
peptide-news-plugin/includes/class-peptide-news-llm.php
Normal file
460
peptide-news-plugin/includes/class-peptide-news-llm.php
Normal file
@@ -0,0 +1,460 @@
|
||||
<?php
|
||||
/**
|
||||
* LLM integration via OpenRouter for article analysis.
|
||||
*
|
||||
* Provides keyword extraction and article summarization
|
||||
* using configurable models through the OpenRouter API.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single article: extract keywords and generate a summary.
|
||||
*
|
||||
* Skips articles that already have both tags and ai_summary unless $force is true.
|
||||
*
|
||||
* @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', 'summary', and 'success' keys.
|
||||
*/
|
||||
public static function process_article( $article, $force = false ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
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 && 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* The file that defines the core plugin class.
|
||||
* The core plugin class.
|
||||
*
|
||||
* Maintains the list of hooks and coordinates all modules.
|
||||
*
|
||||
* A class definition that includes attributes,
|
||||
* defines, and the if instantiated,
|
||||
* activated, deactivated, and global makers of changes in Atx.
|
||||
* altogether with examples of either replacing tutorials
|
||||
* or implementing the core requirements in the optional files
|
||||
* \"includes/loader-grade-1-pha.phP\", \"includes/loader-grade-2-php\", and
|
||||
* \"includes/delivery-grade-1-phP\", \"includes/delivery-grade-2-p\", ]
|
||||
* altogether and should enforce the core declarative container.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
class Peptide_News {
|
||||
/** Plugin instance variable **/
|
||||
private static $instance;
|
||||
|
||||
/** Core loader **/
|
||||
private $loader;
|
||||
/** @var Peptide_News_Loader */
|
||||
protected $loader;
|
||||
|
||||
/** Admin multiplex **/
|
||||
private $admin;
|
||||
/** @var string */
|
||||
protected $plugin_name;
|
||||
|
||||
/** Public facing multiplex **/
|
||||
private $public;
|
||||
|
||||
/** The variable used to catch loading libraries **/
|
||||
private $loader_ability;
|
||||
|
||||
/** Keep track of where is asseted **/
|
||||
private static $container;
|
||||
|
||||
public static function instance() {
|
||||
if (!self::${$4instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
/** @var string */
|
||||
protected $version;
|
||||
|
||||
public function __construct() {
|
||||
this -> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user