Deploy 868d31c from main

This commit is contained in:
peptiderepo
2026-04-22 10:14:27 +00:00
commit e62b51a764
28 changed files with 6032 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
<?php
/**
* Admin page rendering for Peptide Search AI settings.
*
* What: Settings page HTML, field renderers, usage summary, batch enrich scripts.
* Who calls it: PSA_Admin::add_menu_pages() registers render_settings_page as page callback.
* Dependencies: PSA_Config, PSA_Cost_Tracker, PSA_Encryption.
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-admin.php — Registration, sanitization, migration tools.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Admin_Page {
/**
* Enqueue batch enrichment script on the PSA settings page only.
*
* @param string $hook Admin page hook suffix.
*/
public static function enqueue_batch_scripts( string $hook ): void {
if ( 'settings_page_peptide-search-ai' !== $hook ) {
return;
}
wp_enqueue_script( 'psa-batch-enrich', PSA_PLUGIN_URL . 'assets/js/psa-batch-enrich.js', array( 'jquery' ), PSA_VERSION, true );
wp_localize_script( 'psa-batch-enrich', 'psaBatchEnrich', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'psa_batch_enrich' ),
) );
}
// ── Field renderers ─────────────────────────────────────────────────
public static function render_section_description(): void {
printf(
'<p>' . __( 'This plugin uses <a href="%s" target="_blank" rel="noopener">OpenRouter</a> to access AI models.', 'peptide-search-ai' ) . '</p>',
esc_url( 'https://openrouter.ai/' )
);
}
public static function render_api_key_field(): void {
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
echo '<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span> ';
echo '<strong>' . esc_html__( 'Configured via wp-config.php', 'peptide-search-ai' ) . '</strong>';
echo '<p class="description">' . esc_html__( 'The API key is securely set using the PSA_OPENROUTER_KEY constant.', 'peptide-search-ai' ) . '</p>';
} else {
$value = PSA_Admin::get_setting( 'api_key', '' );
$display_value = ! empty( $value ) ? '••••••••' : '';
printf(
'<input type="password" name="psa_settings[api_key]" value="%s" class="regular-text" autocomplete="off" />',
esc_attr( $display_value )
);
printf(
'<p class="description">%s <a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener">%s</a>.</p>',
esc_html__( 'Your OpenRouter API key.', 'peptide-search-ai' ),
esc_html__( 'Get one here', 'peptide-search-ai' )
);
}
}
public static function render_model_field(): void {
$value = PSA_Admin::get_setting( 'ai_model', '' );
printf(
'<input type="text" name="psa_settings[ai_model]" value="%s" class="regular-text" placeholder="%s" />',
esc_attr( $value ),
esc_attr__( 'Leave blank for default', 'peptide-search-ai' )
);
echo '<p class="description">' . esc_html__( 'Model for full content generation. Default: google/gemini-2.5-flash', 'peptide-search-ai' ) . '</p>';
}
public static function render_validation_model_field(): void {
$value = PSA_Admin::get_setting( 'validation_model', '' );
printf(
'<input type="text" name="psa_settings[validation_model]" value="%s" class="regular-text" placeholder="%s" />',
esc_attr( $value ),
esc_attr__( 'Leave blank for default', 'peptide-search-ai' )
);
echo '<p class="description">' . esc_html__( 'Lighter model for name validation. Default: google/gemini-2.0-flash-001', 'peptide-search-ai' ) . '</p>';
}
public static function render_publish_field(): void {
$value = PSA_Admin::get_setting( 'auto_publish', 'draft' );
echo '<select name="psa_settings[auto_publish]">';
printf( '<option value="draft" %s>%s</option>', selected( $value, 'draft', false ), esc_html__( 'Save as Draft', 'peptide-search-ai' ) );
printf( '<option value="publish" %s>%s</option>', selected( $value, 'publish', false ), esc_html__( 'Publish Immediately', 'peptide-search-ai' ) );
echo '</select>';
}
public static function render_pubchem_field(): void {
$value = PSA_Admin::get_setting( 'use_pubchem', '1' );
printf(
'<label><input type="checkbox" name="psa_settings[use_pubchem]" value="1" %s /> %s</label>',
checked( $value, '1', false ),
esc_html__( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' )
);
}
public static function render_budget_field(): void {
$value = PSA_Admin::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET );
printf(
'<input type="number" name="psa_settings[monthly_budget]" value="%s" step="0.01" min="0" />',
esc_attr( $value )
);
echo '<p class="description">' . esc_html__( 'Set to 0 for unlimited.', 'peptide-search-ai' ) . '</p>';
}
// ── Settings page ───────────────────────────────────────────────────
public static function render_settings_page(): void {
$migrate_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=migrate_categories' ), 'psa_migrate_categories' );
?>
<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( 'API Usage & Costs', 'peptide-search-ai' ); ?></h2>
<?php self::render_usage_summary(); ?>
<hr />
<h2><?php esc_html_e( 'Data Tools', 'peptide-search-ai' ); ?></h2>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Assign Categories', 'peptide-search-ai' ); ?></th>
<td>
<a href="<?php echo esc_url( $migrate_url ); ?>" class="button"><?php esc_html_e( 'Migrate Existing Peptides to Categories', 'peptide-search-ai' ); ?></a>
<p class="description"><?php esc_html_e( 'Reads AI-generated category data from existing peptides and assigns taxonomy terms. Safe to run multiple times.', 'peptide-search-ai' ); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Re-enrich Data', 'peptide-search-ai' ); ?></th>
<td>
<button type="button" id="psa-batch-enrich-start" class="button button-primary"><?php esc_html_e( 'Re-enrich Peptides', 'peptide-search-ai' ); ?></button>
<span id="psa-batch-enrich-status" style="margin-left:10px;color:#666;"></span>
<p class="description"><?php esc_html_e( 'Processes one peptide at a time via AI — no WP-Cron dependency. Respects daily generation cap.', 'peptide-search-ai' ); ?></p>
<div id="psa-batch-enrich-progress" style="display:none;margin-top:10px;max-width:500px;">
<div style="background:#e0e0e0;border-radius:3px;height:20px;overflow:hidden;">
<div id="psa-batch-enrich-bar" style="background:#0073aa;height:100%;width:0%;transition:width 0.3s;"></div>
</div>
<span id="psa-batch-enrich-pct" style="font-size:12px;color:#666;"></span>
</div>
<div id="psa-batch-enrich-log" style="display:none;margin-top:10px;max-height:200px;overflow-y:auto;padding:8px;background:#f9f9f9;border:1px solid #ddd;border-radius:3px;max-width:500px;font-family:monospace;font-size:12px;"></div>
</td>
</tr>
</table>
<hr />
<h2><?php esc_html_e( 'Shortcodes', 'peptide-search-ai' ); ?></h2>
<p><code>[peptide_search]</code> — <?php esc_html_e( 'Search form with auto-generation', 'peptide-search-ai' ); ?></p>
<p><code>[peptide_directory]</code> — <?php esc_html_e( 'Browsable directory grid with filters and detail modal', 'peptide-search-ai' ); ?></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> — <?php esc_html_e( 'Search (published only)', 'peptide-search-ai' ); ?></p>
<p><code>GET /wp-json/peptide-search-ai/v1/compounds?search=&amp;category=&amp;page=1</code> — <?php esc_html_e( 'Directory listing', 'peptide-search-ai' ); ?></p>
</div>
<?php
}
/** Render the API usage summary section with recent call log. */
private static function render_usage_summary(): void {
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
$estimated_count = PSA_Cost_Tracker::get_monthly_estimated_count();
$budget = floatval( PSA_Admin::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
$budget_remaining = ( 0.0 === $budget ) ? __( 'Unlimited', 'peptide-search-ai' ) : '$' . number_format( max( 0, $budget - $current_spend ), 2 );
$recent_logs = PSA_Cost_Tracker::get_recent_logs( 20 );
?>
<div style="background:#f5f5f5;padding:15px;border-radius:4px;margin-bottom:20px;">
<h3><?php esc_html_e( 'This Month', 'peptide-search-ai' ); ?></h3>
<p>
<strong><?php esc_html_e( 'Spend:', 'peptide-search-ai' ); ?></strong> $<?php echo esc_html( number_format( $current_spend, 2 ) ); ?>
<?php if ( $estimated_count > 0 ) : ?>
<span style="color:#b45309;font-size:12px;" title="<?php echo esc_attr( $estimated_count ); ?> API calls used character-based token estimates">&#9888; includes estimates</span>
<?php endif; ?>
<br />
<strong><?php esc_html_e( 'Tokens:', 'peptide-search-ai' ); ?></strong> <?php echo esc_html( number_format( $current_tokens ) ); ?><br />
<strong><?php esc_html_e( 'Budget Remaining:', 'peptide-search-ai' ); ?></strong> <?php echo esc_html( $budget_remaining ); ?>
</p>
</div>
<?php if ( ! empty( $recent_logs ) ) : ?>
<h3><?php esc_html_e( 'Recent API Calls (Last 20)', 'peptide-search-ai' ); ?></h3>
<table class="widefat striped" style="margin-bottom:20px;">
<thead>
<tr>
<th><?php esc_html_e( 'Date', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Model', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Tokens', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Cost', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Type', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Peptide', 'peptide-search-ai' ); ?></th>
<th><?php esc_html_e( 'Status', 'peptide-search-ai' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $recent_logs as $log ) : ?>
<?php $is_estimated = ( isset( $log->token_source ) && 'estimated' === $log->token_source ); ?>
<tr<?php echo $is_estimated ? ' style="color:#92400e;"' : ''; ?>>
<td><?php echo esc_html( $log->created_at ); ?></td>
<td><code><?php echo esc_html( $log->model ); ?></code></td>
<td><?php echo $is_estimated ? '<span title="Estimated">~' : ''; ?><?php echo esc_html( number_format( $log->total_tokens ) ); ?><?php echo $is_estimated ? '</span>' : ''; ?></td>
<td><?php echo $is_estimated ? '<span title="Estimated">~$' : '$'; ?><?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?><?php echo $is_estimated ? '</span>' : ''; ?></td>
<td><?php echo esc_html( $log->request_type ); ?></td>
<td><?php echo esc_html( $log->peptide_name ); ?></td>
<td><?php echo ( 1 === (int) $log->success ) ? '&#10003;' : '&#10007;'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif;
}
}

290
includes/class-psa-admin.php Executable file
View File

@@ -0,0 +1,290 @@
<?php
/**
* Admin settings registration, sanitization, and data tools for Peptide Search AI.
*
* What: Settings registration, sanitization, admin action handlers, migration, re-enrichment.
* Who calls it: WordPress admin_menu and admin_init hooks via PSA_Admin::init().
* Dependencies: PSA_Encryption, PSA_Config, PSA_Post_Type, PSA_Admin_Page (rendering).
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-admin-page.php — Settings page rendering and field renderers.
* @see includes/class-psa-encryption.php
* @see includes/class-psa-cost-tracker.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Admin {
public static function init(): void {
add_action( 'admin_menu', array( __CLASS__, 'add_menu_pages' ) );
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_action( 'admin_init', array( __CLASS__, 'handle_admin_actions' ) );
add_action( 'admin_enqueue_scripts', array( 'PSA_Admin_Page', 'enqueue_batch_scripts' ) );
}
/** Add settings page under Settings menu. */
public static function add_menu_pages(): void {
add_options_page(
__( 'Peptide Search AI', 'peptide-search-ai' ),
__( 'Peptide Search AI', 'peptide-search-ai' ),
'manage_options',
'peptide-search-ai',
array( 'PSA_Admin_Page', 'render_settings_page' )
);
}
/** Register settings fields — rendering delegated to PSA_Admin_Page. */
public static function register_settings(): void {
register_setting( 'psa_settings_group', 'psa_settings', array(
'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ),
) );
add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_section_description' ), 'peptide-search-ai' );
add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_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( 'PSA_Admin_Page', 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' );
add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_pubchem_field' ), 'peptide-search-ai', 'psa_behavior_section' );
add_settings_field( 'monthly_budget', __( 'Monthly API Budget (USD)', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_budget_field' ), 'peptide-search-ai', 'psa_behavior_section' );
}
/**
* Sanitize settings input.
*
* @param array $input Raw form input.
* @return array Sanitized settings.
*/
public static function sanitize_settings( array $input ): array {
$api_key_input = $input['api_key'] ?? '';
$api_key_value = '';
if ( ! empty( $api_key_input ) && '••••••••' !== $api_key_input ) {
$sanitized = sanitize_text_field( $api_key_input );
$encrypted = PSA_Encryption::encrypt( $sanitized );
$api_key_value = ( false !== $encrypted ) ? $encrypted : '';
} else {
$api_key_value = self::get_setting( 'api_key', '' );
}
return array(
'api_key' => $api_key_value,
'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' ),
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
);
}
/** Handle admin actions (migration, re-enrich) via GET params with nonce verification. */
public static function handle_admin_actions(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['psa_action'] ) || ! current_user_can( 'manage_options' ) ) {
return;
}
$action = sanitize_text_field( wp_unslash( $_GET['psa_action'] ) );
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
if ( 'migrate_categories' === $action && wp_verify_nonce( $nonce, 'psa_migrate_categories' ) ) {
self::run_category_migration();
add_action( 'admin_notices', function () {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Category migration completed.', 'peptide-search-ai' ) . '</p></div>';
} );
}
if ( 'reenrich' === $action && wp_verify_nonce( $nonce, 'psa_reenrich' ) ) {
$queued = self::queue_reenrichment();
add_action( 'admin_notices', function () use ( $queued ) {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( sprintf(
__( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ), $queued
) ) . '</p></div>';
} );
}
}
// ── Category migration ──────────────────────────────────────────────
/**
* Assign peptide_category terms to existing posts from free-text meta.
* Idempotent — safe to run multiple times.
*/
private static function run_category_migration(): void {
$posts = get_posts( array(
'post_type' => 'peptide',
'post_status' => array( 'publish', 'draft' ),
'posts_per_page' => -1,
'fields' => 'ids',
) );
foreach ( $posts as $post_id ) {
$terms = wp_get_object_terms( $post_id, 'peptide_category' );
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
continue;
}
$category = get_post_meta( $post_id, 'psa_category', true );
$label = get_post_meta( $post_id, 'psa_category_label', true );
$text = ! empty( $category ) ? $category : $label;
if ( empty( $text ) ) {
continue;
}
$slug = self::map_category_to_slug( $text );
if ( ! empty( $slug ) ) {
wp_set_object_terms( $post_id, $slug, 'peptide_category' );
}
}
}
/**
* Map free-text category label to taxonomy slug.
*
* @param string $text Category label from AI or meta.
* @return string Taxonomy term slug, or empty if no match.
*/
private static function map_category_to_slug( string $text ): string {
$map = array(
'gh secretagogues' => 'growth-hormone',
'growth hormone' => 'growth-hormone',
'healing & repair' => 'tissue-repair',
'healing and repair' => 'tissue-repair',
'tissue repair' => 'tissue-repair',
'tissue healing' => 'tissue-repair',
'cytoprotective' => 'tissue-repair',
'melanocortin peptides' => 'dermatological',
'melanocortin' => 'dermatological',
'dermatological' => 'dermatological',
'metabolic & anti-aging' => 'metabolic',
'metabolic' => 'metabolic',
'anti-aging' => 'aging-research',
'aging research' => 'aging-research',
'immunology' => 'immunology',
'immune' => 'immunology',
'nootropic' => 'immunology',
'neuroprotective' => 'immunology',
'lipid metabolism' => 'lipid-metabolism',
'endocrine' => 'endocrine',
);
$lower = strtolower( trim( $text ) );
if ( isset( $map[ $lower ] ) ) {
return $map[ $lower ];
}
// Fuzzy match against registered default categories.
foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $slug => $name ) {
if ( stripos( $text, $name ) !== false || stripos( $text, str_replace( '-', ' ', $slug ) ) !== false ) {
return $slug;
}
}
return '';
}
// ── Re-enrichment ───────────────────────────────────────────────────
/**
* Queue re-enrichment for published peptides missing extended fields.
* Respects the daily generation cap to avoid cost overruns.
*
* @return int Number of peptides queued.
*/
private static function queue_reenrichment(): int {
$posts = get_posts( array(
'post_type' => 'peptide',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'OR',
array( 'key' => 'psa_half_life', 'compare' => 'NOT EXISTS' ),
array( 'key' => 'psa_half_life', 'value' => '' ),
),
) );
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
$daily_count = (int) get_transient( $daily_key );
$queued = 0;
foreach ( $posts as $post_id ) {
if ( ( $daily_count + $queued ) >= PSA_Config::DAILY_GENERATION_CAP ) {
break;
}
$post = get_post( $post_id );
if ( ! $post ) {
continue;
}
update_post_meta( $post_id, 'psa_source', 'pending' );
wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) );
update_post_meta( $post_id, 'psa_generation_started', time() );
$args = array( $post_id, $post->post_title );
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
// Stagger 30s apart to avoid rate-limit hammering.
wp_schedule_single_event( time() + ( $queued * 30 ), 'psa_generate_peptide_background', $args );
}
$queued++;
}
set_transient( $daily_key, $daily_count + $queued, DAY_IN_SECONDS );
if ( $queued > 0 ) {
spawn_cron();
}
return $queued;
}
// ── Settings accessor ───────────────────────────────────────────────
/**
* Get a single setting with a default.
*
* @param string $key Setting key.
* @param mixed $default Default value.
* @return mixed
*/
public static function get_setting( string $key, $default = '' ) {
$options = get_option( 'psa_settings', array() );
return $options[ $key ] ?? $default;
}
// ── Backward-compatible proxies (rendering moved to PSA_Admin_Page) ─
/** @see PSA_Admin_Page::enqueue_batch_scripts() */
public static function enqueue_batch_scripts( string $hook ): void { PSA_Admin_Page::enqueue_batch_scripts( $hook ); }
/** @see PSA_Admin_Page::render_section_description() */
public static function render_section_description(): void { PSA_Admin_Page::render_section_description(); }
/** @see PSA_Admin_Page::render_api_key_field() */
public static function render_api_key_field(): void { PSA_Admin_Page::render_api_key_field(); }
/** @see PSA_Admin_Page::render_model_field() */
public static function render_model_field(): void { PSA_Admin_Page::render_model_field(); }
/** @see PSA_Admin_Page::render_validation_model_field() */
public static function render_validation_model_field(): void { PSA_Admin_Page::render_validation_model_field(); }
/** @see PSA_Admin_Page::render_publish_field() */
public static function render_publish_field(): void { PSA_Admin_Page::render_publish_field(); }
/** @see PSA_Admin_Page::render_pubchem_field() */
public static function render_pubchem_field(): void { PSA_Admin_Page::render_pubchem_field(); }
/** @see PSA_Admin_Page::render_budget_field() */
public static function render_budget_field(): void { PSA_Admin_Page::render_budget_field(); }
/** @see PSA_Admin_Page::render_settings_page() */
public static function render_settings_page(): void { PSA_Admin_Page::render_settings_page(); }
}

