diff --git a/inc/API.php b/inc/API.php index fdcc1b0b..84b20cc3 100644 --- a/inc/API.php +++ b/inc/API.php @@ -296,6 +296,10 @@ public function register_routes() { 'type' => 'string', 'enum' => [ 'stream', 'background' ], ], + 'page_url' => [ + 'required' => false, + 'type' => 'string', + ], ], 'callback' => [ $this, 'send_chat' ], 'permission_callback' => function ( $request ) { @@ -2126,6 +2130,7 @@ public function send_chat( $request ) { 'context' => $prepared['context'], 'is_test' => $is_test, 'source_post_ids' => $this->source_post_ids, + 'page' => isset( $prepared['page'] ) ? $prepared['page'] : null, ], 5 * MINUTE_IN_SECONDS ); @@ -2175,21 +2180,25 @@ public function send_chat( $request ) { * stored under a run token that `get_chat` reads back, keeping the widget's * send-then-poll contract intact without an OpenAI background run. * - * @param array{thread_id:string,message:string,context:string} $prepared Prepared turn. - * @param bool $is_test Admin live-preview chat. - * @param int|null $record_id Existing conversation record. + * @param array{thread_id:string,message:string,context:string,page:array|null} $prepared Prepared turn. + * @param bool $is_test Admin live-preview chat. + * @param int|null $record_id Existing conversation record. * * @return \WP_REST_Response */ private function send_chat_connect( $prepared, $is_test, $record_id ) { - $result = Hyve_Connect::instance()->chat( - [ - 'message' => $prepared['message'], - 'thread_id' => '' !== $prepared['thread_id'] ? $prepared['thread_id'] : null, - 'settings' => Hyve_Connect::chat_settings(), - 'stream' => false, - ] - ); + $payload = [ + 'message' => $prepared['message'], + 'thread_id' => '' !== $prepared['thread_id'] ? $prepared['thread_id'] : null, + 'settings' => Hyve_Connect::chat_settings(), + 'stream' => false, + ]; + + if ( ! empty( $prepared['page'] ) ) { + $payload['page'] = $prepared['page']; + } + + $result = Hyve_Connect::instance()->chat( $payload ); if ( is_wp_error( $result ) ) { return rest_ensure_response( @@ -2290,7 +2299,7 @@ private function get_chat_connect( $request ) { * * @param \WP_REST_Request> $request Request. * - * @return array{thread_id:string,message:string,context:string}|\WP_Error + * @return array{thread_id:string,message:string,context:string,page:array|null}|\WP_Error */ private function prepare_chat( $request ) { $message = $request->get_param( 'message' ); @@ -2299,6 +2308,8 @@ private function prepare_chat( $request ) { return new \WP_Error( 'missing_message', __( 'Message was flagged.', 'hyve-lite' ) ); } + $page = Page_Context::instance()->for_request( $request ); + // Connect mode: moderation, embedding, retrieval, and thread minting all // happen server-side inside hyve-chat, so there is no local prep. The // platform mints the thread id on turn 1 and echoes it back. @@ -2310,6 +2321,7 @@ private function prepare_chat( $request ) { 'thread_id' => $thread_id ? $thread_id : '', 'message' => $message, 'context' => '', + 'page' => $page ? Page_Context::instance()->payload( $page ) : null, ]; } @@ -2323,7 +2335,12 @@ private function prepare_chat( $request ) { $record_id = $request->get_param( 'record_id' ); $record_id = $record_id ? $record_id : null; $retrieval_query = $this->build_retrieval_query( $message, $record_id, $request->get_param( 'thread_id' ) ); - $message_vector = $openai->create_embeddings( $retrieval_query ); + + if ( $page && '' !== $page['title'] ) { + $retrieval_query .= "\n" . $page['title']; + } + + $message_vector = $openai->create_embeddings( $retrieval_query ); if ( is_wp_error( $message_vector ) ) { return new \WP_Error( 'no_embeddings', __( 'No embeddings found.', 'hyve-lite' ) ); @@ -2358,6 +2375,23 @@ private function prepare_chat( $request ) { $article_context = $this->search_knowledge_base( $message_vector, $similarity_score_threshold ); + if ( $page ) { + $article_context .= Page_Context::instance()->context_block( $page, $article_context ); + } + + // The visitor is already on this page; linking them to it as a + // source would be noise. + if ( $page && $page['id'] ) { + $this->source_post_ids = array_values( + array_filter( + $this->source_post_ids, + function ( $source_id ) use ( $page ) { + return intval( $source_id ) !== $page['id']; + } + ) + ); + } + $hash = hash( 'md5', strtolower( $message ) ); // TTL must outlast the slowest reply (streaming can run up to the 120s // cURL cap) so the embedding is still available when hyve_chat_response @@ -2368,6 +2402,7 @@ private function prepare_chat( $request ) { 'thread_id' => $thread_id, 'message' => $message, 'context' => $article_context, + 'page' => null, ]; } diff --git a/inc/DB_Table.php b/inc/DB_Table.php index c0f7c59b..35a4d410 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -498,6 +498,26 @@ public function get_post_id( $id ) { return $wpdb->get_var( $wpdb->prepare( 'SELECT post_id FROM %i WHERE id = %d', $this->table_name, $id ) ); } + /** + * Get the processed chunks of a source post. + * + * Used to pin the visitor's current page into the chat context. Rows stay + * in this table in both storage backends (Qdrant only holds the vectors), + * so this works regardless of where embeddings live. + * + * @since 1.5.0 + * + * @param int $post_id The source post ID. + * @param int $limit Maximum chunks to return. + * + * @return array + */ + public function get_chunks_by_post_id( $post_id, $limit = 20 ) { + global $wpdb; + + return $wpdb->get_results( $wpdb->prepare( 'SELECT post_title, post_content, token_count FROM %i WHERE post_id = %d AND post_status = %s ORDER BY id ASC LIMIT %d', $this->table_name, $post_id, 'processed', $limit ) ); + } + /** * Update storage of all rows. * diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 05754d4a..b3e02a83 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -339,7 +339,7 @@ public function get_quota() { * The terminal `job_complete` payload (reply, answered, sources, thread_id, * usage) is returned to the caller. * - * @param array $payload hyve-chat input ({message,thread_id,settings,stream}). + * @param array $payload hyve-chat input ({message,thread_id,settings,stream,page?}). * @param callable(string, array): void $on_event Receives each intra-stream event (delta, kb_state, sources). * * @return array|\WP_Error The job_complete payload, or an error. @@ -437,7 +437,7 @@ public function stream_chat( $payload, $on_event ) { * Same envelope as the stream, minus `delta` events; returns the terminal * `job_complete` payload. * - * @param array $payload hyve-chat input ({message,thread_id,settings,stream:false}). + * @param array $payload hyve-chat input ({message,thread_id,settings,stream:false,page?}). * * @return array|\WP_Error */ diff --git a/inc/Page_Context.php b/inc/Page_Context.php new file mode 100644 index 00000000..ac5221ac --- /dev/null +++ b/inc/Page_Context.php @@ -0,0 +1,444 @@ +> $request Chat request. + * + * @return array{id:int,title:string,url:string,indexed:bool}|null + */ + public function for_request( $request ) { + if ( ! self::is_enabled() ) { + return null; + } + + $url = $request->get_param( 'page_url' ); + + if ( empty( $url ) || ! is_string( $url ) ) { + return null; + } + + $validated = $this->validate_same_origin( $url ); + + if ( null === $validated ) { + return null; + } + + $post_id = function_exists( 'wpcom_vip_url_to_postid' ) + ? wpcom_vip_url_to_postid( $validated['with_query'] ) + : url_to_postid( $validated['with_query'] ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid -- Cached VIP variant used when available. + + if ( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof \WP_Post || ! is_post_publicly_viewable( $post ) || ! empty( $post->post_password ) ) { + return null; + } + + return [ + 'id' => $post_id, + 'title' => get_the_title( $post ), + 'url' => (string) get_permalink( $post ), + 'indexed' => (bool) get_post_meta( $post_id, '_hyve_added', true ), + ]; + } + + // No post behind the URL: a loop page (home, archive). The query + // string is dropped so pagination/filter variants share one cache + // entry and callers cannot mint unbounded cache keys. + return [ + 'id' => 0, + 'title' => '', + 'url' => $validated['clean'], + 'indexed' => false, + ]; + } + + /** + * Validate that a visitor-supplied URL points at this site and rebuild it + * from its parsed parts. + * + * The host and port must match the site's own; the scheme is replaced + * with the site's scheme rather than compared, so mixed http/https + * front-ends behind proxies still validate. Userinfo, fragments and (for + * the clean form) query strings never survive the rebuild. + * + * @param string $url The URL sent by the widget. + * + * @return array{clean:string,with_query:string}|null Null when the URL is not same-origin. + */ + private function validate_same_origin( $url ) { + $parts = wp_parse_url( $url ); + $home = wp_parse_url( home_url() ); + + if ( empty( $parts['host'] ) || empty( $parts['scheme'] ) || empty( $home['host'] ) ) { + return null; + } + + if ( ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) ) { + return null; + } + + if ( strtolower( $parts['host'] ) !== strtolower( $home['host'] ) ) { + return null; + } + + $url_port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; + $home_port = isset( $home['port'] ) ? (int) $home['port'] : 0; + + if ( $url_port !== $home_port ) { + return null; + } + + $origin = ( isset( $home['scheme'] ) ? $home['scheme'] : 'http' ) . '://' . $parts['host'] . ( $url_port ? ':' . $url_port : '' ); + $path = isset( $parts['path'] ) ? $parts['path'] : '/'; + $clean = $origin . $path; + + return [ + 'clean' => $clean, + 'with_query' => $clean . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ), + ]; + } + + /** + * Build the current-page block appended to the self-hosted chat context. + * + * @param array{id:int,title:string,url:string,indexed:bool} $page Resolved page. + * @param string $existing_context Knowledge base context already assembled. + * + * @return string Empty when no usable content was found. + */ + public function context_block( $page, $existing_context = '' ) { + $content = $this->page_content( $page, $existing_context ); + $label = '' !== $page['title'] ? $page['title'] . ' (' . $page['url'] . ')' : $page['url']; + + if ( '' === $content ) { + // The pinned chunks were all already retrieved; still tell the model + // which page the visitor is on so "this"-style questions resolve. + if ( $page['indexed'] ) { + return "\n ===CURRENT PAGE=== The visitor is currently viewing the page: " . $label . ' ===END CURRENT PAGE==='; + } + + return ''; + } + + return "\n ===START CURRENT PAGE=== The visitor is currently viewing this page: " . $label . '. ' . $content . ' ===END CURRENT PAGE==='; + } + + /** + * The page's content from the source matching how the page resolved: + * stored chunks for indexed posts, extracted content otherwise, keyed by + * post for posts and by URL for loop pages. + * + * @param array{id:int,title:string,url:string,indexed:bool} $page Resolved page. + * @param string $existing_context Knowledge base context already assembled. + * + * @return string + */ + private function page_content( $page, $existing_context = '' ) { + if ( $page['indexed'] ) { + return $this->pinned_chunks( $page['id'], $existing_context ); + } + + return $page['id'] ? $this->unindexed_content( $page['id'] ) : $this->url_content( $page['url'] ); + } + + /** + * Build the page portion of a Hyve Connect chat payload. + * + * Indexed pages send identity only (the platform holds their chunks and + * pins them server-side); unindexed pages also carry the extracted content. + * Loop pages resolve without a post id or title, so those keys are omitted. + * + * @param array{id:int,title:string,url:string,indexed:bool} $page Resolved page. + * + * @return array{url:string,id?:int,title?:string,content?:string} + */ + public function payload( $page ) { + $payload = [ 'url' => $page['url'] ]; + + if ( $page['id'] ) { + $payload['id'] = $page['id']; + } + + if ( '' !== $page['title'] ) { + $payload['title'] = $page['title']; + } + + if ( ! $page['indexed'] ) { + $content = $this->page_content( $page ); + + if ( '' !== $content ) { + $payload['content'] = $content; + } + } + + return $payload; + } + + /** + * Concatenate the page's existing knowledge base chunks, up to the token + * budget, skipping chunks the similarity search already put in context. + * + * @param int $post_id Page post ID. + * @param string $existing_context Knowledge base context already assembled. + * + * @return string + */ + private function pinned_chunks( $post_id, $existing_context ) { + $chunks = DB_Table::instance()->get_chunks_by_post_id( $post_id ); + + if ( empty( $chunks ) ) { + return ''; + } + + $limit = self::token_limit(); + $tokens = 0; + $parts = []; + + foreach ( $chunks as $chunk ) { + if ( '' !== $existing_context && false !== strpos( $existing_context, $chunk->post_content ) ) { + continue; + } + + $count = intval( $chunk->token_count ); + + if ( $limit < $tokens + $count ) { + break; + } + + $parts[] = $chunk->post_content; + $tokens += $count; + } + + return implode( "\n", $parts ); + } + + /** + * Content for a page that is missing from the knowledge base. + * + * Extraction (a loopback render + scrape in Pro) is slow, so the trimmed + * result is cached per post, keyed on the post's modified date with a TTL + * on top, because builder output can change without touching the post + * itself. Failures are cached briefly so a broken loopback is not hammered + * on every chat turn. + * + * @param int $post_id Page post ID. + * + * @return string + */ + private function unindexed_content( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof \WP_Post ) { + return ''; + } + + $cached = get_transient( self::CACHE_PREFIX . $post_id ); + + if ( is_array( $cached ) && isset( $cached['modified'], $cached['content'] ) && $cached['modified'] === $post->post_modified_gmt ) { + return (string) $cached['content']; + } + + /** + * Filters the raw content used as page context for a page that is not + * in the knowledge base. + * + * Returning a non-empty string (HTML or plain text) supplies the page + * content directly; the Pro plugin hooks its scraper here to extract + * the rendered page, which captures builder and product output that + * never reaches post_content. + * + * @since 1.5.0 + * + * @param string $content The page content. Default empty. + * @param \WP_Post|null $post The post being resolved, or null for a loop page (home, archive). + * @param string $url The page URL to extract. + */ + $content = (string) apply_filters( 'hyve_page_context_content', '', $post, (string) get_permalink( $post ) ); + $content = $this->trim_to_limit( wp_strip_all_tags( $content ) ); + + set_transient( + self::CACHE_PREFIX . $post_id, + [ + 'modified' => $post->post_modified_gmt, + 'content' => $content, + ], + '' === $content ? 15 * MINUTE_IN_SECONDS : $this->cache_ttl() + ); + + return $content; + } + + /** + * Content for a loop page (home, archive) that resolves to no single post. + * + * Same extraction and trimming as posts, but keyed by the normalized URL + * since there is no post to key on, and TTL-only invalidation since there + * is no modified date to compare. + * + * @param string $url The normalized, same-origin page URL. + * + * @return string + */ + private function url_content( $url ) { + $key = self::CACHE_PREFIX . 'url_' . md5( $url ); + $cached = get_transient( $key ); + + if ( is_array( $cached ) && isset( $cached['content'] ) ) { + return (string) $cached['content']; + } + + /** This filter is documented in inc/Page_Context.php */ + $content = (string) apply_filters( 'hyve_page_context_content', '', null, $url ); + $content = $this->trim_to_limit( wp_strip_all_tags( $content ) ); + + set_transient( + $key, + [ 'content' => $content ], + '' === $content ? 15 * MINUTE_IN_SECONDS : $this->cache_ttl() + ); + + return $content; + } + + /** + * How long extracted page content stays cached. + * + * @return int + */ + private function cache_ttl() { + /** + * Filters how long extracted page content is cached, in seconds. + * + * @since 1.5.0 + * + * @param int $ttl Cache lifetime. Default one day. + */ + return (int) apply_filters( 'hyve_page_context_cache_ttl', DAY_IN_SECONDS ); + } + + /** + * Trim text to the page context token budget, on sentence boundaries. + * + * @param string $text Plain text. + * + * @return string + */ + private function trim_to_limit( $text ) { + $text = trim( (string) preg_replace( '/\s+/', ' ', $text ) ); + + if ( '' === $text ) { + return ''; + } + + $chunks = Tokenizer::create_chunks( $text, self::token_limit() ); + + if ( empty( $chunks ) ) { + return ''; + } + + $trimmed = trim( $chunks[0] ); + + // create_chunks re-appends the sentence separator, so text that already + // ended in a period comes back with two. + if ( '..' === substr( $trimmed, -2 ) && '...' !== substr( $trimmed, -3 ) ) { + $trimmed = substr( $trimmed, 0, -1 ); + } + + return $trimmed; + } +} diff --git a/inc/Stream.php b/inc/Stream.php index 589428fa..2fc0fc36 100644 --- a/inc/Stream.php +++ b/inc/Stream.php @@ -174,6 +174,7 @@ public function stream() { $context = isset( $job['context'] ) ? $job['context'] : ''; $is_test = ! empty( $job['is_test'] ); $source_post_ids = isset( $job['source_post_ids'] ) ? $job['source_post_ids'] : []; + $page = isset( $job['page'] ) && is_array( $job['page'] ) ? $job['page'] : null; Main::add_labels_to_default_settings(); $settings = Main::get_settings(); @@ -185,7 +186,7 @@ public function stream() { // `delta` events (plus kb_state/sources), so they are forwarded straight // through and the terminal reply is assembled from job_complete. if ( Hyve_Connect::is_active() ) { - $this->stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message ); + $this->stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message, $page ); exit; } @@ -307,16 +308,17 @@ public function stream() { * from which the plugin assembles the final reply, owning presentation * (default_message on unanswered, source links) exactly as self-hosted. * - * @param string $message Visitor message. - * @param string $thread_id Thread id (empty starts a conversation). - * @param int|null $record_id Existing conversation record. - * @param bool $is_test Admin live-preview chat (not recorded). - * @param array $settings Plugin settings. - * @param string $default_message Fallback shown when the model cannot answer. + * @param string $message Visitor message. + * @param string $thread_id Thread id (empty starts a conversation). + * @param int|null $record_id Existing conversation record. + * @param bool $is_test Admin live-preview chat (not recorded). + * @param array $settings Plugin settings. + * @param string $default_message Fallback shown when the model cannot answer. + * @param array|null $page Current page payload (see Page_Context::payload()). * * @return void */ - private function stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message ) { + private function stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message, $page = null ) { $sources = []; $on_event = function ( $event, $data ) use ( &$sources ) { @@ -328,15 +330,18 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se // kb_state is informational here; the terminal event drives the outcome. }; - $result = Hyve_Connect::instance()->stream_chat( - [ - 'message' => $message, - 'thread_id' => '' !== $thread_id ? $thread_id : null, - 'settings' => Hyve_Connect::chat_settings( $settings ), - 'stream' => true, - ], - $on_event - ); + $payload = [ + 'message' => $message, + 'thread_id' => '' !== $thread_id ? $thread_id : null, + 'settings' => Hyve_Connect::chat_settings( $settings ), + 'stream' => true, + ]; + + if ( ! empty( $page ) ) { + $payload['page'] = $page; + } + + $result = Hyve_Connect::instance()->stream_chat( $payload, $on_event ); if ( is_wp_error( $result ) ) { $error_code = $result->get_error_code(); diff --git a/src/backend/screens/ChatBehavior.js b/src/backend/screens/ChatBehavior.js index 0d4f6430..76dae537 100644 --- a/src/backend/screens/ChatBehavior.js +++ b/src/backend/screens/ChatBehavior.js @@ -213,6 +213,7 @@ const ConversationCard = () => { const { setSetting } = useDispatch( 'hyve' ); const soundEnabled = Boolean( settings.sound_enabled ?? true ); + const pageAwareness = Boolean( settings.page_context_enabled ?? true ); return ( { } /> + + { __( 'Page awareness', 'hyve-lite' ) }{ ' ' } + { ! isPro && } + + } + description={ __( + 'Let the assistant see the page a visitor is chatting from, so questions like "how much does this cost?" get answered from that page, even when it isn\'t in the Knowledge Base.', + 'hyve-lite' + ) } + > + + setSetting( 'page_context_enabled', Boolean( value ) ) + } + /> + post_id = $this->factory()->post->create( + [ + 'post_title' => 'Blue Widget', + 'post_status' => 'publish', + 'post_content' => 'A widget.', + ] + ); + + add_filter( 'hyve_page_context_enabled', '__return_true' ); + } + + /** + * Reset filters and cached content between tests. + */ + public function tear_down() { + remove_all_filters( 'hyve_page_context_enabled' ); + remove_all_filters( 'hyve_page_context_content' ); + delete_transient( Page_Context::CACHE_PREFIX . $this->post_id ); + delete_transient( Page_Context::CACHE_PREFIX . 'url_' . md5( $this->home_root_url() ) ); + + parent::tear_down(); + } + + /** + * The site root as the normalized resolver produces it. + * + * @return string + */ + private function home_root_url() { + return home_url( '/' ); + } + + /** + * Build a chat request carrying a page URL. + * + * @param string $url The page_url param. + * + * @return WP_REST_Request + */ + private function chat_request( $url ) { + $request = new WP_REST_Request( 'POST', '/hyve/v1/chat' ); + $request->set_param( 'page_url', $url ); + + return $request; + } + + /** + * The feature is off unless the filter enables it (Pro's toggle). + */ + public function test_disabled_by_default() { + remove_all_filters( 'hyve_page_context_enabled' ); + + $this->assertNull( Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ) ); + } + + /** + * A published post's URL resolves with identity and indexed state. + */ + public function test_resolves_public_post() { + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + + $this->assertSame( $this->post_id, $page['id'] ); + $this->assertSame( 'Blue Widget', $page['title'] ); + $this->assertFalse( $page['indexed'] ); + + update_post_meta( $this->post_id, '_hyve_added', 1 ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + $this->assertTrue( $page['indexed'] ); + } + + /** + * Non-public content must never resolve: the chat endpoint is public, so + * drafts, private and password-protected posts would leak otherwise. + */ + public function test_rejects_non_public_posts() { + $draft = $this->factory()->post->create( [ 'post_status' => 'draft' ] ); + $private = $this->factory()->post->create( [ 'post_status' => 'private' ] ); + $guarded = $this->factory()->post->create( + [ + 'post_status' => 'publish', + 'post_password' => 'secret', + ] + ); + $instance = Page_Context::instance(); + + $this->assertNull( $instance->for_request( $this->chat_request( home_url( '/?p=' . $draft ) ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( home_url( '/?p=' . $private ) ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( home_url( '/?p=' . $guarded ) ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( home_url( '/?p=999999' ) ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( '' ) ) ); + } + + /** + * Only same-origin URLs are accepted: the endpoint is public and triggers + * a server-side fetch, so foreign hosts, ports and schemes are rejected. + */ + public function test_rejects_cross_origin_urls() { + $instance = Page_Context::instance(); + + $this->assertNull( $instance->for_request( $this->chat_request( 'https://evil.com/?p=' . $this->post_id ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( 'http://example.org:8443/' ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( 'ftp://example.org/' ) ) ); + $this->assertNull( $instance->for_request( $this->chat_request( 'not a url' ) ) ); + } + + /** + * Unindexed pages use the content filter, and the trimmed result is cached + * so extraction does not rerun on every chat turn. + */ + public function test_unindexed_content_is_cached() { + $runs = 0; + + add_filter( + 'hyve_page_context_content', + function () use ( &$runs ) { + ++$runs; + + return '

Plans start at $9 per month.

'; + } + ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + $block = Page_Context::instance()->context_block( $page ); + + $this->assertStringContainsString( 'Plans start at $9 per month.', $block ); + $this->assertStringContainsString( '===START CURRENT PAGE===', $block ); + $this->assertStringContainsString( 'Blue Widget', $block ); + $this->assertSame( 1, $runs ); + + Page_Context::instance()->context_block( $page ); + $this->assertSame( 1, $runs ); + } + + /** + * Editing the post invalidates the cached content. + */ + public function test_cache_invalidated_on_post_update() { + $runs = 0; + + add_filter( + 'hyve_page_context_content', + function () use ( &$runs ) { + ++$runs; + + return 'Version ' . $runs; + } + ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + + $this->assertStringContainsString( 'Version 1', Page_Context::instance()->context_block( $page ) ); + + // Stale the cached record's modified marker, as a post edit would. + $key = Page_Context::CACHE_PREFIX . $this->post_id; + $cached = get_transient( $key ); + + $cached['modified'] = '2000-01-01 00:00:00'; + set_transient( $key, $cached, DAY_IN_SECONDS ); + + $this->assertStringContainsString( 'Version 2', Page_Context::instance()->context_block( $page ) ); + $this->assertSame( 2, $runs ); + } + + /** + * A failed extraction produces no block and the chat turn proceeds. + */ + public function test_no_content_yields_empty_block() { + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + + $this->assertSame( '', Page_Context::instance()->context_block( $page ) ); + } + + /** + * A loop page (home, archive) resolves without a post: the extractor gets + * the normalized URL, the result is cached by URL, and query strings do + * not fragment the cache. + */ + public function test_loop_page_resolves_by_url() { + $runs = 0; + $seen_url = null; + $seen_post = 'unset'; + + add_filter( + 'hyve_page_context_content', + function ( $content, $post, $url ) use ( &$runs, &$seen_url, &$seen_post ) { + ++$runs; + $seen_url = $url; + $seen_post = $post; + + return 'Latest widget news and reviews.'; + }, + 10, + 3 + ); + + $page = Page_Context::instance()->for_request( $this->chat_request( home_url( '/' ) ) ); + + $this->assertSame( 0, $page['id'] ); + $this->assertSame( '', $page['title'] ); + $this->assertSame( $this->home_root_url(), $page['url'] ); + $this->assertFalse( $page['indexed'] ); + + $block = Page_Context::instance()->context_block( $page ); + + $this->assertStringContainsString( 'Latest widget news and reviews.', $block ); + $this->assertStringContainsString( $this->home_root_url(), $block ); + $this->assertSame( $this->home_root_url(), $seen_url ); + $this->assertNull( $seen_post ); + + // Query-string variants normalize onto the same cache entry. + $paged = Page_Context::instance()->for_request( $this->chat_request( home_url( '/?s=widgets&paged=2' ) ) ); + + $this->assertSame( $this->home_root_url(), $paged['url'] ); + + Page_Context::instance()->context_block( $paged ); + $this->assertSame( 1, $runs ); + } + + /** + * Indexed pages pin their stored chunks, skipping chunks the similarity + * search already placed in context. + */ + public function test_indexed_page_pins_chunks() { + update_post_meta( $this->post_id, '_hyve_added', 1 ); + + $table = DB_Table::instance(); + $table->insert( + [ + 'post_id' => $this->post_id, + 'post_title' => 'Blue Widget', + 'post_content' => 'The widget costs $19.', + 'token_count' => 10, + 'post_status' => 'processed', + ] + ); + $table->insert( + [ + 'post_id' => $this->post_id, + 'post_title' => 'Blue Widget', + 'post_content' => 'Free shipping worldwide.', + 'token_count' => 10, + 'post_status' => 'processed', + ] + ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + $block = Page_Context::instance()->context_block( $page ); + + $this->assertStringContainsString( 'The widget costs $19.', $block ); + $this->assertStringContainsString( 'Free shipping worldwide.', $block ); + + // A chunk already in the retrieved context is not pinned twice. + $existing = '===START POST=== Blue Widget - The widget costs $19. ===END POST==='; + $block = Page_Context::instance()->context_block( $page, $existing ); + + $this->assertStringNotContainsString( 'The widget costs $19.', $block ); + $this->assertStringContainsString( 'Free shipping worldwide.', $block ); + } + + /** + * When every chunk is already in context, the page is still identified. + */ + public function test_indexed_page_identity_marker_when_all_deduped() { + update_post_meta( $this->post_id, '_hyve_added', 1 ); + + DB_Table::instance()->insert( + [ + 'post_id' => $this->post_id, + 'post_title' => 'Blue Widget', + 'post_content' => 'The widget costs $19.', + 'token_count' => 10, + 'post_status' => 'processed', + ] + ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + $block = Page_Context::instance()->context_block( $page, 'The widget costs $19.' ); + + $this->assertStringContainsString( '===CURRENT PAGE===', $block ); + $this->assertStringContainsString( 'Blue Widget', $block ); + $this->assertStringNotContainsString( 'costs $19', $block ); + } + + /** + * The Connect payload carries identity always, content only when unindexed, + * and no post id for loop pages. + */ + public function test_connect_payload_shape() { + add_filter( + 'hyve_page_context_content', + function () { + return 'Plans start at $9.'; + } + ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + $payload = Page_Context::instance()->payload( $page ); + + $this->assertSame( $this->post_id, $payload['id'] ); + $this->assertSame( 'Blue Widget', $payload['title'] ); + $this->assertSame( 'Plans start at $9.', $payload['content'] ); + + update_post_meta( $this->post_id, '_hyve_added', 1 ); + + $page = Page_Context::instance()->for_request( $this->chat_request( get_permalink( $this->post_id ) ) ); + $payload = Page_Context::instance()->payload( $page ); + + $this->assertArrayNotHasKey( 'content', $payload ); + + $page = Page_Context::instance()->for_request( $this->chat_request( home_url( '/' ) ) ); + $payload = Page_Context::instance()->payload( $page ); + + $this->assertArrayNotHasKey( 'id', $payload ); + $this->assertArrayNotHasKey( 'title', $payload ); + $this->assertSame( $this->home_root_url(), $payload['url'] ); + $this->assertSame( 'Plans start at $9.', $payload['content'] ); + } +}