perf: improve rate limiting resilience and reduce cache invalidation overhead
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,247 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin settings page for Peptide Search AI.
|
||||
* Configured for OpenRouter API access.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Admin {
|
||||
|
||||
public static function init() {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'add_menu_pages' ) );
|
||||
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add settings page under Settings menu.
|
||||
*/
|
||||
public static function add_menu_pages() {
|
||||
add_options_page(
|
||||
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
||||
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
||||
'manage_options',
|
||||
'peptide-search-ai',
|
||||
array( __CLASS__, 'render_settings_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings fields.
|
||||
*/
|
||||
public static function register_settings() {
|
||||
register_setting(
|
||||
'psa_settings_group',
|
||||
'psa_settings',
|
||||
array(
|
||||
'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ),
|
||||
)
|
||||
);
|
||||
|
||||
// OpenRouter Section.
|
||||
add_settings_section(
|
||||
'psa_ai_section',
|
||||
__( 'OpenRouter API Settings', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_section_description' ),
|
||||
'peptide-search-ai'
|
||||
);
|
||||
add_settings_field(
|
||||
'api_key',
|
||||
__( 'API Key', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_api_key_field' ),
|
||||
'peptide-search-ai',
|
||||
'psa_ai_section'
|
||||
);
|
||||
add_settings_field(
|
||||
'ai_model',
|
||||
__( 'Generation Model', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_model_field' ),
|
||||
'peptide-search-ai',
|
||||
'psa_ai_section'
|
||||
);
|
||||
add_settings_field(
|
||||
'validation_model',
|
||||
__( 'Validation Model', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_validation_model_field' ),
|
||||
'peptide-search-ai',
|
||||
'psa_ai_section'
|
||||
);
|
||||
|
||||
// Behavior Section.
|
||||
add_settings_section(
|
||||
'psa_behavior_section',
|
||||
__( 'Behavior Settings', 'peptide-search-ai' ),
|
||||
null,
|
||||
'peptide-search-ai'
|
||||
);
|
||||
add_settings_field(
|
||||
'auto_publish',
|
||||
__( 'Auto-Publish', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_publish_field' ),
|
||||
'peptide-search-ai',
|
||||
'psa_behavior_section'
|
||||
);
|
||||
add_settings_field(
|
||||
'use_pubchem',
|
||||
__( 'PubChem Enrichment', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_pubchem_field' ),
|
||||
'peptide-search-ai',
|
||||
'psa_behavior_section'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize settings input.
|
||||
*
|
||||
* @param array $input Raw input from the form.
|
||||
* @return array Sanitized settings.
|
||||
*/
|
||||
public static function sanitize_settings( $input ) {
|
||||
return array(
|
||||
'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
|
||||
'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
|
||||
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
|
||||
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
|
||||
// Checkbox: if key is absent in POST, it was unchecked → '0'.
|
||||
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Field renderers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static function render_section_description() {
|
||||
?>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
__( 'This plugin uses <a href="%s" target="_blank" rel="noopener">OpenRouter</a> to access AI models. OpenRouter provides a unified API for hundreds of models including open-source options like Gemini Flash, Llama, Mistral, Qwen, and DeepSeek, as well as commercial models.', 'peptide-search-ai' ),
|
||||
esc_url( 'https://openrouter.ai/' )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function render_api_key_field() {
|
||||
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
|
||||
?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span>
|
||||
<strong><?php esc_html_e( 'Configured via wp-config.php', 'peptide-search-ai' ); ?></strong>
|
||||
<p class="description"><?php esc_html_e( 'The API key is securely set using the PSA_OPENROUTER_KEY constant in wp-config.php. This is the recommended approach.', 'peptide-search-ai' ); ?></p>
|
||||
<?php
|
||||
} else {
|
||||
$value = self::get_setting( 'api_key', '' );
|
||||
?>
|
||||
<input type="password" name="psa_settings[api_key]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" autocomplete="off" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Your OpenRouter API key. Get one at', 'peptide-search-ai' ); ?>
|
||||
<a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener">openrouter.ai/settings/keys</a>.<br />
|
||||
<strong><?php esc_html_e( 'Recommended:', 'peptide-search-ai' ); ?></strong> <?php esc_html_e( 'For better security, add', 'peptide-search-ai' ); ?> <code>define( 'PSA_OPENROUTER_KEY', 'your-key-here' );</code> <?php esc_html_e( 'to your wp-config.php instead.', 'peptide-search-ai' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
public static function render_model_field() {
|
||||
$value = self::get_setting( 'ai_model', '' );
|
||||
?>
|
||||
<input type="text" name="psa_settings[ai_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default', 'peptide-search-ai' ); ?>" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Model for full content generation. Default:', 'peptide-search-ai' ); ?> <code>google/gemini-2.5-flash</code>.<br />
|
||||
<?php esc_html_e( 'Browse models at', 'peptide-search-ai' ); ?> <a href="https://openrouter.ai/models" target="_blank" rel="noopener">openrouter.ai/models</a>.
|
||||
<?php esc_html_e( 'Examples:', 'peptide-search-ai' ); ?> <code>meta-llama/llama-4-maverick</code>, <code>deepseek/deepseek-chat-v3-0324</code>, <code>qwen/qwen-2.5-72b-instruct</code>.
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function render_validation_model_field() {
|
||||
$value = self::get_setting( 'validation_model', '' );
|
||||
?>
|
||||
<input type="text" name="psa_settings[validation_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default (fast/cheap model)', 'peptide-search-ai' ); ?>" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Lighter model for peptide name validation. Default:', 'peptide-search-ai' ); ?> <code>google/gemini-2.0-flash-001</code>.<br />
|
||||
<?php esc_html_e( 'Use a fast, cheap model here to keep validation quick. Examples:', 'peptide-search-ai' ); ?> <code>meta-llama/llama-4-scout</code>, <code>mistralai/mistral-small-3.1-24b-instruct</code>.
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function render_publish_field() {
|
||||
$value = self::get_setting( 'auto_publish', 'draft' );
|
||||
?>
|
||||
<select name="psa_settings[auto_publish]">
|
||||
<option value="draft" <?php selected( $value, 'draft' ); ?>><?php esc_html_e( 'Save as Draft (review before publishing)', 'peptide-search-ai' ); ?></option>
|
||||
<option value="publish" <?php selected( $value, 'publish' ); ?>><?php esc_html_e( 'Publish Immediately', 'peptide-search-ai' ); ?></option>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( 'Whether AI-generated peptides are published immediately or saved as drafts for review.', 'peptide-search-ai' ); ?></p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function render_pubchem_field() {
|
||||
$value = self::get_setting( 'use_pubchem', '1' );
|
||||
?>
|
||||
<label>
|
||||
<input type="checkbox" name="psa_settings[use_pubchem]" value="1" <?php checked( $value, '1' ); ?> />
|
||||
<?php esc_html_e( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' ); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e( 'When enabled, the plugin queries PubChem\'s free API to validate molecular weight, formula, and other properties.', 'peptide-search-ai' ); ?></p>
|
||||
<?php
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static function render_settings_page() {
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'Peptide Search AI Settings', 'peptide-search-ai' ); ?></h1>
|
||||
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( 'psa_settings_group' );
|
||||
do_settings_sections( 'peptide-search-ai' );
|
||||
submit_button();
|
||||
?>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
<h2><?php esc_html_e( 'How It Works', 'peptide-search-ai' ); ?></h2>
|
||||
<ol style="max-width:600px; line-height:1.8;">
|
||||
<li><?php esc_html_e( 'Add the shortcode [peptide_search] to any page.', 'peptide-search-ai' ); ?></li>
|
||||
<li><?php esc_html_e( 'Visitors search for a peptide name.', 'peptide-search-ai' ); ?></li>
|
||||
<li><?php esc_html_e( 'If found, results appear instantly.', 'peptide-search-ai' ); ?></li>
|
||||
<li><?php esc_html_e( 'If not found, the system automatically validates the name, creates a placeholder, and generates content in the background.', 'peptide-search-ai' ); ?></li>
|
||||
<li><?php esc_html_e( 'The visitor sees a "being added" message and can check back later.', 'peptide-search-ai' ); ?></li>
|
||||
</ol>
|
||||
|
||||
<hr />
|
||||
<h2><?php esc_html_e( 'Shortcode', 'peptide-search-ai' ); ?></h2>
|
||||
<p><code>[peptide_search]</code></p>
|
||||
<p><?php esc_html_e( 'Customize placeholder:', 'peptide-search-ai' ); ?> <code>[peptide_search placeholder="Search peptides..."]</code></p>
|
||||
|
||||
<hr />
|
||||
<h2><?php esc_html_e( 'REST API', 'peptide-search-ai' ); ?></h2>
|
||||
<p><code>GET /wp-json/peptides/v1/search?q=BPC-157</code></p>
|
||||
<p class="description"><?php esc_html_e( 'The REST endpoint returns published peptides only — it does not trigger auto-generation.', 'peptide-search-ai' ); ?></p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a single setting with a default.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $default Default value.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function get_setting( $key, $default = '' ) {
|
||||
$options = get_option( 'psa_settings', array() );
|
||||
return $options[ $key ] ?? $default;
|
||||
}
|
||||
}
|
||||
@@ -1,735 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Handles AI content generation for new peptide entries.
|
||||
* Uses OpenRouter API to access a wide range of open-source and commercial models.
|
||||
* Includes peptide name validation and background generation.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_AI_Generator {
|
||||
|
||||
/**
|
||||
* OpenRouter API base URL.
|
||||
*/
|
||||
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
public static function init() {
|
||||
add_action( 'psa_generate_peptide_background', array( __CLASS__, 'background_generate' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether a search term is a legitimate peptide name.
|
||||
* Uses a lightweight AI call to check before committing to full generation.
|
||||
*
|
||||
* @param string $name The name to validate.
|
||||
* @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string }
|
||||
*/
|
||||
public static function validate_peptide_name( $name ) {
|
||||
// Input character validation — block injection attempts before API call.
|
||||
if ( ! self::validate_peptide_input( $name ) ) {
|
||||
return array(
|
||||
'is_valid' => false,
|
||||
'canonical_name' => $name,
|
||||
'reason' => 'Invalid characters in peptide name.',
|
||||
);
|
||||
}
|
||||
|
||||
$options = self::get_settings();
|
||||
$api_key = $options['api_key'];
|
||||
$model = $options['validation_model'];
|
||||
|
||||
if ( empty( $api_key ) ) {
|
||||
return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' );
|
||||
}
|
||||
|
||||
// Check transient cache first (avoid repeated API calls for same term).
|
||||
$cache_key = 'psa_validate_' . md5( strtolower( trim( $name ) ) );
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$prompt = self::build_validation_prompt( $name );
|
||||
// Allow filtering of validation prompt before sending.
|
||||
$prompt = apply_filters( 'psa_validation_prompt', $prompt, $name );
|
||||
$response = self::call_openrouter(
|
||||
$api_key,
|
||||
$model ? $model : 'google/gemini-2.0-flash-001',
|
||||
$prompt,
|
||||
PSA_Config::VALIDATION_MAX_TOKENS
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$data = self::parse_ai_response( $response );
|
||||
if ( is_wp_error( $data ) ) {
|
||||
return array(
|
||||
'is_valid' => false,
|
||||
'canonical_name' => $name,
|
||||
'reason' => 'Could not verify this peptide name.',
|
||||
);
|
||||
}
|
||||
|
||||
$result = array(
|
||||
'is_valid' => ! empty( $data['is_valid'] ) && true === $data['is_valid'],
|
||||
'canonical_name' => sanitize_text_field( $data['canonical_name'] ?? $name ),
|
||||
'reason' => sanitize_text_field( $data['reason'] ?? '' ),
|
||||
);
|
||||
|
||||
// Cache for 24 hours (using PSA_Config for consistency).
|
||||
set_transient( $cache_key, $result, DAY_IN_SECONDS ); // Validation cache TTL
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background generation handler (called via WP-Cron or synchronously).
|
||||
*
|
||||
* @param int $post_id The placeholder post ID.
|
||||
* @param string $peptide_name The peptide to research.
|
||||
*/
|
||||
public static function background_generate( $post_id, $peptide_name ) {
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) {
|
||||
error_log( 'PSA: background_generate skipped — post ' . $post_id . ' not a draft peptide.' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process posts that are still in 'pending' state.
|
||||
$source = get_post_meta( $post_id, 'psa_source', true );
|
||||
if ( 'pending' !== $source ) {
|
||||
error_log( 'PSA: background_generate skipped — post ' . $post_id . ' source is "' . $source . '", not "pending".' );
|
||||
return;
|
||||
}
|
||||
|
||||
$options = self::get_settings();
|
||||
$auto_publish = $options['auto_publish'];
|
||||
|
||||
error_log( 'PSA: Generating content for "' . $peptide_name . '" (post ' . $post_id . ') using model: ' . ( $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash' ) );
|
||||
|
||||
// Allow developers to hook before generation starts.
|
||||
do_action( 'psa_before_generation', $post_id, $peptide_name );
|
||||
|
||||
// Generate the full content.
|
||||
$result = self::generate_peptide_content( $peptide_name );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
$err_msg = $result->get_error_message();
|
||||
error_log( 'PSA: Generation FAILED for "' . $peptide_name . '": ' . $err_msg );
|
||||
update_post_meta( $post_id, 'psa_generation_error', $err_msg );
|
||||
return;
|
||||
}
|
||||
|
||||
error_log( 'PSA: AI content generated successfully for "' . $peptide_name . '". Fields: ' . implode( ', ', array_keys( $result ) ) );
|
||||
|
||||
// FIX: Use 'overview' field (which the prompt returns) with fallback to 'description'.
|
||||
$post_content = $result['overview'] ?? $result['description'] ?? '';
|
||||
|
||||
// Safeguard: if post_content is still empty after generation, log a warning.
|
||||
if ( empty( trim( $post_content ) ) ) {
|
||||
error_log( 'PSA: WARNING — AI returned valid JSON but post content (overview/description) is empty for "' . $peptide_name . '". Available keys: ' . implode( ', ', array_keys( $result ) ) );
|
||||
update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description content. Available fields: ' . implode( ', ', array_keys( $result ) ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Try PubChem enrichment.
|
||||
$pubchem_data = null;
|
||||
if ( ! empty( $options['use_pubchem'] ) ) {
|
||||
$pubchem_result = PSA_PubChem::lookup( $peptide_name );
|
||||
if ( is_wp_error( $pubchem_result ) ) {
|
||||
// Log PubChem API failures.
|
||||
error_log( 'PSA: PubChem lookup failed for "' . $peptide_name . '": ' . $pubchem_result->get_error_message() );
|
||||
} elseif ( is_array( $pubchem_result ) ) {
|
||||
$pubchem_data = $pubchem_result;
|
||||
error_log( 'PSA: PubChem data found for "' . $peptide_name . '" (CID: ' . ( $pubchem_data['cid'] ?? 'unknown' ) . ')' );
|
||||
}
|
||||
}
|
||||
|
||||
// Update the placeholder with real content.
|
||||
$post_status = ( 'publish' === $auto_publish ) ? 'publish' : 'draft';
|
||||
|
||||
$update_result = wp_update_post(
|
||||
array(
|
||||
'ID' => $post_id,
|
||||
'post_title' => sanitize_text_field( $result['name'] ?? $peptide_name ),
|
||||
'post_content' => wp_kses_post( $post_content ),
|
||||
'post_status' => $post_status,
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
if ( is_wp_error( $update_result ) ) {
|
||||
error_log( 'PSA: wp_update_post FAILED for post ' . $post_id . ': ' . $update_result->get_error_message() );
|
||||
update_post_meta( $post_id, 'psa_generation_error', 'Failed to update post: ' . $update_result->get_error_message() );
|
||||
return;
|
||||
}
|
||||
|
||||
// Save all meta fields — prefer PubChem data for molecular properties.
|
||||
self::save_peptide_meta( $post_id, $result, $pubchem_data );
|
||||
|
||||
// Create a matching Knowledge Base article.
|
||||
self::create_kb_article( $post_id, $result, $peptide_name, $post_status );
|
||||
|
||||
// Clean up generation tracking.
|
||||
delete_post_meta( $post_id, 'psa_generation_started' );
|
||||
delete_post_meta( $post_id, 'psa_generation_error' );
|
||||
update_post_meta( $post_id, 'psa_generation_completed', time() );
|
||||
|
||||
// Allow developers to hook after generation completes.
|
||||
do_action( 'psa_after_generation', $post_id, $result, $peptide_name );
|
||||
|
||||
error_log( 'PSA: Successfully completed generation for "' . $peptide_name . '" (post ' . $post_id . ', status: ' . $post_status . ')' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate full peptide content via AI.
|
||||
*
|
||||
* @param string $peptide_name The peptide to research.
|
||||
* @return array|WP_Error Parsed peptide data or error.
|
||||
*/
|
||||
public static function generate_peptide_content( $peptide_name ) {
|
||||
$options = self::get_settings();
|
||||
$api_key = $options['api_key'];
|
||||
$model = $options['ai_model'];
|
||||
|
||||
if ( empty( $api_key ) ) {
|
||||
return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' );
|
||||
}
|
||||
|
||||
$prompt = self::build_generation_prompt( $peptide_name );
|
||||
// Allow filtering of generation prompt before sending.
|
||||
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
|
||||
$response = self::call_openrouter(
|
||||
$api_key,
|
||||
$model ? $model : 'google/gemini-2.5-flash',
|
||||
$prompt,
|
||||
PSA_Config::GENERATION_MAX_TOKENS
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return self::parse_ai_response( $response );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get plugin settings with defaults.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_settings() {
|
||||
$defaults = array(
|
||||
'api_key' => '',
|
||||
'ai_model' => '',
|
||||
'validation_model' => '',
|
||||
'auto_publish' => 'draft',
|
||||
'use_pubchem' => '1',
|
||||
);
|
||||
|
||||
$settings = wp_parse_args( get_option( 'psa_settings', array() ), $defaults );
|
||||
|
||||
// Prefer wp-config.php constant over database value for security.
|
||||
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
|
||||
$settings['api_key'] = PSA_OPENROUTER_KEY;
|
||||
}
|
||||
|
||||
// Allow filtering of settings before use.
|
||||
$settings = apply_filters( 'psa_ai_settings', $settings );
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save meta fields for a peptide post.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
* @param array $ai_data AI-generated data.
|
||||
* @param array|null $pubchem_data PubChem data (or null).
|
||||
*/
|
||||
private static function save_peptide_meta( $post_id, $ai_data, $pubchem_data ) {
|
||||
$meta = array(
|
||||
'psa_sequence' => $ai_data['sequence'] ?? '',
|
||||
'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] )
|
||||
? $pubchem_data['molecular_weight']
|
||||
: ( $ai_data['molecular_weight'] ?? '' ),
|
||||
'psa_molecular_formula' => ! empty( $pubchem_data['molecular_formula'] )
|
||||
? $pubchem_data['molecular_formula']
|
||||
: ( $ai_data['molecular_formula'] ?? '' ),
|
||||
'psa_aliases' => $ai_data['aliases'] ?? '',
|
||||
'psa_mechanism' => $ai_data['mechanism'] ?? '',
|
||||
'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
|
||||
'psa_category_label' => $ai_data['category_label'] ?? '',
|
||||
'psa_origin' => $ai_data['origin'] ?? '',
|
||||
'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
|
||||
'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
|
||||
'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
|
||||
'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
|
||||
'psa_references' => $ai_data['references'] ?? '',
|
||||
'psa_category' => $ai_data['category'] ?? '',
|
||||
'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
|
||||
'psa_pubchem_cid' => $pubchem_data['cid'] ?? '',
|
||||
);
|
||||
|
||||
// Allow filtering of meta before save.
|
||||
$meta = apply_filters( 'psa_peptide_meta', $meta, $post_id, $ai_data, $pubchem_data );
|
||||
|
||||
foreach ( $meta as $key => $value ) {
|
||||
update_post_meta( $post_id, $key, $value );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate peptide name input characters and block injection patterns.
|
||||
*
|
||||
* @param string $name The peptide name to validate.
|
||||
* @return bool True if input looks legitimate.
|
||||
*/
|
||||
private static function validate_peptide_input( $name ) {
|
||||
// Allow only characters that appear in legitimate peptide names.
|
||||
if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) {
|
||||
return false;
|
||||
}
|
||||
// Block obvious prompt injection patterns.
|
||||
$blocked = array( 'ignore', 'instruction', 'override', 'system prompt', 'forget', 'disregard', 'pretend' );
|
||||
$lower = strtolower( $name );
|
||||
foreach ( $blocked as $word ) {
|
||||
if ( strpos( $lower, $word ) !== false ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the lightweight validation prompt.
|
||||
*
|
||||
* Uses wp_json_encode() to safely encode the peptide name within the prompt.
|
||||
* This ensures special characters and quotes are properly escaped before sending to the AI.
|
||||
* The JSON-encoded format also helps the AI parser recognize the input clearly.
|
||||
*
|
||||
* @param string $name The peptide name to validate.
|
||||
* @return string Prompt text ready to send to OpenRouter API.
|
||||
*/
|
||||
private static function build_validation_prompt( $name ) {
|
||||
$json_name = wp_json_encode( $name );
|
||||
return <<<PROMPT
|
||||
You are a peptide and biochemistry expert. Determine if the peptide name given in the following JSON-encoded string is a real, recognized peptide, protein fragment, or peptide-based compound.
|
||||
|
||||
Peptide name: {$json_name}
|
||||
|
||||
Return ONLY valid JSON (no markdown fences) with exactly these fields:
|
||||
|
||||
{
|
||||
"is_valid": true or false,
|
||||
"canonical_name": "The standard/official name for this peptide (correcting capitalization or common abbreviations)",
|
||||
"reason": "Brief explanation of why this is or isn't a recognized peptide"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Return is_valid: true only for real peptides, peptide hormones, peptide-based drugs, neuropeptides, antimicrobial peptides, synthetic peptides used in research, or well-known protein fragments.
|
||||
- Return is_valid: false for random words, made-up names, non-peptide drugs, non-peptide supplements, general proteins (unless commonly referred to as peptides), or anything that is clearly not a peptide.
|
||||
- Be generous with research peptides and experimental compounds that are known in the scientific literature.
|
||||
- Common examples that ARE valid: BPC-157, Thymosin Beta-4, TB-500, GHK-Cu, Melanotan II, Ipamorelin, CJC-1295, Semaglutide, Tirzepatide, AOD-9604, PT-141, DSIP, Epitalon, LL-37, Oxytocin, Vasopressin, Substance P, etc.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the comprehensive generation prompt.
|
||||
*
|
||||
* Constructs a detailed prompt that instructs the AI to generate structured JSON output
|
||||
* with all required fields for a complete peptide database entry (name, sequence, mechanism,
|
||||
* research benefits, dosing, safety, legal status, references, etc).
|
||||
*
|
||||
* The prompt is designed to ensure the AI returns clean, factual information suitable for
|
||||
* scientific audiences. Uses JSON encoding for the peptide name to properly escape special characters.
|
||||
*
|
||||
* @param string $peptide_name The peptide to research.
|
||||
* @return string Prompt text ready to send to OpenRouter API.
|
||||
*/
|
||||
private static function build_generation_prompt( $peptide_name ) {
|
||||
$json_name = wp_json_encode( $peptide_name );
|
||||
return <<<PROMPT
|
||||
You are a scientific writer specializing in peptide research. Generate a comprehensive database entry for the peptide given in the following JSON-encoded string.
|
||||
|
||||
Peptide name: {$json_name}
|
||||
|
||||
Return ONLY valid JSON (no markdown fences, no commentary) with exactly these fields:
|
||||
|
||||
{
|
||||
"name": "Official peptide name",
|
||||
"aliases": "Comma-separated list of alternative names, abbreviations, or synonyms",
|
||||
"category_label": "e.g. Tissue Healing / Cytoprotective, or GH Secretagogue, etc.",
|
||||
"origin": "Origin description, e.g. Synthetic (derived from human gastric juice protein)",
|
||||
"sequence": "Full amino acid sequence in three-letter code with dashes, e.g. Gly-Glu-Pro-Pro-Pro (N amino acids). Use single-letter if three-letter not standard.",
|
||||
"molecular_weight": "Molecular weight in Daltons (e.g. 1419.53 Da)",
|
||||
"molecular_formula": "Chemical formula (e.g. C62H98N16O22)",
|
||||
"overview": "2-3 paragraphs: what this peptide is, its origin, discovery, and significance. Written for a scientifically literate audience.",
|
||||
"mechanism": "Detailed mechanism of action. Start with an intro paragraph, then describe each pathway as a labeled item like: Pathway name: Description. Separate items with double newlines.",
|
||||
"research_benefits": "Detailed research applications organized by therapeutic area. Use area names as subheadings followed by a paragraph, e.g.:\n\nMusculoskeletal Healing\nParagraph about musculoskeletal research...\n\nGastrointestinal Protection\nParagraph about GI research...\n\nInclude 3-5 areas depending on the peptide.",
|
||||
"administration_dosing": "Include a disclaimer that this is from research only. Then describe: typical research dose range, routes of administration, frequency, and duration from studies.",
|
||||
"safety_side_effects": "Known safety profile from animal studies. Include any anecdotally reported side effects. Note theoretical concerns. Mention that long-term human safety data is limited.",
|
||||
"legal_regulatory": "Current regulatory and legal status. Mention FDA status, WADA status if applicable, classification as research chemical.",
|
||||
"references": "5-8 real published scientific references with authors, title, journal, year. Use real studies only.",
|
||||
"category": "Classify into exactly ONE: GH Secretagogues, Healing & Repair, Melanocortin Peptides, Metabolic & Anti-Aging, Nootropic & Neuroprotective"
|
||||
}
|
||||
|
||||
Important:
|
||||
- Use factual, evidence-based information only.
|
||||
- If information is uncertain or unknown, say so explicitly rather than fabricating data.
|
||||
- All content is for research and educational purposes.
|
||||
- Write in clean paragraphs. Do not use markdown formatting symbols like # or ** or *.
|
||||
- For research_benefits, use the therapeutic area name on its own line as a subheading, followed by a descriptive paragraph.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OpenRouter API Call
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Call OpenRouter API with automatic retry on rate limits.
|
||||
*
|
||||
* Implements exponential backoff retry strategy for rate limit (429) errors.
|
||||
* On first rate limit: waits 5s before retry.
|
||||
* On second rate limit: waits 10s before retry.
|
||||
* On third rate limit: waits 20s before final attempt.
|
||||
* Other errors are returned immediately without retry.
|
||||
*
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @param string $model The model identifier.
|
||||
* @param string $prompt The prompt text.
|
||||
* @param int $max_tokens Maximum tokens to generate.
|
||||
* @return string|WP_Error Response text or error.
|
||||
*/
|
||||
private static function call_openrouter( $api_key, $model, $prompt, $max_tokens = PSA_Config::GENERATION_MAX_TOKENS ) {
|
||||
$max_retries = PSA_Config::API_RETRY_MAX;
|
||||
$base_delay = PSA_Config::API_RETRY_BASE_DELAY; // seconds
|
||||
|
||||
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
|
||||
$result = self::call_openrouter_once( $api_key, $model, $prompt, $max_tokens );
|
||||
|
||||
// If not a rate limit error, return immediately.
|
||||
if ( ! is_wp_error( $result ) || 'rate_limited' !== $result->get_error_code() ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Rate limited — retry with exponential backoff.
|
||||
if ( $attempt < $max_retries ) {
|
||||
$delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s
|
||||
// Avoid blocking PHP workers during WP-Cron execution.
|
||||
if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
|
||||
error_log( 'PSA: Rate limited during cron on attempt ' . $attempt . '/' . $max_retries . '. Returning to free PHP worker.' );
|
||||
return new WP_Error( 'rate_limited', 'Rate limited during background generation. Will retry on next cron trigger.' );
|
||||
}
|
||||
error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' );
|
||||
sleep( $delay );
|
||||
}
|
||||
}
|
||||
|
||||
error_log( 'PSA: All ' . $max_retries . ' attempts failed due to rate limiting.' );
|
||||
return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached after ' . $max_retries . ' retries. Consider adding credits at openrouter.ai or switching to a non-free model.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Single OpenRouter API call (no retry).
|
||||
*
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @param string $model The model identifier.
|
||||
* @param string $prompt The prompt text.
|
||||
* @param int $max_tokens Maximum tokens to generate.
|
||||
* @return string|WP_Error Response text or error.
|
||||
*/
|
||||
private static function call_openrouter_once( $api_key, $model, $prompt, $max_tokens = 4096 ) {
|
||||
$site_url = get_bloginfo( 'url' );
|
||||
$site_name = get_bloginfo( 'name' );
|
||||
|
||||
$response = wp_remote_post(
|
||||
self::API_URL,
|
||||
array(
|
||||
'timeout' => 120,
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'HTTP-Referer' => $site_url,
|
||||
'X-Title' => $site_name,
|
||||
),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'model' => $model,
|
||||
'messages' => array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => 'You are a scientific database assistant. Return only valid JSON.',
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
),
|
||||
),
|
||||
'max_tokens' => $max_tokens,
|
||||
'temperature' => 0.3,
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
error_log( 'PSA: OpenRouter connection error: ' . $response->get_error_message() );
|
||||
return new WP_Error( 'api_error', 'Failed to connect to OpenRouter API: ' . $response->get_error_message() );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
$raw_body = wp_remote_retrieve_body( $response );
|
||||
$body = json_decode( $raw_body, true );
|
||||
|
||||
error_log( 'PSA: OpenRouter response code: ' . $code . ' for model: ' . $model );
|
||||
|
||||
if ( 200 !== $code ) {
|
||||
if ( 429 === $code ) {
|
||||
error_log( 'PSA: OpenRouter rate limited (429)' );
|
||||
return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached. Please try again in a few moments.' );
|
||||
}
|
||||
if ( 402 === $code ) {
|
||||
error_log( 'PSA: OpenRouter insufficient credits (402)' );
|
||||
return new WP_Error( 'insufficient_credits', 'OpenRouter account has insufficient credits. Please add funds at openrouter.ai.' );
|
||||
}
|
||||
$msg = $body['error']['message'] ?? ( 'Unknown error (HTTP ' . $code . ')' );
|
||||
error_log( 'PSA: OpenRouter API error (' . $code . '): ' . $msg );
|
||||
error_log( 'PSA: OpenRouter raw response: ' . substr( $raw_body, 0, 500 ) );
|
||||
return new WP_Error( 'api_error', 'OpenRouter API error: ' . $msg );
|
||||
}
|
||||
|
||||
if ( empty( $body['choices'][0]['message']['content'] ) ) {
|
||||
error_log( 'PSA: OpenRouter returned empty content. Raw: ' . substr( $raw_body, 0, 500 ) );
|
||||
return new WP_Error( 'api_error', 'OpenRouter API returned an empty response.' );
|
||||
}
|
||||
|
||||
return $body['choices'][0]['message']['content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response text into structured data.
|
||||
*
|
||||
* @param string $response_text Raw response text.
|
||||
* @return array|WP_Error Parsed data or error.
|
||||
*/
|
||||
private static function parse_ai_response( $response_text ) {
|
||||
// Strip any <think>...</think> tags (some models like DeepSeek include reasoning).
|
||||
$cleaned = preg_replace( '/<think>.*?<\/think>/s', '', $response_text );
|
||||
|
||||
// Strip markdown code fences if present.
|
||||
$cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $cleaned );
|
||||
$cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned );
|
||||
$cleaned = trim( $cleaned );
|
||||
|
||||
$data = json_decode( $cleaned, true );
|
||||
if ( JSON_ERROR_NONE !== json_last_error() ) {
|
||||
error_log( 'PSA: JSON parse error: ' . json_last_error_msg() . '. Raw (first 500 chars): ' . substr( $response_text, 0, 500 ) );
|
||||
return new WP_Error(
|
||||
'parse_error',
|
||||
'Failed to parse AI response as JSON: ' . json_last_error_msg()
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a matching Echo Knowledge Base article with full structured content.
|
||||
*
|
||||
* @param int $peptide_post_id The peptide post ID.
|
||||
* @param array $ai_data The parsed AI data.
|
||||
* @param string $peptide_name The peptide name.
|
||||
* @param string $post_status The post status (default: 'draft').
|
||||
*/
|
||||
private static function create_kb_article( $peptide_post_id, $ai_data, $peptide_name, $post_status = 'draft' ) {
|
||||
if ( ! post_type_exists( 'epkb_post_type_1' ) ) {
|
||||
error_log( 'PSA: Echo Knowledge Base not active — skipping KB article.' );
|
||||
return;
|
||||
}
|
||||
|
||||
$title = sanitize_text_field( $ai_data['name'] ?? $peptide_name );
|
||||
$existing = get_posts(
|
||||
array(
|
||||
'post_type' => 'epkb_post_type_1',
|
||||
'title' => $title,
|
||||
'post_status' => 'any',
|
||||
'numberposts' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $existing ) ) {
|
||||
error_log( 'PSA: KB article already exists for "' . $peptide_name . '".' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Build header meta line.
|
||||
$cat_label = ! empty( $ai_data['category_label'] ) ? $ai_data['category_label'] : '';
|
||||
$origin = ! empty( $ai_data['origin'] ) ? $ai_data['origin'] : '';
|
||||
$sequence = ! empty( $ai_data['sequence'] ) ? $ai_data['sequence'] : '';
|
||||
|
||||
$header = '';
|
||||
if ( $cat_label ) {
|
||||
$header .= '<strong>Category:</strong> ' . esc_html( $cat_label );
|
||||
}
|
||||
if ( $origin ) {
|
||||
$header .= ' | <strong>Origin:</strong> ' . esc_html( $origin );
|
||||
}
|
||||
if ( $sequence ) {
|
||||
$header .= ' | <strong>Sequence:</strong> ' . esc_html( $sequence );
|
||||
}
|
||||
|
||||
// Build full article content.
|
||||
$c = $header ? '<p>' . $header . '</p>' . "\n\n" : '';
|
||||
|
||||
// Overview.
|
||||
$c .= '<h2>Overview</h2>' . "\n";
|
||||
$c .= self::paragraphs( $ai_data['overview'] ?? $ai_data['description'] ?? '' );
|
||||
|
||||
// Mechanism of Action.
|
||||
if ( ! empty( $ai_data['mechanism'] ) ) {
|
||||
$c .= '<h2>Mechanism of Action</h2>' . "\n";
|
||||
$c .= self::paragraphs( $ai_data['mechanism'] );
|
||||
}
|
||||
|
||||
// Research & Potential Benefits (with h3 subheadings).
|
||||
if ( ! empty( $ai_data['research_benefits'] ) || ! empty( $ai_data['research_applications'] ) ) {
|
||||
$c .= '<h2>Research & Potential Benefits</h2>' . "\n";
|
||||
$c .= '<p><em>The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.</em></p>' . "\n";
|
||||
$research = $ai_data['research_benefits'] ?? $ai_data['research_applications'] ?? '';
|
||||
$c .= self::research_with_subheadings( $research );
|
||||
}
|
||||
|
||||
// Administration & Dosing.
|
||||
if ( ! empty( $ai_data['administration_dosing'] ) || ! empty( $ai_data['dosage_info'] ) ) {
|
||||
$c .= '<h2>Administration & Dosing (Research Context)</h2>' . "\n";
|
||||
$c .= '<p><strong>Important:</strong> The following dosing information is derived from animal studies and extrapolated research. This peptide is not approved for human use. This information is provided for educational purposes only.</p>' . "\n";
|
||||
$c .= self::paragraphs( $ai_data['administration_dosing'] ?? $ai_data['dosage_info'] ?? '' );
|
||||
}
|
||||
|
||||
// Safety & Side Effects.
|
||||
if ( ! empty( $ai_data['safety_side_effects'] ) || ! empty( $ai_data['safety_profile'] ) ) {
|
||||
$c .= '<h2>Safety & Side Effects</h2>' . "\n";
|
||||
$c .= self::paragraphs( $ai_data['safety_side_effects'] ?? $ai_data['safety_profile'] ?? '' );
|
||||
}
|
||||
|
||||
// Legal & Regulatory Status.
|
||||
if ( ! empty( $ai_data['legal_regulatory'] ) ) {
|
||||
$c .= '<h2>Legal & Regulatory Status</h2>' . "\n";
|
||||
$c .= self::paragraphs( $ai_data['legal_regulatory'] );
|
||||
}
|
||||
|
||||
// References.
|
||||
if ( ! empty( $ai_data['references'] ) ) {
|
||||
$c .= '<h2>Notable Research References</h2>' . "\n";
|
||||
$c .= self::paragraphs( $ai_data['references'] );
|
||||
}
|
||||
|
||||
// Medical Disclaimer.
|
||||
$c .= "\n" . '<p><strong>Medical Disclaimer:</strong> The information on this page is provided for educational and research purposes only. This peptide is not approved for human therapeutic use. Nothing on this page constitutes medical advice. Always consult a licensed healthcare professional before using any peptide, supplement, or research chemical.</p>';
|
||||
|
||||
$kb_post_id = wp_insert_post(
|
||||
array(
|
||||
'post_title' => $title,
|
||||
'post_content' => wp_kses_post( $c ),
|
||||
'post_type' => 'epkb_post_type_1',
|
||||
'post_status' => $post_status,
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
if ( is_wp_error( $kb_post_id ) ) {
|
||||
error_log( 'PSA: Failed to create KB article: ' . $kb_post_id->get_error_message() );
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign KB category.
|
||||
$category_map = array(
|
||||
'GH Secretagogues' => 'gh-secretagogues',
|
||||
'Healing & Repair' => 'healing-repair',
|
||||
'Melanocortin Peptides' => 'melanocortin-peptides',
|
||||
'Metabolic & Anti-Aging' => 'metabolic-anti-aging',
|
||||
'Nootropic & Neuroprotective' => 'nootropic-neuroprotective',
|
||||
);
|
||||
|
||||
$cat_name = trim( $ai_data['category'] ?? '' );
|
||||
if ( isset( $category_map[ $cat_name ] ) ) {
|
||||
$term = get_term_by( 'slug', $category_map[ $cat_name ], 'epkb_post_type_1_category' );
|
||||
if ( $term ) {
|
||||
wp_set_object_terms( $kb_post_id, array( (int) $term->term_id ), 'epkb_post_type_1_category' );
|
||||
}
|
||||
}
|
||||
|
||||
update_post_meta( $peptide_post_id, 'psa_kb_article_id', $kb_post_id );
|
||||
update_post_meta( $kb_post_id, 'psa_peptide_post_id', $peptide_post_id );
|
||||
|
||||
error_log( 'PSA: Created KB article #' . $kb_post_id . ' for "' . $peptide_name . '"' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert text to HTML paragraphs.
|
||||
*/
|
||||
private static function paragraphs( $text ) {
|
||||
if ( empty( $text ) ) {
|
||||
return '';
|
||||
}
|
||||
$text = str_replace( "\r\n", "\n", $text );
|
||||
$parts = preg_split( '/\n{2,}/', trim( $text ) );
|
||||
$out = '';
|
||||
foreach ( $parts as $p ) {
|
||||
$p = trim( $p );
|
||||
if ( '' === $p ) {
|
||||
continue;
|
||||
}
|
||||
$out .= '<p>' . wp_kses_post( $p ) . '</p>' . "\n";
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse research text into h3 subheadings + paragraphs.
|
||||
*/
|
||||
private static function research_with_subheadings( $text ) {
|
||||
if ( empty( $text ) ) {
|
||||
return '';
|
||||
}
|
||||
$text = str_replace( "\r\n", "\n", trim( $text ) );
|
||||
$blocks = preg_split( '/\n{2,}/', $text );
|
||||
$out = '';
|
||||
foreach ( $blocks as $block ) {
|
||||
$block = trim( $block );
|
||||
if ( '' === $block ) {
|
||||
continue;
|
||||
}
|
||||
// If block is short (under 80 chars) and has no period, treat as a subheading.
|
||||
if ( strlen( $block ) < 80 && strpos( $block, '.' ) === false ) {
|
||||
$out .= '<h3>' . esc_html( $block ) . '</h3>' . "\n";
|
||||
} else {
|
||||
// Check if first line is a subheading followed by content.
|
||||
$first_nl = strpos( $block, "\n" );
|
||||
if ( false !== $first_nl ) {
|
||||
$first_line = trim( substr( $block, 0, $first_nl ) );
|
||||
$rest = trim( substr( $block, $first_nl + 1 ) );
|
||||
if ( strlen( $first_line ) < 80 && strpos( $first_line, '.' ) === false ) {
|
||||
$out .= '<h3>' . esc_html( $first_line ) . '</h3>' . "\n";
|
||||
$out .= '<p>' . wp_kses_post( $rest ) . '</p>' . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$out .= '<p>' . wp_kses_post( $block ) . '</p>' . "\n";
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuration constants for Peptide Search AI.
|
||||
* Centralizes all magic numbers and configuration values.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Config {
|
||||
|
||||
// Search query validation
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
const MAX_QUERY_LENGTH = 100;
|
||||
|
||||
// Rate limiting (requests per hour)
|
||||
const REST_RATE_LIMIT = 20;
|
||||
const AJAX_RATE_LIMIT = 10;
|
||||
|
||||
// Daily generation cap
|
||||
const DAILY_GENERATION_CAP = 50;
|
||||
|
||||
// Generation timing
|
||||
const PENDING_TIMEOUT = 300; // seconds (5 minutes)
|
||||
const MAX_GENERATION_RETRIES = 3;
|
||||
|
||||
// AI token limits
|
||||
const VALIDATION_MAX_TOKENS = 300;
|
||||
const GENERATION_MAX_TOKENS = 4096;
|
||||
|
||||
// Cache TTL
|
||||
const VALIDATION_CACHE_TTL = null; // Set to DAY_IN_SECONDS at runtime
|
||||
const PUBCHEM_CACHE_TTL = null; // Set to 7 * DAY_IN_SECONDS at runtime
|
||||
|
||||
// API retry settings
|
||||
const API_RETRY_MAX = 3;
|
||||
const API_RETRY_BASE_DELAY = 5; // seconds
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Standardized error handling for Peptide Search AI.
|
||||
* Provides a unified interface for sending error responses with consistent structure.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
* @deprecated 4.1.0 Currently unused. Retained for potential future integration.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Error {
|
||||
|
||||
// Error type constants
|
||||
const TYPE_VALIDATION = 'validation_error';
|
||||
const TYPE_RATE_LIMIT = 'rate_limit_error';
|
||||
const TYPE_SYSTEM = 'system_error';
|
||||
const TYPE_NOT_FOUND = 'not_found';
|
||||
|
||||
/**
|
||||
* Send a standardized error response.
|
||||
*
|
||||
* @param string $type The error type (one of the TYPE_* constants).
|
||||
* @param string $message User-friendly error message.
|
||||
* @param int $http_code HTTP response code (default 400).
|
||||
* @return void
|
||||
*/
|
||||
public static function send( $type, $message, $http_code = 400 ) {
|
||||
wp_send_json_error(
|
||||
array(
|
||||
'error_type' => $type,
|
||||
'message' => $message,
|
||||
),
|
||||
$http_code
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Registers the Peptide custom post type and its meta fields.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Post_Type {
|
||||
|
||||
/** Meta field keys used throughout the plugin. */
|
||||
const META_FIELDS = array(
|
||||
'psa_sequence' => 'Amino Acid Sequence',
|
||||
'psa_molecular_weight' => 'Molecular Weight',
|
||||
'psa_molecular_formula' => 'Molecular Formula',
|
||||
'psa_aliases' => 'Aliases / Synonyms',
|
||||
'psa_mechanism' => 'Mechanism of Action',
|
||||
'psa_research_apps' => 'Research Applications',
|
||||
'psa_safety_profile' => 'Safety & Side Effects',
|
||||
'psa_dosage_info' => 'Dosage Information',
|
||||
'psa_references' => 'References & Citations',
|
||||
'psa_source' => 'Data Source',
|
||||
'psa_pubchem_cid' => 'PubChem CID',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register admin-only hooks (meta boxes, save).
|
||||
*/
|
||||
public static function init_admin() {
|
||||
add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
|
||||
add_action( 'save_post_peptide', array( __CLASS__, 'save_meta' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the peptide CPT.
|
||||
*/
|
||||
public static function register_peptide_post_type() {
|
||||
if ( post_type_exists( 'peptide' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = array(
|
||||
'name' => 'Peptides',
|
||||
'singular_name' => 'Peptide',
|
||||
'menu_name' => 'Peptides',
|
||||
'add_new' => 'Add Peptide',
|
||||
'add_new_item' => 'Add New Peptide',
|
||||
'edit_item' => 'Edit Peptide',
|
||||
'new_item' => 'New Peptide',
|
||||
'view_item' => 'View Peptide',
|
||||
'search_items' => 'Search Peptides',
|
||||
'not_found' => 'No peptides found',
|
||||
'not_found_in_trash' => 'No peptides found in Trash',
|
||||
);
|
||||
|
||||
register_post_type(
|
||||
'peptide',
|
||||
array(
|
||||
'labels' => $labels,
|
||||
'public' => true,
|
||||
'has_archive' => true,
|
||||
'rewrite' => array( 'slug' => 'peptides' ),
|
||||
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
|
||||
'menu_icon' => 'dashicons-database',
|
||||
'show_in_rest' => true,
|
||||
'capability_type' => 'post',
|
||||
'hierarchical' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add meta boxes to the peptide editor.
|
||||
*/
|
||||
public static function add_meta_boxes() {
|
||||
add_meta_box(
|
||||
'psa_peptide_data',
|
||||
'Peptide Scientific Data',
|
||||
array( __CLASS__, 'render_meta_box' ),
|
||||
'peptide',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the meta box fields.
|
||||
*
|
||||
* @param WP_Post $post The current post object.
|
||||
*/
|
||||
public static function render_meta_box( $post ) {
|
||||
wp_nonce_field( 'psa_save_meta', 'psa_meta_nonce' );
|
||||
|
||||
$textarea_fields = array(
|
||||
'psa_sequence',
|
||||
'psa_mechanism',
|
||||
'psa_research_apps',
|
||||
'psa_safety_profile',
|
||||
'psa_dosage_info',
|
||||
'psa_references',
|
||||
);
|
||||
|
||||
echo '<table class="form-table">';
|
||||
foreach ( self::META_FIELDS as $key => $label ) {
|
||||
$value = get_post_meta( $post->ID, $key, true );
|
||||
echo '<tr>';
|
||||
echo '<th><label for="' . esc_attr( $key ) . '">' . esc_html( $label ) . '</label></th>';
|
||||
echo '<td>';
|
||||
|
||||
if ( 'psa_source' === $key ) {
|
||||
$options = array(
|
||||
'' => '— Select —',
|
||||
'ai-generated' => 'AI Generated',
|
||||
'pubchem' => 'PubChem Verified',
|
||||
'manual' => 'Manual Entry',
|
||||
'pending' => 'Pending Generation',
|
||||
);
|
||||
echo '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '">';
|
||||
foreach ( $options as $opt_val => $opt_label ) {
|
||||
echo '<option value="' . esc_attr( $opt_val ) . '"' . selected( $value, $opt_val, false ) . '>' . esc_html( $opt_label ) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
} elseif ( in_array( $key, $textarea_fields, true ) ) {
|
||||
echo '<textarea name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" rows="4" class="large-text">' . esc_textarea( $value ) . '</textarea>';
|
||||
} else {
|
||||
echo '<input type="text" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="regular-text" />';
|
||||
}
|
||||
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</table>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save meta field values.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
*/
|
||||
public static function save_meta( $post_id ) {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
if ( ! isset( $_POST['psa_meta_nonce'] ) ||
|
||||
! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
|
||||
return;
|
||||
}
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( self::META_FIELDS as $key => $label ) {
|
||||
if ( isset( $_POST[ $key ] ) ) {
|
||||
$value = sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) );
|
||||
update_post_meta( $post_id, $key, $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* PubChem PUG REST API integration for molecular data enrichment.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_PubChem {
|
||||
|
||||
const API_BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
|
||||
|
||||
/**
|
||||
* Look up a peptide/compound by name and return molecular properties.
|
||||
* Results are cached in transients to respect PubChem rate limits.
|
||||
*
|
||||
* @param string $name Peptide or compound name.
|
||||
* @return array|WP_Error|null Array of properties, WP_Error on failure, or null if not found.
|
||||
*/
|
||||
public static function lookup( $name ) {
|
||||
$options = get_option( 'psa_settings', array() );
|
||||
$use_pubchem = $options['use_pubchem'] ?? '1';
|
||||
|
||||
if ( '1' !== $use_pubchem ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first (respect PubChem's 5 req/sec limit).
|
||||
$cache_key = 'psa_pubchem_' . md5( strtolower( trim( $name ) ) );
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
// We store 'not_found' string to differentiate from cache miss.
|
||||
if ( 'not_found' === $cached ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_wp_error( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Step 1: Get the CID by compound name.
|
||||
$cid = self::get_cid_by_name( $name );
|
||||
if ( is_wp_error( $cid ) ) {
|
||||
set_transient( $cache_key, $cid, 1 * HOUR_IN_SECONDS );
|
||||
return $cid;
|
||||
}
|
||||
if ( ! $cid ) {
|
||||
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Get properties by CID.
|
||||
$properties = self::get_properties( $cid );
|
||||
if ( is_wp_error( $properties ) ) {
|
||||
set_transient( $cache_key, $properties, 1 * HOUR_IN_SECONDS );
|
||||
return $properties;
|
||||
}
|
||||
if ( ! $properties ) {
|
||||
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
$properties['cid'] = $cid;
|
||||
|
||||
// Cache successful lookups for 7 days.
|
||||
$ttl = PSA_Config::PUBCHEM_CACHE_TTL ? PSA_Config::PUBCHEM_CACHE_TTL : 7 * DAY_IN_SECONDS;
|
||||
set_transient( $cache_key, $properties, $ttl );
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PubChem Compound ID (CID) by name.
|
||||
*
|
||||
* @param string $name Compound name.
|
||||
* @return int|WP_Error|null CID, WP_Error on API failure, or null if not found.
|
||||
*/
|
||||
private static function get_cid_by_name( $name ) {
|
||||
$url = self::API_BASE . '/compound/name/' . rawurlencode( $name ) . '/cids/JSON';
|
||||
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error( 'pubchem_api_error', 'PubChem API request failed: ' . $response->get_error_message() );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return new WP_Error( 'pubchem_http_error', 'PubChem returned HTTP ' . $code );
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
return isset( $body['IdentifierList']['CID'][0] )
|
||||
? (int) $body['IdentifierList']['CID'][0]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get molecular properties for a given CID.
|
||||
*
|
||||
* @param int $cid PubChem Compound ID.
|
||||
* @return array|WP_Error|null Properties array, WP_Error on failure, or null if not found.
|
||||
*/
|
||||
private static function get_properties( $cid ) {
|
||||
$props = 'MolecularFormula,MolecularWeight,IUPACName,CanonicalSMILES,InChI';
|
||||
$url = self::API_BASE . '/compound/cid/' . (int) $cid . '/property/' . $props . '/JSON';
|
||||
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error( 'pubchem_api_error', 'PubChem API request failed: ' . $response->get_error_message() );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return new WP_Error( 'pubchem_http_error', 'PubChem returned HTTP ' . $code );
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! isset( $body['PropertyTable']['Properties'][0] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$p = $body['PropertyTable']['Properties'][0];
|
||||
|
||||
return array(
|
||||
'molecular_formula' => $p['MolecularFormula'] ?? '',
|
||||
'molecular_weight' => isset( $p['MolecularWeight'] ) ? $p['MolecularWeight'] . ' Da' : '',
|
||||
'iupac_name' => $p['IUPACName'] ?? '',
|
||||
'smiles' => $p['CanonicalSMILES'] ?? '',
|
||||
'inchi' => $p['InChI'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Handles AJAX search, the [peptide_search] shortcode, and the REST API endpoint.
|
||||
* When a peptide is not found, it automatically validates and queues generation.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Search {
|
||||
|
||||
/**
|
||||
* Static counter for unique shortcode instance IDs.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $instance_count = 0;
|
||||
|
||||
public static function init() {
|
||||
add_shortcode( 'peptide_search', array( __CLASS__, 'render_search_form' ) );
|
||||
add_action( 'wp_ajax_psa_search', array( __CLASS__, 'ajax_search' ) );
|
||||
add_action( 'wp_ajax_nopriv_psa_search', array( __CLASS__, 'ajax_search' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
|
||||
// Cache invalidation when a peptide is saved.
|
||||
add_action( 'save_post_peptide', array( __CLASS__, 'invalidate_search_cache' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all search caches when a peptide is published/updated.
|
||||
* Since we can't know which search queries would be affected, clear all caches.
|
||||
*
|
||||
* @param int $post_id The post ID being saved.
|
||||
*/
|
||||
public static function invalidate_search_cache( $post_id ) {
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->options}
|
||||
WHERE option_name LIKE '_transient_psa_search_%'
|
||||
OR option_name LIKE '_transient_timeout_psa_search_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes for external/headless access.
|
||||
* Note: REST route only returns existing published results — it does NOT trigger generation.
|
||||
*/
|
||||
public static function register_rest_routes() {
|
||||
register_rest_route(
|
||||
'peptides/v1',
|
||||
'/search',
|
||||
array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( __CLASS__, 'rest_search' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'q' => array(
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ( $param ) {
|
||||
return is_string( $param ) && strlen( $param ) >= PSA_Config::MIN_QUERY_LENGTH;
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST search callback — returns published peptides only (no generation trigger).
|
||||
*
|
||||
* @param WP_REST_Request $request The REST request.
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public static function rest_search( $request ) {
|
||||
// Rate limiting: max REST_RATE_LIMIT requests per IP per hour.
|
||||
$ip = psa_get_client_ip();
|
||||
$rate_key = 'psa_rest_rate_' . md5( $ip );
|
||||
$count = (int) get_transient( $rate_key );
|
||||
|
||||
if ( $count >= PSA_Config::REST_RATE_LIMIT ) {
|
||||
return new WP_REST_Response(
|
||||
array( 'error' => 'Rate limit exceeded. Please try again later.' ),
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
|
||||
|
||||
$query = $request->get_param( 'q' );
|
||||
|
||||
// Max input length validation.
|
||||
if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
|
||||
return new WP_REST_Response(
|
||||
array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ),
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$results = self::search_peptides( $query );
|
||||
return new WP_REST_Response( $results, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the [peptide_search] shortcode.
|
||||
*
|
||||
* Uses class-based selectors (no IDs) to support multiple shortcode instances
|
||||
* on the same page (e.g., hero section + search overlay). Each instance is
|
||||
* independently functional via scoped JS initialization.
|
||||
*
|
||||
* @param array $atts Shortcode attributes.
|
||||
* @return string HTML output.
|
||||
*/
|
||||
public static function render_search_form( $atts ) {
|
||||
self::$instance_count++;
|
||||
$instance = self::$instance_count;
|
||||
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...',
|
||||
),
|
||||
$atts,
|
||||
'peptide_search'
|
||||
);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="psa-search-wrap" data-psa-instance="<?php echo esc_attr( $instance ); ?>">
|
||||
<form class="psa-search-form" role="search" autocomplete="off">
|
||||
<div class="psa-search-input-wrap">
|
||||
<input type="text" class="psa-search-input" placeholder="<?php echo esc_attr( $atts['placeholder'] ); ?>" aria-label="Search peptide database" minlength="2" required />
|
||||
<button type="submit" class="psa-search-btn">
|
||||
<span class="psa-search-icon">🔍</span> Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="psa-search-results" aria-live="polite" aria-busy="false" style="display:none;"></div>
|
||||
<div class="psa-checking" role="status" style="display:none;">
|
||||
<div class="psa-checking-inner">
|
||||
<div class="psa-spinner"></div>
|
||||
<p class="psa-checking-text">Searching our database...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="psa-pending" role="status" style="display:none;">
|
||||
<div class="psa-pending-inner">
|
||||
<div class="psa-pending-icon">⏳</div>
|
||||
<p>This peptide is currently being added to our database. Please check back again later.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="psa-invalid" role="status" style="display:none;">
|
||||
<div class="psa-invalid-inner">
|
||||
<p class="psa-invalid-text">Peptide not found.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="psa-error" role="status" style="display:none;"></div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: search for peptides.
|
||||
* Orchestrates complete workflow: search, pending check, rate limiting, validation, generation.
|
||||
*
|
||||
* @return void Exits with wp_send_json_* responses.
|
||||
*/
|
||||
public static function ajax_search() {
|
||||
check_ajax_referer( 'psa_search_nonce', 'nonce' );
|
||||
|
||||
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
|
||||
|
||||
// Validate query length using PSA_Config constants.
|
||||
if ( mb_strlen( $query ) < PSA_Config::MIN_QUERY_LENGTH || mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
|
||||
wp_send_json_error( 'Search query must be between ' . PSA_Config::MIN_QUERY_LENGTH . ' and ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' );
|
||||
}
|
||||
|
||||
// 1. Search existing published peptides.
|
||||
$results = self::search_peptides( $query );
|
||||
|
||||
if ( ! empty( $results['results'] ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'found',
|
||||
'data' => $results,
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if already pending — handles retries if timed out.
|
||||
$pending = self::find_pending_peptide( $query );
|
||||
if ( $pending ) {
|
||||
self::handle_pending_retry( $pending );
|
||||
return; // handle_pending_retry() exits with wp_send_json_success()
|
||||
}
|
||||
|
||||
// 3. Check rate limit before attempting new generation.
|
||||
if ( ! self::check_rate_limit() ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'rate_limited',
|
||||
'message' => 'Too many requests. Please try again later.',
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Validate and generate.
|
||||
self::handle_new_generation( $query );
|
||||
return; // handle_new_generation() exits with wp_send_json_*()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a pending peptide — retry if timed out, else return pending status.
|
||||
*
|
||||
* @param WP_Post $pending The pending post.
|
||||
* @return void Exits with wp_send_json_success().
|
||||
*/
|
||||
private static function handle_pending_retry( $pending ) {
|
||||
$started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true );
|
||||
$retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true );
|
||||
$timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT;
|
||||
|
||||
if ( $timed_out ) {
|
||||
if ( $retry_count >= PSA_Config::MAX_GENERATION_RETRIES ) {
|
||||
// Max retries exceeded — mark as failed.
|
||||
error_log( 'PSA: Max retries (' . PSA_Config::MAX_GENERATION_RETRIES . ') exceeded for "' . $pending->post_title . '" (post ' . $pending->ID . '). Marking as failed.' );
|
||||
update_post_meta( $pending->ID, 'psa_source', 'failed' );
|
||||
update_post_meta( $pending->ID, 'psa_generation_error', 'Generation failed after ' . PSA_Config::MAX_GENERATION_RETRIES . ' attempts.' );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'invalid',
|
||||
'message' => 'We were unable to generate content for this peptide. Please try again later.',
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry generation.
|
||||
error_log( 'PSA: Retrying stale pending peptide ' . $pending->post_title . ' (post ' . $pending->ID . ', attempt ' . ( $retry_count + 1 ) . '/' . PSA_Config::MAX_GENERATION_RETRIES . ')' );
|
||||
update_post_meta( $pending->ID, 'psa_generation_started', time() );
|
||||
update_post_meta( $pending->ID, 'psa_generation_attempts', $retry_count + 1 );
|
||||
self::schedule_background_generation( $pending->ID, $pending->post_title );
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'pending',
|
||||
'peptide_name' => $pending->post_title,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for AJAX generation requests.
|
||||
*
|
||||
* @return bool True if under limit, false if limit exceeded.
|
||||
*/
|
||||
private static function check_rate_limit() {
|
||||
$ip = psa_get_client_ip();
|
||||
$rate_key = 'psa_rate_' . md5( $ip );
|
||||
$count = (int) get_transient( $rate_key );
|
||||
|
||||
return $count < PSA_Config::AJAX_RATE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new generation request — validate, create placeholder, and schedule generation.
|
||||
*
|
||||
* @param string $query The search query (peptide name).
|
||||
* @return void Exits with wp_send_json_* responses.
|
||||
*/
|
||||
private static function handle_new_generation( $query ) {
|
||||
// Global daily generation cap to prevent cost overruns.
|
||||
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
|
||||
$daily_count = (int) get_transient( $daily_key );
|
||||
if ( $daily_count >= PSA_Config::DAILY_GENERATION_CAP ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'rate_limited',
|
||||
'message' => 'Our system has reached its daily limit for adding new peptides. Please try again tomorrow.',
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate peptide name via AI.
|
||||
$validation = PSA_AI_Generator::validate_peptide_name( $query );
|
||||
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
error_log( 'PSA: Validation error for "' . $query . '": ' . $validation->get_error_message() );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'invalid',
|
||||
'message' => 'Could not verify this peptide name.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( empty( $validation['is_valid'] ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'invalid',
|
||||
'message' => $validation['reason'] ?? 'This does not appear to be a recognized peptide.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// It's a real peptide — increment rate limit and create placeholder.
|
||||
$ip = psa_get_client_ip();
|
||||
$rate_key = 'psa_rate_' . md5( $ip );
|
||||
$count = (int) get_transient( $rate_key );
|
||||
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
|
||||
|
||||
$canonical_name = sanitize_text_field( $validation['canonical_name'] ?? $query );
|
||||
$placeholder_id = self::create_pending_placeholder( $canonical_name );
|
||||
|
||||
if ( is_wp_error( $placeholder_id ) ) {
|
||||
wp_send_json_error( 'Failed to create peptide entry. Please try again.' );
|
||||
}
|
||||
|
||||
// Increment global daily generation counter.
|
||||
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
|
||||
$daily_count = (int) get_transient( $daily_key );
|
||||
set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS );
|
||||
|
||||
// Schedule async background generation.
|
||||
error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' );
|
||||
self::schedule_background_generation( $placeholder_id, $canonical_name );
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'pending',
|
||||
'peptide_name' => $canonical_name,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing pending peptide post by name.
|
||||
*
|
||||
* Performs a multi-table query to locate pending draft posts by title or alias.
|
||||
* Queries the posts table joined with postmeta for 'psa_source' = 'pending' status,
|
||||
* plus LEFT JOIN on postmeta for aliases. Returns the most recent match.
|
||||
*
|
||||
* @param string $query Search term (peptide name or alias).
|
||||
* @return WP_Post|null Pending post object or null if not found.
|
||||
*/
|
||||
public static function find_pending_peptide( $query ) {
|
||||
global $wpdb;
|
||||
|
||||
$like = '%' . $wpdb->esc_like( $query ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->postmeta} src ON p.ID = src.post_id
|
||||
AND src.meta_key = 'psa_source' AND src.meta_value = 'pending'
|
||||
LEFT JOIN {$wpdb->postmeta} alias ON p.ID = alias.post_id
|
||||
AND alias.meta_key = 'psa_aliases'
|
||||
WHERE p.post_type = 'peptide'
|
||||
AND p.post_status = 'draft'
|
||||
AND ( p.post_title LIKE %s OR alias.meta_value LIKE %s )
|
||||
ORDER BY p.post_date DESC
|
||||
LIMIT 1",
|
||||
$like,
|
||||
$like
|
||||
)
|
||||
);
|
||||
|
||||
return $post_id ? get_post( $post_id ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a placeholder draft post so we can track the pending state.
|
||||
*
|
||||
* @param string $peptide_name The canonical peptide name.
|
||||
* @return int|WP_Error Post ID or error.
|
||||
*/
|
||||
public static function create_pending_placeholder( $peptide_name ) {
|
||||
$post_id = wp_insert_post(
|
||||
array(
|
||||
'post_type' => 'peptide',
|
||||
'post_title' => $peptide_name,
|
||||
'post_content' => '',
|
||||
'post_status' => 'draft',
|
||||
'post_author' => psa_get_admin_user_id(),
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
if ( ! is_wp_error( $post_id ) ) {
|
||||
update_post_meta( $post_id, 'psa_source', 'pending' );
|
||||
update_post_meta( $post_id, 'psa_generation_started', time() );
|
||||
// Allow developers to hook into placeholder creation.
|
||||
do_action( 'psa_peptide_created', $post_id, $peptide_name );
|
||||
}
|
||||
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule background generation using WP-Cron (non-blocking).
|
||||
*
|
||||
* @param int $post_id The placeholder post ID.
|
||||
* @param string $peptide_name The peptide to research.
|
||||
*/
|
||||
public static function schedule_background_generation( $post_id, $peptide_name ) {
|
||||
$args = array( $post_id, $peptide_name );
|
||||
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
|
||||
wp_schedule_single_event( time(), 'psa_generate_peptide_background', $args );
|
||||
}
|
||||
spawn_cron();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core search logic — queries CPT by title and aliases (published only).
|
||||
* Includes transient caching and batch post/meta priming to avoid N+1 queries.
|
||||
*
|
||||
* @param string $query Search term.
|
||||
* @return array { results: array, total: int, query: string }
|
||||
*/
|
||||
public static function search_peptides( $query ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check transient cache first.
|
||||
$cache_key = 'psa_search_' . md5( sanitize_text_field( $query ) );
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$like = '%' . $wpdb->esc_like( $query ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$post_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN {$wpdb->postmeta} m ON p.ID = m.post_id AND m.meta_key = 'psa_aliases'
|
||||
WHERE p.post_type = 'peptide'
|
||||
AND p.post_status = 'publish'
|
||||
AND ( p.post_title LIKE %s OR m.meta_value LIKE %s )
|
||||
ORDER BY p.post_title ASC
|
||||
LIMIT 20",
|
||||
$like,
|
||||
$like
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $post_ids ) ) {
|
||||
// Prime the WordPress object cache for all posts and their meta in batch.
|
||||
_prime_post_caches( $post_ids );
|
||||
update_meta_cache( 'post', $post_ids );
|
||||
}
|
||||
|
||||
$results = array();
|
||||
foreach ( $post_ids as $id ) {
|
||||
$results[] = self::format_peptide( (int) $id );
|
||||
}
|
||||
|
||||
$response = array(
|
||||
'results' => $results,
|
||||
'total' => count( $results ),
|
||||
'query' => $query,
|
||||
);
|
||||
|
||||
// Cache results for 6 hours.
|
||||
set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS );
|
||||
|
||||
// Allow filtering of search results before return.
|
||||
$response = apply_filters( 'psa_search_results', $response, $query );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a peptide post for JSON response.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
* @return array
|
||||
*/
|
||||
public static function format_peptide( $post_id ) {
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array(
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'url' => get_permalink( $post->ID ),
|
||||
'excerpt' => wp_trim_words( $post->post_content, 30 ),
|
||||
'sequence' => get_post_meta( $post->ID, 'psa_sequence', true ),
|
||||
'molecular_weight' => get_post_meta( $post->ID, 'psa_molecular_weight', true ),
|
||||
'molecular_formula' => get_post_meta( $post->ID, 'psa_molecular_formula', true ),
|
||||
'source' => get_post_meta( $post->ID, 'psa_source', true ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom template rendering for single peptide pages.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Template {
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'the_content', array( __CLASS__, 'append_peptide_data' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Append structured peptide data below the post content on single peptide pages.
|
||||
*
|
||||
* @param string $content The post content.
|
||||
* @return string Modified content.
|
||||
*/
|
||||
public static function append_peptide_data( $content ) {
|
||||
if ( ! is_singular( 'peptide' ) || ! in_the_loop() || ! is_main_query() ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$post_id = get_the_ID();
|
||||
$source = get_post_meta( $post_id, 'psa_source', true );
|
||||
$pubchem_cid = get_post_meta( $post_id, 'psa_pubchem_cid', true );
|
||||
|
||||
// Build template data for filtering.
|
||||
$data = array(
|
||||
'post_id' => $post_id,
|
||||
'source' => $source,
|
||||
'pubchem_cid' => $pubchem_cid,
|
||||
);
|
||||
|
||||
// Allow filtering of template data before rendering.
|
||||
$data = apply_filters( 'psa_template_data', $data, $post_id );
|
||||
|
||||
// Restore values from possibly filtered data.
|
||||
$post_id = $data['post_id'] ?? $post_id;
|
||||
$source = $data['source'] ?? $source;
|
||||
$pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid;
|
||||
|
||||
$html = '<div class="psa-peptide-data">';
|
||||
|
||||
// Quick facts box.
|
||||
$weight = get_post_meta( $post_id, 'psa_molecular_weight', true );
|
||||
$formula = get_post_meta( $post_id, 'psa_molecular_formula', true );
|
||||
$seq = get_post_meta( $post_id, 'psa_sequence', true );
|
||||
$aliases = get_post_meta( $post_id, 'psa_aliases', true );
|
||||
|
||||
if ( $weight || $formula || $seq ) {
|
||||
$html .= '<div class="psa-quick-facts">';
|
||||
$html .= '<h3>Quick Facts</h3>';
|
||||
$html .= '<table class="psa-facts-table">';
|
||||
|
||||
if ( $aliases ) {
|
||||
$html .= '<tr><th>Also Known As</th><td>' . esc_html( $aliases ) . '</td></tr>';
|
||||
}
|
||||
if ( $seq ) {
|
||||
$html .= '<tr><th>Sequence</th><td><code class="psa-sequence">' . esc_html( $seq ) . '</code></td></tr>';
|
||||
}
|
||||
if ( $formula ) {
|
||||
$html .= '<tr><th>Molecular Formula</th><td>' . esc_html( $formula ) . '</td></tr>';
|
||||
}
|
||||
if ( $weight ) {
|
||||
$html .= '<tr><th>Molecular Weight</th><td>' . esc_html( $weight ) . '</td></tr>';
|
||||
}
|
||||
if ( $pubchem_cid ) {
|
||||
$cid_int = intval( $pubchem_cid );
|
||||
$html .= '<tr><th>PubChem CID</th><td><a href="https://pubchem.ncbi.nlm.nih.gov/compound/' . $cid_int . '" target="_blank" rel="noopener">' . $cid_int . '</a></td></tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
// Detailed sections.
|
||||
$detail_fields = array(
|
||||
'psa_mechanism' => 'Mechanism of Action',
|
||||
'psa_research_apps' => 'Research Applications',
|
||||
'psa_safety_profile' => 'Safety & Side Effects',
|
||||
'psa_dosage_info' => 'Dosage Information',
|
||||
'psa_references' => 'References',
|
||||
);
|
||||
|
||||
foreach ( $detail_fields as $key => $label ) {
|
||||
$value = get_post_meta( $post_id, $key, true );
|
||||
if ( ! empty( $value ) ) {
|
||||
$html .= '<div class="psa-section">';
|
||||
$html .= '<h3>' . esc_html( $label ) . '</h3>';
|
||||
$html .= '<div class="psa-section-content">' . wp_kses_post( wpautop( $value ) ) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Source badge.
|
||||
if ( $source && 'pending' !== $source ) {
|
||||
$badges = array(
|
||||
'ai-generated' => array(
|
||||
'label' => 'AI-Generated Content',
|
||||
'class' => 'psa-badge-ai',
|
||||
),
|
||||
'pubchem' => array(
|
||||
'label' => 'PubChem Verified',
|
||||
'class' => 'psa-badge-verified',
|
||||
),
|
||||
'manual' => array(
|
||||
'label' => 'Manually Curated',
|
||||
'class' => 'psa-badge-manual',
|
||||
),
|
||||
);
|
||||
$badge = $badges[ $source ] ?? $badges['ai-generated'];
|
||||
|
||||
$html .= '<div class="psa-source-badge ' . esc_attr( $badge['class'] ) . '">';
|
||||
$html .= esc_html( $badge['label'] );
|
||||
if ( 'ai-generated' === $source ) {
|
||||
$html .= ' — This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $content . $html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user