View File

@@ -0,0 +1,228 @@
<?php
/**
* AI prompt templates and post-generation data handlers for peptide entries.
*
* What: Prompt construction (validation + generation), meta field saving, category assignment.
* Who calls it: PSA_AI_Generator during validate/generate workflows.
* Dependencies: PSA_Config, PSA_Post_Type (for DEFAULT_CATEGORIES).
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-ai-generator.php — Orchestration that calls these methods.
* @see includes/class-psa-post-type.php — DEFAULT_CATEGORIES used in assign_category_term.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_AI_Content {
/**
* Build the lightweight validation prompt.
*
* @param string $name The peptide name to validate.
* @return string Prompt text for the OpenRouter API.
*/
public static function build_validation_prompt( string $name ): string {
$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 (v4.3.0: includes extended fields).
*
* @param string $peptide_name The peptide to research.
* @return string Prompt text for the OpenRouter API.
*/
public static function build_generation_prompt( string $peptide_name ): string {
$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.",
"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.",
"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 of: Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine",
"half_life": "Approximate half-life (e.g. '~4-6 hours', '15-20 minutes'). Write 'Unknown' if not established.",
"stability": "Stability information (e.g. '28 Days at 2-8°C after reconstitution'). Include lyophilized and reconstituted if available.",
"solubility": "Recommended reconstitution solvent (e.g. 'Bacteriostatic Water', 'Sterile Water')",
"vial_size_mg": "Most common research vial size in mg as a number (e.g. 5, 10, 2). Use 0 if not applicable.",
"storage_lyophilized": "Storage conditions for lyophilized/powder form (e.g. '-20°C, protect from light')",
"storage_reconstituted": "Storage conditions after reconstitution (e.g. '2-8°C, use within 28 days')",
"typical_dose_mcg": "Typical research dose range (e.g. '200-300 mcg', '100-200 mcg/kg'). Include units.",
"cycle_parameters": "Typical research cycle/protocol info (e.g. '4-6 weeks on, 2 weeks off', 'Daily subcutaneous injection')"
}
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 the "category" field, use EXACTLY one of the eight listed categories.
PROMPT;
}
/**
* Save all meta fields for a peptide post (core + extended).
* Prefers PubChem data for molecular properties when available.
*
* @param int $post_id The post ID.
* @param array $ai_data AI-generated data.
* @param array|null $pubchem_data PubChem data (or null).
*/
public static function save_peptide_meta( int $post_id, array $ai_data, ?array $pubchem_data ): void {
$amino_acid_count = self::derive_amino_acid_count( $ai_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'] ?? '',
'psa_half_life' => $ai_data['half_life'] ?? '',
'psa_stability' => $ai_data['stability'] ?? '',
'psa_solubility' => $ai_data['solubility'] ?? '',
'psa_vial_size_mg' => $ai_data['vial_size_mg'] ?? '',
'psa_storage_lyophilized' => $ai_data['storage_lyophilized'] ?? '',
'psa_storage_reconstituted' => $ai_data['storage_reconstituted'] ?? '',
'psa_typical_dose_mcg' => $ai_data['typical_dose_mcg'] ?? '',
'psa_cycle_parameters' => $ai_data['cycle_parameters'] ?? '',
'psa_amino_acid_count' => $amino_acid_count,
);
$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 );
}
}
/**
* Auto-assign a peptide_category taxonomy term based on AI-returned category.
*
* @param int $post_id The peptide post ID.
* @param array $ai_data AI-generated data containing 'category' or 'category_label'.
*/
public static function assign_category_term( int $post_id, array $ai_data ): void {
$ai_category = $ai_data['category'] ?? $ai_data['category_label'] ?? '';
if ( empty( $ai_category ) ) {
return;
}
// Why: AI returns varied labels ("GH Secretagogues", "Healing & Repair") that need
// to map to our controlled vocabulary of taxonomy terms.
$category_map = array(
'gh secretagogues' => 'growth-hormone',
'growth hormone' => 'growth-hormone',
'healing & repair' => 'tissue-repair',
'healing and repair' => 'tissue-repair',
'tissue repair' => 'tissue-repair',
'tissue healing' => 'tissue-repair',
'cytoprotective' => 'tissue-repair',
'melanocortin peptides' => 'dermatological',
'melanocortin' => 'dermatological',
'dermatological' => 'dermatological',
'metabolic & anti-aging' => 'metabolic',
'metabolic and anti-aging' => 'metabolic',
'metabolic' => 'metabolic',
'anti-aging' => 'aging-research',
'aging research' => 'aging-research',
'nootropic & neuroprotective' => 'immunology',
'nootropic' => 'immunology',
'neuroprotective' => 'immunology',
'immunology' => 'immunology',
'immune' => 'immunology',
'lipid metabolism' => 'lipid-metabolism',
'endocrine' => 'endocrine',
);
$lower = strtolower( trim( $ai_category ) );
$slug = $category_map[ $lower ] ?? '';
// Fuzzy fallback: check if AI category contains any known slug keyword.
if ( empty( $slug ) ) {
foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $cat_slug => $cat_name ) {
if ( stripos( $ai_category, $cat_name ) !== false || stripos( $ai_category, str_replace( '-', ' ', $cat_slug ) ) !== false ) {
$slug = $cat_slug;
break;
}
}
}
if ( ! empty( $slug ) ) {
wp_set_object_terms( $post_id, $slug, 'peptide_category' );
}
}
/**
* Derive amino acid count from sequence data or explicit AI field.
*
* @param array $ai_data AI-generated data.
* @return string Amino acid count as string, or empty.
*/
private static function derive_amino_acid_count( array $ai_data ): string {
$sequence = $ai_data['sequence'] ?? '';
$count = '';
if ( ! empty( $sequence ) ) {
// Three-letter codes use dashes; single-letter codes are uppercase chars.
$count = ( strpos( $sequence, '-' ) !== false )
? (string) count( explode( '-', $sequence ) )
: (string) strlen( preg_replace( '/[^A-Z]/', '', $sequence ) );
}
// AI may return this directly — prefer explicit value.
if ( ! empty( $ai_data['amino_acid_count'] ) ) {
$count = (string) intval( $ai_data['amino_acid_count'] );
}
return $count;
}
}

