From ec01e1eeed50bdd01087f34460366c870115805a Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Sat, 18 Jul 2026 12:05:13 +0530 Subject: [PATCH 1/2] Add page awareness: ground the chatbot in the visitor's current page The widget sends the page URL with each chat turn. The server accepts same-origin URLs only, resolves them to a post when possible, pins the post's KB chunks into the context when indexed, and falls back to extracted page content (cached per post or per URL) when not. Connect mode forwards a page object so the platform can do the same. Pro-only, behind the hyve_page_context_enabled filter. Also adopt the platform-minted thread_id from the streaming done event so Connect chat history survives page navigation. --- inc/API.php | 61 ++- inc/DB_Table.php | 20 + inc/Hyve_Connect.php | 4 +- inc/Page_Context.php | 443 +++++++++++++++++++++ inc/Stream.php | 39 +- src/backend/screens/ChatBehavior.js | 27 ++ src/frontend/App.js | 22 + tests/php/unit/tests/test-page-context.php | 349 ++++++++++++++++ 8 files changed, 933 insertions(+), 32 deletions(-) create mode 100644 inc/Page_Context.php create mode 100644 tests/php/unit/tests/test-page-context.php diff --git a/inc/API.php b/inc/API.php index ec679f9a..c4a8589b 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 ) { @@ -2125,6 +2129,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 ); @@ -2174,21 +2179,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( @@ -2289,7 +2298,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' ); @@ -2298,6 +2307,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. @@ -2309,6 +2320,7 @@ private function prepare_chat( $request ) { 'thread_id' => $thread_id ? $thread_id : '', 'message' => $message, 'context' => '', + 'page' => $page ? Page_Context::instance()->payload( $page ) : null, ]; } @@ -2322,7 +2334,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' ) ); @@ -2357,6 +2374,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 @@ -2367,6 +2401,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 3d3247f5..e659aa60 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..dd8a055f --- /dev/null +++ b/inc/Page_Context.php @@ -0,0 +1,443 @@ +> $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. + * + * @param array{id:int,title:string,url:string,indexed:bool} $page Resolved page. + * + * @return array{id:int,title:string,url: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 a193e7b8..fde47f99 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'] ); + } +} From cdbd8f3425cf7bc51b3a7142b6b8bbac792031b8 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Sat, 18 Jul 2026 13:27:05 +0530 Subject: [PATCH 2/2] fix: phpstan errors --- inc/Page_Context.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/Page_Context.php b/inc/Page_Context.php index dd8a055f..ac5221ac 100644 --- a/inc/Page_Context.php +++ b/inc/Page_Context.php @@ -241,10 +241,11 @@ private function page_content( $page, $existing_context = '' ) { * * 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{id:int,title:string,url:string,content?:string} + * @return array{url:string,id?:int,title?:string,content?:string} */ public function payload( $page ) { $payload = [ 'url' => $page['url'] ];