*/ public const TOGGLE_KEYS = array( 'description' => '_aicommerce_use_optimized_description', 'short_description' => '_aicommerce_use_optimized_short_description', 'faqs' => '_aicommerce_use_optimized_faqs', ); /** * Minimum days before re-optimization is allowed without force. * * @var int */ private const CACHE_DAYS = 7; /** * AI provider. * * @var AIProviderInterface */ private AIProviderInterface $provider; /** * Product data extractor. * * @var ProductDataExtractor */ private ProductDataExtractor $extractor; /** * Constructor. * * @param AIProviderInterface $provider AI content provider. * @param ProductDataExtractor $extractor Product data extractor. */ public function __construct( AIProviderInterface $provider, ProductDataExtractor $extractor ) { $this->provider = $provider; $this->extractor = $extractor; } /** * Optimize a single product's content for AI discoverability. * * @param int $product_id WooCommerce product ID. * @param bool $force Force re-optimization even if recent. * * @return array{ * description: string, * short_description: string, * faqs: array, * provider: string, * tokens_estimated: int, * cost_estimate_usd: float * } * * @throws \InvalidArgumentException If product is not found. * @throws AIProviderException On AI API errors. */ public function optimize_product( int $product_id, bool $force = false ): array { if ( ! aicommerce_is_pro() ) { throw new \AICommerce\Core\LicenseException( 'Pro', 'Content optimizer' ); } $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { throw new \InvalidArgumentException( esc_html( sprintf( /* translators: %d: product ID */ __( 'Product %d not found.', 'ai-discoverability-kit' ), $product_id ) ) ); } // Check if optimization is cached and still fresh. if ( ! $force ) { $last_date = (int) get_post_meta( $product_id, self::META_DATE, true ); if ( $last_date > 0 && ( time() - $last_date ) < ( self::CACHE_DAYS * DAY_IN_SECONDS ) ) { return $this->get_stored_result( $product_id ); } } // Snapshot original content for verification. $original_content = get_post_field( 'post_content', $product_id ); $original_price = get_post_meta( $product_id, '_regular_price', true ); // Extract product data. $data = $this->extractor->extract( $product ); $total_input_tokens = 0; $total_output_tokens = 0; // Step 1: Generate optimized description. $desc_prompt = $this->build_description_prompt( $data, $product ); $total_input_tokens += $this->estimate_tokens( $desc_prompt ); $description = $this->provider->complete( $desc_prompt, 600 ); $total_output_tokens += $this->estimate_tokens( $description ); // Step 2: Generate optimized short description. $short_prompt = $this->build_short_description_prompt( $data, $description ); $total_input_tokens += $this->estimate_tokens( $short_prompt ); $short_description = $this->provider->complete( $short_prompt, 150 ); $total_output_tokens += $this->estimate_tokens( $short_description ); // Step 3: Generate FAQs. $faq_prompt = $this->build_faq_prompt( $data ); $total_input_tokens += $this->estimate_tokens( $faq_prompt ); $faq_response = $this->provider->complete( $faq_prompt, 800 ); $total_output_tokens += $this->estimate_tokens( $faq_response ); $faqs = $this->parse_faqs( $faq_response, $data ); // Calculate cost. $cost = $this->provider->estimate_cost_usd( $total_input_tokens, $total_output_tokens ); $total_tokens = $total_input_tokens + $total_output_tokens; // Step 4: Store results NON-DESTRUCTIVELY. update_post_meta( $product_id, self::META_DESCRIPTION, $description ); update_post_meta( $product_id, self::META_SHORT_DESCRIPTION, $short_description ); update_post_meta( $product_id, self::META_FAQS, wp_json_encode( $faqs ) ); update_post_meta( $product_id, self::META_DATE, time() ); update_post_meta( $product_id, self::META_PROVIDER, $this->provider->get_provider_name() ); update_post_meta( $product_id, self::META_TOKENS, $total_tokens ); // VERIFY: original content is unchanged. $verify_content = get_post_field( 'post_content', $product_id ); $verify_price = get_post_meta( $product_id, '_regular_price', true ); if ( $verify_content !== $original_content || $verify_price !== $original_price ) { // This should never happen — log it as a critical error. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'AICommerce: CRITICAL — original product content was modified during optimization for product ' . $product_id ); } } return array( 'description' => $description, 'short_description' => $short_description, 'faqs' => $faqs, 'provider' => $this->provider->get_provider_name(), 'tokens_estimated' => $total_tokens, 'cost_estimate_usd' => round( $cost, 6 ), ); } /** * Get previously stored optimization result. * * @param int $product_id Product ID. * * @return array The stored optimization result. */ public function get_stored_result( int $product_id ): array { $faqs_json = get_post_meta( $product_id, self::META_FAQS, true ); $faqs = array(); if ( ! empty( $faqs_json ) ) { $decoded = json_decode( $faqs_json, true ); if ( is_array( $decoded ) ) { $faqs = $decoded; } } return array( 'description' => (string) get_post_meta( $product_id, self::META_DESCRIPTION, true ), 'short_description' => (string) get_post_meta( $product_id, self::META_SHORT_DESCRIPTION, true ), 'faqs' => $faqs, 'provider' => (string) get_post_meta( $product_id, self::META_PROVIDER, true ), 'tokens_estimated' => (int) get_post_meta( $product_id, self::META_TOKENS, true ), 'cost_estimate_usd' => 0.0, ); } /** * Get the active fields for a product. * * @param int $product_id Product ID. * * @return array List of active field names. */ public function get_active_fields( int $product_id ): array { $active = array(); foreach ( self::TOGGLE_KEYS as $field => $meta_key ) { if ( 'yes' === get_post_meta( $product_id, $meta_key, true ) ) { $active[] = $field; } } return $active; } /** * Set the active state for content fields. * * @param int $product_id Product ID. * @param string[] $fields Field names to update. * @param bool $active Whether to activate or deactivate. * * @return void */ public function set_field_active( int $product_id, array $fields, bool $active ): void { $value = $active ? 'yes' : 'no'; foreach ( $fields as $field ) { if ( 'all' === $field ) { foreach ( self::TOGGLE_KEYS as $meta_key ) { update_post_meta( $product_id, $meta_key, $value ); } return; } if ( isset( self::TOGGLE_KEYS[ $field ] ) ) { update_post_meta( $product_id, self::TOGGLE_KEYS[ $field ], $value ); } } } /** * Inject optimized short description on the front end. * * Hooked to woocommerce_short_description filter. * * @param string $content Original short description. * * @return string Optimized or original content. */ public function maybe_inject_short_description( string $content ): string { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return $content; } $product_id = get_the_ID(); if ( 'yes' !== get_post_meta( $product_id, self::TOGGLE_KEYS['short_description'], true ) ) { return $content; } $optimized = get_post_meta( $product_id, self::META_SHORT_DESCRIPTION, true ); // Escape late: this value is returned into a WooCommerce output filter, so // allow only post-safe HTML (wp_kses_post) to prevent stored XSS. return ! empty( $optimized ) ? wp_kses_post( $optimized ) : $content; } /** * Inject optimized description on the front end. * * Hooked to woocommerce_product_description_heading or the_content filter * when on a single product page. * * @param string $content Original description. * * @return string Optimized or original content. */ public function maybe_inject_description( string $content ): string { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return $content; } $product_id = get_the_ID(); if ( 'yes' !== get_post_meta( $product_id, self::TOGGLE_KEYS['description'], true ) ) { return $content; } $optimized = get_post_meta( $product_id, self::META_DESCRIPTION, true ); // Escape late: this value is returned into the_content and output by WordPress, // so allow only post-safe HTML (wp_kses_post) to prevent stored XSS. return ! empty( $optimized ) ? wp_kses_post( $optimized ) : $content; } /** * Build the description optimization prompt. * * @param array $data Extracted product data. * @param \WC_Product $product The product. * * @return string The prompt. */ private function build_description_prompt( array $data, \WC_Product $product ): string { $categories = ''; if ( ! empty( $data['categories'] ) ) { $categories = implode( ' > ', array_column( $data['categories'], 'name' ) ); } $attributes = ''; if ( ! empty( $data['attributes'] ) ) { $attributes = wp_json_encode( $data['attributes'] ); } $prompt = sprintf( "You are an expert ecommerce copywriter. Rewrite this product description to be discovered\n" . "by AI shopping assistants like ChatGPT, Perplexity, and Google Gemini.\n\n" . "Product: %s\n" . "Category: %s\n" . "Current description: %s\n" . "Price: %s %s\n" . "Key attributes: %s\n\n" . "Requirements:\n" . "- Answer: 'What is %s good for?' and 'Who would love %s?'\n" . "- Include 2-3 specific use cases naturally (e.g., 'perfect for home offices')\n" . "- Mention key features in plain language\n" . "- 150-250 words. NO keyword stuffing.\n" . '- Return ONLY the description text. No preamble.', $data['title'] ?? '', $categories, $data['description'] ?? '', $data['price'] ?? '', $data['currency'] ?? '', $attributes ?: '[]', $data['title'] ?? '', $data['title'] ?? '' ); /** * Filters the description rewrite prompt. * * @param string $prompt The prompt to send to the AI provider. * @param \WC_Product $product The WooCommerce product. */ return apply_filters( 'aicommerce/content/rewrite_prompt', $prompt, $product ); } /** * Build the short description prompt. * * @param array $data Extracted product data. * @param string $description The optimized full description. * * @return string The prompt. */ private function build_short_description_prompt( array $data, string $description ): string { return sprintf( "Write a 40-60 word summary of this product for an AI shopping assistant.\n" . "Product: %s\n" . "Full description: %s\n" . 'Return ONLY the short description. No preamble.', $data['title'] ?? '', $description ); } /** * Build the FAQ generation prompt. * * @param array $data Extracted product data. * * @return string The prompt. */ private function build_faq_prompt( array $data ): string { return sprintf( "Generate exactly 6 question-and-answer pairs for this product FAQ.\n" . "Product: %s\n" . "Price: %s %s\n" . "Description: %s\n\n" . "Cover: compatibility or fit, main use case, quality or material,\n" . "shipping or availability, gift suitability, one unique feature.\n\n" . 'Return ONLY valid JSON array, no markdown, no preamble:' . "\n" . '[{"q": "question text", "a": "answer text"}, ...]', $data['title'] ?? '', $data['price'] ?? '', $data['currency'] ?? '', $data['short_description'] ?? $data['description'] ?? '' ); } /** * Parse FAQ JSON response from AI provider. * * Retries once with a stricter prompt if the initial response is invalid JSON. * * @param string $response AI response text. * @param array $data Product data for retry prompt. * * @return array Parsed FAQs. */ private function parse_faqs( string $response, array $data ): array { $faqs = json_decode( $response, true ); if ( is_array( $faqs ) && ! empty( $faqs ) && isset( $faqs[0]['q'] ) ) { return $faqs; } // Retry with stricter prompt. $retry_prompt = sprintf( "Generate exactly 6 FAQ pairs for product: %s\n" . 'Return ONLY a JSON array. No explanation. No markdown. Starting with [{' . "\n" . 'Format: [{"q": "question", "a": "answer"}, ...]', $data['title'] ?? '' ); try { $retry_response = $this->provider->complete( $retry_prompt, 800 ); $faqs = json_decode( $retry_response, true ); if ( is_array( $faqs ) && ! empty( $faqs ) && isset( $faqs[0]['q'] ) ) { return $faqs; } } catch ( AIProviderException $e ) { unset( $e ); // Intentional: fall through to empty array on failure. } return array(); } /** * Rough token estimate based on character count. * * @param string $text Text to estimate. * * @return int Estimated token count. */ private function estimate_tokens( string $text ): int { return (int) ceil( strlen( $text ) / 4 ); } }