View File

@@ -0,0 +1,282 @@
<?php
/**
* Orchestrates AI content generation for new peptide entries.
*
* What: Validation, generation, PubChem enrichment, background processing, dry-run costing.
* Who calls it: PSA_Search_Handler (validation + generation scheduling), WP-Cron (background_generate).
* Dependencies: PSA_Config, PSA_Cost_Tracker, PSA_Encryption, PSA_OpenRouter, PSA_PubChem,
* PSA_KB_Builder, PSA_AI_Content (prompts + meta saving).
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-ai-content.php — Prompt templates, meta saving, category assignment.
* @see includes/class-psa-search-handler.php
* @see includes/class-psa-cost-tracker.php
* @see includes/class-psa-pubchem.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_AI_Generator {
public static function init(): void {
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 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( string $name ) {
if ( ! self::validate_peptide_input( $name ) ) {
return array( 'is_valid' => false, 'canonical_name' => $name, 'reason' => 'Invalid characters in peptide name.' );
}
$options = self::get_settings();
if ( empty( $options['api_key'] ) ) {
return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' );
}
if ( PSA_Cost_Tracker::is_budget_exceeded() ) {
return new WP_Error( 'budget_exceeded', 'Monthly API budget reached.' );
}
// Transient cache to 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 = PSA_AI_Content::build_validation_prompt( $name );
$prompt = apply_filters( 'psa_validation_prompt', $prompt, $name );
$response = PSA_OpenRouter::send_request(
$options['api_key'],
$options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001',
$prompt,
PSA_Config::VALIDATION_MAX_TOKENS,
'validation',
$name
);
if ( is_wp_error( $response ) ) {
return $response;
}
$data = PSA_OpenRouter::parse_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'] ?? '' ),
);
set_transient( $cache_key, $result, DAY_IN_SECONDS );
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( int $post_id, string $peptide_name ): void {
$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;
}
$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();
error_log( 'PSA: Generating content for "' . $peptide_name . '" (post ' . $post_id . ') using model: ' . ( $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash' ) );
do_action( 'psa_before_generation', $post_id, $peptide_name );
$result = self::generate_peptide_content( $peptide_name );
if ( is_wp_error( $result ) ) {
error_log( 'PSA: Generation FAILED for "' . $peptide_name . '": ' . $result->get_error_message() );
update_post_meta( $post_id, 'psa_generation_error', $result->get_error_message() );
return;
}
error_log( 'PSA: AI content generated successfully for "' . $peptide_name . '". Fields: ' . implode( ', ', array_keys( $result ) ) );
$post_content = $result['overview'] ?? $result['description'] ?? '';
if ( empty( trim( $post_content ) ) ) {
error_log( 'PSA: WARNING — AI returned valid JSON but overview/description is empty for "' . $peptide_name . '".' );
update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description. Fields: ' . implode( ', ', array_keys( $result ) ) );
return;
}
// PubChem enrichment for molecular data.
$pubchem_data = null;
if ( ! empty( $options['use_pubchem'] ) ) {
$pubchem_result = PSA_PubChem::lookup( $peptide_name );
if ( is_wp_error( $pubchem_result ) ) {
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' ) . ')' );
}
}
$post_status = ( 'publish' === $options['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;
}
PSA_AI_Content::save_peptide_meta( $post_id, $result, $pubchem_data );
PSA_AI_Content::assign_category_term( $post_id, $result );
PSA_KB_Builder::create_article( $post_id, $result, $peptide_name, $post_status );
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() );
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( string $peptide_name ) {
$options = self::get_settings();
if ( empty( $options['api_key'] ) ) {
return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' );
}
if ( PSA_Cost_Tracker::is_budget_exceeded() ) {
return new WP_Error( 'budget_exceeded', 'Monthly API budget reached.' );
}
$prompt = PSA_AI_Content::build_generation_prompt( $peptide_name );
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
$response = PSA_OpenRouter::send_request(
$options['api_key'],
$options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash',
$prompt,
PSA_Config::GENERATION_MAX_TOKENS,
'generation',
$peptide_name
);
if ( is_wp_error( $response ) ) {
return $response;
}
return PSA_OpenRouter::parse_response( $response );
}
/**
* Dry-run: estimate costs without making API calls.
*
* @param string $peptide_name The peptide name to estimate.
* @return array { validation: {tokens, cost}, generation: {tokens, cost}, total }
*/
public static function dry_run( string $peptide_name ): array {
$options = self::get_settings();
$validation_model = $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001';
$generation_model = $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash';
$val_prompt = PSA_AI_Content::build_validation_prompt( $peptide_name );
$val_tokens = (int) ceil( strlen( $val_prompt ) / 4 );
$val_cost = PSA_Cost_Tracker::estimate_cost( $validation_model, $val_tokens, PSA_Config::VALIDATION_MAX_TOKENS );
$gen_prompt = PSA_AI_Content::build_generation_prompt( $peptide_name );
$gen_tokens = (int) ceil( strlen( $gen_prompt ) / 4 );
$gen_cost = PSA_Cost_Tracker::estimate_cost( $generation_model, $gen_tokens, PSA_Config::GENERATION_MAX_TOKENS );
return array(
'validation' => array( 'tokens' => $val_tokens + PSA_Config::VALIDATION_MAX_TOKENS, 'estimated_cost_usd' => $val_cost ),
'generation' => array( 'tokens' => $gen_tokens + PSA_Config::GENERATION_MAX_TOKENS, 'estimated_cost_usd' => $gen_cost ),
'total_estimated_cost_usd' => $val_cost + $gen_cost,
);
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Get plugin settings with defaults, decrypted API key, and constant override.
*
* @return array
*/
private static function get_settings(): array {
$defaults = array( 'api_key' => '', 'ai_model' => '', 'validation_model' => '', 'auto_publish' => 'draft', 'use_pubchem' => '1' );
$settings = wp_parse_args( get_option( 'psa_settings', array() ), $defaults );
if ( ! empty( $settings['api_key'] ) && ! ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) ) {
$decrypted = PSA_Encryption::decrypt( $settings['api_key'] );
if ( false !== $decrypted ) {
$settings['api_key'] = $decrypted;
}
}
// wp-config.php constant takes precedence for security.
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
$settings['api_key'] = PSA_OPENROUTER_KEY;
}
return apply_filters( 'psa_ai_settings', $settings );
}
/**
* 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( string $name ): bool {
if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) {
return false;
}
$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;
}
// ── Backward-compatible proxies ─────────────────────────────────────
/** @see PSA_AI_Content::build_validation_prompt() */
public static function build_validation_prompt( string $name ): string { return PSA_AI_Content::build_validation_prompt( $name ); }
/** @see PSA_AI_Content::build_generation_prompt() */
public static function build_generation_prompt( string $peptide_name ): string { return PSA_AI_Content::build_generation_prompt( $peptide_name ); }
/** @see PSA_AI_Content::save_peptide_meta() */
public static function save_peptide_meta( int $post_id, array $ai_data, ?array $pubchem_data ): void { PSA_AI_Content::save_peptide_meta( $post_id, $ai_data, $pubchem_data ); }
/** @see PSA_AI_Content::assign_category_term() */
public static function assign_category_term( int $post_id, array $ai_data ): void { PSA_AI_Content::assign_category_term( $post_id, $ai_data ); }
}

View File

