ai = $ai; } /** * {@inheritDoc} */ public function get_id() { return 'attributes_ai'; } /** * {@inheritDoc} */ public function get_label() { return __( 'Suggest attributes (AI)', 'agentictrack-agent-readiness-for-woocommerce' ); } /** * {@inheritDoc} */ public function get_description() { return __( 'Proposes machine-readable attributes (e.g. material, color, size) from the product title and description. You approve before they are added.', 'agentictrack-agent-readiness-for-woocommerce' ); } /** * {@inheritDoc} * * Does not call the AI; only reports that there is room to add attributes. */ public function preview( WC_Product $product ) { $count = $this->count_attributes( $product ); if ( $count >= 3 ) { return array(); } return array( array( 'field' => 'attributes', 'label' => __( 'Structured attributes', 'agentictrack-agent-readiness-for-woocommerce' ), 'before' => sprintf( /* translators: %d: current attribute count. */ _n( '%d attribute', '%d attributes', $count, 'agentictrack-agent-readiness-for-woocommerce' ), $count ), 'after' => '', 'needs_input' => false, 'auto' => false, 'generate' => true, 'note' => __( 'Generate attribute suggestions from the product context to review before they are added.', 'agentictrack-agent-readiness-for-woocommerce' ), ), ); } /** * Generate proposed attributes for review, WITHOUT writing anything. * * @param WC_Product $product Product. * @return array|WP_Error Review payload, or WP_Error. */ public function generate( WC_Product $product ) { $pairs = $this->ask_ai( $product ); if ( is_wp_error( $pairs ) ) { return $pairs; } // Drop any the merchant already has so the preview reflects what will change. $new = array(); foreach ( $pairs as $name => $value ) { if ( ! $this->has_attribute( $product, $name ) ) { $new[ $name ] = $value; } } if ( empty( $new ) ) { return new WP_Error( 'arwc_ai_nothing_new', __( 'AI did not find any new attributes to add.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $display = array(); foreach ( $new as $name => $value ) { $display[] = $name . ': ' . $value; } return array( 'field' => 'attributes', 'label' => __( 'Structured attributes', 'agentictrack-agent-readiness-for-woocommerce' ), 'before' => sprintf( /* translators: %d: current attribute count. */ _n( '%d attribute now', '%d attributes now', $this->count_attributes( $product ), 'agentictrack-agent-readiness-for-woocommerce' ), $this->count_attributes( $product ) ), 'after' => implode( ' ยท ', $display ), 'editable' => false, 'apply_field' => 'attributes_json', 'apply_value' => wp_json_encode( $new ), 'note' => __( 'These attributes will be added. Apply to save.', 'agentictrack-agent-readiness-for-woocommerce' ), ); } /** * {@inheritDoc} */ public function apply( WC_Product $product, array $input ) { // Preferred path: apply the reviewed attributes the merchant approved. if ( isset( $input['attributes_json'] ) && '' !== trim( (string) $input['attributes_json'] ) ) { $pairs = $this->parse_pairs( (string) wp_unslash( $input['attributes_json'] ) ); if ( empty( $pairs ) ) { return new WP_Error( 'arwc_ai_empty', __( 'No valid attributes to add.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } return $this->add_pairs( $product, $pairs ); } // Fallback path: generate and add in one step. $pairs = $this->ask_ai( $product ); if ( is_wp_error( $pairs ) ) { return $pairs; } if ( empty( $pairs ) ) { return new WP_Error( 'arwc_ai_empty', __( 'AI did not return any usable attributes.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } return $this->add_pairs( $product, $pairs ); } /** * Ask the model for attribute name/value pairs (no writes). * * @param WC_Product $product Product. * @return array|WP_Error */ protected function ask_ai( WC_Product $product ) { if ( ! $this->ai instanceof ARWC_OpenRouter ) { return new WP_Error( 'arwc_ai_unavailable', __( 'AI fixes are unavailable.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $desc = trim( wp_strip_all_tags( (string) $product->get_description() . ' ' . (string) $product->get_short_description() ) ); $system = 'You extract structured product attributes for an e-commerce catalog. Return ONLY a compact JSON object mapping attribute names to string values, e.g. {"Material":"Cotton","Color":"Navy"}. Use at most ' . self::MAX_ATTRS . ' attributes. Only include attributes you can justify from the given text or title; never invent values. If nothing is justified, return {}.'; $user = sprintf( "Title: %s\n\nText:\n%s", $product->get_name(), '' === $desc ? '(none)' : $desc ); $raw = $this->ai->complete( $system, $user ); if ( is_wp_error( $raw ) ) { return $raw; } return $this->parse_pairs( $raw ); } /** * Add name/value pairs as custom attributes (skipping any already present). * * @param WC_Product $product Product. * @param array $pairs Attribute pairs. * @return ARWC_Fix_Result */ protected function add_pairs( WC_Product $product, array $pairs ) { $attributes = $product->get_attributes(); $result = $this->result(); $added = 0; foreach ( $pairs as $name => $value ) { if ( $added >= self::MAX_ATTRS ) { break; } if ( $this->has_attribute( $product, $name ) ) { continue; // Do not overwrite an attribute the merchant already has. } $attribute = new WC_Product_Attribute(); $attribute->set_name( $name ); $attribute->set_options( array( $value ) ); $attribute->set_visible( true ); $attributes[] = $attribute; $added++; $result->add_change( 'attr_' . sanitize_key( $name ), $name, '', $value ); } if ( $added > 0 ) { $product->set_attributes( $attributes ); $product->save(); } return $result; } /** * Whether the product already has a (non-taxonomy) attribute by this name. * * @param WC_Product $product Product. * @param string $name Attribute name. * @return bool */ protected function has_attribute( WC_Product $product, $name ) { foreach ( $product->get_attributes() as $attribute ) { if ( $attribute instanceof WC_Product_Attribute && ! $attribute->is_taxonomy() && strtolower( $attribute->get_name() ) === strtolower( (string) $name ) ) { return true; } } return false; } /** * Count existing attributes that carry values. * * @param WC_Product $product Product. * @return int */ protected function count_attributes( WC_Product $product ) { $count = 0; foreach ( $product->get_attributes() as $attribute ) { if ( $attribute instanceof WC_Product_Attribute && ! empty( $attribute->get_options() ) ) { $count++; } } return $count; } /** * Parse the model's JSON into a clean name => value map (sanitized). * * @param string $raw Raw model output. * @return array */ protected function parse_pairs( $raw ) { // Be lenient: extract the first {...} block if the model added prose. if ( preg_match( '/\{.*\}/s', $raw, $m ) ) { $raw = $m[0]; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { return array(); } $pairs = array(); foreach ( $data as $name => $value ) { if ( is_array( $value ) ) { $value = implode( ', ', array_map( 'strval', $value ) ); } $name = sanitize_text_field( (string) $name ); $value = sanitize_text_field( (string) $value ); if ( '' === $name || '' === $value ) { continue; } $pairs[ $name ] = $value; } return $pairs; } }