diff --git a/includes/class-psa-search-handler.php b/includes/class-psa-search-handler.php new file mode 100644 index 0000000..ef778e0 --- /dev/null +++ b/includes/class-psa-search-handler.php @@ -0,0 +1,211 @@ + 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(); + } +} diff --git a/includes/class-psa-search.php b/includes/class-psa-search.php index 85582fa..42bbc84 100755 --- a/includes/class-psa-search.php +++ b/includes/class-psa-search.php @@ -1,13 +1,13 @@ = PSA_Config::REST_RATE_LIMIT ) { - return new WP_REST_Response( - array( 'error' => 'Rate limit exceeded. Please try again later.' ), - 429 - ); + return new WP_REST_Response( array( 'error' => 'Rate limit exceeded. Please try again later.' ), 429 ); } - // Update both transient and object cache for resilience. 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' ); - // Max input length validation. if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) { return new WP_REST_Response( array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ), @@ -163,16 +122,13 @@ class PSA_Search { ); } - $results = self::search_peptides( $query ); - return new WP_REST_Response( $results, 200 ); + return new WP_REST_Response( self::search_peptides( $query ), 200 ); } /** * Render the [peptide_search] shortcode. * - * Uses class-based selectors (no IDs) to support multiple shortcode instances - * on the same page (e.g., hero section + search overlay). Each instance is - * independently functional via scoped JS initialization. + * Uses class-based selectors (no IDs) to support multiple instances per page. * * @param array $atts Shortcode attributes. * @return string HTML output. @@ -182,9 +138,7 @@ class PSA_Search { $instance = self::$instance_count; $atts = shortcode_atts( - array( - 'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...', - ), + array( 'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...' ), $atts, 'peptide_search' ); @@ -225,210 +179,10 @@ class PSA_Search { } /** - * AJAX handler: search for peptides. - * Orchestrates complete workflow: search, pending check, rate limiting, validation, generation. - * - * @return void Exits with wp_send_json_* responses. - */ - public static function ajax_search(): void { - check_ajax_referer( 'psa_search_nonce', 'nonce' ); - - $query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; - - // Validate query length using PSA_Config constants. - if ( mb_strlen( $query ) < PSA_Config::MIN_QUERY_LENGTH || mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) { - wp_send_json_error( 'Search query must be between ' . PSA_Config::MIN_QUERY_LENGTH . ' and ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ); - } - - // 1. Search existing published peptides. - $results = self::search_peptides( $query ); - - if ( ! empty( $results['results'] ) ) { - wp_send_json_success( - array( - 'status' => 'found', - 'data' => $results, - ) - ); - return; - } - - // 2. Check if already pending — handles retries if timed out. - $pending = self::find_pending_peptide( $query ); - if ( $pending ) { - self::handle_pending_retry( $pending ); - return; // handle_pending_retry() exits with wp_send_json_success() - } - - // 3. Check rate limit before attempting new generation. - if ( ! self::check_rate_limit() ) { - wp_send_json_success( - array( - 'status' => 'rate_limited', - 'message' => 'Too many requests. Please try again later.', - ) - ); - return; - } - - // 4. Validate and generate. - self::handle_new_generation( $query ); - return; // handle_new_generation() exits with wp_send_json_*() - } - - /** - * Handle a pending peptide — retry if timed out, else return pending status. - * - * @param WP_Post $pending The pending post. - * @return void Exits with wp_send_json_success(). - */ - private static function handle_pending_retry( $pending ): 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 ) { - // Max retries exceeded — mark as failed. - error_log( 'PSA: Max retries (' . PSA_Config::MAX_GENERATION_RETRIES . ') exceeded for "' . $pending->post_title . '" (post ' . $pending->ID . '). Marking as failed.' ); - update_post_meta( $pending->ID, 'psa_source', 'failed' ); - update_post_meta( $pending->ID, 'psa_generation_error', 'Generation failed after ' . PSA_Config::MAX_GENERATION_RETRIES . ' attempts.' ); - wp_send_json_success( - array( - 'status' => 'invalid', - 'message' => 'We were unable to generate content for this peptide. Please try again later.', - ) - ); - return; - } - - // Retry generation. - error_log( 'PSA: Retrying stale pending peptide ' . $pending->post_title . ' (post ' . $pending->ID . ', attempt ' . ( $retry_count + 1 ) . '/' . PSA_Config::MAX_GENERATION_RETRIES . ')' ); - update_post_meta( $pending->ID, 'psa_generation_started', time() ); - update_post_meta( $pending->ID, 'psa_generation_attempts', $retry_count + 1 ); - self::schedule_background_generation( $pending->ID, $pending->post_title ); - } - - wp_send_json_success( - array( - 'status' => 'pending', - 'peptide_name' => $pending->post_title, - ) - ); - } - - /** - * Check rate limit for AJAX generation requests. - * - * Uses dual-layer caching (transient + object cache) for resilience. - * If Redis/Memcached evicts the transient, we fall back to the object cache. - * If either source reports the limit exceeded, the request is rate-limited. - * - * Limitation: IP spoofing or cache server restart could bypass the limit. - * For high-traffic deployments, consider a custom DB table with persistent - * rate limit tracking (e.g., wp_psa_rate_limits with indexed IP + timestamp). - * - * @return bool True if under limit, false if limit exceeded. - */ - private static function check_rate_limit(): bool { - $ip = psa_get_client_ip(); - $rate_key = 'psa_rate_' . md5( $ip ); - - // Check transient first. - $count_transient = (int) get_transient( $rate_key ); - - // Check object cache as secondary source for resilience. - $count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' ); - - // Use the highest count from either source. - $count = max( $count_transient, $count_cache ); - - return $count < PSA_Config::AJAX_RATE_LIMIT; - } - - /** - * Handle a new generation request — validate, create placeholder, and schedule generation. - * - * @param string $query The search query (peptide name). - * @return void Exits with wp_send_json_* responses. - */ - private static function handle_new_generation( 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; - } - - // Validate peptide name via AI. - $validation = PSA_AI_Generator::validate_peptide_name( $query ); - - if ( is_wp_error( $validation ) ) { - error_log( 'PSA: Validation error for "' . $query . '": ' . $validation->get_error_message() ); - wp_send_json_success( - array( - 'status' => 'invalid', - 'message' => 'Could not verify this peptide name.', - ) - ); - } - - if ( empty( $validation['is_valid'] ) ) { - wp_send_json_success( - array( - 'status' => 'invalid', - 'message' => $validation['reason'] ?? 'This does not appear to be a recognized peptide.', - ) - ); - } - - // It's a real peptide — increment rate limit and create placeholder. - // Update both transient and object cache for resilience. - $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. - $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' ); - $daily_count = (int) get_transient( $daily_key ); - set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS ); - - // Schedule async background generation. - error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' ); - self::schedule_background_generation( $placeholder_id, $canonical_name ); - - wp_send_json_success( - array( - 'status' => 'pending', - 'peptide_name' => $canonical_name, - ) - ); - } - - /** - * Find an existing pending peptide post by name. - * - * Performs a multi-table query to locate pending draft posts by title or alias. - * Queries the posts table joined with postmeta for 'psa_source' = 'pending' status, - * plus LEFT JOIN on postmeta for aliases. Returns the most recent match. + * 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 object or null if not found. + * @return WP_Post|null Pending post or null. */ public static function find_pending_peptide( string $query ) { global $wpdb; @@ -458,62 +212,16 @@ class PSA_Search { } /** - * Create a placeholder draft post so we can track the pending state. + * Core search — queries CPT by title and aliases (published only). * - * @param string $peptide_name The 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() ); - // Allow developers to hook into placeholder creation. - do_action( 'psa_peptide_created', $post_id, $peptide_name ); - } - - return $post_id; - } - - /** - * Schedule background generation using WP-Cron (non-blocking). - * - * @param int $post_id The placeholder post ID. - * @param string $peptide_name The peptide to research. - */ - public static function schedule_background_generation( 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(); - } - - /** - * Core search logic — queries CPT by title and aliases (published only). - * Includes transient caching and batch post/meta priming to avoid N+1 queries. - * - * Cache key includes the generation counter, so invalidate_search_cache() - * can purge all caches by incrementing the counter (O(1) vs. expensive DELETE). + * Includes transient caching with generation-counter invalidation. * * @param string $query Search term. - * @return array { results: array, total: int, query: string } + * @return array{results: array, total: int, query: string} */ public static function search_peptides( string $query ): array { global $wpdb; - // Check transient cache first. - // Include generation counter in cache key for fast invalidation. $cache_key = 'psa_search_' . self::get_cache_generation() . '_' . md5( sanitize_text_field( $query ) ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { @@ -539,7 +247,6 @@ class PSA_Search { ); if ( ! empty( $post_ids ) ) { - // Prime the WordPress object cache for all posts and their meta in batch. _prime_post_caches( $post_ids ); update_meta_cache( 'post', $post_ids ); } @@ -549,19 +256,11 @@ class PSA_Search { $results[] = self::format_peptide( (int) $id ); } - $response = array( - 'results' => $results, - 'total' => count( $results ), - 'query' => $query, - ); + $response = array( 'results' => $results, 'total' => count( $results ), 'query' => $query ); - // Cache results for 6 hours. set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS ); - // Allow filtering of search results before return. - $response = apply_filters( 'psa_search_results', $response, $query ); - - return $response; + return apply_filters( 'psa_search_results', $response, $query ); } /** @@ -587,4 +286,15 @@ class PSA_Search { '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 ); } } diff --git a/peptide-search-ai.php b/peptide-search-ai.php index e1661c3..883f338 100755 --- a/peptide-search-ai.php +++ b/peptide-search-ai.php @@ -23,6 +23,7 @@ define( 'PSA_PLUGIN_FILE', __FILE__ ); require_once PSA_PLUGIN_DIR . 'includes/class-psa-config.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-encryption.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php'; +require_once PSA_PLUGIN_DIR . 'includes/class-psa-search-handler.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.php'; require_once PSA_PLUGIN_DIR . 'includes/class-psa-openrouter.php';