@@ -0,0 +1,182 @@
<?php
/**
* AJAX-based batch enrichment for peptides missing extended meta fields.
*
* What: Provides a cron-independent way to re-enrich peptides one at a time via AJAX polling.
* Who calls it: PSA_Admin settings page JavaScript; AJAX hooks registered on admin_init.
* Dependencies: PSA_AI_Generator (content generation), PSA_Config (caps), PSA_Cost_Tracker (budget).
*
* @package PeptideSearchAI
* @see includes/class-psa-admin.php
* @see includes/class-psa-ai-generator.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Batch_Enrichment {
/**
* Register AJAX handlers for batch enrichment.
* Called from psa_init() in the main plugin file.
*/
public static function init(): void {
add_action( 'wp_ajax_psa_batch_enrich_status', array( __CLASS__, 'ajax_status' ) );
add_action( 'wp_ajax_psa_batch_enrich_next', array( __CLASS__, 'ajax_process_next' ) );
}
/**
* AJAX: Return the count of peptides that need enrichment.
*
* Response JSON: { total: int, remaining: int, completed: int }
* Side effects: None (read-only).
*/
public static function ajax_status(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
check_ajax_referer( 'psa_batch_enrich', '_nonce' );
$needs_enrichment = self::get_unenriched_ids();
$total_published = self::get_total_published_count();
wp_send_json_success(
array(
'total' => $total_published,
'remaining' => count( $needs_enrichment ),
'completed' => $total_published - count( $needs_enrichment ),
)
);
}
/**
* AJAX: Process one peptide — set to draft, generate, republish.
*
* Response JSON on success: { post_id: int, name: string, status: 'ok', remaining: int }
* Response JSON on skip: { status: 'done', remaining: 0 }
* Response JSON on error: { post_id: int, name: string, status: 'error', message: string, remaining: int }
*
* Side effects: Updates post meta via AI generation, logs API cost.
*/
public static function ajax_process_next(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
check_ajax_referer( 'psa_batch_enrich', '_nonce' );
// Admin-initiated batch enrichment intentionally bypasses the daily cap.
// The cap exists to prevent runaway cron-based costs; AJAX enrichment is
// explicit, authenticated, rate-limited by the 2-second JS polling interval,
// and still respects the monthly budget check inside generate_peptide_content().
$ids = self::get_unenriched_ids();
if ( empty( $ids ) ) {
wp_send_json_success(
array(
'status' => 'done',
'remaining' => 0,
)
);
}
$post_id = $ids[0];
$post = get_post( $post_id );
if ( ! $post ) {
wp_send_json_error( 'Post not found: ' . $post_id, 404 );
}
$peptide_name = $post->post_title;
$original_status = $post->post_status;
// Temporarily set to draft + pending so background_generate processes it.
wp_update_post(
array(
'ID' => $post_id,
'post_status' => 'draft',
)
);
update_post_meta( $post_id, 'psa_source', 'pending' );
update_post_meta( $post_id, 'psa_generation_started', time() );
// Call the generator directly (synchronous — no cron dependency).
PSA_AI_Generator::background_generate( $post_id, $peptide_name );
// Check outcome: did generation succeed?
$error = get_post_meta( $post_id, 'psa_generation_error', true );
$refreshed_post = get_post( $post_id );
// If still in draft (generation failed or auto_publish is 'draft'), force republish.
if ( $refreshed_post && 'draft' === $refreshed_post->post_status ) {
wp_update_post(
array(
'ID' => $post_id,
'post_status' => 'publish',
)
);
}
$remaining = count( self::get_unenriched_ids() );
if ( ! empty( $error ) ) {
wp_send_json_success(
array(
'status' => 'error',
'post_id' => $post_id,
'name' => $peptide_name,
'message' => $error,
'remaining' => $remaining,
)
);
}
wp_send_json_success(
array(
'status' => 'ok',
'post_id' => $post_id,
'name' => $peptide_name,
'remaining' => $remaining,
)
);
}
/**
* Get IDs of published peptides missing the half_life extended field.
*
* @return int[] Post IDs.
*/
private static function get_unenriched_ids(): array {
return get_posts(
array(
'post_type' => 'peptide',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'psa_half_life',
'compare' => 'NOT EXISTS',
),
array(
'key' => 'psa_half_life',
'value' => '',
),
),
)
);
}
/**
* Get total published peptide count.
*
* @return int
*/
private static function get_total_published_count(): int {
$counts = wp_count_posts( 'peptide' );
return (int) ( $counts->publish ?? 0 );
}
}

48
includes/class-psa-config.php Executable file
View File

@@ -0,0 +1,48 @@
<?php
/**
* Configuration constants for Peptide Search AI.
*
* What: Centralizes all magic numbers and configuration values used across the plugin.
* Who calls it: Every class in the plugin reads these constants for limits, timeouts, and defaults.
* Dependencies: None (pure constants class).
*
* @package PeptideSearchAI
*/
declare( strict_types=1 );
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
// Cost tracking and budget
const DEFAULT_MONTHLY_BUDGET = 0; // 0 = unlimited
}

View File

@@ -0,0 +1,136 @@
<?php
/**
* Read-only reporting queries for API usage data.
*
* What: Monthly spend/token/estimate-count summaries and recent log retrieval.
* Who calls it: PSA_Admin_Page (usage summary), PSA_Cost_Tracker (budget check).
* Dependencies: WordPress $wpdb.
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-cost-tracker.php — Write-side: logging, cost estimation, table management.
* @see includes/class-psa-admin-page.php — Renders usage summary using these queries.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Cost_Reporter {
/**
* Get total spend for a given month.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return float Total USD spent.
*/
public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float {
global $wpdb;
list( $start, $end ) = self::month_range( $year, $month );
$table = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$start,
$end
)
);
return floatval( $result );
}
/**
* Get total token usage for a given month.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return int Total tokens.
*/
public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int {
global $wpdb;
list( $start, $end ) = self::month_range( $year, $month );
$table = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COALESCE(SUM(total_tokens), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$start,
$end
)
);
return (int) $result;
}
/**
* Count API calls this month that used character-based token estimates.
*
* Why: Lets the admin UI flag when reported costs include approximations
* so operators can distinguish measured spend from estimated spend.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return int Number of estimated rows.
*/
public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int {
global $wpdb;
list( $start, $end ) = self::month_range( $year, $month );
$table = $wpdb->prefix . 'psa_api_logs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$start,
$end,
'estimated'
)
);
return (int) $result;
}
/**
* Get the most recent API call logs.
*
* @param int $limit Maximum number of logs to return.
* @return array Array of log row objects.
*/
public static function get_recent_logs( int $limit = 20 ): array {
global $wpdb;
$table = $wpdb->prefix . 'psa_api_logs';
$limit = max( 1, (int) $limit );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$results = $wpdb->get_results(
$wpdb->prepare(
'SELECT * FROM ' . $table . ' ORDER BY created_at DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$limit
)
);
return is_array( $results ) ? $results : array();
}
/**
* Build start/end datetime strings for a given month.
*
* @param int|null $year Year (defaults to current).
* @param int|null $month Month (defaults to current).
* @return array{0: string, 1: string} [start_date, end_datetime]
*/
private static function month_range( ?int $year, ?int $month ): array {
$year = $year ?? (int) gmdate( 'Y' );
$month = max( 1, min( 12, $month ?? (int) gmdate( 'm' ) ) );
$start = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
$end = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ) . ' 23:59:59';
return array( $start, $end );
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* Logs API usage costs and enforces monthly budget limits.
*
* What: Table management, API call logging, cost estimation, budget enforcement.
* Who calls it: PSA_OpenRouter (logs calls), PSA_AI_Generator (checks budget).
* Dependencies: WordPress $wpdb, PSA_Config, PSA_Cost_Reporter (for spend queries).
*
* @package PeptideSearchAI
* @since 1.0.0
* @see includes/class-psa-cost-reporter.php — Read-side: monthly summaries, recent logs.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Cost_Tracker {
/** Create the API logs table if it doesn't exist. Uses dbDelta for safe schema updates. */
public static function create_table(): void {
global $wpdb;
$table = $wpdb->prefix . 'psa_api_logs';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
model VARCHAR(100) NOT NULL,
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
estimated_cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
request_type VARCHAR(20) NOT NULL,
peptide_name VARCHAR(200) DEFAULT '',
success TINYINT(1) NOT NULL DEFAULT 1,
token_source VARCHAR(20) NOT NULL DEFAULT 'api',
INDEX idx_created_at (created_at),
INDEX idx_request_type (request_type)
) $charset;";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
if ( ! empty( $wpdb->last_error ) ) {
error_log( 'PSA: Table creation error: ' . $wpdb->last_error );
}
}
/**
* Log an API call with token counts and cost.
*
* @param array $data {
* @type string $provider, @type string $model, @type int $prompt_tokens,
* @type int $completion_tokens, @type int $total_tokens, @type string $request_type,
* @type string $peptide_name, @type bool $success, @type float $estimated_cost_usd,
* @type string $token_source 'api'|'estimated'|'none'
* }
* @return int|false Insert ID or false on error.
*/
public static function log_api_call( array $data ) {
global $wpdb;
$table = $wpdb->prefix . 'psa_api_logs';
$prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 );
$completion_tokens = (int) ( $data['completion_tokens'] ?? 0 );
$total_tokens = (int) ( $data['total_tokens'] ?? 0 );
$estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 );
if ( 0 === $total_tokens && ( $prompt_tokens > 0 || $completion_tokens > 0 ) ) {
$total_tokens = $prompt_tokens + $completion_tokens;
}
if ( 0 === $estimated_cost && $prompt_tokens > 0 ) {
$estimated_cost = self::estimate_cost( $data['model'] ?? '', $prompt_tokens, $completion_tokens );
}
$insert_data = array(
'provider' => sanitize_text_field( $data['provider'] ?? 'openrouter' ),
'model' => sanitize_text_field( $data['model'] ?? '' ),
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'total_tokens' => $total_tokens,
'estimated_cost_usd' => $estimated_cost,
'request_type' => sanitize_text_field( $data['request_type'] ?? 'generation' ),
'peptide_name' => sanitize_text_field( $data['peptide_name'] ?? '' ),
'success' => ! empty( $data['success'] ) ? 1 : 0,
'token_source' => sanitize_text_field( $data['token_source'] ?? 'api' ),
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->insert( $table, $insert_data, array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' ) );
if ( false === $result ) {
error_log( 'PSA: Failed to insert API log: ' . $wpdb->last_error );
return false;
}
return $wpdb->insert_id;
}
/**
* Check if current month's budget has been exceeded. Budget of 0 = unlimited.
*
* @return bool True if budget exceeded.
*/
public static function is_budget_exceeded(): bool {
$settings = get_option( 'psa_settings', array() );
$budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET );
if ( 0.0 === $budget ) {
return false;
}
return PSA_Cost_Reporter::get_monthly_spend() >= $budget;
}
/**
* Estimate cost for an API call based on model and token counts.
*
* @param string $model Model identifier.
* @param int $prompt_tokens Tokens in prompt.
* @param int $completion_tokens Tokens in completion.
* @return float Estimated cost in USD.
*/
public static function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
// Per 1M tokens (input, output). Conservative default for unknown models.
$pricing = array(
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ),
'deepseek/deepseek-chat' => array( 0.27, 1.10 ),
'deepseek/deepseek-r1' => array( 0.55, 2.19 ),
'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ),
);
$model = sanitize_text_field( $model );
if ( isset( $pricing[ $model ] ) ) {
list( $in, $out ) = $pricing[ $model ];
} else {
$in = 1.0;
$out = 2.0;
}
return (float) ( ( $in / 1000000 ) * $prompt_tokens + ( $out / 1000000 ) * $completion_tokens );
}
/** Drop the API logs table during uninstall. */
public static function drop_table(): void {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'psa_api_logs' );
}
// ── Backward-compatible proxies (reporting moved to PSA_Cost_Reporter) ──
/** @see PSA_Cost_Reporter::get_monthly_spend() */
public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float { return PSA_Cost_Reporter::get_monthly_spend( $year, $month ); }
/** @see PSA_Cost_Reporter::get_monthly_tokens() */
public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int { return PSA_Cost_Reporter::get_monthly_tokens( $year, $month ); }
/** @see PSA_Cost_Reporter::get_monthly_estimated_count() */
public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int { return PSA_Cost_Reporter::get_monthly_estimated_count( $year, $month ); }
/** @see PSA_Cost_Reporter::get_recent_logs() */
public static function get_recent_logs( int $limit = 20 ): array { return PSA_Cost_Reporter::get_recent_logs( $limit ); }
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* Dependency check for Peptide Repo Core (PR Core).
*
* What: Verifies PR Core >= REQUIRED_PR_CORE_VERSION is active so PSA can rely
* on the `peptide` CPT + `peptide_category` taxonomy being registered. Renders
* an admin notice when the dependency is unmet. Serves as a boot-path gate:
* PSA surfaces that operate on the `peptide` CPT (meta boxes, admin columns,
* directory widget, KB renderer, search widget, template) call
* is_satisfied() and degrade gracefully — they do not fatal — when PR Core is
* missing or out of date.
*
* Who calls it: Main plugin bootstrap (`psa_init` + `psa_admin_init`) and each
* CPT-dependent class's `init()` method before hooking into WordPress.
* Dependencies: PR Core defines the PR_CORE_VERSION constant in its main
* plugin file; absence of the constant is treated as "not installed."
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-post-type.php — Gated via init_admin().
* @see includes/class-psa-directory.php — Gated via init().
* @see includes/class-psa-template.php — Gated via init().
* @see includes/class-psa-search.php — Gated via init().
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Dependency_Check {
/**
* Minimum PR Core version that registers the `peptide` CPT + `peptide_category`
* taxonomy in the shape PSA depends on. Coordinated release: see
* `convo/peptidesearch/threads/2026-04-peptide-cpt-consolidation/`.
*/
const REQUIRED_PR_CORE_VERSION = '0.2.0';
/** Register the admin notice hook. Safe to call multiple times. */
public static function init(): void {
add_action( 'admin_notices', array( __CLASS__, 'maybe_render_notice' ) );
}
/**
* Whether PR Core is installed, active, and at a version PSA accepts.
*
* Pure function — no side effects. Safe to call from any boot-path gate.
*
* @return bool True when PR_CORE_VERSION is defined and >= REQUIRED_PR_CORE_VERSION.
*/
public static function is_satisfied(): bool {
if ( ! defined( 'PR_CORE_VERSION' ) ) {
return false;
}
return version_compare( (string) constant( 'PR_CORE_VERSION' ), self::REQUIRED_PR_CORE_VERSION, '>=' );
}
/**
* Render the admin notice when the dependency is unmet.
*
* Hooked to `admin_notices`. Skips rendering when the dependency is
* satisfied and when the current user lacks the `activate_plugins`
* capability (the only role that can act on the notice).
*/
public static function maybe_render_notice(): void {
if ( self::is_satisfied() ) {
return;
}
if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) {
return;
}
$msg = esc_html__(
'Peptide Search AI requires Peptide Repo Core 0.2.0 or newer for peptide data (custom post type and taxonomy). Please install or update Peptide Repo Core — until then, peptide meta boxes, the directory widget, and the search widget are disabled.',
'peptide-search-ai'
);
printf( '<div class="notice notice-error"><p>%s</p></div>', $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg already escaped via esc_html__.
}
}

