| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\REST; |
| 4 |
|
| 5 |
use WP_REST_Request; |
| 6 |
use WPDeveloper\BetterDocs\Core\BaseAPI; |
| 7 |
use WPDeveloper\BetterDocs\Core\SiteProfiler; |
| 8 |
use WPDeveloper\BetterDocs\Core\Settings; |
| 9 |
use WPDeveloper\BetterDocs\Utils\AIUsage; |
| 10 |
use WPDeveloper\BetterDocs\Dependencies\DI\Container; |
| 11 |
|
| 12 |
/** |
| 13 |
* REST surface for the AI "Generate Sample Docs" feature (Phase 2). |
| 14 |
* |
| 15 |
* Routes (namespace betterdocs/v1): |
| 16 |
* POST sample-docs/detect → SiteProfiler profile + locally suggested categories |
| 17 |
* POST sample-docs/generate → signs + forwards to the hosted proxy, returns articles |
| 18 |
* POST sample-docs/insert → DocBuilder creates the terms/posts (Phase 4) |
| 19 |
* POST sample-docs/undo → DocBuilder removes the flagged sample content (Phase 4) |
| 20 |
* |
| 21 |
* @since 4.5.3 |
| 22 |
*/ |
| 23 |
class SampleDocs extends BaseAPI { |
| 24 |
/** Hard caps mirrored on the proxy (Phase 0 contract) — legacy single-call docs. */ |
| 25 |
const MAX_CATEGORIES = 3; |
| 26 |
const MAX_ARTICLES_PER_CATEGORY = 3; |
| 27 |
|
| 28 |
/** |
| 29 |
* FAQ caps — mirrored on the proxy. Upper bounds only: the AI designs the groups |
| 30 |
* and the questions from the site's real content and right-sizes below these. The |
| 31 |
* ceiling is generous (6 groups / 18 questions) so a content-rich site can get a |
| 32 |
* real FAQ, but the AI is told to produce only as many as the content warrants and |
| 33 |
* never to pad to the cap — a small site still gets a small FAQ. |
| 34 |
*/ |
| 35 |
const MAX_FAQ_CATEGORIES = 6; |
| 36 |
const MAX_FAQ_ARTICLES_PER_CATEGORY = 3; |
| 37 |
const MAX_FAQ_ARTICLES_TOTAL = 18; |
| 38 |
|
| 39 |
/** |
| 40 |
* Product FAQ caps. Higher than the general FAQ on purpose: this tab writes ONE group |
| 41 |
* per real product category (and attaches it to that category), so the group cap has |
| 42 |
* to cover every category the store actually has — with a smaller group cap, a |
| 43 |
* store with many product categories simply lost half of them. |
| 44 |
*/ |
| 45 |
const MAX_PRODUCT_FAQ_CATEGORIES = 12; |
| 46 |
const MAX_PRODUCT_FAQ_ARTICLES_TOTAL = 36; |
| 47 |
|
| 48 |
/** |
| 49 |
* How many detected site subjects (doc categories, key pages, product categories) |
| 50 |
* to offer as FAQ scope chips. They are topics to cover, NOT groups — the AI decides |
| 51 |
* how many groups a set of topics warrants (folding several into one, or dropping a |
| 52 |
* thin one) and produces at most MAX_FAQ_CATEGORIES groups of its own design. |
| 53 |
*/ |
| 54 |
const MAX_FAQ_TOPICS = 6; |
| 55 |
|
| 56 |
/** Product-category chips offered on the WooCommerce tab (one group is written per kept chip). */ |
| 57 |
const MAX_PRODUCT_FAQ_TOPICS = 12; |
| 58 |
|
| 59 |
/** |
| 60 |
* Deep "full knowledge base" caps (docs only) — mirrored on the proxy. These are |
| 61 |
* upper bounds only: the AI right-sizes the KB to what the site actually needs and |
| 62 |
* may return fewer. |
| 63 |
*/ |
| 64 |
const MAX_KB_CATEGORIES = 6; |
| 65 |
const MAX_KB_ARTICLES_PER_CATEGORY = 4; |
| 66 |
const MAX_KB_ARTICLES_TOTAL = 18; |
| 67 |
|
| 68 |
/** Default per-category palette/icons (matches the design mockup). */ |
| 69 |
const PALETTE = [ '#00B884', '#3B82F6', '#8B5CF6', '#F59E0B', '#0EA5E9', '#EF4444' ]; |
| 70 |
|
| 71 |
/** |
| 72 |
* @var SiteProfiler |
| 73 |
*/ |
| 74 |
protected $profiler; |
| 75 |
|
| 76 |
public function __construct( Settings $settings, Container $container, SiteProfiler $profiler ) { |
| 77 |
parent::__construct( $settings, $container ); |
| 78 |
$this->profiler = $profiler; |
| 79 |
} |
| 80 |
|
| 81 |
public function permission_check(): bool { |
| 82 |
return current_user_can( 'edit_docs_settings' ); |
| 83 |
} |
| 84 |
|
| 85 |
public function register() { |
| 86 |
$this->post( 'sample-docs/detect', [ $this, 'detect' ] ); |
| 87 |
$this->post( 'sample-docs/generate', [ $this, 'generate' ] ); |
| 88 |
// Deep KB (docs) — the multi-call outline→expand flow. |
| 89 |
$this->post( 'sample-docs/outline', [ $this, 'outline' ] ); |
| 90 |
$this->post( 'sample-docs/article', [ $this, 'article' ] ); |
| 91 |
$this->post( 'sample-docs/insert', [ $this, 'insert' ] ); |
| 92 |
$this->post( 'sample-docs/undo', [ $this, 'undo' ] ); |
| 93 |
} |
| 94 |
|
| 95 |
/** |
| 96 |
* Detection step — returns the site profile and locally-derived suggested |
| 97 |
* categories (no AI). Feeds the design's "detecting" + "detected profile" screens. |
| 98 |
*/ |
| 99 |
public function detect( WP_REST_Request $request ) { |
| 100 |
if ( ! $this->is_enabled() ) { |
| 101 |
return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 ); |
| 102 |
} |
| 103 |
|
| 104 |
$content_type = $this->content_type( $request ); |
| 105 |
|
| 106 |
// Every tab now reads its topic chips off the site's REAL content — the General FAQ |
| 107 |
// and docs tabs from doc categories + key pages, the WooCommerce tab from product |
| 108 |
// categories — so detect() needs the content digest for all of them. It's cached |
| 109 |
// separately (and the docs outline call reuses the same cache), so this is one scan |
| 110 |
// per site, not one per click. |
| 111 |
$profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ), true ); |
| 112 |
|
| 113 |
/** |
| 114 |
* Telemetry: site detection ran. |
| 115 |
* |
| 116 |
* @param string $type Detected site type. |
| 117 |
* @param string $content_type docs|faq |
| 118 |
*/ |
| 119 |
do_action( 'betterdocs_sample_docs_detected', $profile['type'] ?? 'general', $content_type ); |
| 120 |
|
| 121 |
return $this->success( |
| 122 |
[ |
| 123 |
'profile' => $profile, |
| 124 |
'categories' => $this->suggested_categories( $profile, $content_type ), |
| 125 |
] |
| 126 |
); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Generation step — signs the request and forwards it to the hosted proxy, |
| 131 |
* which calls OpenAI and returns structured categories + articles. |
| 132 |
*/ |
| 133 |
public function generate( WP_REST_Request $request ) { |
| 134 |
if ( ! $this->is_enabled() ) { |
| 135 |
return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 ); |
| 136 |
} |
| 137 |
|
| 138 |
$content_type = $this->content_type( $request ); |
| 139 |
$profile = (array) $request->get_param( 'profile' ); |
| 140 |
// For a general FAQ the incoming list is the owner's kept TOPICS (scope), not the |
| 141 |
// groups to produce — so it is capped at MAX_FAQ_TOPICS, which is separate from the |
| 142 |
// group cap. The AI decides how many of at most MAX_FAQ_CATEGORIES groups those |
| 143 |
// topics warrant, folding several into one or dropping a thin one. |
| 144 |
$categories = $this->sanitize_categories( |
| 145 |
(array) $request->get_param( 'categories' ), |
| 146 |
false, |
| 147 |
'docs' === $content_type |
| 148 |
? $this->max_categories( $content_type ) |
| 149 |
: ( 'product_faq' === $content_type ? self::MAX_PRODUCT_FAQ_TOPICS : self::MAX_FAQ_TOPICS ), |
| 150 |
$this->max_articles( $content_type ) |
| 151 |
); |
| 152 |
|
| 153 |
if ( empty( $profile ) ) { |
| 154 |
$profile = $this->profiler->build(); |
| 155 |
} |
| 156 |
|
| 157 |
// Product FAQs used to be generated deterministically from the store's WooCommerce |
| 158 |
// SETTINGS (StoreFaqContent) — which produced generic store policy (Payments, |
| 159 |
// Shipping, Returns) and said nothing about what the store actually sells. They |
| 160 |
// now go through the AI like every other type, grounded in the real products and |
| 161 |
// product categories from the content digest. |
| 162 |
|
| 163 |
// FAQ / product FAQ (and the legacy docs fallback) are grounded in the site's REAL |
| 164 |
// content — |
| 165 |
// the same homepage/About/page/post/product excerpts the deep docs flow uses — |
| 166 |
// so questions and answers are specific to what the site offers, not generic. |
| 167 |
// Enrich the profile when the client didn't send the content digest. |
| 168 |
if ( empty( $profile['content'] ) ) { |
| 169 |
$profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ), true ); |
| 170 |
} |
| 171 |
if ( empty( $categories ) ) { |
| 172 |
$categories = $this->suggested_categories( $profile, $content_type ); |
| 173 |
} |
| 174 |
|
| 175 |
// The store-wide "Shipping, Returns & Payments" group is offered on the WooCommerce |
| 176 |
// profile screen as a chip so the owner can see (and remove) it. It is NOT a product |
| 177 |
// category, so pull it out of what goes to the AI, and remember whether the owner |
| 178 |
// kept it — the deterministic group is only prepended below if they did. |
| 179 |
$include_store_wide = false; |
| 180 |
if ( 'product_faq' === $content_type ) { |
| 181 |
$kept = []; |
| 182 |
foreach ( $categories as $cat ) { |
| 183 |
if ( ! empty( $cat['all_products'] ) ) { |
| 184 |
$include_store_wide = true; |
| 185 |
} else { |
| 186 |
$kept[] = $cat; |
| 187 |
} |
| 188 |
} |
| 189 |
$categories = $kept; |
| 190 |
} |
| 191 |
|
| 192 |
// Subjects the owner removed on the profile screen must be skipped, not merely |
| 193 |
// left out of the hint list — the AI sees the whole content digest and would |
| 194 |
// otherwise write about them anyway. |
| 195 |
$scope = $this->topic_scope( $profile, $content_type, $categories ); |
| 196 |
|
| 197 |
$payload = [ |
| 198 |
'profile' => $profile, |
| 199 |
'categories' => $categories, |
| 200 |
'options' => [ |
| 201 |
'content_type' => $content_type, |
| 202 |
'maxCategories' => $this->max_categories( $content_type ), |
| 203 |
'maxArticlesPerCategory' => $this->max_articles( $content_type ), |
| 204 |
'locale' => isset( $profile['site']['locale'] ) ? $profile['site']['locale'] : get_locale(), |
| 205 |
// Optional one-line steer from the owner ("what should these FAQs cover?"). |
| 206 |
'intent' => sanitize_text_field( (string) $request->get_param( 'intent' ) ), |
| 207 |
'exclude_topics' => $scope['exclude'], |
| 208 |
], |
| 209 |
]; |
| 210 |
|
| 211 |
$response = $this->call_proxy( $payload, $content_type ); |
| 212 |
|
| 213 |
if ( is_wp_error( $response ) ) { |
| 214 |
$code = $response->get_error_code(); |
| 215 |
|
| 216 |
/** |
| 217 |
* Telemetry: generation failed (e.g. quota_exceeded, proxy_error). |
| 218 |
* |
| 219 |
* @param string $content_type docs|faq |
| 220 |
* @param string $code Error code. |
| 221 |
*/ |
| 222 |
do_action( 'betterdocs_sample_docs_generation_failed', $content_type, $code ); |
| 223 |
|
| 224 |
// NOTE: no store-policy fallback for the Product tab. StoreFaqContent generates |
| 225 |
// generic store policy (Payments/Shipping/Returns), which is exactly what the |
| 226 |
// product FAQ was fixed to stop producing — silently serving it on a proxy |
| 227 |
// outage would just reintroduce the bug under a different trigger. Surface the |
| 228 |
// error instead and let the user retry. |
| 229 |
|
| 230 |
// Typed errors the UI maps to the quota / fallback screens. |
| 231 |
$data = $response->get_error_data(); |
| 232 |
$status = is_array( $data ) && isset( $data['status'] ) ? $data['status'] : 502; |
| 233 |
|
| 234 |
return $this->error( $code, $response->get_error_message(), $status, [ 'fallback' => 'static' ] ); |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* Telemetry: generation succeeded. |
| 239 |
* |
| 240 |
* @param string $content_type docs|faq |
| 241 |
* @param int $count Number of categories returned. |
| 242 |
*/ |
| 243 |
$categories = $response['categories']; |
| 244 |
|
| 245 |
// Layer 1 (WooCommerce only): prepend ONE deterministic, settings-grounded |
| 246 |
// store-wide group (payments/shipping/returns/orders) flagged to show on every |
| 247 |
// product page. The AI writes the per-category product groups (Layer 2); the |
| 248 |
// store-wide policy answers come from the real Woo settings, not the AI, so they |
| 249 |
// can never invent a return window or gateway the store doesn't have. |
| 250 |
if ( 'product_faq' === $content_type && $include_store_wide ) { |
| 251 |
$store_wide = $this->store_wide_group( $profile ); |
| 252 |
if ( ! empty( $store_wide ) ) { |
| 253 |
array_unshift( $categories, $store_wide ); |
| 254 |
} |
| 255 |
} |
| 256 |
|
| 257 |
do_action( 'betterdocs_sample_docs_generated', $content_type, count( $categories ) ); |
| 258 |
|
| 259 |
// Usage telemetry: one successful generation, bucketed by content type |
| 260 |
// (docs/faq/product_faq). No single post here, so post_id = 0. |
| 261 |
AIUsage::record( 'sample_docs', 0, $content_type ); |
| 262 |
|
| 263 |
return $this->success( |
| 264 |
[ |
| 265 |
'content_type' => $content_type, |
| 266 |
'categories' => $categories, |
| 267 |
'meta' => isset( $response['meta'] ) ? $response['meta'] : [], |
| 268 |
] |
| 269 |
); |
| 270 |
} |
| 271 |
|
| 272 |
/** |
| 273 |
* The consolidated store-wide product FAQ group (Layer 1), tagged so the builder |
| 274 |
* flags it "show on all products". Built deterministically from the store's real |
| 275 |
* WooCommerce settings via StoreFaqContent — no AI, no hallucinated policy. |
| 276 |
* |
| 277 |
* @return array|null A sanitized category array with `all_products => true`, or null. |
| 278 |
*/ |
| 279 |
protected function store_wide_group( array $profile ) { |
| 280 |
$store = $this->store_faq(); |
| 281 |
if ( null === $store ) { |
| 282 |
return null; |
| 283 |
} |
| 284 |
|
| 285 |
$group = $store->generate_consolidated( $profile ); |
| 286 |
if ( empty( $group['articles'] ) ) { |
| 287 |
return null; |
| 288 |
} |
| 289 |
|
| 290 |
// Reuse the standard sanitizer (one group, its own question count — not the |
| 291 |
// per-category caps), then tag it for the all-products routing in the builder. |
| 292 |
$clean = $this->sanitize_categories( [ $group ], true, 1, count( $group['articles'] ) ); |
| 293 |
if ( empty( $clean[0] ) ) { |
| 294 |
return null; |
| 295 |
} |
| 296 |
|
| 297 |
$clean[0]['all_products'] = true; |
| 298 |
return $clean[0]; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Lazily resolve the deterministic store-FAQ generator (Layer 1 answers). |
| 303 |
* |
| 304 |
* @return \WPDeveloper\BetterDocs\Core\StoreFaqContent|null |
| 305 |
*/ |
| 306 |
protected function store_faq() { |
| 307 |
$class = 'WPDeveloper\\BetterDocs\\Core\\StoreFaqContent'; |
| 308 |
if ( ! class_exists( $class ) ) { |
| 309 |
return null; |
| 310 |
} |
| 311 |
return $this->container->get( $class ); |
| 312 |
} |
| 313 |
|
| 314 |
/** |
| 315 |
* Deep KB step 1 — design the whole knowledge base. Sends the content-enriched |
| 316 |
* site profile to the proxy's /outline endpoint and returns the information |
| 317 |
* architecture + a job_token the article step reuses. |
| 318 |
*/ |
| 319 |
public function outline( WP_REST_Request $request ) { |
| 320 |
if ( ! $this->is_enabled() ) { |
| 321 |
return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 ); |
| 322 |
} |
| 323 |
|
| 324 |
// The deep flow is documentation-only; FAQ/product_faq keep the single-call path. |
| 325 |
$profile = (array) $request->get_param( 'profile' ); |
| 326 |
if ( empty( $profile ) || empty( $profile['content'] ) ) { |
| 327 |
// Enrich with the real content digest the outline call needs. |
| 328 |
$profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ), true ); |
| 329 |
} |
| 330 |
|
| 331 |
$intent = sanitize_text_field( (string) $request->get_param( 'intent' ) ); |
| 332 |
|
| 333 |
// Honor the topics the owner kept on the profile screen. The outline call used to |
| 334 |
// send NO categories at all, so removing a chip did nothing to the generated KB — |
| 335 |
// the deep flow silently lost the "skip this category" behaviour the single-call |
| 336 |
// flow had. Both the kept and the REMOVED subjects go to the proxy: naming what to |
| 337 |
// skip is what actually keeps it out, since the AI still sees the whole content |
| 338 |
// digest and would otherwise design that category right back in. |
| 339 |
$scope = $this->topic_scope( $profile, 'docs', (array) $request->get_param( 'categories' ) ); |
| 340 |
|
| 341 |
$payload = [ |
| 342 |
'profile' => $profile, |
| 343 |
'options' => [ |
| 344 |
'content_type' => 'docs', |
| 345 |
'locale' => isset( $profile['site']['locale'] ) ? $profile['site']['locale'] : get_locale(), |
| 346 |
// Upper bounds only — the proxy prompt tells the AI to right-size the KB |
| 347 |
// to what the site genuinely needs and return fewer when appropriate. |
| 348 |
'max_categories' => self::MAX_KB_CATEGORIES, |
| 349 |
'max_articles' => self::MAX_KB_ARTICLES_TOTAL, |
| 350 |
'intent' => $intent, |
| 351 |
'topics' => $scope['include'], |
| 352 |
'exclude_topics' => $scope['exclude'], |
| 353 |
], |
| 354 |
]; |
| 355 |
|
| 356 |
$parsed = $this->proxy_request( 'outline', $payload, 30 ); |
| 357 |
if ( is_wp_error( $parsed ) ) { |
| 358 |
$data = $parsed->get_error_data(); |
| 359 |
$http = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502; |
| 360 |
$upstream = is_array( $data ) && isset( $data['upstream'] ) ? (int) $data['upstream'] : $http; |
| 361 |
do_action( 'betterdocs_sample_docs_generation_failed', 'docs', $parsed->get_error_code() ); |
| 362 |
return $this->error( $parsed->get_error_code(), $parsed->get_error_message(), $http, [ 'fallback' => 'static', 'proxy_status' => $upstream ] ); |
| 363 |
} |
| 364 |
|
| 365 |
if ( empty( $parsed['outline']['categories'] ) || empty( $parsed['job_token'] ) ) { |
| 366 |
return $this->error( 'proxy_error', __( 'The AI service returned an unexpected response.', 'betterdocs' ), 502 ); |
| 367 |
} |
| 368 |
|
| 369 |
$outline = $this->sanitize_outline( $parsed['outline'] ); |
| 370 |
|
| 371 |
do_action( 'betterdocs_sample_docs_generated', 'docs', count( $outline['categories'] ) ); |
| 372 |
|
| 373 |
// Usage telemetry: the deep-KB outline is the once-per-generation success point |
| 374 |
// for the docs flow (article() runs per-article and must NOT be counted). |
| 375 |
AIUsage::record( 'sample_docs', 0, 'docs' ); |
| 376 |
|
| 377 |
return $this->success( |
| 378 |
[ |
| 379 |
'content_type' => 'docs', |
| 380 |
'job_token' => sanitize_text_field( (string) $parsed['job_token'] ), |
| 381 |
'outline' => $outline, |
| 382 |
'meta' => isset( $parsed['meta'] ) ? $parsed['meta'] : [], |
| 383 |
] |
| 384 |
); |
| 385 |
} |
| 386 |
|
| 387 |
/** |
| 388 |
* Deep KB step 2 — expand one article of a previously issued outline. Thin pass- |
| 389 |
* through to the proxy's /article endpoint; the React flow loops it per index. |
| 390 |
*/ |
| 391 |
public function article( WP_REST_Request $request ) { |
| 392 |
if ( ! $this->is_enabled() ) { |
| 393 |
return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 ); |
| 394 |
} |
| 395 |
|
| 396 |
$job_token = sanitize_text_field( (string) $request->get_param( 'job_token' ) ); |
| 397 |
$index = (int) $request->get_param( 'index' ); |
| 398 |
|
| 399 |
if ( '' === $job_token || $index < 0 ) { |
| 400 |
return $this->error( 'bad_request', __( 'A job token and article index are required.', 'betterdocs' ), 400 ); |
| 401 |
} |
| 402 |
|
| 403 |
$parsed = $this->proxy_request( 'article', [ 'job_token' => $job_token, 'index' => $index ], 30 ); |
| 404 |
if ( is_wp_error( $parsed ) ) { |
| 405 |
$data = $parsed->get_error_data(); |
| 406 |
$http = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502; |
| 407 |
$upstream = is_array( $data ) && isset( $data['upstream'] ) ? (int) $data['upstream'] : $http; |
| 408 |
return $this->error( $parsed->get_error_code(), $parsed->get_error_message(), $http, [ 'proxy_status' => $upstream ] ); |
| 409 |
} |
| 410 |
|
| 411 |
if ( empty( $parsed['article']['content_html'] ) ) { |
| 412 |
return $this->error( 'proxy_error', __( 'The AI service returned an unexpected response.', 'betterdocs' ), 502 ); |
| 413 |
} |
| 414 |
|
| 415 |
return $this->success( |
| 416 |
[ |
| 417 |
'index' => isset( $parsed['index'] ) ? (int) $parsed['index'] : $index, |
| 418 |
'article' => $this->sanitize_kb_article( $parsed['article'] ), |
| 419 |
'meta' => isset( $parsed['meta'] ) ? $parsed['meta'] : [], |
| 420 |
] |
| 421 |
); |
| 422 |
} |
| 423 |
|
| 424 |
/** |
| 425 |
* Insert step — DocBuilder creates the terms/posts. Wired in Phase 4. |
| 426 |
*/ |
| 427 |
public function insert( WP_REST_Request $request ) { |
| 428 |
if ( ! $this->is_enabled() ) { |
| 429 |
return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 ); |
| 430 |
} |
| 431 |
|
| 432 |
$builder = $this->builder(); |
| 433 |
if ( null === $builder ) { |
| 434 |
return $this->error( 'not_implemented', __( 'Inserting sample docs is not available yet.', 'betterdocs' ), 501 ); |
| 435 |
} |
| 436 |
|
| 437 |
$content_type = $this->content_type( $request ); |
| 438 |
|
| 439 |
$raw = (array) $request->get_param( 'categories' ); |
| 440 |
|
| 441 |
// Docs come from the deep outline→expand flow (up to 8 categories × 6 articles); |
| 442 |
// the 3×3 sanitizer would silently truncate them. FAQ/product_faq keep the caps. |
| 443 |
if ( 'docs' === $content_type ) { |
| 444 |
$categories = $this->sanitize_kb_categories( $raw ); |
| 445 |
} elseif ( 'product_faq' === $content_type ) { |
| 446 |
// The store-wide group (Layer 1) is one extra group on top of the per-category |
| 447 |
// cap, so it must not count against it — split it out, cap the per-category |
| 448 |
// groups, then re-attach it. Otherwise the last product category is dropped. |
| 449 |
$store_wide = []; |
| 450 |
$per_cat = []; |
| 451 |
foreach ( $raw as $cat ) { |
| 452 |
if ( ! empty( $cat['all_products'] ) ) { |
| 453 |
$store_wide[] = $cat; |
| 454 |
} else { |
| 455 |
$per_cat[] = $cat; |
| 456 |
} |
| 457 |
} |
| 458 |
$categories = $this->sanitize_categories( |
| 459 |
$per_cat, |
| 460 |
true, |
| 461 |
$this->max_categories( $content_type ), |
| 462 |
$this->max_articles( $content_type ), |
| 463 |
$this->max_articles_total( $content_type ) |
| 464 |
); |
| 465 |
if ( ! empty( $store_wide[0] ) ) { |
| 466 |
$clean = $this->sanitize_categories( [ $store_wide[0] ], true, 1, count( (array) ( $store_wide[0]['articles'] ?? [] ) ) ); |
| 467 |
if ( ! empty( $clean[0] ) ) { |
| 468 |
$clean[0]['all_products'] = true; |
| 469 |
array_unshift( $categories, $clean[0] ); |
| 470 |
} |
| 471 |
} |
| 472 |
} else { |
| 473 |
$categories = $this->sanitize_categories( |
| 474 |
$raw, |
| 475 |
true, |
| 476 |
$this->max_categories( $content_type ), |
| 477 |
$this->max_articles( $content_type ), |
| 478 |
$this->max_articles_total( $content_type ) |
| 479 |
); |
| 480 |
} |
| 481 |
|
| 482 |
$result = $builder->build( $categories, $content_type ); |
| 483 |
|
| 484 |
if ( is_wp_error( $result ) ) { |
| 485 |
return $this->error( $result->get_error_code(), $result->get_error_message(), 400 ); |
| 486 |
} |
| 487 |
|
| 488 |
return $this->success( $result ); |
| 489 |
} |
| 490 |
|
| 491 |
/** |
| 492 |
* Undo step — remove exactly the sample content we created. Wired in Phase 4. |
| 493 |
*/ |
| 494 |
public function undo( WP_REST_Request $request ) { |
| 495 |
if ( ! $this->is_enabled() ) { |
| 496 |
return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 ); |
| 497 |
} |
| 498 |
|
| 499 |
$builder = $this->builder(); |
| 500 |
if ( null === $builder ) { |
| 501 |
return $this->error( 'not_implemented', __( 'Removing sample docs is not available yet.', 'betterdocs' ), 501 ); |
| 502 |
} |
| 503 |
|
| 504 |
$content_type = $this->content_type( $request ); |
| 505 |
return $this->success( $builder->undo( $content_type ) ); |
| 506 |
} |
| 507 |
|
| 508 |
/** |
| 509 |
* Lazily resolve the DocBuilder (added in Phase 4) so this class loads even |
| 510 |
* before the builder exists. |
| 511 |
* |
| 512 |
* @return \WPDeveloper\BetterDocs\Core\SampleDocBuilder|null |
| 513 |
*/ |
| 514 |
protected function builder() { |
| 515 |
$class = 'WPDeveloper\\BetterDocs\\Core\\SampleDocBuilder'; |
| 516 |
if ( ! class_exists( $class ) ) { |
| 517 |
return null; |
| 518 |
} |
| 519 |
return $this->container->get( $class ); |
| 520 |
} |
| 521 |
|
| 522 |
/* --------------------------------------------------------------------- */ |
| 523 |
/* Proxy plumbing */ |
| 524 |
/* --------------------------------------------------------------------- */ |
| 525 |
|
| 526 |
/** |
| 527 |
* Legacy single-call generation — sign + POST to the proxy and validate the |
| 528 |
* { categories, meta } shape (used by the FAQ path). |
| 529 |
* |
| 530 |
* @return array|\WP_Error Parsed { categories, meta } on success. |
| 531 |
*/ |
| 532 |
protected function call_proxy( array $payload, $content_type = 'docs' ) { |
| 533 |
$parsed = $this->proxy_request( '', $payload ); |
| 534 |
if ( is_wp_error( $parsed ) ) { |
| 535 |
return $parsed; |
| 536 |
} |
| 537 |
|
| 538 |
if ( empty( $parsed['categories'] ) || ! is_array( $parsed['categories'] ) ) { |
| 539 |
return $this->error( 'proxy_error', __( 'The AI service returned an unexpected response.', 'betterdocs' ), 502 ); |
| 540 |
} |
| 541 |
|
| 542 |
// Enforce caps + sanitize defensively on our side too — with THIS content type's |
| 543 |
// caps. Sanitizing an FAQ with the docs 3x3 defaults would silently throw away |
| 544 |
// every group and question the proxy right-sized beyond the third. |
| 545 |
$parsed['categories'] = $this->sanitize_categories( |
| 546 |
$parsed['categories'], |
| 547 |
true, |
| 548 |
$this->max_categories( $content_type ), |
| 549 |
$this->max_articles( $content_type ), |
| 550 |
$this->max_articles_total( $content_type ) |
| 551 |
); |
| 552 |
|
| 553 |
return $parsed; |
| 554 |
} |
| 555 |
|
| 556 |
/** |
| 557 |
* Sign + POST a payload to a hosted-proxy action ('' = legacy generate, 'outline', |
| 558 |
* 'article'), with one retry on transport failure. Returns the parsed JSON array on |
| 559 |
* a 2xx response, or a typed WP_Error otherwise. Shape validation is the caller's job. |
| 560 |
* |
| 561 |
* @param string $action Sub-path under v1/sample-docs ('' | 'outline' | 'article'). |
| 562 |
* @param array $payload Request body. |
| 563 |
* @param int $timeout_base Base HTTP timeout in seconds (per single OpenAI call). |
| 564 |
* @return array|\WP_Error |
| 565 |
*/ |
| 566 |
protected function proxy_request( $action, array $payload, $timeout_base = 20 ) { |
| 567 |
$url = $this->proxy_endpoint( $action ); |
| 568 |
$secret = $this->proxy_secret(); |
| 569 |
$body = wp_json_encode( $payload ); |
| 570 |
$timeout = $this->request_timeout( $timeout_base ); |
| 571 |
|
| 572 |
$args = [ |
| 573 |
'timeout' => $timeout, |
| 574 |
'headers' => [ |
| 575 |
'Content-Type' => 'application/json', |
| 576 |
'Accept' => 'application/json', |
| 577 |
'X-BetterDocs-Site' => esc_url_raw( home_url() ), |
| 578 |
'X-BetterDocs-License' => $this->license_key(), |
| 579 |
'X-BetterDocs-Signature' => hash_hmac( 'sha256', $body, $secret ), |
| 580 |
], |
| 581 |
'body' => $body, |
| 582 |
]; |
| 583 |
|
| 584 |
$attempts = 0; |
| 585 |
$response = null; |
| 586 |
while ( $attempts < 2 ) { |
| 587 |
$attempts++; |
| 588 |
|
| 589 |
// Give this attempt a fresh execution budget: without it a hung upstream |
| 590 |
// trips PHP's max_execution_time mid-cURL and the route dies with a raw |
| 591 |
// 500 critical error instead of the typed JSON the modal understands. |
| 592 |
$reset = function_exists( 'set_time_limit' ) && @set_time_limit( $timeout + 15 ); |
| 593 |
|
| 594 |
$response = wp_remote_post( $url, $args ); |
| 595 |
|
| 596 |
// Retry ONLY on a genuine transport failure (no HTTP response). A 5xx may |
| 597 |
// mean the proxy already called OpenAI and spent tokens, so re-POSTing |
| 598 |
// would risk double-billing — treat any received status as final. |
| 599 |
if ( ! is_wp_error( $response ) ) { |
| 600 |
break; |
| 601 |
} |
| 602 |
|
| 603 |
// If the time limit could not be reset (disabled by the host), a second |
| 604 |
// full-length attempt could still fatal mid-cURL — surface the transport |
| 605 |
// error instead of risking the retry. |
| 606 |
if ( ! $reset ) { |
| 607 |
break; |
| 608 |
} |
| 609 |
} |
| 610 |
|
| 611 |
if ( is_wp_error( $response ) ) { |
| 612 |
return $this->error( 'proxy_unreachable', __( 'Could not reach the BetterDocs AI service. Please try again.', 'betterdocs' ), 502, [ 'upstream' => 0 ] ); |
| 613 |
} |
| 614 |
|
| 615 |
$status = wp_remote_retrieve_response_code( $response ); |
| 616 |
$parsed = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 617 |
|
| 618 |
if ( 429 === $status || ( isset( $parsed['status'] ) && 'quota_exceeded' === $parsed['status'] ) ) { |
| 619 |
return $this->error( 'quota_exceeded', __( 'You have used your free AI generation for this site.', 'betterdocs' ), 429, [ 'upstream' => 429 ] ); |
| 620 |
} |
| 621 |
|
| 622 |
if ( $status >= 400 || ! is_array( $parsed ) ) { |
| 623 |
$message = isset( $parsed['message'] ) ? $parsed['message'] : __( 'The AI service returned an unexpected response.', 'betterdocs' ); |
| 624 |
// Preserve the real upstream status under a distinct key so callers (and the |
| 625 |
// UI) can special-case e.g. 404 (old proxy → classic fallback) or 410 (job |
| 626 |
// expired → regenerate); error() itself overwrites data['status'] with $status. |
| 627 |
return $this->error( 'proxy_error', $message, 502, [ 'upstream' => $status ? $status : 502 ] ); |
| 628 |
} |
| 629 |
|
| 630 |
return $parsed; |
| 631 |
} |
| 632 |
|
| 633 |
/** |
| 634 |
* HTTP timeout (seconds) for a proxy call, kept safely below PHP's |
| 635 |
* max_execution_time so a hung upstream returns a clean WP_Error (typed |
| 636 |
* JSON + static fallback in the wizard) instead of fataling mid-cURL. |
| 637 |
* |
| 638 |
* @param int $base Base timeout in seconds for a single OpenAI call. |
| 639 |
* @return int |
| 640 |
*/ |
| 641 |
protected function request_timeout( $base = 20 ) { |
| 642 |
$timeout = max( 5, (int) $base ); |
| 643 |
$max_exec = (int) ini_get( 'max_execution_time' ); |
| 644 |
|
| 645 |
if ( $max_exec > 0 ) { |
| 646 |
$timeout = min( $timeout, max( 5, $max_exec - 10 ) ); |
| 647 |
} |
| 648 |
|
| 649 |
/** Filter the HTTP timeout (seconds) for hosted AI proxy requests. */ |
| 650 |
return (int) apply_filters( 'betterdocs_ai_proxy_timeout', $timeout ); |
| 651 |
} |
| 652 |
|
| 653 |
/** |
| 654 |
* Full proxy endpoint URL for an action ('' | 'outline' | 'article'). |
| 655 |
* |
| 656 |
* @return string |
| 657 |
*/ |
| 658 |
protected function proxy_endpoint( $action = '' ) { |
| 659 |
$base = get_option( 'betterdocs_ai_proxy_url', 'https://api.betterdocs.co/ai' ); |
| 660 |
/** Filter the hosted proxy base URL. */ |
| 661 |
$base = apply_filters( 'betterdocs_ai_proxy_url', $base ); |
| 662 |
$url = trailingslashit( $base ) . 'v1/sample-docs'; |
| 663 |
return '' !== $action ? $url . '/' . ltrim( (string) $action, '/' ) : $url; |
| 664 |
} |
| 665 |
|
| 666 |
protected function proxy_secret() { |
| 667 |
$secret = get_option( 'betterdocs_ai_proxy_secret', 'betterdocs-local-dev-secret' ); |
| 668 |
/** Filter the HMAC shared secret used to sign proxy requests. */ |
| 669 |
return apply_filters( 'betterdocs_ai_proxy_secret', $secret ); |
| 670 |
} |
| 671 |
|
| 672 |
protected function license_key() { |
| 673 |
/** Filter the license key sent to the proxy for per-site identity. */ |
| 674 |
return apply_filters( 'betterdocs_ai_proxy_license', (string) get_option( 'betterdocs_pro_licenses', '' ) ); |
| 675 |
} |
| 676 |
|
| 677 |
/* --------------------------------------------------------------------- */ |
| 678 |
/* Helpers */ |
| 679 |
/* --------------------------------------------------------------------- */ |
| 680 |
|
| 681 |
protected function is_enabled() { |
| 682 |
return (bool) $this->settings->get( 'enable_ai_sample_docs', true ); |
| 683 |
} |
| 684 |
|
| 685 |
protected function content_type( WP_REST_Request $request ) { |
| 686 |
$type = $request->get_param( 'content_type' ); |
| 687 |
if ( 'faq' === $type || 'product_faq' === $type ) { |
| 688 |
return $type; |
| 689 |
} |
| 690 |
return 'docs'; |
| 691 |
} |
| 692 |
|
| 693 |
/** |
| 694 |
* Per-content-type category cap. Product FAQs cover the four store-ops groups |
| 695 |
* (Payments & Billing, Shipping & Delivery, Returns & Refunds, Orders & Account); |
| 696 |
* a general FAQ is right-sized by the AI up to the FAQ cap; docs keep the legacy |
| 697 |
* single-call cap (the real KB goes through outline→expand). |
| 698 |
* |
| 699 |
* @return int |
| 700 |
*/ |
| 701 |
protected function max_categories( $content_type ) { |
| 702 |
if ( 'product_faq' === $content_type ) { |
| 703 |
return self::MAX_PRODUCT_FAQ_CATEGORIES; |
| 704 |
} |
| 705 |
return 'docs' === $content_type ? self::MAX_CATEGORIES : self::MAX_FAQ_CATEGORIES; |
| 706 |
} |
| 707 |
|
| 708 |
/** |
| 709 |
* Per-content-type cap on entries within one category/group. |
| 710 |
* |
| 711 |
* @return int |
| 712 |
*/ |
| 713 |
protected function max_articles( $content_type ) { |
| 714 |
return 'docs' === $content_type ? self::MAX_ARTICLES_PER_CATEGORY : self::MAX_FAQ_ARTICLES_PER_CATEGORY; |
| 715 |
} |
| 716 |
|
| 717 |
/** |
| 718 |
* Per-content-type cap on total entries (0 = no separate total cap). |
| 719 |
* |
| 720 |
* @return int |
| 721 |
*/ |
| 722 |
protected function max_articles_total( $content_type ) { |
| 723 |
if ( 'product_faq' === $content_type ) { |
| 724 |
return self::MAX_PRODUCT_FAQ_ARTICLES_TOTAL; |
| 725 |
} |
| 726 |
return 'docs' === $content_type ? 0 : self::MAX_FAQ_ARTICLES_TOTAL; |
| 727 |
} |
| 728 |
|
| 729 |
/** |
| 730 |
* Deterministic, type-aware suggested categories (no AI) for the profile screen. |
| 731 |
* |
| 732 |
* @return array |
| 733 |
*/ |
| 734 |
protected function suggested_categories( array $profile, $content_type ) { |
| 735 |
// WooCommerce Product FAQ: the chips are the store's REAL product categories — |
| 736 |
// this tab writes FAQs about what the store SELLS. (It used to show the canned |
| 737 |
// store-policy groups from StoreFaqContent: Payments, Shipping, Returns…) |
| 738 |
if ( 'product_faq' === $content_type ) { |
| 739 |
return $this->product_topics( $profile ); |
| 740 |
} |
| 741 |
|
| 742 |
// General FAQ AND docs both show the SUBJECTS actually detected on the site (doc |
| 743 |
// categories, key pages) as scope chips — no canned lists. Docs used to show a |
| 744 |
// hardcoded preset structure ("Getting Started / Shipping & Delivery / Product |
| 745 |
// Guides") with placeholder article titles, which the profile screen counted as a |
| 746 |
// fixed "3 categories, 9 docs". That count was always misleading: the deep |
| 747 |
// outline flow designs and right-sizes the REAL knowledge base from the site |
| 748 |
// content, so the preset numbers never matched what got generated. The owner |
| 749 |
// prunes the detected subjects to scope generation; the proxy designs the rest. |
| 750 |
return $this->detected_topics( $profile ); |
| 751 |
} |
| 752 |
|
| 753 |
/** |
| 754 |
* Subjects actually detected ON the site, for the GENERAL FAQ profile screen — doc |
| 755 |
* categories first (what the site already documents is what people ask about), then |
| 756 |
* real key pages (Pricing, Security, Integrations…). These are SUBJECTS, not FAQ |
| 757 |
* group names: the AI designs the groups, the questions and the answers itself. |
| 758 |
* Pruning a chip scopes the FAQ. |
| 759 |
* |
| 760 |
* Product categories are NOT included: they are the WooCommerce tab's material |
| 761 |
* (see product_topics()), and a general site FAQ has no business offering to write |
| 762 |
* about "Hoodies". |
| 763 |
* |
| 764 |
* Returns [] on a site with no usable content — the AI then works from the site |
| 765 |
* profile alone rather than from a canned list. |
| 766 |
* |
| 767 |
* @return array |
| 768 |
*/ |
| 769 |
/** |
| 770 |
* Subjects for the WooCommerce (Product FAQ) tab: what the store actually SELLS. |
| 771 |
* |
| 772 |
* Real product categories first (they map 1:1 onto the FAQ group the AI designs, so |
| 773 |
* the group can then be assigned to that product category and show on those product |
| 774 |
* pages), falling back to product names on a store with no categories. Never store |
| 775 |
* policy — a shopper on a product page asks about the product. |
| 776 |
* |
| 777 |
* @return array |
| 778 |
*/ |
| 779 |
protected function product_topics( array $profile ) { |
| 780 |
$woo = isset( $profile['woocommerce'] ) && is_array( $profile['woocommerce'] ) ? $profile['woocommerce'] : []; |
| 781 |
$content = isset( $profile['content'] ) && is_array( $profile['content'] ) ? $profile['content'] : []; |
| 782 |
|
| 783 |
$names = []; |
| 784 |
|
| 785 |
if ( ! empty( $woo['product_categories'] ) ) { |
| 786 |
$names = array_map( 'strval', (array) $woo['product_categories'] ); |
| 787 |
} |
| 788 |
|
| 789 |
// No product categories (a small store selling a handful of items): fall back to |
| 790 |
// the products themselves. |
| 791 |
if ( empty( $names ) ) { |
| 792 |
if ( ! empty( $content['products'] ) && is_array( $content['products'] ) ) { |
| 793 |
foreach ( $content['products'] as $product ) { |
| 794 |
if ( ! empty( $product['title'] ) ) { |
| 795 |
$names[] = (string) $product['title']; |
| 796 |
} |
| 797 |
} |
| 798 |
} elseif ( ! empty( $woo['sample_products'] ) ) { |
| 799 |
$names = array_map( 'strval', (array) $woo['sample_products'] ); |
| 800 |
} |
| 801 |
} |
| 802 |
|
| 803 |
$chips = $this->topic_chips( $names, self::MAX_PRODUCT_FAQ_TOPICS ); |
| 804 |
|
| 805 |
// Offer the always-available store-wide group as the FIRST chip, so the owner can |
| 806 |
// see — and, by removing it, opt out of — the deterministic "Shipping, Returns & |
| 807 |
// Payments" group that shows on every product page. It is NOT a product category; |
| 808 |
// the `all_products` flag tells generate() to route it to the store-wide layer |
| 809 |
// rather than the AI. |
| 810 |
$store = $this->store_faq(); |
| 811 |
if ( $store ) { |
| 812 |
$def = $store->consolidated_definition(); |
| 813 |
array_unshift( |
| 814 |
$chips, |
| 815 |
[ |
| 816 |
'id' => 'sd_storewide', |
| 817 |
'name' => $def['name'], |
| 818 |
'icon' => isset( $def['icon'] ) ? sanitize_key( $def['icon'] ) : 'truck', |
| 819 |
'color' => self::PALETTE[0], |
| 820 |
'all_products' => true, |
| 821 |
// Real question titles so the profile screen shows a count. |
| 822 |
'articles' => array_values( (array) $def['questions'] ), |
| 823 |
] |
| 824 |
); |
| 825 |
} |
| 826 |
|
| 827 |
return $chips; |
| 828 |
} |
| 829 |
|
| 830 |
protected function detected_topics( array $profile ) { |
| 831 |
$topics = isset( $profile['topics'] ) && is_array( $profile['topics'] ) ? $profile['topics'] : []; |
| 832 |
$content = isset( $profile['content'] ) && is_array( $profile['content'] ) ? $profile['content'] : []; |
| 833 |
|
| 834 |
$names = []; |
| 835 |
|
| 836 |
// 1. What the site already documents. |
| 837 |
if ( ! empty( $topics['doc_categories'] ) ) { |
| 838 |
$names = array_merge( $names, array_map( 'strval', (array) $topics['doc_categories'] ) ); |
| 839 |
} |
| 840 |
|
| 841 |
// 2. Real key pages — minus the boilerplate every WP site has, which nobody |
| 842 |
// writes an FAQ group about. |
| 843 |
if ( ! empty( $content['pages'] ) && is_array( $content['pages'] ) ) { |
| 844 |
foreach ( $content['pages'] as $page ) { |
| 845 |
$title = isset( $page['title'] ) ? (string) $page['title'] : ''; |
| 846 |
if ( '' !== $title && ! $this->is_boilerplate_page( $title ) ) { |
| 847 |
$names[] = $title; |
| 848 |
} |
| 849 |
} |
| 850 |
} |
| 851 |
|
| 852 |
// NOTE: product categories are deliberately NOT topics here. They belong to the |
| 853 |
// WooCommerce (Product FAQ) tab, which is generated per product category — pulling |
| 854 |
// them into the General FAQ made a general site FAQ offer to write about |
| 855 |
// "Clothing", "Hoodies", "Music". |
| 856 |
|
| 857 |
return $this->topic_chips( $names ); |
| 858 |
} |
| 859 |
|
| 860 |
/** |
| 861 |
* Turn a raw list of subject names into profile-screen chips: de-duped |
| 862 |
* case-insensitively (first spelling wins) and bounded to MAX_FAQ_TOPICS. |
| 863 |
* |
| 864 |
* @return array |
| 865 |
*/ |
| 866 |
protected function topic_chips( array $names, $max = self::MAX_FAQ_TOPICS ) { |
| 867 |
$max = max( 1, (int) $max ); |
| 868 |
$seen = []; |
| 869 |
$clean = []; |
| 870 |
foreach ( $names as $name ) { |
| 871 |
$name = trim( wp_strip_all_tags( (string) $name ) ); |
| 872 |
$key = strtolower( $name ); |
| 873 |
if ( '' === $name || isset( $seen[ $key ] ) ) { |
| 874 |
continue; |
| 875 |
} |
| 876 |
$seen[ $key ] = true; |
| 877 |
$clean[] = $name; |
| 878 |
if ( count( $clean ) >= $max ) { |
| 879 |
break; |
| 880 |
} |
| 881 |
} |
| 882 |
|
| 883 |
$cats = []; |
| 884 |
foreach ( $clean as $i => $name ) { |
| 885 |
$cats[] = [ |
| 886 |
'id' => sanitize_key( 'sd_' . $i ), |
| 887 |
'name' => sanitize_text_field( $name ), |
| 888 |
'icon' => 'help', |
| 889 |
'color' => self::PALETTE[ $i % count( self::PALETTE ) ], |
| 890 |
// No seeded questions: the AI writes them. The FAQ profile screen counts |
| 891 |
// topics, not questions, so nothing needs a placeholder here. |
| 892 |
'articles' => [], |
| 893 |
]; |
| 894 |
} |
| 895 |
|
| 896 |
return $cats; |
| 897 |
} |
| 898 |
|
| 899 |
/** |
| 900 |
* Pages that are site *plumbing* — a cart, a login form, an index — and so carry no |
| 901 |
* subject anyone asks a question about. |
| 902 |
* |
| 903 |
* Policy pages (privacy, terms, refunds) are deliberately NOT skipped: they are |
| 904 |
* plumbing for documentation but they are prime FAQ material ("Do you sell my data?", |
| 905 |
* "Can I get a refund?"). Skipping them meant a site with no docs yet — the normal |
| 906 |
* case when generating samples — detected zero topics, while the AI, which reads the |
| 907 |
* whole content digest rather than these chips, went on to build "Privacy & Data" and |
| 908 |
* "Terms & Licensing" groups from them anyway. |
| 909 |
* |
| 910 |
* @return bool |
| 911 |
*/ |
| 912 |
protected function is_boilerplate_page( $title ) { |
| 913 |
$skip = [ 'home', 'homepage', 'front page', 'sample page', 'blog', 'news', 'shop', 'store', 'cart', 'checkout', 'my account', 'account', 'login', 'log in', 'register', 'sign up', 'search results', '404', 'page not found' ]; |
| 914 |
return in_array( strtolower( trim( wp_strip_all_tags( (string) $title ) ) ), $skip, true ); |
| 915 |
} |
| 916 |
|
| 917 |
/** |
| 918 |
* What the owner kept, and what they REMOVED, on the profile screen. |
| 919 |
* |
| 920 |
* Removing a chip must actually skip that subject — for docs, FAQ and product FAQ |
| 921 |
* alike. Sending only the kept list isn't enough: the AI also sees the site's full |
| 922 |
* content digest, so a removed subject happily reappears unless it is named as |
| 923 |
* off-limits. The removed set is derived server-side by diffing the deterministic |
| 924 |
* suggestion list against what the client sent back, so no client change (and no |
| 925 |
* trust in the client) is needed. |
| 926 |
* |
| 927 |
* @param array $kept Categories the client sent back (each with a 'name'). |
| 928 |
* @param string $content_type docs|faq|product_faq |
| 929 |
* @return array { include: string[], exclude: string[] } |
| 930 |
*/ |
| 931 |
protected function topic_scope( array $profile, $content_type, array $kept ) { |
| 932 |
$suggested = $this->suggested_categories( $profile, $content_type ); |
| 933 |
|
| 934 |
$kept_names = []; |
| 935 |
foreach ( $kept as $cat ) { |
| 936 |
$name = is_array( $cat ) ? ( isset( $cat['name'] ) ? $cat['name'] : '' ) : $cat; |
| 937 |
$name = trim( wp_strip_all_tags( (string) $name ) ); |
| 938 |
if ( '' !== $name ) { |
| 939 |
$kept_names[ $this->normalize_category_name( $name ) ] = $name; |
| 940 |
} |
| 941 |
} |
| 942 |
|
| 943 |
$exclude = []; |
| 944 |
foreach ( $suggested as $cat ) { |
| 945 |
// The store-wide chip is not a real topic the AI writes about (it's the |
| 946 |
// deterministic Layer-1 group), so never push it into the AI's exclude list. |
| 947 |
if ( is_array( $cat ) && ! empty( $cat['all_products'] ) ) { |
| 948 |
continue; |
| 949 |
} |
| 950 |
$name = isset( $cat['name'] ) ? (string) $cat['name'] : ''; |
| 951 |
if ( '' !== $name && ! isset( $kept_names[ $this->normalize_category_name( $name ) ] ) ) { |
| 952 |
$exclude[] = $name; |
| 953 |
} |
| 954 |
} |
| 955 |
|
| 956 |
return [ |
| 957 |
'include' => array_values( $kept_names ), |
| 958 |
'exclude' => $exclude, |
| 959 |
]; |
| 960 |
} |
| 961 |
|
| 962 |
/** |
| 963 |
* Keep only the generated categories the user left selected on the profile |
| 964 |
* screen, matched by normalized name. Falls back to the full generated set when |
| 965 |
* the selection is empty or nothing matches, so we never return zero categories. |
| 966 |
* |
| 967 |
* @param array $generated Categories produced by the deterministic generator. |
| 968 |
* @param array $selected The user's kept categories (each with a 'name'). |
| 969 |
* @return array |
| 970 |
*/ |
| 971 |
protected function filter_selected_categories( array $generated, array $selected ) { |
| 972 |
if ( empty( $selected ) ) { |
| 973 |
return $generated; |
| 974 |
} |
| 975 |
|
| 976 |
$wanted = []; |
| 977 |
foreach ( $selected as $cat ) { |
| 978 |
if ( is_array( $cat ) && ! empty( $cat['name'] ) ) { |
| 979 |
$wanted[ $this->normalize_category_name( $cat['name'] ) ] = true; |
| 980 |
} |
| 981 |
} |
| 982 |
if ( empty( $wanted ) ) { |
| 983 |
return $generated; |
| 984 |
} |
| 985 |
|
| 986 |
$filtered = array_values( |
| 987 |
array_filter( |
| 988 |
$generated, |
| 989 |
function ( $group ) use ( $wanted ) { |
| 990 |
return ! empty( $group['name'] ) && isset( $wanted[ $this->normalize_category_name( $group['name'] ) ] ); |
| 991 |
} |
| 992 |
) |
| 993 |
); |
| 994 |
|
| 995 |
return ! empty( $filtered ) ? $filtered : $generated; |
| 996 |
} |
| 997 |
|
| 998 |
/** |
| 999 |
* Normalize a category name for loose matching between the user's selection and |
| 1000 |
* the generated set (case/whitespace/tag-insensitive). |
| 1001 |
* |
| 1002 |
* @return string |
| 1003 |
*/ |
| 1004 |
protected function normalize_category_name( $name ) { |
| 1005 |
return strtolower( trim( wp_strip_all_tags( (string) $name ) ) ); |
| 1006 |
} |
| 1007 |
|
| 1008 |
/** |
| 1009 |
* Sanitize + cap an incoming categories array (used both for the user's edited |
| 1010 |
* list and the proxy response). |
| 1011 |
* |
| 1012 |
* @param bool $with_content Keep article body HTML (proxy response) vs titles only. |
| 1013 |
* @param int $max Category cap (defaults to MAX_CATEGORIES; FAQ uses more). |
| 1014 |
* @param int $max_articles Per-category entry cap. |
| 1015 |
* @param int $max_total Total entry cap across all categories (0 = none). |
| 1016 |
* @return array |
| 1017 |
*/ |
| 1018 |
protected function sanitize_categories( array $categories, $with_content = false, $max = self::MAX_CATEGORIES, $max_articles = self::MAX_ARTICLES_PER_CATEGORY, $max_total = 0 ) { |
| 1019 |
$max = max( 1, (int) $max ); |
| 1020 |
$max_articles = max( 1, (int) $max_articles ); |
| 1021 |
$max_total = max( 0, (int) $max_total ); |
| 1022 |
$total = 0; |
| 1023 |
$clean = []; |
| 1024 |
foreach ( array_slice( $categories, 0, $max ) as $i => $cat ) { |
| 1025 |
if ( empty( $cat['name'] ) || ( $max_total > 0 && $total >= $max_total ) ) { |
| 1026 |
continue; |
| 1027 |
} |
| 1028 |
|
| 1029 |
$articles = []; |
| 1030 |
$raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : []; |
| 1031 |
foreach ( array_slice( $raw, 0, $max_articles ) as $article ) { |
| 1032 |
if ( $max_total > 0 && $total >= $max_total ) { |
| 1033 |
break; |
| 1034 |
} |
| 1035 |
if ( is_array( $article ) ) { |
| 1036 |
$entry = [ |
| 1037 |
'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ), |
| 1038 |
'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ), |
| 1039 |
]; |
| 1040 |
if ( $with_content ) { |
| 1041 |
$entry['content_html'] = wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' ); |
| 1042 |
} |
| 1043 |
if ( '' !== $entry['title'] ) { |
| 1044 |
$articles[] = $entry; |
| 1045 |
$total++; |
| 1046 |
} |
| 1047 |
} else { |
| 1048 |
$title = sanitize_text_field( $article ); |
| 1049 |
if ( '' !== $title ) { |
| 1050 |
$articles[] = $with_content ? [ 'title' => $title, 'content_html' => '', 'excerpt' => '' ] : $title; |
| 1051 |
$total++; |
| 1052 |
} |
| 1053 |
} |
| 1054 |
} |
| 1055 |
|
| 1056 |
$clean[] = [ |
| 1057 |
'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ), |
| 1058 |
'name' => sanitize_text_field( $cat['name'] ), |
| 1059 |
'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '', |
| 1060 |
'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book', |
| 1061 |
'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ], |
| 1062 |
// Product FAQ: the WooCommerce product category this group is about, so the |
| 1063 |
// builder can attach the group to it (the FAQ then shows on those product |
| 1064 |
// pages instead of nowhere). |
| 1065 |
'product_category' => isset( $cat['product_category'] ) ? sanitize_text_field( $cat['product_category'] ) : '', |
| 1066 |
// Product FAQ Layer 1: the consolidated store-wide group, flagged so the |
| 1067 |
// builder assigns it to "all products" rather than one category. |
| 1068 |
'all_products' => ! empty( $cat['all_products'] ), |
| 1069 |
'articles' => $articles, |
| 1070 |
]; |
| 1071 |
} |
| 1072 |
|
| 1073 |
return $clean; |
| 1074 |
} |
| 1075 |
|
| 1076 |
/* --------------------------------------------------------------------- */ |
| 1077 |
/* Deep KB sanitizers */ |
| 1078 |
/* --------------------------------------------------------------------- */ |
| 1079 |
|
| 1080 |
/** |
| 1081 |
* Sanitize the proxy's designed outline for the client: clamp counts, keep the |
| 1082 |
* per-article specs (index/type/slug/summary), and assign a display color per |
| 1083 |
* category. Does NOT run the 3×3 sanitizer. |
| 1084 |
* |
| 1085 |
* @return array { total_articles, categories:[ { id, name, slug, icon, color, description, articles:[…] } ] } |
| 1086 |
*/ |
| 1087 |
protected function sanitize_outline( array $outline ) { |
| 1088 |
$cats_in = isset( $outline['categories'] ) && is_array( $outline['categories'] ) ? $outline['categories'] : []; |
| 1089 |
|
| 1090 |
$categories = []; |
| 1091 |
$total = 0; |
| 1092 |
foreach ( array_slice( $cats_in, 0, self::MAX_KB_CATEGORIES ) as $i => $cat ) { |
| 1093 |
if ( empty( $cat['name'] ) || $total >= self::MAX_KB_ARTICLES_TOTAL ) { |
| 1094 |
continue; |
| 1095 |
} |
| 1096 |
|
| 1097 |
$articles = []; |
| 1098 |
$arts_in = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : []; |
| 1099 |
foreach ( array_slice( $arts_in, 0, self::MAX_KB_ARTICLES_PER_CATEGORY ) as $art ) { |
| 1100 |
if ( $total >= self::MAX_KB_ARTICLES_TOTAL || empty( $art['title'] ) ) { |
| 1101 |
continue; |
| 1102 |
} |
| 1103 |
$links = []; |
| 1104 |
if ( isset( $art['links'] ) && is_array( $art['links'] ) ) { |
| 1105 |
foreach ( $art['links'] as $l ) { |
| 1106 |
$links[] = sanitize_title( (string) $l ); |
| 1107 |
} |
| 1108 |
} |
| 1109 |
$articles[] = [ |
| 1110 |
'index' => isset( $art['index'] ) ? (int) $art['index'] : $total, |
| 1111 |
'type' => sanitize_key( isset( $art['type'] ) ? $art['type'] : 'guide' ), |
| 1112 |
'title' => sanitize_text_field( $art['title'] ), |
| 1113 |
'slug' => sanitize_title( isset( $art['slug'] ) ? $art['slug'] : $art['title'] ), |
| 1114 |
'summary' => sanitize_text_field( isset( $art['summary'] ) ? $art['summary'] : '' ), |
| 1115 |
'links' => array_values( array_filter( $links ) ), |
| 1116 |
]; |
| 1117 |
$total++; |
| 1118 |
} |
| 1119 |
|
| 1120 |
if ( empty( $articles ) ) { |
| 1121 |
continue; |
| 1122 |
} |
| 1123 |
|
| 1124 |
$categories[] = [ |
| 1125 |
'id' => sanitize_key( isset( $cat['id'] ) ? 'sd_' . $cat['id'] : 'sd_' . $i ), |
| 1126 |
'name' => sanitize_text_field( $cat['name'] ), |
| 1127 |
'slug' => sanitize_title( isset( $cat['slug'] ) ? $cat['slug'] : $cat['name'] ), |
| 1128 |
'icon' => sanitize_key( isset( $cat['icon'] ) ? $cat['icon'] : 'book' ), |
| 1129 |
'color' => self::PALETTE[ count( $categories ) % count( self::PALETTE ) ], |
| 1130 |
'description' => sanitize_text_field( isset( $cat['description'] ) ? $cat['description'] : '' ), |
| 1131 |
'articles' => $articles, |
| 1132 |
]; |
| 1133 |
} |
| 1134 |
|
| 1135 |
return [ |
| 1136 |
'total_articles' => $total, |
| 1137 |
'categories' => $categories, |
| 1138 |
]; |
| 1139 |
} |
| 1140 |
|
| 1141 |
/** |
| 1142 |
* Sanitize a single expanded article. Keeps the `#bd-link--slug` cross-link |
| 1143 |
* sentinels (fragment hrefs survive wp_kses_post) for the builder to resolve. |
| 1144 |
* |
| 1145 |
* @return array { title, slug, type, category_slug, content_html, excerpt } |
| 1146 |
*/ |
| 1147 |
protected function sanitize_kb_article( array $article ) { |
| 1148 |
return [ |
| 1149 |
'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ), |
| 1150 |
'slug' => sanitize_title( isset( $article['slug'] ) ? $article['slug'] : '' ), |
| 1151 |
'type' => sanitize_key( isset( $article['type'] ) ? $article['type'] : 'guide' ), |
| 1152 |
'category_slug' => sanitize_title( isset( $article['category_slug'] ) ? $article['category_slug'] : '' ), |
| 1153 |
'content_html' => wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' ), |
| 1154 |
'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ), |
| 1155 |
]; |
| 1156 |
} |
| 1157 |
|
| 1158 |
/** |
| 1159 |
* Sanitize + cap an assembled deep KB (categories with expanded articles) for |
| 1160 |
* insertion. Preserves article slug/type (needed for cross-link resolution and |
| 1161 |
* intro/quickstart ordering) and keeps content HTML. |
| 1162 |
* |
| 1163 |
* @return array |
| 1164 |
*/ |
| 1165 |
protected function sanitize_kb_categories( array $categories ) { |
| 1166 |
$clean = []; |
| 1167 |
$total = 0; |
| 1168 |
|
| 1169 |
foreach ( array_slice( $categories, 0, self::MAX_KB_CATEGORIES ) as $i => $cat ) { |
| 1170 |
if ( empty( $cat['name'] ) || $total >= self::MAX_KB_ARTICLES_TOTAL ) { |
| 1171 |
continue; |
| 1172 |
} |
| 1173 |
|
| 1174 |
$articles = []; |
| 1175 |
$raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : []; |
| 1176 |
foreach ( array_slice( $raw, 0, self::MAX_KB_ARTICLES_PER_CATEGORY ) as $article ) { |
| 1177 |
if ( $total >= self::MAX_KB_ARTICLES_TOTAL || ! is_array( $article ) || empty( $article['title'] ) ) { |
| 1178 |
continue; |
| 1179 |
} |
| 1180 |
$articles[] = [ |
| 1181 |
'title' => sanitize_text_field( $article['title'] ), |
| 1182 |
'slug' => sanitize_title( isset( $article['slug'] ) ? $article['slug'] : $article['title'] ), |
| 1183 |
'type' => sanitize_key( isset( $article['type'] ) ? $article['type'] : 'guide' ), |
| 1184 |
'content_html' => wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' ), |
| 1185 |
'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ), |
| 1186 |
]; |
| 1187 |
$total++; |
| 1188 |
} |
| 1189 |
|
| 1190 |
if ( empty( $articles ) ) { |
| 1191 |
continue; |
| 1192 |
} |
| 1193 |
|
| 1194 |
$clean[] = [ |
| 1195 |
'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ), |
| 1196 |
'name' => sanitize_text_field( $cat['name'] ), |
| 1197 |
'slug' => sanitize_title( isset( $cat['slug'] ) ? $cat['slug'] : $cat['name'] ), |
| 1198 |
'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '', |
| 1199 |
'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book', |
| 1200 |
'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ], |
| 1201 |
'articles' => $articles, |
| 1202 |
]; |
| 1203 |
} |
| 1204 |
|
| 1205 |
return $clean; |
| 1206 |
} |
| 1207 |
} |
| 1208 |
|