| @@ -27,23 +27,54 @@ | ||
| 27 | 27 | */ |
| 28 | 28 | const CACHE_TTL = HOUR_IN_SECONDS; |
| 29 | 29 | |
| 30 | 30 | /** |
| 31 | + * Transient key for the cached content digest (per-locale). Kept separate from the | |
| 32 | + * base profile so the lightweight detect() path never carries the heavier excerpts. | |
| 33 | + * @var string | |
| 34 | + */ | |
| 35 | + const CONTENT_CACHE_KEY = 'betterdocs_site_content'; | |
| 36 | + | |
| 37 | + /** | |
| 31 | 38 | * Build the full site profile. |
| 32 | 39 | * |
| 33 | - * @param bool $fresh Skip the cache and recompute. | |
| 40 | + * @param bool $fresh Skip the cache and recompute. | |
| 41 | + * @param bool $with_content Also attach the richer `content` digest (real page/post/ | |
| 42 | + * product excerpts). Only the AI KB "outline" call needs it; | |
| 43 | + * detect() leaves it off to stay light. | |
| 34 | 44 | * @return array |
| 35 | 45 | */ |
| 36 | - public function build( $fresh = false ) { | |
| 46 | + public function build( $fresh = false, $with_content = false ) { | |
| 37 | 47 | $cache_key = self::CACHE_KEY . '_' . get_locale(); |
| 38 | 48 | |
| 49 | + $profile = null; | |
| 39 | 50 | if ( ! $fresh ) { |
| 40 | 51 | $cached = get_transient( $cache_key ); |
| 41 | 52 | if ( is_array( $cached ) ) { |
| 42 | - return $cached; | |
| 53 | + $profile = $cached; | |
| 43 | 54 | } |
| 44 | 55 | } |
| 45 | 56 | |
| 57 | + if ( null === $profile ) { | |
| 58 | + $profile = $this->build_base(); | |
| 59 | + set_transient( $cache_key, $profile, self::CACHE_TTL ); | |
| 60 | + } | |
| 61 | + | |
| 62 | + if ( $with_content ) { | |
| 63 | + // Computed + cached separately so it never bloats the base profile transient. | |
| 64 | + $profile['content'] = $this->content_digest( $fresh ); | |
| 65 | + } | |
| 66 | + | |
| 67 | + return $profile; | |
| 68 | + } | |
| 69 | + | |
| 70 | + /** | |
| 71 | + * Compute the base (content-free) profile. Split out of build() so the heavier | |
| 72 | + * content digest can be attached on demand without polluting the base cache. | |
| 73 | + * | |
| 74 | + * @return array | |
| 75 | + */ | |
| 76 | + private function build_base() { | |
| 46 | 77 | $site = [ |
| 47 | 78 | 'title' => sanitize_text_field( get_bloginfo( 'name' ) ), |
| 48 | 79 | 'tagline' => sanitize_text_field( get_bloginfo( 'description' ) ), |
| 49 | 80 | 'url' => esc_url_raw( home_url() ), |
| @@ -71,16 +102,232 @@ | ||
| 71 | 102 | * Filter the computed site profile before it is cached/returned. |
| 72 | 103 | * |
| 73 | 104 | * @param array $profile |
| 74 | 105 | */ |
| 75 | - $profile = apply_filters( 'betterdocs_site_profile', $profile ); | |
| 106 | + return apply_filters( 'betterdocs_site_profile', $profile ); | |
| 107 | + } | |
| 76 | 108 | |
| 77 | - set_transient( $cache_key, $profile, self::CACHE_TTL ); | |
| 109 | + /** | |
| 110 | + * Real, privacy-safe, size-bounded content excerpts that let the AI understand what | |
| 111 | + * the site actually does (not just page/nav titles). Only published, public content | |
| 112 | + * — already on the public web — is excerpted: no drafts, private, user or order data. | |
| 113 | + * | |
| 114 | + * Cached separately from the base profile and gated by filters so it can be trimmed | |
| 115 | + * or disabled per site. | |
| 116 | + * | |
| 117 | + * @param bool $fresh Skip the content cache and recompute. | |
| 118 | + * @return array | |
| 119 | + */ | |
| 120 | + public function content_digest( $fresh = false ) { | |
| 121 | + /** | |
| 122 | + * Allow a site to opt out of sending real content excerpts to the AI proxy. | |
| 123 | + * | |
| 124 | + * @param bool $enabled | |
| 125 | + */ | |
| 126 | + if ( ! apply_filters( 'betterdocs_site_profile_include_content', true ) ) { | |
| 127 | + return []; | |
| 128 | + } | |
| 78 | 129 | |
| 79 | - return $profile; | |
| 130 | + $cache_key = self::CONTENT_CACHE_KEY . '_' . get_locale(); | |
| 131 | + if ( ! $fresh ) { | |
| 132 | + $cached = get_transient( $cache_key ); | |
| 133 | + if ( is_array( $cached ) ) { | |
| 134 | + return $cached; | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + $content = [ | |
| 139 | + 'summary' => $this->home_about_summary(), | |
| 140 | + 'pages' => $this->page_excerpts(), | |
| 141 | + 'posts' => $this->post_excerpts(), | |
| 142 | + ]; | |
| 143 | + | |
| 144 | + if ( class_exists( 'WooCommerce' ) ) { | |
| 145 | + $content['products'] = $this->product_excerpts(); | |
| 146 | + } | |
| 147 | + | |
| 148 | + $content = array_filter( | |
| 149 | + $content, | |
| 150 | + function ( $value ) { | |
| 151 | + return ! ( is_array( $value ) && empty( $value ) ) && '' !== $value; | |
| 152 | + } | |
| 153 | + ); | |
| 154 | + | |
| 155 | + /** | |
| 156 | + * Filter the computed content digest before it is cached/returned. | |
| 157 | + * | |
| 158 | + * @param array $content | |
| 159 | + * @param SiteProfiler $profiler | |
| 160 | + */ | |
| 161 | + $content = apply_filters( 'betterdocs_site_profile_content', $content, $this ); | |
| 162 | + | |
| 163 | + set_transient( $cache_key, $content, self::CACHE_TTL ); | |
| 164 | + | |
| 165 | + return $content; | |
| 80 | 166 | } |
| 81 | 167 | |
| 82 | 168 | /** |
| 169 | + * Short plain-text summary drawn from the front page and an About-style page — the | |
| 170 | + * best single signal of "what is this site about". | |
| 171 | + * | |
| 172 | + * @return string | |
| 173 | + */ | |
| 174 | + private function home_about_summary() { | |
| 175 | + $parts = []; | |
| 176 | + | |
| 177 | + $front_id = (int) get_option( 'page_on_front' ); | |
| 178 | + if ( 'page' === get_option( 'show_on_front' ) && $front_id > 0 ) { | |
| 179 | + $ex = $this->excerpt_of( get_post_field( 'post_content', $front_id ), 300 ); | |
| 180 | + if ( '' !== $ex ) { | |
| 181 | + $parts[] = $ex; | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + foreach ( [ 'about', 'about-us', 'company', 'who-we-are' ] as $slug ) { | |
| 186 | + $page = get_page_by_path( $slug ); | |
| 187 | + if ( $page instanceof \WP_Post && 'publish' === $page->post_status ) { | |
| 188 | + $ex = $this->excerpt_of( $page->post_content, 300 ); | |
| 189 | + if ( '' !== $ex ) { | |
| 190 | + $parts[] = $ex; | |
| 191 | + break; | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + return $this->excerpt_of( implode( ' ', $parts ), 600 ); | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * Title + short excerpt for the first few published pages (About, Pricing, Features…). | |
| 201 | + * | |
| 202 | + * @return array | |
| 203 | + */ | |
| 204 | + private function page_excerpts() { | |
| 205 | + // Exclude WooCommerce's utility pages (Shop/Cart/Checkout/My account) and the | |
| 206 | + // privacy page: they carry no subject anyone documents or FAQs about, but on a | |
| 207 | + // store they have the lowest menu_order and so used to consume the whole (small) | |
| 208 | + // budget — starving the real content pages (About, Pricing, Financing, Delivery…). | |
| 209 | + $exclude = array_filter( | |
| 210 | + [ | |
| 211 | + (int) get_option( 'woocommerce_shop_page_id' ), | |
| 212 | + (int) get_option( 'woocommerce_cart_page_id' ), | |
| 213 | + (int) get_option( 'woocommerce_checkout_page_id' ), | |
| 214 | + (int) get_option( 'woocommerce_myaccount_page_id' ), | |
| 215 | + (int) get_option( 'wp_page_for_privacy_policy' ), | |
| 216 | + ] | |
| 217 | + ); | |
| 218 | + | |
| 219 | + $pages = get_posts( | |
| 220 | + [ | |
| 221 | + 'post_type' => 'page', | |
| 222 | + 'posts_per_page' => 15, | |
| 223 | + 'orderby' => 'menu_order', | |
| 224 | + 'order' => 'ASC', | |
| 225 | + 'post_status' => 'publish', | |
| 226 | + 'post__not_in' => $exclude, | |
| 227 | + ] | |
| 228 | + ); | |
| 229 | + | |
| 230 | + $out = []; | |
| 231 | + foreach ( $pages as $page ) { | |
| 232 | + $title = sanitize_text_field( get_the_title( $page ) ); | |
| 233 | + if ( '' === $title ) { | |
| 234 | + continue; | |
| 235 | + } | |
| 236 | + $out[] = [ | |
| 237 | + 'title' => $title, | |
| 238 | + 'excerpt' => $this->excerpt_of( $page->post_content, 150 ), | |
| 239 | + ]; | |
| 240 | + } | |
| 241 | + | |
| 242 | + return array_values( $out ); | |
| 243 | + } | |
| 244 | + | |
| 245 | + /** | |
| 246 | + * Title + short excerpt for recent published blog posts — signals real topics. | |
| 247 | + * | |
| 248 | + * @return array | |
| 249 | + */ | |
| 250 | + private function post_excerpts() { | |
| 251 | + $posts = get_posts( | |
| 252 | + [ | |
| 253 | + 'post_type' => 'post', | |
| 254 | + 'posts_per_page' => 8, | |
| 255 | + 'orderby' => 'date', | |
| 256 | + 'order' => 'DESC', | |
| 257 | + 'post_status' => 'publish', | |
| 258 | + ] | |
| 259 | + ); | |
| 260 | + | |
| 261 | + $out = []; | |
| 262 | + foreach ( $posts as $post ) { | |
| 263 | + $title = sanitize_text_field( get_the_title( $post ) ); | |
| 264 | + if ( '' === $title ) { | |
| 265 | + continue; | |
| 266 | + } | |
| 267 | + $raw = '' !== trim( (string) $post->post_excerpt ) ? $post->post_excerpt : $post->post_content; | |
| 268 | + $out[] = [ | |
| 269 | + 'title' => $title, | |
| 270 | + 'excerpt' => $this->excerpt_of( $raw, 120 ), | |
| 271 | + ]; | |
| 272 | + } | |
| 273 | + | |
| 274 | + return array_values( $out ); | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * Title + short description for a few recent WooCommerce products. | |
| 279 | + * | |
| 280 | + * @return array | |
| 281 | + */ | |
| 282 | + private function product_excerpts() { | |
| 283 | + $products = get_posts( | |
| 284 | + [ | |
| 285 | + 'post_type' => 'product', | |
| 286 | + 'posts_per_page' => 8, | |
| 287 | + 'orderby' => 'date', | |
| 288 | + 'order' => 'DESC', | |
| 289 | + 'post_status' => 'publish', | |
| 290 | + ] | |
| 291 | + ); | |
| 292 | + | |
| 293 | + $out = []; | |
| 294 | + foreach ( $products as $product ) { | |
| 295 | + $title = sanitize_text_field( get_the_title( $product ) ); | |
| 296 | + if ( '' === $title ) { | |
| 297 | + continue; | |
| 298 | + } | |
| 299 | + $raw = '' !== trim( (string) $product->post_excerpt ) ? $product->post_excerpt : $product->post_content; | |
| 300 | + $out[] = [ | |
| 301 | + 'title' => $title, | |
| 302 | + 'excerpt' => $this->excerpt_of( $raw, 120 ), | |
| 303 | + ]; | |
| 304 | + } | |
| 305 | + | |
| 306 | + return array_values( $out ); | |
| 307 | + } | |
| 308 | + | |
| 309 | + /** | |
| 310 | + * Turn raw post content into a clean, bounded, single-line plain-text excerpt. | |
| 311 | + * Strips shortcodes + tags (handles page-builder markup) and collapses whitespace. | |
| 312 | + * | |
| 313 | + * @param string $raw | |
| 314 | + * @param int $chars | |
| 315 | + * @return string | |
| 316 | + */ | |
| 317 | + private function excerpt_of( $raw, $chars ) { | |
| 318 | + $text = wp_strip_all_tags( strip_shortcodes( (string) $raw ) ); | |
| 319 | + $text = trim( preg_replace( '/\s+/', ' ', $text ) ); | |
| 320 | + if ( '' === $text ) { | |
| 321 | + return ''; | |
| 322 | + } | |
| 323 | + if ( mb_strlen( $text ) > $chars ) { | |
| 324 | + $text = rtrim( mb_substr( $text, 0, $chars ) ) . '…'; | |
| 325 | + } | |
| 326 | + return sanitize_text_field( $text ); | |
| 327 | + } | |
| 328 | + | |
| 329 | + /** | |
| 83 | 330 | * Primary site type, keyed off active plugins (highest signal). |
| 84 | 331 | * First match wins. |
| 85 | 332 | * |
| 86 | 333 | * @return string |
| @@ -613,8 +860,9 @@ | ||
| 613 | 860 | * @return void |
| 614 | 861 | */ |
| 615 | 862 | public function flush() { |
| 616 | 863 | delete_transient( self::CACHE_KEY . '_' . get_locale() ); |
| 864 | + delete_transient( self::CONTENT_CACHE_KEY . '_' . get_locale() ); | |
| 617 | 865 | } |
| 618 | 866 | |
| 619 | 867 | /** |
| 620 | 868 | * Active plugins on this site (network-active included). |