View File

@@ -0,0 +1,159 @@
<?php
/**
* REST API endpoint for the browsable peptide directory.
*
* What: GET /v1/compounds — paginated, filterable compound listing with basic/full field sets.
* Who calls it: PSA_Directory::init() registers the REST route; frontend JS fetches data.
* Dependencies: PSA_Post_Type (meta fields, taxonomy), PSA_Directory (constants).
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-directory.php — Shortcode rendering, hook registration.
* @see assets/js/psa-directory.js — Frontend grid, filter, modal logic.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Directory_API {
/**
* Register the /v1/compounds endpoint. Public (no auth) — rate limiting at server/CDN level.
*/
public static function register_rest_routes(): void {
register_rest_route( 'peptide-search-ai/v1', '/compounds', array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'rest_compounds' ),
'permission_callback' => '__return_true',
'args' => array(
'search' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ),
'category' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ),
'fields' => array( 'type' => 'string', 'enum' => array( 'basic', 'full' ), 'default' => 'full' ),
'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint' ),
'per_page' => array( 'type' => 'integer', 'default' => PSA_Directory::PER_PAGE_DEFAULT, 'minimum' => 1, 'maximum' => PSA_Directory::PER_PAGE_MAX, 'sanitize_callback' => 'absint' ),
),
) );
}
/**
* REST callback for GET /v1/compounds.
* Supports search, category filtering, pagination, and basic/full field sets.
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response
*/
public static function rest_compounds( $request ) {
$search = $request->get_param( 'search' );
$category = $request->get_param( 'category' );
$fields = $request->get_param( 'fields' );
$page = max( 1, (int) $request->get_param( 'page' ) );
$per_page = min( PSA_Directory::PER_PAGE_MAX, max( 1, (int) $request->get_param( 'per_page' ) ) );
$query_args = array(
'post_type' => 'peptide',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'title',
'order' => 'ASC',
);
if ( ! empty( $search ) ) {
$query_args['s'] = $search;
}
if ( ! empty( $category ) ) {
$query_args['tax_query'] = array( array(
'taxonomy' => 'peptide_category',
'field' => 'slug',
'terms' => $category,
) );
}
$query = new WP_Query( $query_args );
$post_ids = wp_list_pluck( $query->posts, 'ID' );
if ( ! empty( $post_ids ) ) {
update_meta_cache( 'post', $post_ids );
}
$compounds = array();
foreach ( $query->posts as $post ) {
$compounds[] = self::format_compound( $post, $fields );
}
$response = new WP_REST_Response( array(
'compounds' => $compounds,
'total' => (int) $query->found_posts,
'total_pages' => (int) $query->max_num_pages,
'page' => $page,
'per_page' => $per_page,
), 200 );
$response->header( 'X-WP-Total', (string) $query->found_posts );
$response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
return $response;
}
/**
* Format a single compound for REST response.
*
* @param WP_Post $post The peptide post.
* @param string $fields 'basic' for lightweight data, 'full' for everything.
* @return array Formatted compound data.
*/
public static function format_compound( $post, string $fields = 'full' ): array {
$id = $post->ID;
$terms = wp_get_object_terms( $id, 'peptide_category', array( 'fields' => 'all' ) );
$categories = array();
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$categories[] = array( 'slug' => $term->slug, 'name' => $term->name );
}
}
$basic = array(
'id' => $id,
'name' => $post->post_title,
'slug' => $post->post_name,
'url' => get_permalink( $id ),
'categories' => $categories,
'half_life' => get_post_meta( $id, 'psa_half_life', true ),
'stability' => get_post_meta( $id, 'psa_stability', true ),
'source' => get_post_meta( $id, 'psa_source', true ),
'extras' => apply_filters( 'psa_directory_card_extras', '', $id ),
);
if ( 'basic' === $fields ) {
$basic['vial_size_mg'] = get_post_meta( $id, 'psa_vial_size_mg', true );
return $basic;
}
$full = array_merge( $basic, array(
'excerpt' => wp_trim_words( $post->post_content, 30 ),
'description' => wp_trim_words( $post->post_content, 80 ),
'sequence' => get_post_meta( $id, 'psa_sequence', true ),
'molecular_weight' => get_post_meta( $id, 'psa_molecular_weight', true ),
'molecular_formula' => get_post_meta( $id, 'psa_molecular_formula', true ),
'aliases' => get_post_meta( $id, 'psa_aliases', true ),
'mechanism' => get_post_meta( $id, 'psa_mechanism', true ),
'research_apps' => get_post_meta( $id, 'psa_research_apps', true ),
'safety_profile' => get_post_meta( $id, 'psa_safety_profile', true ),
'dosage_info' => get_post_meta( $id, 'psa_dosage_info', true ),
'references' => get_post_meta( $id, 'psa_references', true ),
'pubchem_cid' => get_post_meta( $id, 'psa_pubchem_cid', true ),
'solubility' => get_post_meta( $id, 'psa_solubility', true ),
'vial_size_mg' => get_post_meta( $id, 'psa_vial_size_mg', true ),
'storage_lyophilized' => get_post_meta( $id, 'psa_storage_lyophilized', true ),
'storage_reconstituted' => get_post_meta( $id, 'psa_storage_reconstituted', true ),
'typical_dose_mcg' => get_post_meta( $id, 'psa_typical_dose_mcg', true ),
'cycle_parameters' => get_post_meta( $id, 'psa_cycle_parameters', true ),
'amino_acid_count' => get_post_meta( $id, 'psa_amino_acid_count', true ),
) );
return apply_filters( 'psa_rest_compound_data', $full, $id );
}
}

114
includes/class-psa-directory.php Executable file
View File

@@ -0,0 +1,114 @@
<?php
/**
* Browsable peptide directory — shortcode rendering and hook registration.
*
* What: Provides the [peptide_directory] shortcode and delegates REST to PSA_Directory_API.
* Who calls it: WordPress shortcode parser, main plugin file via PSA_Directory::init().
* Dependencies: PSA_Directory_API (REST endpoint), frontend JS/CSS.
*
* @package PeptideSearchAI
* @since 4.0.0
* @see includes/class-psa-directory-api.php — REST endpoint and compound formatting.
* @see assets/js/psa-directory.js — Frontend grid, filter, modal logic.
* @see assets/css/psa-directory.css — Directory-specific styles.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Directory {
/** Cards per page for directory pagination. */
const PER_PAGE_DEFAULT = 12;
const PER_PAGE_MAX = 100;
/** Register hooks. */
public static function init(): void {
add_shortcode( 'peptide_directory', array( __CLASS__, 'render_shortcode' ) );
add_action( 'rest_api_init', array( 'PSA_Directory_API', 'register_rest_routes' ) );
}
/**
* Render the [peptide_directory] shortcode.
* Outputs a container div populated by frontend JS via REST.
*
* @param array $atts Shortcode attributes (reserved for future use).
* @return string HTML output.
*/
public static function render_shortcode( $atts ): string {
$terms = get_terms( array(
'taxonomy' => 'peptide_category',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
) );
$categories = array();
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$categories[] = array( 'slug' => $term->slug, 'name' => $term->name );
}
}
wp_localize_script( 'psa-directory', 'psaDirectory', array(
'restUrl' => esc_url_raw( rest_url( 'peptide-search-ai/v1/compounds' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'categories' => $categories,
'perPage' => self::PER_PAGE_DEFAULT,
'i18n' => array(
'title' => __( 'Peptide Directory', 'peptide-search-ai' ),
'subtitle' => __( 'Browse reagents with detailed research parameters.', 'peptide-search-ai' ),
'searchPlaceholder' => __( 'Search peptides...', 'peptide-search-ai' ),
'showAll' => __( 'Show All', 'peptide-search-ai' ),
'loadMore' => __( 'Load More', 'peptide-search-ai' ),
'loading' => __( 'Loading...', 'peptide-search-ai' ),
'noResults' => __( 'No peptides found matching your criteria.', 'peptide-search-ai' ),
'viewDetails' => __( 'View Research Details', 'peptide-search-ai' ),
'viewFullPage' => __( 'View Full Page', 'peptide-search-ai' ),
'close' => __( 'Close', 'peptide-search-ai' ),
'halfLife' => __( 'Half-Life', 'peptide-search-ai' ),
'stability' => __( 'Stability', 'peptide-search-ai' ),
'solubility' => __( 'Solubility', 'peptide-search-ai' ),
'vialSize' => __( 'Vial Size', 'peptide-search-ai' ),
'storageLyo' => __( 'Storage (Lyophilized)', 'peptide-search-ai' ),
'storageRecon' => __( 'Storage (Reconstituted)', 'peptide-search-ai' ),
'molecularWeight' => __( 'Molecular Weight', 'peptide-search-ai' ),
'formula' => __( 'Formula', 'peptide-search-ai' ),
'typicalDose' => __( 'Typical Dose', 'peptide-search-ai' ),
'cycleParams' => __( 'Cycle Parameters', 'peptide-search-ai' ),
'sequence' => __( 'Sequence', 'peptide-search-ai' ),
'singleReagents' => __( 'Single Reagents', 'peptide-search-ai' ),
'protocolModels' => __( 'Protocol Models', 'peptide-search-ai' ),
'comingSoon' => __( 'Coming Soon', 'peptide-search-ai' ),
'researchParams' => __( 'Research Parameters', 'peptide-search-ai' ),
'communityObs' => __( 'Community Observations', 'peptide-search-ai' ),
'pubchemLink' => __( 'View on PubChem', 'peptide-search-ai' ),
'copySequence' => __( 'Copy', 'peptide-search-ai' ),
'copied' => __( 'Copied!', 'peptide-search-ai' ),
),
) );
ob_start();
?>
<div id="psa-directory-root" class="psa-dir-wrap" aria-label="<?php esc_attr_e( 'Peptide Directory', 'peptide-search-ai' ); ?>">
<noscript>
<p><?php esc_html_e( 'Please enable JavaScript to browse the peptide directory.', 'peptide-search-ai' ); ?></p>
</noscript>
</div>
<?php
return ob_get_clean();
}
// ── Backward-compatible proxies (REST moved to PSA_Directory_API) ───
/** @see PSA_Directory_API::register_rest_routes() */
public static function register_rest_routes(): void { PSA_Directory_API::register_rest_routes(); }
/** @see PSA_Directory_API::rest_compounds() */
public static function rest_compounds( $request ) { return PSA_Directory_API::rest_compounds( $request ); }
/** @see PSA_Directory_API::format_compound() */
public static function format_compound( $post, string $fields = 'full' ): array { return PSA_Directory_API::format_compound( $post, $fields ); }
}

130
includes/class-psa-encryption.php Executable file
View File

@@ -0,0 +1,130 @@
<?php
/**
* Handles encryption/decryption of sensitive data at rest.
*
* What: Provides AES-256-CBC encryption for API keys stored in the database.
* Who calls it: PSA_Admin (encrypt on save), PSA_AI_Generator (decrypt on read).
* Dependencies: PHP openssl extension, WordPress wp_salt() function.
*
* @package PeptideSearchAI
* @see includes/class-psa-admin.php
* @see includes/class-psa-ai-generator.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Encryption {
/**
* Encrypt plaintext using AES-256-CBC.
*
* Prepends the IV to the ciphertext and base64-encodes the result.
*
* @param string $plaintext The plaintext to encrypt.
* @return string|false Base64-encoded IV + ciphertext, or false on failure.
*/
public static function encrypt( string $plaintext ) {
if ( empty( $plaintext ) ) {
return false;
}
$key = self::get_key();
if ( false === $key ) {
return false;
}
// Generate a random IV for this encryption.
$iv = openssl_random_pseudo_bytes( 16 );
if ( false === $iv ) {
return false;
}
// Encrypt using AES-256-CBC.
$ciphertext = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
if ( false === $ciphertext ) {
return false;
}
// Prepend IV to ciphertext and base64-encode.
$encrypted = base64_encode( $iv . $ciphertext );
return $encrypted;
}
/**
* Decrypt AES-256-CBC ciphertext.
*
* Expects base64-encoded input with IV prepended (as produced by encrypt()).
*
* @param string $ciphertext The base64-encoded IV + ciphertext.
* @return string|false Decrypted plaintext, or false on failure.
*/
public static function decrypt( string $ciphertext ) {
if ( empty( $ciphertext ) ) {
return false;
}
$key = self::get_key();
if ( false === $key ) {
return false;
}
// Base64-decode and extract IV + ciphertext.
$data = base64_decode( $ciphertext, true );
if ( false === $data || strlen( $data ) < 16 ) {
return false;
}
// First 16 bytes are the IV.
$iv = substr( $data, 0, 16 );
$encrypted_data = substr( $data, 16 );
// Decrypt.
$plaintext = openssl_decrypt( $encrypted_data, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
return $plaintext;
}
/**
* Check if a value looks encrypted.
*
* A value is considered encrypted if it's base64-decodable and contains
* at least 16 bytes (IV) + some ciphertext.
*
* @param string $value The value to check.
* @return bool True if the value appears to be encrypted.
*/
public static function is_encrypted( $value ): bool {
if ( empty( $value ) || ! is_string( $value ) ) {
return false;
}
$data = base64_decode( $value, true );
if ( false === $data || strlen( $data ) < 16 ) {
return false;
}
return true;
}
/**
* Derive a 32-byte encryption key from WordPress salt.
*
* Uses SHA-256 hash of wp_salt( 'auth' ) to produce a consistent 32-byte key.
*
* @return string|false 32-byte key, or false if salt is unavailable.
*/
private static function get_key() {
$salt = wp_salt( 'auth' );
if ( empty( $salt ) ) {
return false;
}
// SHA-256 produces a 64-character hex string (32 bytes when decoded).
$key = hash( 'sha256', $salt, true );
return $key;
}
}

247
includes/class-psa-kb-builder.php Executable file
View File

@@ -0,0 +1,247 @@
<?php
/**
* Echo Knowledge Base article builder for generated peptide content.
*
* What: Creates structured KB articles from AI-generated peptide data with proper
* HTML formatting, category assignment, and cross-referencing to the source peptide post.
* Who calls it: PSA_AI_Generator::background_generate() after successful content generation.
* Dependencies: WordPress post API, Echo Knowledge Base CPT (epkb_post_type_1).
*
* @package PeptideSearchAI
* @see includes/class-psa-ai-generator.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_KB_Builder {
/**
* Category slug mapping for Echo Knowledge Base taxonomy.
*/
const 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',
);
/**
* Create a matching Echo Knowledge Base article with full structured content.
*
* Checks for Echo KB plugin, avoids duplicates, builds structured HTML with
* h2/h3 headings, assigns KB category, and cross-references the peptide post.
*
* @param int $peptide_post_id The peptide post ID to cross-reference.
* @param array $ai_data The parsed AI data with all content fields.
* @param string $peptide_name The peptide name for logging and fallback title.
* @param string $post_status The post status ('draft' or 'publish').
* @return void
*/
public static function create_article( int $peptide_post_id, array $ai_data, string $peptide_name, string $post_status = 'draft' ): void {
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;
}
$content = self::build_article_content( $ai_data );
$kb_post_id = wp_insert_post(
array(
'post_title' => $title,
'post_content' => wp_kses_post( $content ),
'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;
}
self::assign_category( $kb_post_id, $ai_data );
// Cross-reference both posts.
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 . '"' );
}
/**
* Build the full article HTML content from AI data.
*
* @param array $ai_data The parsed AI data.
* @return string HTML content for the KB article.
*/
private static function build_article_content( array $ai_data ): string {
// 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 );
}
$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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>';
return $c;
}
/**
* Assign Echo KB category to a KB article based on AI-classified category.
*
* @param int $kb_post_id The KB article post ID.
* @param array $ai_data The parsed AI data containing 'category' field.
* @return void
*/
private static function assign_category( int $kb_post_id, array $ai_data ): void {
$cat_name = trim( $ai_data['category'] ?? '' );
if ( isset( self::CATEGORY_MAP[ $cat_name ] ) ) {
$term = get_term_by( 'slug', self::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' );
}
}
}
/**
* Convert text to HTML paragraphs.
*
* Splits on double newlines and wraps each chunk in <p> tags.
*
* @param string $text Raw text content.
* @return string HTML paragraphs.
*/
public static function paragraphs( string $text ): string {
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.
*
* Short lines (<80 chars, no period) become h3 subheadings.
* Longer blocks become regular paragraphs.
*
* @param string $text Research benefits text with embedded subheadings.
* @return string HTML with h3 subheadings and paragraphs.
*/
public static function research_with_subheadings( string $text ): string {
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;
}
}

218
includes/class-psa-openrouter.php Executable file
View File

@@ -0,0 +1,218 @@
<?php
/**
* OpenRouter API communication layer.
*
* What: Handles HTTP requests to OpenRouter API with retry logic, rate limit handling,
* and response parsing. Logs all calls via PSA_Cost_Tracker.
* Who calls it: PSA_AI_Generator for validation and generation API calls.
* Dependencies: WordPress HTTP API (wp_remote_post), PSA_Config, PSA_Cost_Tracker.
*
* @package PeptideSearchAI
* @see includes/class-psa-ai-generator.php
* @see includes/class-psa-cost-tracker.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_OpenRouter {
/**
* OpenRouter API base URL.
*/
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
/**
* 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.
* @param string $request_type 'validation' or 'generation' (default).
* @param string $peptide_name Peptide name for logging.
* @return string|WP_Error Response text or error.
*/
public static function send_request( string $api_key, string $model, string $prompt, int $max_tokens = PSA_Config::GENERATION_MAX_TOKENS, string $request_type = 'generation', string $peptide_name = '' ) {
$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::send_request_once( $api_key, $model, $prompt, $max_tokens, $request_type, $peptide_name );
// 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.
* @param string $request_type 'validation' or 'generation' (default).
* @param string $peptide_name Peptide name for logging.
* @return string|WP_Error Response text or error.
*/
private static function send_request_once( string $api_key, string $model, string $prompt, int $max_tokens = 4096, string $request_type = 'generation', string $peptide_name = '' ) {
$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.' );
}
// Log API call with token usage.
$response_text = $body['choices'][0]['message']['content'];
$prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
$completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 );
$token_source = 'api';
// Diagnostic: log the raw usage structure once per model so we can verify
// whether the provider returns token counts. Remove after confirming.
if ( 0 === $prompt_tokens ) {
$usage_raw = isset( $body['usage'] ) ? wp_json_encode( $body['usage'] ) : 'missing';
error_log( 'PSA: OpenRouter usage field for model ' . $model . ': ' . substr( $usage_raw, 0, 300 ) );
}
// Fallback: estimate tokens from character length when the API reports zero.
// Why: Some OpenRouter models (e.g. deepseek) omit the usage object entirely.
// Approximation: ~3.75 characters per token for English scientific text.
if ( 0 === $prompt_tokens && 0 === $completion_tokens ) {
$prompt_tokens = (int) ceil( strlen( $prompt ) / 3.75 );
$completion_tokens = (int) ceil( strlen( $response_text ) / 3.75 );
$token_source = 'estimated';
error_log( 'PSA: Token usage estimated from character length for ' . $peptide_name . ': prompt=' . $prompt_tokens . ', completion=' . $completion_tokens );
}
PSA_Cost_Tracker::log_api_call(
array(
'provider' => 'openrouter',
'model' => $model,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'request_type' => $request_type,
'peptide_name' => $peptide_name,
'success' => true,
'token_source' => $token_source,
)
);
return $response_text;
}
/**
* Parse AI response text into structured data.
*
* Strips thinking tags (e.g., DeepSeek) and markdown code fences,
* then decodes the JSON response.
*
* @param string $response_text Raw response text from OpenRouter.
* @return array|WP_Error Parsed data or error.
*/
public static function parse_response( string $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;
}
}

