__( 'Detect from file extension', 'aihouse' ), 'markdown' => __( 'Markdown (.md) — each heading becomes one item', 'aihouse' ), 'csv' => __( 'CSV (.csv) — two columns with a header row', 'aihouse' ), 'json' => __( 'JSON (.json) — array of objects', 'aihouse' ), 'text' => __( 'Plain text (.txt) — blocks separated by a --- line', 'aihouse' ), ); } /** * Sample file names. * * @return array */ public static function templates() { return array( 'markdown' => 'knowledge-template.md', 'csv' => 'knowledge-template.csv', 'json' => 'knowledge-template.json', ); } /** * Detects the format from the file name and sniffs the contents when the extension is unknown. * * @param string $filename File name. * @param string $contents File contents. * @return string */ public function detect_format( $filename, $contents ) { $extension = strtolower( (string) pathinfo( (string) $filename, PATHINFO_EXTENSION ) ); $by_extension = array( 'md' => 'markdown', 'markdown' => 'markdown', 'txt' => 'text', 'text' => 'text', 'csv' => 'csv', 'json' => 'json', ); if ( isset( $by_extension[ $extension ] ) ) { return $by_extension[ $extension ]; } $trimmed = ltrim( (string) $contents ); if ( '' !== $trimmed && ( '{' === $trimmed[0] || '[' === $trimmed[0] ) ) { $decoded = json_decode( $contents, true ); if ( null !== $decoded ) { return 'json'; } } if ( preg_match( '/^\s{0,3}#{1,6}\s+\S/m', (string) $contents ) ) { return 'markdown'; } $first_line = strtok( (string) $contents, "\r\n" ); if ( false !== $first_line && substr_count( $first_line, ',' ) >= 1 ) { return 'csv'; } return 'text'; } /** * Parses the contents into a list of items. * * @param string $format Selected format. * @param string $contents File contents. * @param string $filename File name, used as the fallback title. * @return array|WP_Error */ public function parse( $format, $contents, $filename = '' ) { $contents = (string) $contents; if ( '' === trim( $contents ) ) { return new WP_Error( 'mybot_empty_file', __( 'The file is empty.', 'aihouse' ) ); } if ( strlen( $contents ) > self::MAX_BYTES ) { return new WP_Error( 'mybot_file_too_large', sprintf( /* translators: %s: maximum size */ __( 'The file exceeds %s. Split it and import in several passes.', 'aihouse' ), size_format( self::MAX_BYTES ) ) ); } $format = ( 'auto' === $format ) ? $this->detect_format( $filename, $contents ) : sanitize_key( $format ); $default_title = '' !== $filename ? preg_replace( '/\.[a-z0-9]+$/i', '', (string) $filename ) : __( 'Manually imported knowledge', 'aihouse' ); switch ( $format ) { case 'csv': $entries = $this->parse_csv( $contents ); break; case 'json': $entries = $this->parse_json( $contents ); break; case 'markdown': $entries = $this->parse_blocks( $contents, $default_title, true ); break; case 'text': $entries = $this->parse_blocks( $contents, $default_title, false ); break; default: return new WP_Error( 'mybot_bad_format', __( 'Unsupported file format.', 'aihouse' ) ); } if ( is_wp_error( $entries ) ) { return $entries; } $entries = $this->normalize( $entries ); if ( empty( $entries ) ) { return new WP_Error( 'mybot_no_entries', __( 'No items found in the file. Check the selected format.', 'aihouse' ) ); } return array( 'format' => $format, 'entries' => $entries, 'total' => count( $entries ), ); } /** * Splits by heading (markdown) or by separator line (plain text). * * For markdown, the split happens only at the smallest heading level in use, so * nested headings stay inside the content instead of being cut into separate items. * * @param string $contents File contents. * @param string $default_title Fallback title. * @param bool $by_heading Whether to split by heading. * @return array */ protected function parse_blocks( $contents, $default_title, $by_heading ) { if ( ! $by_heading ) { $blocks = preg_split( '/^\s*-{3,}\s*$/m', (string) $contents ); $entries = array(); foreach ( (array) $blocks as $index => $block ) { $block = trim( (string) $block ); if ( '' === $block ) { continue; } // The first line of each block is treated as the title when it is short. $lines = preg_split( '/\r\n|\r|\n/', $block ); $first = trim( (string) $lines[0] ); $title = ( MYBOT_Chunker::length( $first ) <= 120 && count( $lines ) > 1 ) ? $first : sprintf( '%s %d', $default_title, $index + 1 ); $body = ( $title === $first && count( $lines ) > 1 ) ? trim( implode( "\n", array_slice( $lines, 1 ) ) ) : $block; $entries[] = array( 'title' => $title, 'content' => $body, ); } return $entries; } $lines = preg_split( '/\r\n|\r|\n/', (string) $contents ); // Find the smallest heading level in use. $level = 7; foreach ( $lines as $line ) { if ( preg_match( '/^\s{0,3}(#{1,6})\s+\S/', $line, $matches ) ) { $level = min( $level, strlen( $matches[1] ) ); } } if ( 7 === $level ) { // No headings found: treat the whole file as one item. return array( array( 'title' => $default_title, 'content' => trim( (string) $contents ), ), ); } $entries = array(); $title = ''; $buffer = array(); /** * Flushes the item currently being collected into the list. * * @param array $entries Target list. * @param string $title Title. * @param array $buffer Content lines. * @param string $default_title Fallback title. * @return void */ $flush = function ( &$entries, $title, $buffer, $default_title ) { $body = trim( implode( "\n", $buffer ) ); if ( '' === $body ) { return; } $entries[] = array( 'title' => '' !== $title ? $title : $default_title, 'content' => $body, ); }; foreach ( $lines as $line ) { $pattern = '/^\s{0,3}#{' . $level . '}\s+(.+?)\s*#*\s*$/u'; if ( preg_match( $pattern, $line, $matches ) ) { $flush( $entries, $title, $buffer, $default_title ); $title = trim( wp_strip_all_tags( $matches[1] ) ); $buffer = array(); continue; } $buffer[] = $line; } $flush( $entries, $title, $buffer, $default_title ); return $entries; } /** * Parses a CSV file. * * Uses fgetcsv on a temporary stream so that cells containing commas and line breaks are handled correctly. * * @param string $contents File contents. * @return array */ protected function parse_csv( $contents ) { $stream = fopen( 'php://temp', 'r+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- in-memory temporary stream, nothing is written to disk. if ( false === $stream ) { return array(); } fwrite( $stream, (string) $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite rewind( $stream ); $title_index = null; $content_index = null; $entries = array(); $row_number = 0; while ( false !== ( $row = fgetcsv( $stream, 0, ',', '"', '\\' ) ) ) { ++$row_number; if ( ! is_array( $row ) || ( 1 === count( $row ) && null === $row[0] ) ) { continue; } $row = array_map( function ( $cell ) { return trim( (string) $cell ); }, $row ); if ( 1 === $row_number ) { $found_title = $this->find_column( $row, self::$title_keys ); $found_content = $this->find_column( $row, self::$content_keys ); if ( null !== $found_title && null !== $found_content ) { $title_index = $found_title; $content_index = $found_content; continue; } // No header row: default to column 1 as the title and column 2 as the content. $title_index = 0; $content_index = 1; } $title = isset( $row[ $title_index ] ) ? $row[ $title_index ] : ''; $content = isset( $row[ $content_index ] ) ? $row[ $content_index ] : ''; if ( '' === $title && '' === $content ) { continue; } $entries[] = array( 'title' => $title, 'content' => $content, ); } fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $entries; } /** * Finds a column index by its recognized name. * * @param array $row Header row. * @param array $keys Names to look for. * @return int|null */ protected function find_column( $row, $keys ) { foreach ( $row as $index => $cell ) { $cell = strtolower( trim( (string) $cell ) ); if ( in_array( $cell, $keys, true ) ) { return (int) $index; } } return null; } /** * Parses a JSON file. * * @param string $contents File contents. * @return array|WP_Error */ protected function parse_json( $contents ) { $decoded = json_decode( (string) $contents, true ); if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { return new WP_Error( 'mybot_bad_json', sprintf( /* translators: %s: JSON parser error message */ __( 'Invalid JSON: %s', 'aihouse' ), json_last_error_msg() ) ); } if ( isset( $decoded['items'] ) && is_array( $decoded['items'] ) ) { $decoded = $decoded['items']; } if ( ! is_array( $decoded ) ) { return new WP_Error( 'mybot_bad_json_shape', __( 'JSON must be an array of items.', 'aihouse' ) ); } $entries = array(); foreach ( $decoded as $item ) { if ( is_string( $item ) ) { $entries[] = array( 'title' => '', 'content' => $item, ); continue; } if ( ! is_array( $item ) ) { continue; } $title = ''; $content = ''; foreach ( array( 'title', 'question', 'name' ) as $key ) { if ( ! empty( $item[ $key ] ) && is_string( $item[ $key ] ) ) { $title = $item[ $key ]; break; } } foreach ( array( 'content', 'answer', 'body', 'text' ) as $key ) { if ( ! empty( $item[ $key ] ) && is_string( $item[ $key ] ) ) { $content = $item[ $key ]; break; } } if ( '' === $title && '' === $content ) { continue; } $entries[] = array( 'title' => $title, 'content' => $content, 'url' => isset( $item['url'] ) && is_string( $item['url'] ) ? $item['url'] : '', ); } return $entries; } /** * Normalizes and limits the list of items. * * @param array $entries Raw list of items. * @return array */ protected function normalize( $entries ) { $clean = array(); foreach ( (array) $entries as $entry ) { $title = isset( $entry['title'] ) ? trim( wp_strip_all_tags( (string) $entry['title'] ) ) : ''; $content = isset( $entry['content'] ) ? trim( (string) $entry['content'] ) : ''; // An item without content gives the bot nothing to answer with. if ( '' === $content ) { continue; } $title = MYBOT_Chunker::slice( $title, 0, self::MAX_TITLE ); if ( '' === $title ) { $title = MYBOT_Chunker::slice( $content, 0, 80 ); } $clean[] = array( 'title' => $title, 'content' => MYBOT_Chunker::slice( $content, 0, self::MAX_CONTENT ), ); if ( count( $clean ) >= self::MAX_ENTRIES ) { break; } } return $clean; } /** * Imports a list of items into the knowledge store. * * The save_post hook is suspended during the write so that hundreds of individual * jobs are not pushed into the queue; a single batched job is pushed afterwards. * * @param array $entries Normalized list of items. * @param string $filename Source file name. * @param string $mode 'merge' keeps existing items, 'replace' deletes everything first. * @return array|WP_Error */ public function import( $entries, $filename = '', $mode = 'merge' ) { if ( empty( $entries ) || ! is_array( $entries ) ) { return new WP_Error( 'mybot_no_entries', __( 'There are no items to import.', 'aihouse' ) ); } $indexer = mybot()->indexer; $deleted = 0; if ( 'replace' === $mode ) { $deleted = $this->delete_all(); } remove_action( 'save_post', array( $indexer, 'on_save_post' ), 20 ); $batch = time(); $ids = array(); $stats = array( 'created' => 0, 'updated' => 0, 'skipped' => 0, 'deleted' => (int) $deleted, 'errors' => array(), ); foreach ( $entries as $entry ) { $title = (string) $entry['title']; $content = (string) $entry['content']; $link = MYBOT_Citations::clean_url( isset( $entry['url'] ) ? $entry['url'] : '' ); $hash = md5( $title . '|' . $content ); $existing_id = $this->find_by_title( $title ); if ( $existing_id ) { $stored = (string) get_post_meta( $existing_id, MYBOT_Knowledge_CPT::META_HASH, true ); if ( $stored === $hash ) { ++$stats['skipped']; $ids[] = $existing_id; continue; } $result = wp_update_post( array( 'ID' => $existing_id, 'post_content' => wp_kses_post( $content ), ), true ); if ( is_wp_error( $result ) ) { $stats['errors'][] = $result->get_error_message(); continue; } update_post_meta( $existing_id, MYBOT_Knowledge_CPT::META_HASH, $hash ); update_post_meta( $existing_id, MYBOT_Knowledge_CPT::META_SOURCE, sanitize_file_name( $filename ) ); update_post_meta( $existing_id, MYBOT_Knowledge_CPT::META_BATCH, $batch ); // A link in the file replaces the stored one only when it is a valid same-site URL. if ( '' !== $link ) { update_post_meta( $existing_id, MYBOT_Citations::META_URL, $link ); } ++$stats['updated']; $ids[] = $existing_id; continue; } $post_id = wp_insert_post( array( 'post_type' => MYBOT_Knowledge_CPT::POST_TYPE, 'post_status' => 'publish', 'post_title' => $title, 'post_content' => wp_kses_post( $content ), ), true ); if ( is_wp_error( $post_id ) ) { $stats['errors'][] = $post_id->get_error_message(); continue; } update_post_meta( $post_id, MYBOT_Knowledge_CPT::META_HASH, $hash ); update_post_meta( $post_id, MYBOT_Knowledge_CPT::META_SOURCE, sanitize_file_name( $filename ) ); update_post_meta( $post_id, MYBOT_Knowledge_CPT::META_BATCH, $batch ); if ( '' !== $link ) { update_post_meta( $post_id, MYBOT_Citations::META_URL, $link ); } ++$stats['created']; $ids[] = $post_id; } add_action( 'save_post', array( $indexer, 'on_save_post' ), 20, 3 ); $stats['queued'] = 0; // A single batched job instead of one job per item. if ( ! empty( $ids ) && mybot()->settings->rag_available() ) { $pushed = mybot()->queue->push( MYBOT_Indexer::JOB_TYPE, array( 'post_ids' => array_values( array_unique( $ids ) ) ), 10 ); if ( $pushed ) { $stats['queued'] = count( array_unique( $ids ) ); } } return $stats; } /** * Finds a knowledge item by title. * * @param string $title Title. * @return int Post ID, or 0 when no match is found. */ protected function find_by_title( $title ) { $query = new WP_Query( array( 'post_type' => MYBOT_Knowledge_CPT::POST_TYPE, 'post_status' => 'publish', 'title' => $title, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, ) ); return empty( $query->posts ) ? 0 : (int) $query->posts[0]; } /** * Lists the existing knowledge items. * * @param int $limit Maximum number of items. * @return array */ public function list_entries( $limit = 100 ) { $query = new WP_Query( array( 'post_type' => MYBOT_Knowledge_CPT::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => max( 1, min( 200, absint( $limit ) ) ), 'orderby' => 'modified', 'order' => 'DESC', ) ); $entries = array(); foreach ( $query->posts as $post ) { $entries[] = array( 'id' => (int) $post->ID, 'title' => $post->post_title, 'chars' => MYBOT_Chunker::length( $post->post_content ), 'source' => (string) get_post_meta( $post->ID, MYBOT_Knowledge_CPT::META_SOURCE, true ), 'indexed' => '' !== (string) get_post_meta( $post->ID, MYBOT_Indexer::HASH_META, true ), 'modified' => $post->post_modified_gmt, ); } return array( 'entries' => $entries, 'total' => (int) $query->found_posts, 'shown' => count( $entries ), ); } /** * Deletes a single knowledge item. * * @param int $post_id Post ID. * @return bool */ public function delete_entry( $post_id ) { $post = get_post( $post_id ); if ( ! $post || ! MYBOT_Knowledge_CPT::is_knowledge( $post->post_type ) ) { return false; } // Delete permanently so that the before_delete_post hook also cleans up the matching vectors. return (bool) wp_delete_post( $post_id, true ); } /** * Deletes the entire knowledge store. * * @return int Number of items deleted. */ public function delete_all() { $query = new WP_Query( array( 'post_type' => MYBOT_Knowledge_CPT::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, ) ); $deleted = 0; foreach ( $query->posts as $post_id ) { if ( wp_delete_post( (int) $post_id, true ) ) { ++$deleted; } } return $deleted; } }