$phrases ) { foreach ( $phrases as $phrase ) { if ( self::opens_with( $plain, $phrase ) ) { return 0 === $index ? self::NAVIGATION : self::LOOKUP; } } } // No match at the start. Now look anywhere in the sentence. // A loose match is fine here. A wrong match only changes the short // opening line ("Of course" or "Let me see"), not the answer. foreach ( [ self::DOING, self::ASKING ] as $index => $phrases ) { foreach ( $phrases as $phrase ) { if ( false !== strpos( $plain, ' ' . $phrase . ' ' ) || self::ends_with( $plain, ' ' . $phrase ) ) { return 0 === $index ? self::NAVIGATION : self::LOOKUP; } } } return self::UNCLEAR; } /** * Check if the sentence starts with this phrase, as whole words. * * "list" must not match "listen", and "add" must not match "address". * After plain(), a whole word ends with a space or the end of the text. * * @param string $text The sentence, from plain(). * @param string $phrase The phrase to look for. * @return bool */ private static function opens_with( string $text, string $phrase ): bool { $length = strlen( $phrase ); return 0 === strpos( $text, $phrase ) && ( strlen( $text ) === $length || ' ' === $text[ $length ] ); } /** * Check if the sentence ends with this phrase. * * @param string $text The sentence. * @param string $phrase The phrase to look for. * @return bool */ private static function ends_with( string $text, string $phrase ): bool { $at = strlen( $text ) - strlen( $phrase ); return $at >= 0 && strpos( $text, $phrase, $at ) === $at; } /** * A fixed reply to small talk, so no model call is needed. * * Why: a model call adds about 4 s and gives nothing for "hello". * handle_greet() does the same for the first hello. * English only, on purpose. Other languages go to the model, which * answers in the right language. * * @param string $text What the caller said. * @return string The line to say, or an empty string when this is a real * question and the model must answer it. */ public static function small_talk_reply( string $text ): string { return self::one_of( self::small_talk_replies( $text ) ); } /** * Every reply we would accept for this piece of small talk. * * Returns all of them, not one. The caller can only play a line whose * audio clip is already made, so it picks from the full list. * See Voice_Controller::pick_said(). * * @param string $text What the caller said. * @return string[] Empty when this is a real question for the model. */ public static function small_talk_replies( string $text ): array { // Try two versions: the raw text, and the text with noise words // ("so", "um", "today") removed. A version must match a phrase fully. // A real question has extra words, so it never matches. $tries = array_values( array_unique( array_filter( [ self::letters( $text ), self::letters( self::plain( $text ) ), ] ) ) ); if ( empty( $tries ) ) { return []; } foreach ( self::small_talk() as $phrases => $replies ) { foreach ( explode( '|', (string) $phrases ) as $phrase ) { if ( in_array( self::letters( $phrase ), $tries, true ) ) { return array_values( array_filter( $replies ) ); } } } return []; } /** * The small talk we answer ourselves, and what we answer with. * * Key: the ways to say it, split by "|". Value: replies to pick from, * so the caller does not hear the same reply twice. * * Keep this list short. A phrase here never reaches the model, so do * not add anything that could be a real question. * * @return array */ private static function small_talk(): array { return [ 'hi|hello|hey|hi there|hello there|yo' => [ __( 'Hey! What can I do for you?', 'aisentic' ), __( "Hello! I'm listening.", 'aisentic' ), ], 'how are you|how are you doing|how you doing|how r u|hows it going|how is it going|how is everything|hows everything|whats up|you okay|you ok' => [ __( "I'm good, thanks for asking! What can I help you with?", 'aisentic' ), __( 'Doing well, thank you! What are we working on?', 'aisentic' ), ], 'good morning|good afternoon|good evening|morning' => [ __( 'Good to hear from you! What shall we do?', 'aisentic' ), ], 'thanks|thank you|thanks a lot|thank you so much|cheers|ta' => [ __( 'Anytime! Anything else?', 'aisentic' ), __( "You're welcome. Need anything else?", 'aisentic' ), ], // Not here on purpose: ok, okay, alright, right, got it, sure, // cool, nice, great, yes. // Why: the assistant ends most answers with a question (rule 9 of // the voice prompt). So "okay" usually means "yes, go ahead". The // model must answer it, because it knows what was asked. 'lovely|perfect|brilliant|wonderful' => [ __( 'Anything else I can find for you?', 'aisentic' ), __( 'Just say the word if you need more.', 'aisentic' ), ], 'who are you|what are you|whats your name|what is your name' => [ __( "I'm your site assistant. I can look things up and open pages for you.", 'aisentic' ), ], 'what can you do|what do you do|help|can you help me' => [ __( 'I can find things on your site and open the right page for you. Just ask.', 'aisentic' ), ], 'bye|goodbye|good bye|see you|see ya|thats all|that is all|nothing|no thanks|no thank you|im done|i am done' => [ __( 'Bye for now! Just say the word when you need me.', 'aisentic' ), __( 'Alright, talk soon!', 'aisentic' ), ], 'sorry|my bad|never mind|nevermind|forget it' => [ __( 'No problem at all. What would you like instead?', 'aisentic' ), ], ]; } /** * A short line to say while the model thinks, chosen to fit the question. * * "Of course" fits "open the settings". "Let me see" fits "how many * events do I have". * Keep lines to 2 or 3 words, each a full phrase. Why: "Happy to — one * second." sounded unnatural, and the dash became a long pause. * * @param string $kind One of the constants above. * @return string Empty for small talk, which needs no cover because it is * answered at once. */ public static function opening_line( string $kind ): string { return self::one_of( self::opening_lines( $kind ) ); } /** * Every opening line that would fit this kind of question. * * Returns all of them, for the same reason as small_talk_replies(). * * @param string $kind One of the constants above. * @return string[] Empty for small talk, which is answered at once. */ public static function opening_lines( string $kind ): array { $lines = [ self::NAVIGATION => [ __( 'Of course.', 'aisentic' ), __( 'Right away.', 'aisentic' ), __( 'Sure.', 'aisentic' ), ], self::LOOKUP => [ __( 'Let me see.', 'aisentic' ), __( 'One moment.', 'aisentic' ), ], self::UNCLEAR => [ __( 'Okay.', 'aisentic' ), __( 'Right.', 'aisentic' ), __( 'Understood.', 'aisentic' ), __( 'Got it.', 'aisentic' ), __( 'Let me check.', 'aisentic' ), __( 'One moment.', 'aisentic' ), ], ]; return isset( $lines[ $kind ] ) ? array_values( array_filter( $lines[ $kind ] ) ) : []; } /** * Pick one at random. * * @param string[] $options Lines to choose between. * @return string */ private static function one_of( array $options ): string { $options = array_values( array_filter( $options ) ); if ( empty( $options ) ) { return ''; } return $options[ wp_rand( 0, count( $options ) - 1 ) ]; } /** * Lowercase, no punctuation, single spaces. * * Also removes noise words from the start and end. Spaces are kept, so * "how many" does not match inside "howmanyevents". * * @param string $text Raw text. * @return string */ private static function plain( string $text ): string { $text = mb_strtolower( trim( $text ) ); $text = (string) preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $text ); $text = (string) preg_replace( '/\s+/u', ' ', $text ); $words = array_values( array_filter( explode( ' ', trim( $text ) ) ) ); // Remove noise words from the start. "so um okay I want" becomes // "i want". Always keep at least one word, so "Okay" stays "okay". $left = count( $words ); while ( $left > 1 && in_array( $words[0], self::LEADING_NOISE, true ) ) { array_shift( $words ); --$left; } // Remove noise words from the end, but always keep at least one word. while ( $left > 1 && in_array( end( $words ), self::TRAILING_NOISE, true ) ) { array_pop( $words ); --$left; } return implode( ' ', $words ); } /** * Letters and numbers only, lowercase. * * So "How are you?", "how are you" and "Howareyou" are the same. * Used for whole-sentence matching of small talk. * * @param string $text Raw text. * @return string */ private static function letters( string $text ): string { return mb_strtolower( (string) preg_replace( '/[^\p{L}\p{N}]+/u', '', $text ) ); } }