View File

@@ -0,0 +1,140 @@
<?php
/**
* Meta box rendering and save handlers for the Peptide CPT editor.
*
* What: Adds and renders meta boxes (core + extended fields), saves meta on post update.
* Who calls it: PSA_Post_Type::init_admin() registers hooks that point here.
* Dependencies: PSA_Post_Type (META_FIELDS, EXTENDED_META_FIELDS constants).
*
* @package PeptideSearchAI
* @since 4.5.0
* @see includes/class-psa-post-type.php — CPT registration, taxonomy, admin columns.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Post_Type_Meta {
/** Register meta boxes on the peptide editor screen. */
public static function add_meta_boxes(): void {
add_meta_box(
'psa_peptide_data',
'Peptide Scientific Data',
array( __CLASS__, 'render_meta_box' ),
'peptide',
'normal',
'high'
);
add_meta_box(
'psa_extended_data',
'Extended Research Data',
array( __CLASS__, 'render_extended_meta_box' ),
'peptide',
'normal',
'default'
);
}
/**
* Render core meta box fields (sequence, molecular data, mechanism, etc.).
*
* @param WP_Post $post The current post object.
*/
public static function render_meta_box( $post ): void {
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 ( PSA_Post_Type::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>';
}
/**
* Render extended meta box fields (half-life, stability, storage, etc.).
*
* @param WP_Post $post The current post object.
*/
public static function render_extended_meta_box( $post ): void {
echo '<table class="form-table">';
foreach ( PSA_Post_Type::EXTENDED_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_vial_size_mg' === $key ) {
echo '<input type="number" step="0.01" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" /> mg';
} elseif ( 'psa_amino_acid_count' === $key ) {
echo '<input type="number" step="1" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" />';
} 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 (core + extended) on post save.
*
* @param int $post_id The post ID.
*/
public static function save_meta( int $post_id ): void {
// 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;
}
$numeric_fields = array( 'psa_vial_size_mg', 'psa_amino_acid_count' );
$all_fields = array_merge( PSA_Post_Type::META_FIELDS, PSA_Post_Type::EXTENDED_META_FIELDS );
foreach ( $all_fields as $key => $label ) {
if ( isset( $_POST[ $key ] ) ) {
$raw = wp_unslash( $_POST[ $key ] );
if ( in_array( $key, $numeric_fields, true ) ) {
$value = ( '' === trim( $raw ) ) ? '' : (string) max( 0, floatval( $raw ) );
} else {
$value = sanitize_textarea_field( $raw );
}
update_post_meta( $post_id, $key, $value );
}
}
}
}

153
includes/class-psa-post-type.php Executable file
View File

@@ -0,0 +1,153 @@
<?php
/**
* PSA's consumer-side integration with the `peptide` custom post type.
*
* As of v4.5.0 the `peptide` CPT and `peptide_category` taxonomy are owned by
* Peptide Repo Core (PR Core) >= 0.2.0 — PSA no longer registers them. This
* class now wires PSA-owned admin surfaces (meta boxes, admin columns) onto
* the CPT that PR Core registers, and exposes the `psa_*` meta key namespace
* via constants for the rest of PSA to consume.
*
* What: Admin columns, meta key definitions, get_all_meta_keys() helper, and
* backward-compatible proxies to PSA_Post_Type_Meta.
* Who calls it: `psa_admin_init()` in the main plugin file, gated by
* PSA_Dependency_Check::is_satisfied() so admin surfaces don't bind against
* a missing CPT when PR Core is absent.
* Dependencies: PR Core >= 0.2.0 for the CPT itself (runtime); WordPress
* meta-box + admin-column APIs; PSA_Post_Type_Meta for meta box rendering.
*
* @package PeptideSearchAI
* @since 1.0.0 (CPT registration removed in 4.5.0)
* @see includes/class-psa-post-type-meta.php — Meta box rendering and save handlers.
* @see includes/class-psa-dependency-check.php — PR Core gate used by init_admin().
* @see includes/class-psa-directory.php — Reads `peptide_category` terms for directory filtering.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Post_Type {
/**
* Canonical slug → display-name map for the peptide categories PSA's
* AI and admin tools know how to assign terms to. This is a consumer
* reference list (used by PSA_AI_Content::assign_category_term() and
* the "Migrate Existing Peptides to Categories" admin button) — not a
* seed list. As of PSA v4.5.0 PR Core >= 0.2.0 owns the
* `peptide_category` taxonomy; the 8 terms below were seeded by PSA
* in pre-v4.5.0 releases and remain intact in `wp_term_taxonomy`. PSA
* no longer seeds them on activation.
*/
const DEFAULT_CATEGORIES = array(
'tissue-repair' => 'Tissue Repair',
'lipid-metabolism' => 'Lipid Metabolism',
'aging-research' => 'Aging Research',
'dermatological' => 'Dermatological',
'metabolic' => 'Metabolic',
'growth-hormone' => 'Growth Hormone',
'immunology' => 'Immunology',
'endocrine' => 'Endocrine',
);
/** Core 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',
);
/** Extended meta fields added in v4.3.0 for the directory feature. */
const EXTENDED_META_FIELDS = array(
'psa_half_life' => 'Half-Life',
'psa_stability' => 'Stability',
'psa_solubility' => 'Solubility / Reconstitution',
'psa_vial_size_mg' => 'Common Vial Size (mg)',
'psa_storage_lyophilized' => 'Storage (Lyophilized)',
'psa_storage_reconstituted' => 'Storage (Reconstituted)',
'psa_typical_dose_mcg' => 'Typical Research Dose',
'psa_cycle_parameters' => 'Cycle / Protocol Parameters',
'psa_amino_acid_count' => 'Amino Acid Count',
);
/**
* Register admin-only hooks (meta boxes, save, admin columns).
*
* Caller (`psa_admin_init`) gates this behind
* PSA_Dependency_Check::is_satisfied() so meta boxes + admin columns
* don't attempt to bind against a `peptide` CPT that PR Core has not
* yet registered.
*/
public static function init_admin(): void {
add_action( 'add_meta_boxes', array( 'PSA_Post_Type_Meta', 'add_meta_boxes' ) );
add_action( 'save_post_peptide', array( 'PSA_Post_Type_Meta', 'save_meta' ) );
add_filter( 'manage_peptide_posts_columns', array( __CLASS__, 'add_admin_columns' ) );
add_action( 'manage_peptide_posts_custom_column', array( __CLASS__, 'render_admin_column' ), 10, 2 );
}
/**
* Add custom columns to the peptide admin list.
*
* @param array $columns Existing columns.
* @return array Modified columns.
*/
public static function add_admin_columns( array $columns ): array {
$new = array();
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
if ( 'title' === $key ) {
$new['psa_source'] = __( 'Source', 'peptide-search-ai' );
}
}
return $new;
}
/**
* Render custom column content in the admin post list.
*
* @param string $column Column key.
* @param int $post_id Post ID.
*/
public static function render_admin_column( string $column, int $post_id ): void {
if ( 'psa_source' === $column ) {
$source = get_post_meta( $post_id, 'psa_source', true );
$labels = array(
'ai-generated' => 'AI', 'pubchem' => 'PubChem',
'manual' => 'Manual', 'pending' => 'Pending', 'failed' => 'Failed',
);
echo esc_html( $labels[ $source ] ?? '—' );
}
}
/**
* Get all meta field keys (core + extended) as a flat array.
*
* @return array<string> Meta keys.
*/
public static function get_all_meta_keys(): array {
return array_merge( array_keys( self::META_FIELDS ), array_keys( self::EXTENDED_META_FIELDS ) );
}
// ── Backward-compatible proxies (meta boxes moved to PSA_Post_Type_Meta) ──
/** @see PSA_Post_Type_Meta::add_meta_boxes() */
public static function add_meta_boxes(): void { PSA_Post_Type_Meta::add_meta_boxes(); }
/** @see PSA_Post_Type_Meta::render_meta_box() */
public static function render_meta_box( $post ): void { PSA_Post_Type_Meta::render_meta_box( $post ); }
/** @see PSA_Post_Type_Meta::render_extended_meta_box() */
public static function render_extended_meta_box( $post ): void { PSA_Post_Type_Meta::render_extended_meta_box( $post ); }
/** @see PSA_Post_Type_Meta::save_meta() */
public static function save_meta( int $post_id ): void { PSA_Post_Type_Meta::save_meta( $post_id ); }
}

158
includes/class-psa-pubchem.php Executable file
View File

@@ -0,0 +1,158 @@
<?php
/**
* PubChem PUG REST API integration for molecular data enrichment.
*
* What: Fetches verified molecular data (formula, weight, SMILES, InChI) from PubChem.
* Who calls it: PSA_AI_Generator::background_generate() during peptide content generation.
* Dependencies: WordPress HTTP API (wp_remote_get), PSA_Config for cache TTL.
*
* @package PeptideSearchAI
* @see includes/class-psa-ai-generator.php
*/
declare( strict_types=1 );
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( string $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( string $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( int $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'] ?? '',
);
}
}

View File

@@ -0,0 +1,211 @@
<?php
/**
* AJAX search handler and peptide generation workflow.
*
* What: Orchestrates the AJAX search flow — checks existing results, handles
* pending retries, validates new peptide names, creates placeholders,
* and schedules background generation.
* Who calls it: PSA_Search::init() registers the AJAX action that invokes ajax_search().
* Dependencies: PSA_Config, PSA_AI_Generator, PSA_Search (for search_peptides/find_pending).
*
* @package PeptideSearchAI
* @since 2.6.0
* @see includes/class-psa-search.php — Core search and cache logic.
* @see includes/class-psa-ai-generator.php — Peptide validation and content generation.
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Search_Handler {
/**
* AJAX handler: search for peptides.
*
* Orchestrates: search → pending check → rate limit → validate → generate.
* Side effects: nonce check, JSON response, may create posts and schedule cron.
*/
public static function ajax_search(): void {
check_ajax_referer( 'psa_search_nonce', 'nonce' );
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
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 = PSA_Search::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 = PSA_Search::find_pending_peptide( $query );
if ( $pending ) {
self::handle_pending_retry( $pending );
return;
}
// 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 );
}
/**
* Handle a pending peptide — retry if timed out, else return pending status.
*
* Side effects: may update post meta, schedule cron, JSON response.
*
* @param WP_Post $pending The pending post.
*/
private static function handle_pending_retry( $pending ): void {
$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 ) {
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;
}
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.
*
* Uses dual-layer caching (transient + object cache) for resilience.
*
* @return bool True if under limit.
*/
private static function check_rate_limit(): bool {
$ip = psa_get_client_ip();
$rate_key = 'psa_rate_' . md5( $ip );
$count_transient = (int) get_transient( $rate_key );
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
$count = max( $count_transient, $count_cache );
return $count < PSA_Config::AJAX_RATE_LIMIT;
}
/**
* Handle a new generation request — validate, create placeholder, and schedule.
*
* Side effects: AI validation call, post creation, rate limit update, cron schedule, JSON response.
*
* @param string $query The search query (peptide name).
*/
private static function handle_new_generation( string $query ): void {
// 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;
}
$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.',
) );
}
// Valid 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 );
wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', 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.
set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS );
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 ) );
}
/**
* Create a placeholder draft post for the pending peptide.
*
* @param string $peptide_name Canonical peptide name.
* @return int|WP_Error Post ID or error.
*/
public static function create_pending_placeholder( string $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() );
do_action( 'psa_peptide_created', $post_id, $peptide_name );
}
return $post_id;
}
/**
* Schedule background generation using WP-Cron (non-blocking).
*
* @param int $post_id Placeholder post ID.
* @param string $peptide_name Peptide to research.
*/
public static function schedule_background_generation( int $post_id, string $peptide_name ): void {
$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();
}
}

