provider = $provider; } /** * Build with the provider selected in Aisentic settings. * * @param Settings $settings Settings. * @return self */ public static function from_settings( Settings $settings ): self { return new self( Llm_Provider_Factory::make( $settings ) ); } /** * Make the text for one type. * * @param string $type One of self::TYPES. * @param string $data The user's keywords (the React code already added the tone). * @return string|array * @throws \InvalidArgumentException For an unknown type. */ public function generate( string $type, string $data ) { if ( ! in_array( $type, self::TYPES, true ) ) { throw new \InvalidArgumentException( sprintf( 'Unknown type: %s', esc_html( $type ) ) ); } $text = $this->complete( self::build_prompt( $type, $data ) ); switch ( $type ) { case 'faq': return self::parse_faq( $text ); case 'tag': return self::insert_or_get_terms( 'etn_tags', self::split_list( $text ) ); case 'category': return self::insert_or_get_terms( 'etn_category', self::split_list( $text ) ); case 'title': // Models often wrap a title in **bold** or quotes. return trim( str_replace( '**', '', $text ), " \t\"'*" ); default: return $text; } } /** * The prompt for one type. * * @param string $type One of self::TYPES. * @param string $data The user's keywords. * @return string */ public static function build_prompt( string $type, string $data ): string { $prompts = [ 'title' => 'Write one short event title, 50 to 80 characters long, based on these keywords: %s. Reply with the title only, as plain text, without quotes.', 'description' => 'Write a short and simple event description based on these keywords: %s. Reply with plain text only.', 'faq' => "Write 5 frequently asked questions with answers for this event: %s\n\nUse exactly this format for each one:\nQ: \nA: ", 'tag' => 'Suggest 3 or 4 short tags for an event about: %s. Reply with the tags only, as a comma-separated list.', 'category' => 'Suggest 3 or 4 event categories for an event about: %s. Reply with the categories only, as a comma-separated list.', ]; return sprintf( $prompts[ $type ], $data ); } /** * Turn "Q: ... A: ..." text into a list of questions and answers. * Accepts markdown stars, numbers like "1." and "Q." as well as "Q:". * * @param string $text AI answer. * @return array */ public static function parse_faq( string $text ): array { $faqs = []; $current = [ 'question' => '', 'answer' => '', ]; $waiting = ''; foreach ( preg_split( '/\R/', $text ) as $line ) { $line = trim( str_replace( [ '**', '__' ], '', $line ) ); $line = trim( (string) preg_replace( '/^(#+|\d+[.)])\s*/', '', $line ) ); if ( preg_match( '/^Q(?:uestion)?\s*\d*\s*[:.]\s*(.*)$/i', $line, $match ) ) { self::add_faq( $faqs, $current ); $current = [ 'question' => trim( $match[1] ), 'answer' => '', ]; $waiting = '' === $current['question'] ? 'question' : ''; } elseif ( preg_match( '/^A(?:nswer)?\s*\d*\s*[:.]\s*(.*)$/i', $line, $match ) ) { $current['answer'] = trim( $match[1] ); $waiting = '' === $current['answer'] ? 'answer' : ''; } elseif ( '' !== $line && '' !== $waiting ) { // "Q:" or "A:" alone on a line, with the text on the next line. $current[ $waiting ] = $line; $waiting = ''; } elseif ( '' !== $line && '' !== $current['answer'] ) { // An answer that goes on to the next line. $current['answer'] .= ' ' . $line; } } self::add_faq( $faqs, $current ); return $faqs; } /** * Add a question to the list when it has both parts. * * @param array $faqs List, changed in place. * @param array $current The question being read. * @return void */ private static function add_faq( array &$faqs, array $current ): void { if ( '' !== $current['question'] && '' !== $current['answer'] ) { $faqs[] = $current; } } /** * Split a comma or line separated answer into clean names. * * @param string $text AI answer. * @return string[] */ public static function split_list( string $text ): array { $names = []; $seen = []; // Split on commas, semicolons, new lines, and the space before a #hashtag. foreach ( preg_split( '/[,;\n]+|\s+(?=#)/u', $text ) as $item ) { $item = str_replace( [ '*', '_' ], '', $item ); // A label in front, like "Tags: music". $item = (string) preg_replace( '/^\s*[A-Za-z ]{1,20}:\s*/', '', $item ); $item = trim( (string) preg_replace( '/^\s*(\d+[.)]|[-•#])\s*/u', '', trim( $item ) ) ); $item = trim( $item, " \t\"'#." ); if ( '' === $item || isset( $seen[ strtolower( $item ) ] ) ) { continue; } $seen[ strtolower( $item ) ] = true; $names[] = ucfirst( $item ); if ( count( $names ) >= self::MAX_LIST_ITEMS ) { break; } } return $names; } /** * Get term IDs, and create the terms that do not exist yet. * Same logic as etn_ai_insert_or_get_terms() in the old Eventin AI plugin. * * @param string $taxonomy Taxonomy, like etn_tags. * @param string[] $names Term names. * @return int[] */ public static function insert_or_get_terms( string $taxonomy, array $names ): array { $ids = []; foreach ( $names as $name ) { $existing = get_term_by( 'name', $name, $taxonomy ); if ( $existing ) { $ids[] = (int) $existing->term_id; continue; } $created = wp_insert_term( $name, $taxonomy ); if ( ! is_wp_error( $created ) && isset( $created['term_id'] ) ) { $ids[] = (int) $created['term_id']; } } return $ids; } /** * Send one prompt and return the trimmed answer. * * @param string $prompt Prompt. * @return string * @throws Eventin_Ai_Exception When the answer is empty. */ private function complete( string $prompt ): string { $message = $this->provider->chat( new UserMessage( $prompt ) ); $text = trim( (string) $message->getContent() ); if ( '' === $text ) { throw new Eventin_Ai_Exception( esc_html__( 'The AI did not return an answer. Please try again.', 'aisentic' ) ); } return $text; } }