$competitors Competitor names (for "X vs Y"). * @param array $existing Prompts already tracked — never re-suggested. * @return array Up to {@see MAX} question strings. */ public static function questions( $brand, $category = '', array $competitors = array(), array $existing = array() ) { $brand = self::tidy( $brand ); $category = self::tidy( $category ); $competitors = self::clean( $competitors ); $candidates = array(); if ( '' !== $brand ) { $candidates[] = sprintf( 'What is %s?', $brand ); } // The questions worth grading: a buyer shopping the category, with no brand in the // question. If AI names the product here, that is real visibility. if ( '' !== $category ) { $candidates[] = sprintf( 'What is the best %s?', $category ); $candidates[] = sprintf( 'How do I choose the right %s?', $category ); $candidates[] = sprintf( 'Which %s do you recommend?', $category ); } if ( '' !== $brand ) { foreach ( array_slice( $competitors, 0, 3 ) as $c ) { $candidates[] = sprintf( '%s vs %s', $brand, $c ); } if ( '' !== $category ) { $candidates[] = sprintf( 'Is %s a good %s?', $brand, $category ); } } return self::refine( $candidates, $existing ); } /** * Ask the site's own configured AI for buyer-intent questions in the product's * category. Returns the same shape as {@see questions()}. * * Deliberately asks for UNBRANDED questions: a question containing the product's name * is nearly guaranteed to name it back, which measures nothing. The signal is whether * AI reaches for this product when the buyer never said its name. * * @param array $product A visibility product row { name, category, domain, competitors, prompts }. * @param CoreSettings $core Core settings (identity). Instantiated if omitted. * @return array|\WP_Error Questions, or an error when no AI is configured. */ public static function ai_questions( array $product, ?CoreSettings $core = null ) { $core = $core instanceof CoreSettings ? $core : new CoreSettings(); $brand = self::brand_of( $product, $core ); $category = self::tidy( $product['category'] ?? '' ); $existing = isset( $product['prompts'] ) ? (array) $product['prompts'] : array(); // Validate the request before the environment: the category is required here for the // same reason the templates refuse without it — buyer questions only mean something // inside a market, and with no category the model would invent one from the site // title, which is exactly the "What is the best JavaScript?" failure this feature was // rewritten to end. Note the guard CANNOT lean on an empty brand: brand_of() falls // back to the site name, so the brand is essentially never empty on a real site. if ( '' === $category ) { return new \WP_Error( 'agentimus_ai_no_category', __( 'Tell us what kind of thing this is (for example “WordPress SEO plugin”) and we can suggest the questions a buyer would ask.', 'agentimus' ), array( 'status' => 400 ) ); } if ( ! Assist::ai_available() || ! function_exists( 'wp_ai_client_prompt' ) ) { return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI is set up on this site yet. Add a provider under Settings → AI, or use the template suggestions.', 'agentimus' ), array( 'status' => 503 ) ); } $system = 'You write the questions real people type into an AI assistant when they are shopping for a ' . 'product like the one described. Reply with the questions ONLY, one per line — no numbering, no ' . 'bullets, no preamble, no commentary. Each must be a natural, standalone question under 100 ' . 'characters, and must end with a question mark. Vary the buying intent (best-of, how-to-choose, ' . 'use-case, price, alternatives). Two hard rules: (1) NEVER put the product\'s own name in a ' . 'question — the whole point is to see whether the assistant recommends it unprompted. (2) A ' . 'competitor\'s name may appear ONLY in an "alternatives to X" style question — never as the ' . 'subject of a recommendation, because a question about a rival measures the rival, not us.'; $builder = wp_ai_client_prompt( self::ai_brief( $brand, $category, $product, $core, $existing ) ) ->using_system_instruction( $system ) ->using_max_tokens( self::AI_TOKENS ) ->using_temperature( 0.7 ); // Higher than Assist's 0.4: we want a varied spread, not one right answer. if ( ! $builder->is_supported_for_text_generation() ) { return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI text model is configured. Add or enable one under Settings → AI.', 'agentimus' ), array( 'status' => 503 ) ); } $text = $builder->generate_text(); if ( is_wp_error( $text ) ) { return $text; } // Enforce the "never name the product" rule rather than trusting the model to obey // it: a question containing our own name is nearly guaranteed to get our name back, // which would score as visibility we did not actually earn. // // This is a plain substring test, so a product named after a common word ("Members", // "Redirection", "Forms") also loses innocent questions — which is why an empty result // falls back to the templates instead of erroring. Better a smaller honest list than a // dead end the owner can only escape by renaming their product. $questions = self::strip_branded( self::parse_lines( is_string( $text ) ? $text : '' ), $brand ); if ( empty( $questions ) ) { return self::questions( $brand, $category, isset( $product['competitors'] ) ? (array) $product['competitors'] : array(), $existing ); } return self::refine( $questions, $existing ); } /** * Drop any question that names the product itself. Pure, so the rule is testable. * * @param array $questions Candidate questions. * @param string $brand The product name (may be ''). * @return array */ public static function strip_branded( array $questions, $brand ) { $brand = self::tidy( $brand ); if ( '' === $brand ) { return array_values( $questions ); } return array_values( array_filter( $questions, static function ( $q ) use ( $brand ) { return false === stripos( (string) $q, $brand ); } ) ); } /** * The facts we hand the model. Everything is already-entered settings — nothing is * scraped and no page content is sent. * * @param string $brand Product name. * @param string $category What it is. * @param array $product The product row. * @param CoreSettings $core Core settings. * @param array $existing Prompts already tracked. * @return string */ private static function ai_brief( $brand, $category, array $product, CoreSettings $core, array $existing ) { $lines = array( sprintf( 'Write %d questions.', self::AI_COUNT ), '' ); if ( '' !== $brand ) { $lines[] = 'Product name (never mention it): ' . $brand; } if ( '' !== $category ) { $lines[] = 'What it is: ' . $category; } $about = self::tidy( $core->identity( 'description', '' ) ); if ( '' === $about ) { $about = self::tidy( get_bloginfo( 'description' ) ); } if ( '' !== $about ) { $lines[] = 'Who makes it: ' . $about; } $rivals = self::clean( isset( $product['competitors'] ) ? (array) $product['competitors'] : array() ); if ( ! empty( $rivals ) ) { $lines[] = 'It competes with: ' . implode( ', ', array_slice( $rivals, 0, 6 ) ); } $tracked = self::clean( $existing ); if ( ! empty( $tracked ) ) { $lines[] = ''; $lines[] = 'Already asked — write DIFFERENT questions to these:'; foreach ( array_slice( $tracked, 0, 15 ) as $q ) { $lines[] = '- ' . $q; } } return implode( "\n", $lines ); } /** * A model's line-per-question reply into a clean list: drop bullets/numbering the * model added anyway, and anything that isn't a plausible question. * * Every pattern here is UTF-8 aware (`/u`), which is load-bearing, not decorative. In * byte mode PCRE's `\R` matches the raw byte 0x85 — which occurs INSIDE ordinary UTF-8 * characters (Cyrillic "х" is D1 85, "Å" is C3 85, and much of CJK) — so the split would * cut mid-character and hand back a mangled fragment that still ends in "?" and still * passes every filter below. A Russian or Chinese site would silently be offered broken * questions, and an accepted one becomes a graded prompt forever. * * @param string $text Raw model output. * @return array */ private static function parse_lines( $text ) { $out = array(); foreach ( (array) preg_split( '/\R/u', (string) $text ) as $line ) { // Strip "1. ", "1) ", "- ", "* ", "• " and any surrounding quotes. $line = preg_replace( '/^\s*(?:\d+[.)]|[-*\x{2022}])\s*/u', '', (string) $line ); $line = trim( (string) $line ); $line = trim( $line, "\"'“”‘’" ); $line = self::tidy( $line ); // Length in CHARACTERS, not bytes — a 120-character Chinese question is ~360 // bytes and is perfectly valid. if ( '' === $line || mb_strlen( $line ) > self::MAX_LENGTH ) { continue; } // A question, not a stray "Here are 12 questions:" preamble. Accepts the // full-width "?" a model answering in Japanese or Chinese will use. if ( ! preg_match( '/[??]$/u', $line ) ) { continue; } $out[] = $line; } return $out; } /** * Suggestions for one tracked product, wiring the real sources: its own name (or the * site's identity name), the category the owner gave it, its competitors, and — * excluded — the prompts it already tracks. * * @param array $product A visibility product row { name, category, competitors, prompts }. * @param CoreSettings $core Core settings (identity). Instantiated if omitted. * @return array */ public static function for_product( array $product, ?CoreSettings $core = null ) { $core = $core instanceof CoreSettings ? $core : new CoreSettings(); return self::questions( self::brand_of( $product, $core ), isset( $product['category'] ) ? (string) $product['category'] : '', isset( $product['competitors'] ) ? (array) $product['competitors'] : array(), isset( $product['prompts'] ) ? (array) $product['prompts'] : array() ); } /** The product's own name, falling back to the site identity name, then the site title. */ private static function brand_of( array $product, CoreSettings $core ) { $named = isset( $product['name'] ) ? self::tidy( $product['name'] ) : ''; if ( '' !== $named ) { return $named; } return self::tidy( $core->display_name() ); } /** * De-dupe candidates (case-insensitively) against the already-tracked prompts and each * other, preserving order, and cap the count. Shared by both generators. * * @param array $candidates Raw candidates, best first. * @param array $existing Prompts already tracked. * @return array */ private static function refine( array $candidates, array $existing ) { $seen = array(); foreach ( $existing as $e ) { $seen[ self::norm( $e ) ] = true; } $out = array(); foreach ( $candidates as $q ) { $key = self::norm( $q ); if ( '' === $key || isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $out[] = $q; if ( count( $out ) >= self::MAX ) { break; } } return $out; } /** Trim, drop empties, de-dupe (case-insensitive) — order preserved. Pure. */ private static function clean( array $list ) { $seen = array(); $out = array(); foreach ( $list as $v ) { $v = self::tidy( $v ); $k = self::norm( $v ); if ( '' === $k || isset( $seen[ $k ] ) ) { continue; } $seen[ $k ] = true; $out[] = $v; } return $out; } /** One free-text value, flattened to a single clean line. Pure. */ private static function tidy( $s ) { $s = preg_replace( '/\s+/u', ' ', (string) $s ); return trim( (string) $s ); } /** A comparison key: lowercased, trimmed, trailing punctuation dropped. Pure. */ private static function norm( $s ) { return rtrim( strtolower( self::tidy( $s ) ), " ?.!" ); } }