300
includes/class-psa-search.php Executable file
View File

@@ -0,0 +1,300 @@
<?php
/**
* Core search, caching, shortcode rendering, and REST endpoint for peptides.
*
* What: Shortcode rendering, REST search, transient caching, result formatting.
* Who calls it: WordPress shortcode parser, REST API, PSA_Search_Handler (for search_peptides).
* Dependencies: PSA_Config, PSA_Search_Handler (for AJAX/generation workflow).
*
* @package PeptideSearchAI
* @see includes/class-psa-search-handler.php — AJAX handler and generation workflow.
* @see includes/class-psa-config.php
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Search {
/** @var int Static counter for unique shortcode instance IDs. */
private static $instance_count = 0;
public static function init(): void {
add_shortcode( 'peptide_search', array( __CLASS__, 'render_search_form' ) );
add_action( 'wp_ajax_psa_search', array( 'PSA_Search_Handler', 'ajax_search' ) );
add_action( 'wp_ajax_nopriv_psa_search', array( 'PSA_Search_Handler', 'ajax_search' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
add_action( 'save_post_peptide', array( __CLASS__, 'invalidate_search_cache' ) );
}
/**
* Get the current cache generation counter.
*
* Used to invalidate all psa_search_* caches without expensive DELETE queries.
* When a peptide is saved, we increment the generation, causing all searches
* to use a new cache key and bypass stale results.
*
* @return int Current generation counter.
*/
private static function get_cache_generation(): int {
return (int) get_option( 'psa_search_cache_gen', 0 );
}
/**
* Invalidate all search caches by incrementing the generation counter.
*
* O(1) option update vs. O(n) DELETE query.
*
* @param int $post_id The post ID being saved.
*/
public static function invalidate_search_cache( int $post_id ): void {
update_option( 'psa_search_cache_gen', self::get_cache_generation() + 1 );
}
/**
* Flush all search caches immediately via DELETE query.
*
* WARNING: Expensive — only use for manual admin actions, not on every save.
*/
public static function flush_all_search_caches(): void {
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 route for external/headless access. */
public static function register_rest_routes(): void {
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).
*
* Uses dual-layer rate limiting (transient + object cache) for resilience.
*
* @param WP_REST_Request $request The REST request.
* @return WP_REST_Response
*/
public static function rest_search( $request ) {
$ip = psa_get_client_ip();
$rate_key = 'psa_rest_rate_' . md5( $ip );
$count_transient = (int) get_transient( $rate_key );
$count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
$count = max( $count_transient, $count_cache );
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 );
wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', HOUR_IN_SECONDS );
$query = $request->get_param( 'q' );
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
);
}
return new WP_REST_Response( self::search_peptides( $query ), 200 );
}
/**
* Render the [peptide_search] shortcode.
*
* Uses class-based selectors (no IDs) to support multiple instances per page.
*
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
public static function render_search_form( $atts ): string {
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">&#128269;</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">&#9203;</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();
}
/**
* Find an existing pending peptide post by name or alias.
*
* @param string $query Search term (peptide name or alias).
* @return WP_Post|null Pending post or null.
*/
public static function find_pending_peptide( string $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;
}
/**
* Core search — queries CPT by title and aliases (published only).
*
* Includes transient caching with generation-counter invalidation.
*
* @param string $query Search term.
* @return array{results: array, total: int, query: string}
*/
public static function search_peptides( string $query ): array {
global $wpdb;
$cache_key = 'psa_search_' . self::get_cache_generation() . '_' . 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_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 );
set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS );
return apply_filters( 'psa_search_results', $response, $query );
}
/**
* Format a peptide post for JSON response.
*
* @param int $post_id The post ID.
* @return array
*/
public static function format_peptide( int $post_id ): array {
$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 ),
);
}
// ── Backward-compatible proxies ─────────────────────────────────────
/** @see PSA_Search_Handler::ajax_search() */
public static function ajax_search(): void { PSA_Search_Handler::ajax_search(); }
/** @see PSA_Search_Handler::create_pending_placeholder() */
public static function create_pending_placeholder( string $peptide_name ) { return PSA_Search_Handler::create_pending_placeholder( $peptide_name ); }
/** @see PSA_Search_Handler::schedule_background_generation() */
public static function schedule_background_generation( int $post_id, string $peptide_name ): void { PSA_Search_Handler::schedule_background_generation( $post_id, $peptide_name ); }
}

224
includes/class-psa-template.php Executable file
View File

@@ -0,0 +1,224 @@
<?php
/**
* Custom template rendering for single peptide pages.
*
* What: Appends structured peptide data (quick facts, extended data, sections, badges) below post content.
* Who calls it: WordPress 'the_content' filter on single peptide pages.
* Dependencies: WordPress post meta API, PSA_Post_Type meta field keys.
*
* @package PeptideSearchAI
* @see includes/class-psa-post-type.php
* @see includes/class-psa-directory.php — detail modal uses the same data via REST
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PSA_Template {
public static function init(): void {
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( string $content ): string {
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 );
$data = array(
'post_id' => $post_id,
'source' => $source,
'pubchem_cid' => $pubchem_cid,
);
$data = apply_filters( 'psa_template_data', $data, $post_id );
$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.
$html .= self::render_quick_facts( $post_id, $pubchem_cid );
// Extended research data box (half-life, stability, storage, etc.).
$html .= self::render_extended_data( $post_id );
// Detailed sections.
$html .= self::render_detail_sections( $post_id );
// Source badge.
$html .= self::render_source_badge( $source );
// Integration hook: allow Peptide Community and other plugins to append content.
ob_start();
do_action( 'psa_after_peptide_detail', $post_id );
$html .= ob_get_clean();
$html .= '</div>';
return $content . $html;
}
/**
* Render the quick facts table.
*
* @param int $post_id Post ID.
* @param string $pubchem_cid PubChem CID (may be empty).
* @return string HTML.
*/
private static function render_quick_facts( int $post_id, string $pubchem_cid ): string {
$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 ) {
return '';
}
$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></div>';
return $html;
}
/**
* Render the extended research data table (v4.3.0 fields).
*
* @param int $post_id Post ID.
* @return string HTML (empty string if no extended data).
*/
private static function render_extended_data( int $post_id ): string {
$fields = array(
'psa_half_life' => 'Half-Life',
'psa_stability' => 'Stability',
'psa_solubility' => 'Solubility',
'psa_vial_size_mg' => 'Vial Size',
'psa_storage_lyophilized' => 'Storage (Lyophilized)',
'psa_storage_reconstituted' => 'Storage (Reconstituted)',
'psa_typical_dose_mcg' => 'Typical Research Dose',
'psa_cycle_parameters' => 'Cycle Parameters',
'psa_amino_acid_count' => 'Amino Acid Count',
);
$rows = '';
foreach ( $fields as $key => $label ) {
$value = get_post_meta( $post_id, $key, true );
if ( ! empty( $value ) ) {
$display = $value;
if ( 'psa_vial_size_mg' === $key ) {
$display = floatval( $value ) . ' mg';
}
$rows .= '<tr><th>' . esc_html( $label ) . '</th><td>' . esc_html( $display ) . '</td></tr>';
}
}
if ( empty( $rows ) ) {
return '';
}
return '<div class="psa-quick-facts">' .
'<h3>Research Parameters</h3>' .
'<table class="psa-facts-table">' . $rows . '</table>' .
'</div>';
}
/**
* Render detailed text sections (mechanism, research apps, safety, etc.).
*
* @param int $post_id Post ID.
* @return string HTML.
*/
private static function render_detail_sections( int $post_id ): string {
$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',
);
$html = '';
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>';
}
}
return $html;
}
/**
* Render the source badge.
*
* @param string $source The data source value.
* @return string HTML (empty if source is empty or 'pending').
*/
private static function render_source_badge( string $source ): string {
if ( ! $source || 'pending' === $source ) {
return '';
}
$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 .= ' &mdash; This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
}
$html .= '</div>';
return $html;
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Schema upgrade helpers for Peptide Search AI.
*
* What: Pure-function helpers that decide whether a database schema upgrade
* is required, plus the driver that runs the upgrade when the installed
* code version is newer than the stored psa_db_version option.
*
* Who triggers it: psa_admin_init() in peptide-search-ai.php calls
* PSA_Upgrade::maybe_run() on every WordPress admin_init hook.
*
* Dependencies: PSA_Cost_Tracker (for re-running dbDelta on the
* cost-tracker table), WordPress options API (get_option / update_option),
* and the PSA_VERSION constant defined in peptide-search-ai.php.
*
* @package PeptideSearchAI
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* PSA_Upgrade — thin wrapper around version_compare + dbDelta.
*
* The class exists so the decision logic (is_needed) can be unit-tested as a
* pure function, while the impure driver (maybe_run) stays isolated.
*/
class PSA_Upgrade {
/**
* Option name used to persist the schema version last migrated to.
*
* @var string
*/
const OPTION_NAME = 'psa_db_version';
/**
* Default stored version for fresh installs. Strictly less than any
* real released version so version_compare treats it as "older".
*
* @var string
*/
const DEFAULT_STORED_VERSION = '0.0.0';
/**
* Decide whether a schema upgrade is needed given a stored version and
* the currently-installed code version. Pure function — no side effects,
* no WordPress API calls — so it can be exercised in isolation.
*
* @param string $stored_version Version string recorded in psa_db_version.
* @param string $current_version Version string of the running code.
* @return bool True if an upgrade must run; false otherwise.
*/
public static function is_needed( string $stored_version, string $current_version ): bool {
return version_compare( $stored_version, $current_version, '<' );
}
/**
* Run database upgrades when the installed code version is newer than
* the stored psa_db_version option. Keeps users from needing to
* deactivate/reactivate the plugin after a schema change.
*
* Side effects: reads psa_db_version option; on upgrade, calls
* PSA_Cost_Tracker::create_table() and writes psa_db_version.
*
* @return void
*/
public static function maybe_run(): void {
$stored = get_option( self::OPTION_NAME, self::DEFAULT_STORED_VERSION );
if ( ! self::is_needed( $stored, PSA_VERSION ) ) {
return;
}
// Re-run CREATE TABLE via dbDelta — additive schema changes (new
// columns, new indexes) are applied automatically.
PSA_Cost_Tracker::create_table();
update_option( self::OPTION_NAME, PSA_VERSION, false );
}
}