'refund', 'how much' => 'price', 'get in touch' => 'contact', 'sign up' => 'join', 'opening hours' => 'hours', 'open hours' => 'hours', 'where are you' => 'location', 'find you' => 'location', 'how long' => 'duration', 'pay for' => 'payment', ); /** Groups of words that mean the same thing in customer questions. */ private static $synonyms = array( array( 'refund', 'refundable', 'money', 'reimburse', 'return' ), array( 'price', 'cost', 'fee', 'rate', 'charge', 'pricing' ), array( 'contact', 'email', 'phone', 'call', 'reach', 'message' ), array( 'location', 'address', 'located', 'directions', 'street', 'avenue', 'road' ), array( 'hours', 'open', 'opening', 'closing', 'closed' ), array( 'book', 'booking', 'appointment', 'reserve', 'schedule', 'timetable' ), array( 'cancel', 'cancellation', 'cancelled', 'canceled' ), array( 'company', 'companies', 'corporate', 'business', 'workplace', 'team', 'office' ), array( 'ship', 'shipping', 'delivery', 'deliver', 'postage' ), array( 'payment', 'pay', 'card', 'paypal' ), array( 'join', 'membership', 'member', 'register' ), array( 'founded', 'founder', 'started', 'history', 'story', 'established' ), array( 'duration', 'long', 'last', 'lasts', 'minutes', 'minute' ), array( 'kid', 'kids', 'child', 'children', 'family' ), array( 'free', 'trial', 'complimentary' ), ); /** Meaningful words of a question, stemmed, without duplicates. */ public static function keywords( $text ) { $text = ' ' . self::normalize( $text ) . ' '; foreach ( self::$phrases as $phrase => $word ) { $text = str_replace( ' ' . $phrase . ' ', ' ' . $word . ' ', $text ); } $out = array(); foreach ( explode( ' ', trim( $text ) ) as $w ) { if ( mb_strlen( $w ) < 2 || in_array( $w, self::$stopwords, true ) ) { continue; } $out[ self::stem( $w ) ] = true; } return array_slice( array_keys( $out ), 0, 10 ); } /** A question word plus words that mean the same ("company" → "corporate", "business"…). */ private static function alternatives( $word ) { static $cache = array(); if ( isset( $cache[ $word ] ) ) { return $cache[ $word ]; } $alts = array( $word ); foreach ( self::$synonyms as $group ) { $stems = array_map( array( __CLASS__, 'stem' ), $group ); if ( in_array( $word, $stems, true ) ) { $alts = array_merge( $alts, $stems ); } } $cache[ $word ] = array_values( array_unique( $alts ) ); return $cache[ $word ]; } public static function hooks() { add_action( 'rest_api_init', array( __CLASS__, 'routes' ) ); } public static function routes() { register_rest_route( 'replyvane/v1', '/answer', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'endpoint' ), 'permission_callback' => '__return_true', ) ); } public static function endpoint( WP_REST_Request $request ) { wp_set_current_user( 0 ); // Public content only, whoever is asking. $none = array( 'answer' => null ); if ( ! Replyvane_Plugin::get( 'answer_from_site' ) ) { return rest_ensure_response( $none ); } $query = mb_substr( trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) ), 0, 300 ); $small = self::small_talk( $query ); if ( $small ) { return rest_ensure_response( $small ); } if ( mb_strlen( $query ) < 3 || ! self::within_limit() ) { return rest_ensure_response( $none ); } // Website never read (e.g. WP-Cron not running): start reading it now. self::ensure_scanned(); // The same question gets the same answer until the site content changes. $engine = Replyvane_AI::enabled() ? Replyvane_AI::provider() . ':' . Replyvane_Plugin::get( 'ai_model' ) : 'builtin'; $key = 'replyvane_ans_' . md5( REPLYVANE_VERSION . '|' . get_option( 'replyvane_kb_version', '0' ) . '|' . $engine . '|' . Replyvane_Plugin::data_version() . '|' . self::normalize( $query ) ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return rest_ensure_response( $cached ); } $result = self::respond( $query ); $data = $result ? $result : $none; unset( $data['provider'] ); // Only shown to the site owner under Website knowledge. // When the AI couldn't answer (no credits, limits, a provider problem), keep // the built-in answer only briefly, so AI answers return once it's fixed. set_transient( $key, $data, self::$ai_failed ? 5 * MINUTE_IN_SECONDS : DAY_IN_SECONDS ); return rest_ensure_response( $data ); } private static function ensure_scanned() { if ( get_transient( 'replyvane_kb_kickoff' ) || Replyvane_Knowledge::remaining() > 0 ) { return; } $stats = Replyvane_Knowledge::stats(); if ( (int) $stats['pages'] > 0 ) { return; } set_transient( 'replyvane_kb_kickoff', 1, HOUR_IN_SECONDS ); Replyvane_Knowledge::start_full_reindex(); if ( function_exists( 'spawn_cron' ) ) { spawn_cron(); } } private static function within_limit() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $key = 'replyvane_aq_' . md5( $ip . wp_salt( 'nonce' ) ); $count = (int) get_transient( $key ); if ( $count >= 60 ) { return false; } set_transient( $key, $count + 1, 10 * MINUTE_IN_SECONDS ); return true; } /* ------------------------------------------------------------------ */ /* Words */ /* ------------------------------------------------------------------ */ public static function normalize( $text ) { $text = mb_strtolower( (string) $text ); $text = preg_replace( '/[^\p{L}\p{N}$€£₹\s]+/u', ' ', $text ); return trim( preg_replace( '/\s+/u', ' ', $text ) ); } public static function stem( $word ) { $len = mb_strlen( $word ); if ( $len > 4 && 'ies' === mb_substr( $word, -3 ) ) { return mb_substr( $word, 0, -3 ) . 'y'; } if ( $len > 5 && 'ing' === mb_substr( $word, -3 ) ) { return mb_substr( $word, 0, -3 ); } if ( $len > 4 && 'ed' === mb_substr( $word, -2 ) && 'eed' !== mb_substr( $word, -3 ) ) { return mb_substr( $word, 0, -2 ); } if ( $len > 3 && 's' === mb_substr( $word, -1 ) && 'ss' !== mb_substr( $word, -2 ) && 'us' !== mb_substr( $word, -2 ) ) { return mb_substr( $word, 0, -1 ); } return $word; } /** Word set of a piece of text (stemmed). */ private static function word_set( $text ) { $set = array(); // Amounts like "$139" or "£20 per month" answer price questions. if ( preg_match( '/[$€£₹]\s?\d|\d\s?(usd|eur|gbp|inr)\b|per (month|year|session|hour)/iu', (string) $text ) ) { $set['price'] = true; } foreach ( explode( ' ', self::normalize( $text ) ) as $w ) { if ( '' !== $w ) { $set[ self::stem( $w ) ] = true; } } return $set; } /** Exact word, or for longer words a shared beginning ("member" ~ "membership"). */ private static function has( array $set, $word ) { if ( isset( $set[ $word ] ) ) { return true; } if ( mb_strlen( $word ) < 4 ) { return false; } foreach ( $set as $w => $unused ) { if ( mb_strlen( $w ) >= 4 && ( 0 === mb_strpos( $w, $word ) || 0 === mb_strpos( $word, $w ) ) ) { return true; } } return false; } private static function has_any( array $set, array $alts ) { foreach ( $alts as $a ) { if ( self::has( $set, $a ) ) { return true; } } return false; } /* ------------------------------------------------------------------ */ /* 1. Retrieve passages that may hold the answer */ /* ------------------------------------------------------------------ */ public static function retrieve( array $words ) { global $wpdb; $table = Replyvane_Knowledge::table(); $rows = array(); $terms = array(); foreach ( $words as $w ) { $terms = array_merge( $terms, self::alternatives( $w ) ); } $terms = array_slice( array_values( array_unique( $terms ) ), 0, 24 ); $suppress = $wpdb->suppress_errors( true ); $search = implode( ' ', $terms ); $ft = $wpdb->get_results( $wpdb->prepare( 'SELECT id, post_id, url, title, heading, content FROM %i WHERE MATCH(title, heading, content) AGAINST (%s IN NATURAL LANGUAGE MODE) ORDER BY MATCH(title, heading, content) AGAINST (%s IN NATURAL LANGUAGE MODE) DESC LIMIT 20', $table, $search, $search ), ARRAY_A ); $wpdb->suppress_errors( $suppress ); foreach ( (array) $ft as $r ) { $rows[ $r['id'] ] = $r; } // Keyword search catches word forms full-text search misses ("plans" vs "plan"). $where = array(); $params = array(); foreach ( array_slice( $terms, 0, 12 ) as $w ) { if ( mb_strlen( $w ) < 3 ) { continue; } $like = '%' . $wpdb->esc_like( $w ) . '%'; $where[] = '(title LIKE %s OR heading LIKE %s OR content LIKE %s)'; $params[] = $like; $params[] = $like; $params[] = $like; } if ( $where ) { // The replacements are one array: the table name, then three values per search word. $found = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber 'SELECT id, post_id, url, title, heading, content FROM %i WHERE ' . implode( ' OR ', array_fill( 0, count( $where ), '(title LIKE %s OR heading LIKE %s OR content LIKE %s)' ) ) . ' LIMIT 200', array_merge( array( $table ), $params ) ), ARRAY_A ); foreach ( (array) $found as $r ) { if ( ! isset( $rows[ $r['id'] ] ) ) { $rows[ $r['id'] ] = $r; } } } return array_slice( array_values( $rows ), 0, 60 ); } /** Splits passages into sections: one heading and the lines under it. */ private static function sections( array $passages ) { $out = array(); foreach ( $passages as $p ) { $heading = (string) $p['heading']; $lines = array(); foreach ( explode( "\n", (string) $p['content'] ) as $line ) { $line = trim( $line ); if ( 0 === strpos( $line, '## ' ) ) { if ( $lines ) { $out[] = self::section( $p, $heading, $lines ); } $heading = substr( $line, 3 ); $lines = array(); continue; } if ( '' !== $line && '(continued)' !== $line ) { $lines[] = $line; } } if ( $lines ) { $out[] = self::section( $p, $heading, $lines ); } } return $out; } private static function section( array $p, $heading, array $lines ) { $head = self::word_set( $p['title'] . ' ' . $heading ); $body = self::word_set( implode( ' ', $lines ) ); static $order = 0; return array( 'order' => ++$order, 'url' => $p['url'], 'title' => $p['title'], 'set_page' => self::word_set( $p['title'] ), 'set_sub' => self::word_set( $heading ), 'heading' => $heading, 'lines' => $lines, 'set_head' => $head, 'set_body' => $body, 'set_all' => $head + $body, ); } /** * How much each question word matters: words found in few sections count * more than words found everywhere. */ private static function weights( array $sections, array $words ) { $n = max( 1, count( $sections ) ); $weights = array(); foreach ( $words as $w ) { $alts = self::alternatives( $w ); $df = 0; foreach ( $sections as $sec ) { if ( self::has_any( $sec['set_all'], $alts ) ) { $df++; } } // A word the site never mentions ("bali") means the site probably // doesn't cover the question, so it counts fully against the match. $weights[ $w ] = 0 === $df ? 1.0 : 1 + log( ( $n + 1 ) / ( $df + 1 ) ); } return $weights; } /* ------------------------------------------------------------------ */ /* 2 + 3. Pick the best section and lines, and compose the answer */ /* ------------------------------------------------------------------ */ /** Splits a passage into lines, and long lines into sentences. */ private static function units( $content ) { $units = array(); foreach ( explode( "\n", $content ) as $line ) { $line = trim( $line ); if ( '' === $line || 0 === strpos( $line, '## ' ) || '(continued)' === $line ) { continue; } if ( mb_strlen( $line ) > 260 ) { foreach ( preg_split( '/(?<=[.!?])\s+(?=[A-Z0-9"“])/u', $line ) as $sentence ) { if ( '' !== trim( $sentence ) ) { $units[] = trim( $sentence ); } } } else { $units[] = $line; } } return $units; } /** Turns "Book now [https://…]" into a Markdown link the chat can display. */ private static function tidy( $line ) { $line = preg_replace_callback( '/^(.{1,60}?)\s*\[(https?:\/\/[^\]\s]+)\]$/u', function ( $m ) { return '[' . trim( $m[1] ) . '](' . $m[2] . ')'; }, $line ); return preg_replace( '/\s*\[(https?:\/\/[^\]\s]+)\]/u', '', $line ); } /** One line per section of a page: "- **Starter**: $89 / month – Up to 8 classes a month." */ private static function overview( array $best, array $same ) { usort( $same, function ( $a, $b ) { return $a['order'] <=> $b['order']; } ); $out = array(); $length = 0; foreach ( $same as $sec ) { $parts = array_filter( array_map( 'trim', explode( ' › ', (string) $sec['heading'] ) ) ); $head = $parts ? end( $parts ) : ''; $text = self::tidy( implode( ' – ', array_slice( $sec['lines'], 0, 3 ) ) ); if ( mb_strlen( $text ) > 220 ) { $text = rtrim( mb_substr( $text, 0, 200 ) ) . '…'; } $line = ( ! $head || 0 === strcasecmp( $head, $sec['title'] ) || count( $parts ) < 2 ) ? $text : '- **' . $head . '**: ' . $text; $length += mb_strlen( $line ); if ( $out && $length > self::MAX_ANSWER + 250 ) { break; } $out[] = $line; } return array( 'answer' => '**' . $best['title'] . "**\n" . implode( "\n", $out ), 'sources' => array( array( 'title' => $best['title'], 'url' => $best['url'], ), ), 'confidence' => round( $best['coverage'], 2 ), ); } /** * Answers the question; if it asks about two things ("membership plans and * services") and the best section covers only one, adds a second part. */ public static function answer( $query ) { $first = self::answer_one( $query ); if ( $first && empty( $first['uncovered'] ) ) { return self::strip( $first ); } // Two questions in one ("… refund policy and where are you located"): // answer each part on its own and put the answers together. $parts = array_values( array_filter( preg_split( '/\s+(?:and|also|plus|&)\s+|[?;]+\s*/iu', (string) $query ), function ( $part ) { return count( self::keywords( $part ) ) > 0; } ) ); if ( count( $parts ) >= 2 ) { $found = array(); $used = array(); foreach ( array_slice( $parts, 0, 3 ) as $part ) { $one = self::answer_one( $part, $used ); if ( $one ) { $found[] = $one; $used[] = $one['sources'][0]['url']; } } if ( count( $found ) >= 2 || ( 1 === count( $found ) && ! $first ) ) { return self::combine( $found ); } } if ( ! $first ) { return null; } // One topic answered, another word left over: try a second topic for it. $second = self::answer_one( implode( ' ', $first['uncovered'] ), array( $first['sources'][0]['url'] ) ); if ( $second && count( $second['uncovered'] ) < count( $first['uncovered'] ) ) { return self::combine( array( $first, $second ) ); } return self::strip( $first ); } /** * Answers with the engine chosen in Settings. The WordPress AI engine (if * chosen) falls back to the built-in engine when no answer comes back. */ public static function respond( $query ) { self::$ai_failed = false; if ( Replyvane_AI::enabled() ) { $ai = Replyvane_AI::answer( $query ); if ( is_array( $ai ) ) { return $ai; } // The AI failed, or said the website doesn't cover this: try the built-in engine. self::$ai_failed = is_wp_error( $ai ); } return self::answer( $query ); } /** True when the last respond() call fell back to the built-in engine because the AI failed. */ private static $ai_failed = false; /** * Friendly replies to greetings, thanks and goodbyes, which aren't questions * about the website. Free and instant with either engine. * * @return array|null */ public static function small_talk( $query ) { $q = trim( preg_replace( '/[^\p{L}\s\']/u', ' ', mb_strtolower( $query ) ) ); $q = preg_replace( '/\s+/', ' ', $q ); if ( '' === $q || str_word_count( $q ) > 6 ) { return null; } $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); if ( preg_match( '/^(hi+|hello+|hey+|hiya|howdy|yo|good (morning|afternoon|evening|day)|greetings|namaste|hola|bonjour|hallo)( there| all| team| everyone)?$/u', $q ) ) { /* translators: %s: site name. */ $text = sprintf( __( 'Hi! How can I help you today? Ask me anything about %s, or choose a question below.', 'replyvane-chat-support' ), $site ); } elseif ( preg_match( '/^(thanks?|thank you( so much| very much)?|thx|ty|cheers|great thanks|ok thanks|okay thanks|perfect thanks)( a lot)?$/u', $q ) ) { $text = __( "You're welcome! Is there anything else I can help with?", 'replyvane-chat-support' ); } elseif ( preg_match( '/^(bye|goodbye|good bye|see you|see ya|take care|have a (nice|good|great) day)$/u', $q ) ) { $text = __( 'Thanks for chatting. Have a great day!', 'replyvane-chat-support' ); } elseif ( preg_match( "/^(how are you|how are you doing|how's it going|whats up|what's up|sup)$/u", $q ) ) { $text = __( "I'm doing well, thanks for asking! What can I help you with?", 'replyvane-chat-support' ); } else { return null; } return array( 'answer' => $text, 'sources' => array(), 'confidence' => 1, 'small_talk' => true, ); } /** * The most relevant public content for a question, for the AI engine: * up to six website sections and three FAQ answers. */ public static function context( $query ) { $out = array( 'sections' => array(), 'faqs' => array(), ); $words = self::keywords( $query ); if ( empty( $words ) ) { return $out; } $sections = self::sections( self::retrieve( $words ) ); if ( $sections ) { $weights = self::weights( $sections, $words ); $max = array_sum( $weights ); foreach ( $sections as &$sec ) { $got = 0; foreach ( $words as $w ) { $alts = self::alternatives( $w ); if ( self::has_any( $sec['set_head'], $alts ) ) { $got += $weights[ $w ]; } elseif ( self::has_any( $sec['set_body'], $alts ) ) { $got += $weights[ $w ] * 0.85; } } $sec['coverage'] = $max > 0 ? $got / $max : 0; } unset( $sec ); usort( $sections, function ( $a, $b ) { return $b['coverage'] <=> $a['coverage']; } ); foreach ( $sections as $sec ) { if ( $sec['coverage'] < 0.25 || count( $out['sections'] ) >= 6 ) { break; } $out['sections'][] = array( 'title' => $sec['title'], 'url' => $sec['url'], 'heading' => $sec['heading'], 'lines' => array_slice( $sec['lines'], 0, 30 ), ); } } // FAQ answers that share the question's words. $scored = array(); foreach ( Replyvane_Store::items( true ) as $item ) { $set = self::word_set( $item['question'] . ' ' . $item['keywords'] ); $score = 0; foreach ( $words as $w ) { if ( self::has_any( $set, self::alternatives( $w ) ) ) { $score++; } } if ( $score > 0 && $score / count( $words ) >= 0.5 ) { $scored[] = array( $score, $item ); } } usort( $scored, function ( $a, $b ) { return $b[0] <=> $a[0]; } ); foreach ( array_slice( $scored, 0, 3 ) as $pair ) { $out['faqs'][] = array( 'question' => $pair[1]['question'], 'answer' => $pair[1]['answer'], ); } return $out; } /** Puts several answers together, each with its own heading, sources merged. */ private static function combine( array $answers ) { $text = array(); $sources = array(); $seen = array(); $conf = 1; foreach ( $answers as $a ) { $text[] = $a['answer']; $conf = min( $conf, $a['confidence'] ); foreach ( $a['sources'] as $src ) { $u = untrailingslashit( $src['url'] ); if ( ! isset( $seen[ $u ] ) ) { $seen[ $u ] = true; $sources[] = $src; } } } return array( 'answer' => implode( "\n\n", $text ), 'sources' => array_slice( $sources, 0, 3 ), 'confidence' => $conf, ); } private static function strip( array $result ) { unset( $result['uncovered'] ); return $result; } private static function answer_one( $query, array $skip_urls = array() ) { $words = self::keywords( $query ); if ( empty( $words ) ) { return null; } $sections = self::sections( self::retrieve( $words ) ); if ( $skip_urls ) { $sections = array_values( array_filter( $sections, function ( $s ) use ( $skip_urls ) { return ! in_array( $s['url'], $skip_urls, true ); } ) ); } if ( empty( $sections ) ) { return null; } $weights = self::weights( $sections, $words ); $max = array_sum( $weights ); // A word in the section's heading or page title counts fully, in its // text slightly less. On ties, prefer the more specific heading. foreach ( $sections as &$sec ) { $got = 0; foreach ( $words as $w ) { $alts = self::alternatives( $w ); if ( self::has_any( $sec['set_head'], $alts ) ) { $got += $weights[ $w ]; } elseif ( self::has_any( $sec['set_body'], $alts ) ) { $got += $weights[ $w ] * 0.85; } } $sec['coverage'] = $max > 0 ? $got / $max : 0; // True when the question matches the page's title rather than this part of it. $sec['page_level'] = true; foreach ( $words as $w ) { $alts = self::alternatives( $w ); if ( self::has_any( $sec['set_all'], $alts ) && ! self::has_any( $sec['set_page'], $alts ) ) { $sec['page_level'] = false; } } $sec['rank'] = $sec['coverage'] + 0.01 * substr_count( $sec['heading'], '›' ) - 0.00002 * mb_strlen( implode( ' ', $sec['lines'] ) ); } unset( $sec ); usort( $sections, function ( $a, $b ) { return $b['rank'] <=> $a['rank']; } ); $best = $sections[0]; if ( $best['coverage'] < self::MIN_COVERAGE ) { return null; } // Second safeguard: most of the question's words must appear, so "can I // bring my kids" isn't answered with "do I need to bring my own mat?". $matched = 0; $uncovered = array(); foreach ( $words as $w ) { if ( self::has_any( $best['set_all'], self::alternatives( $w ) ) ) { $matched++; } elseif ( $weights[ $w ] > 0 ) { $uncovered[] = $w; } } if ( $matched / count( $words ) < 0.6 ) { return null; } // A question about a whole page ("explain your membership plans"): give an // overview of the page, one line per section. $parts_best = array_filter( array_map( 'trim', explode( ' › ', (string) $best['heading'] ) ) ); // The page's opening section won, and it is just one introductory line. $is_intro = ( count( $parts_best ) < 2 || 0 === strcasecmp( end( $parts_best ), $best['title'] ) ) && count( $best['lines'] ) <= 1; if ( $best['page_level'] || $is_intro ) { $same = array_values( array_filter( $sections, function ( $s ) use ( $best ) { return $s['url'] === $best['url']; } ) ); if ( count( $same ) >= 2 ) { $ov = self::overview( $best, $same ); $ov['uncovered'] = $uncovered; return $ov; } } // Short section: all of it. Long section: the best line and what follows. $units = self::units( implode( "\n", $best['lines'] ) ); if ( empty( $units ) ) { return null; } if ( array_sum( array_map( 'mb_strlen', $units ) ) <= self::MAX_ANSWER ) { $picked = array_keys( $units ); } else { $scores = array(); foreach ( $units as $i => $u ) { $set = self::word_set( $u ); $score = 0; foreach ( $words as $w ) { if ( self::has_any( $set, self::alternatives( $w ) ) ) { $score += $weights[ $w ]; } } $scores[ $i ] = $score - ( mb_strlen( $u ) < 12 ? 0.3 : 0 ); } arsort( $scores ); $top = (int) key( $scores ); $start = $top; if ( $top > 0 && preg_match( '/:\s*$/u', $units[ $top - 1 ] ) ) { $start = $top - 1; // Keep the line that introduces a list. } while ( $start > 0 && 0 === strpos( $units[ $start ], '- ' ) && 0 === strpos( $units[ $start - 1 ], '- ' ) && $top - $start < 6 ) { $start--; } $picked = array(); $length = 0; for ( $i = $start, $n = count( $units ); $i < $n; $i++ ) { $length += mb_strlen( $units[ $i ] ); if ( $picked && $length > self::MAX_ANSWER ) { break; } $picked[] = $i; } } $lines = array(); foreach ( $picked as $i ) { $line = self::tidy( $units[ $i ] ); if ( '' !== $line && ! in_array( $line, $lines, true ) ) { $lines[] = $line; } } if ( empty( $lines ) ) { return null; } $parts = array_filter( array_map( 'trim', explode( ' › ', (string) $best['heading'] ) ) ); $heading = $parts ? end( $parts ) : $best['title']; $text = ( $heading && ! in_array( $heading, $lines, true ) ? '**' . $heading . "**\n" : '' ) . implode( "\n", $lines ); // Where it came from, plus up to two other relevant pages. $sources = array( array( 'title' => $best['title'], 'url' => $best['url'], ), ); $seen = array( untrailingslashit( $best['url'] ) => true ); foreach ( array_slice( $sections, 1 ) as $c ) { $u = untrailingslashit( $c['url'] ); if ( isset( $seen[ $u ] ) || $c['coverage'] < self::MIN_COVERAGE ) { continue; } $seen[ $u ] = true; $sources[] = array( 'title' => $c['title'], 'url' => $c['url'], ); if ( count( $sources ) >= 3 ) { break; } } return array( 'answer' => $text, 'sources' => $sources, 'confidence' => round( $best['coverage'], 2 ), 'uncovered' => $uncovered, ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching