PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/REST/SampleDocs.php +749 -123 4.6.14.9.2 View file →
@@ -5,8 +5,9 @@
5 5 use WP_REST_Request;
6 6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 7 use WPDeveloper\BetterDocs\Core\SiteProfiler;
8 8 use WPDeveloper\BetterDocs\Core\Settings;
9 +use WPDeveloper\BetterDocs\Utils\AIUsage;
9 10 use WPDeveloper\BetterDocs\Dependencies\DI\Container;
10 11
11 12 /**
12 13 * REST surface for the AI "Generate Sample Docs" feature (Phase 2).
@@ -19,12 +20,52 @@
19 20 *
20 21 * @since 4.5.3
21 22 */
22 23 class SampleDocs extends BaseAPI {
23 - /** Hard caps mirrored on the proxy (Phase 0 contract). */
24 + /** Hard caps mirrored on the proxy (Phase 0 contract) — legacy single-call docs. */
24 25 const MAX_CATEGORIES = 3;
25 26 const MAX_ARTICLES_PER_CATEGORY = 3;
26 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 +
27 68 /** Default per-category palette/icons (matches the design mockup). */
28 69 const PALETTE = [ '#00B884', '#3B82F6', '#8B5CF6', '#F59E0B', '#0EA5E9', '#EF4444' ];
29 70
30 71 /**
@@ -43,8 +84,11 @@
43 84
44 85 public function register() {
45 86 $this->post( 'sample-docs/detect', [ $this, 'detect' ] );
46 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' ] );
47 91 $this->post( 'sample-docs/insert', [ $this, 'insert' ] );
48 92 $this->post( 'sample-docs/undo', [ $this, 'undo' ] );
49 93 }
50 94
@@ -57,10 +101,16 @@
57 101 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
58 102 }
59 103
60 104 $content_type = $this->content_type( $request );
61 - $profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ) );
62 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 +
63 113 /**
64 114 * Telemetry: site detection ran.
65 115 *
66 116 * @param string $type Detected site type.
@@ -86,36 +136,80 @@
86 136 }
87 137
88 138 $content_type = $this->content_type( $request );
89 139 $profile = (array) $request->get_param( 'profile' );
90 - $categories = $this->sanitize_categories( (array) $request->get_param( 'categories' ), false, $this->max_categories( $content_type ) );
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 + );
91 152
92 153 if ( empty( $profile ) ) {
93 154 $profile = $this->profiler->build();
94 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 + }
95 171 if ( empty( $categories ) ) {
96 172 $categories = $this->suggested_categories( $profile, $content_type );
97 173 }
98 174
99 - // Product FAQs are generated deterministically from the store's real
100 - // WooCommerce settings — accurate, instant, never generic, and with no
101 - // AI/proxy round-trip.
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;
102 180 if ( 'product_faq' === $content_type ) {
103 - return $this->success( $this->build_store_faqs( $profile, $content_type, $categories ) );
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;
104 190 }
105 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 +
106 197 $payload = [
107 198 'profile' => $profile,
108 199 'categories' => $categories,
109 200 'options' => [
110 201 'content_type' => $content_type,
111 - 'maxCategories' => self::MAX_CATEGORIES,
112 - 'maxArticlesPerCategory' => self::MAX_ARTICLES_PER_CATEGORY,
202 + 'maxCategories' => $this->max_categories( $content_type ),
203 + 'maxArticlesPerCategory' => $this->max_articles( $content_type ),
113 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'],
114 208 ],
115 209 ];
116 210
117 - $response = $this->call_proxy( $payload );
211 + $response = $this->call_proxy( $payload, $content_type );
118 212
119 213 if ( is_wp_error( $response ) ) {
120 214 $code = $response->get_error_code();
121 215
@@ -126,8 +220,14 @@
126 220 * @param string $code Error code.
127 221 */
128 222 do_action( 'betterdocs_sample_docs_generation_failed', $content_type, $code );
129 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 +
130 230 // Typed errors the UI maps to the quota / fallback screens.
131 231 $data = $response->get_error_data();
132 232 $status = is_array( $data ) && isset( $data['status'] ) ? $data['status'] : 502;
133 233
@@ -139,14 +239,32 @@
139 239 *
140 240 * @param string $content_type docs|faq
141 241 * @param int $count Number of categories returned.
142 242 */
143 - do_action( 'betterdocs_sample_docs_generated', $content_type, count( $response['categories'] ) );
243 + $categories = $response['categories'];
144 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 +
145 263 return $this->success(
146 264 [
147 265 'content_type' => $content_type,
148 - 'categories' => $response['categories'],
266 + 'categories' => $categories,
149 267 'meta' => isset( $response['meta'] ) ? $response['meta'] : [],
150 268 ]
151 269 );
152 270 }
@@ -151,34 +269,38 @@
151 269 );
152 270 }
153 271
154 272 /**
155 - * Build the deterministic, store-grounded Product FAQ payload from the site's
156 - * real WooCommerce settings (payments, shipping, returns/refunds, account). This
157 - * is the primary generator for product_faq — accurate, instant, and AI-free.
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.
158 276 *
159 - * @return array { content_type, categories, meta }
277 + * @return array|null A sanitized category array with `all_products => true`, or null.
160 278 */
161 - protected function build_store_faqs( $profile, $content_type, $selected = [] ) {
162 - $store = $this->store_faq();
163 - $categories = $store ? $store->generate( (array) $profile ) : [];
164 - // Honor the categories the user kept on the profile screen — the generator
165 - // otherwise always emits the full curated set, so removing a chip had no
166 - // effect on what got generated (fbs).
167 - $categories = $this->filter_selected_categories( $categories, (array) $selected );
168 - $categories = $this->sanitize_categories( $categories, true, $this->max_categories( $content_type ) );
279 + protected function store_wide_group( array $profile ) {
280 + $store = $this->store_faq();
281 + if ( null === $store ) {
282 + return null;
283 + }
169 284
170 - do_action( 'betterdocs_sample_docs_generated', $content_type, count( $categories ) );
285 + $group = $store->generate_consolidated( $profile );
286 + if ( empty( $group['articles'] ) ) {
287 + return null;
288 + }
171 289
172 - return [
173 - 'content_type' => $content_type,
174 - 'categories' => $categories,
175 - 'meta' => [ 'model' => 'betterdocs-local', 'source' => 'deterministic' ],
176 - ];
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];
177 299 }
178 300
179 301 /**
180 - * Lazily resolve the deterministic store-FAQ generator.
302 + * Lazily resolve the deterministic store-FAQ generator (Layer 1 answers).
181 303 *
182 304 * @return \WPDeveloper\BetterDocs\Core\StoreFaqContent|null
183 305 */
184 306 protected function store_faq() {
@@ -189,8 +311,118 @@
189 311 return $this->container->get( $class );
190 312 }
191 313
192 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 + /**
193 425 * Insert step — DocBuilder creates the terms/posts. Wired in Phase 4.
194 426 */
195 427 public function insert( WP_REST_Request $request ) {
196 428 if ( ! $this->is_enabled() ) {
@@ -202,10 +434,52 @@
202 434 return $this->error( 'not_implemented', __( 'Inserting sample docs is not available yet.', 'betterdocs' ), 501 );
203 435 }
204 436
205 437 $content_type = $this->content_type( $request );
206 - $categories = $this->sanitize_categories( (array) $request->get_param( 'categories' ), true, $this->max_categories( $content_type ) );
207 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 +
208 482 $result = $builder->build( $categories, $content_type );
209 483
210 484 if ( is_wp_error( $result ) ) {
211 485 return $this->error( $result->get_error_code(), $result->get_error_message(), 400 );
@@ -249,26 +523,61 @@
249 523 /* Proxy plumbing */
250 524 /* --------------------------------------------------------------------- */
251 525
252 526 /**
253 - * Sign + POST the payload to the hosted proxy, with one retry.
527 + * Legacy single-call generation — sign + POST to the proxy and validate the
528 + * { categories, meta } shape (used by the FAQ path).
254 529 *
255 530 * @return array|\WP_Error Parsed { categories, meta } on success.
256 531 */
257 - protected function call_proxy( array $payload ) {
258 - $url = $this->proxy_url();
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 );
259 568 $secret = $this->proxy_secret();
260 569 $body = wp_json_encode( $payload );
261 - $timeout = $this->request_timeout();
570 + $timeout = $this->request_timeout( $timeout_base );
262 571
263 572 $args = [
264 573 'timeout' => $timeout,
265 574 'headers' => [
266 - 'Content-Type' => 'application/json',
267 - 'Accept' => 'application/json',
268 - 'X-BetterDocs-Site' => esc_url_raw( home_url() ),
269 - 'X-BetterDocs-License' => $this->license_key(),
270 - 'X-BetterDocs-Signature' => hash_hmac( 'sha256', $body, $secret ),
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 ),
271 580 ],
272 581 'body' => $body,
273 582 ];
274 583
@@ -299,9 +608,9 @@
299 608 }
300 609 }
301 610
302 611 if ( is_wp_error( $response ) ) {
303 - return $this->error( 'proxy_unreachable', __( 'Could not reach the BetterDocs AI service. Please try again.', 'betterdocs' ), 502 );
612 + return $this->error( 'proxy_unreachable', __( 'Could not reach the BetterDocs AI service. Please try again.', 'betterdocs' ), 502, [ 'upstream' => 0 ] );
304 613 }
305 614
306 615 $status = wp_remote_retrieve_response_code( $response );
307 616 $parsed = json_decode( wp_remote_retrieve_body( $response ), true );
@@ -306,31 +615,32 @@
306 615 $status = wp_remote_retrieve_response_code( $response );
307 616 $parsed = json_decode( wp_remote_retrieve_body( $response ), true );
308 617
309 618 if ( 429 === $status || ( isset( $parsed['status'] ) && 'quota_exceeded' === $parsed['status'] ) ) {
310 - return $this->error( 'quota_exceeded', __( 'You have used your free AI generation for this site.', 'betterdocs' ), 429 );
619 + return $this->error( 'quota_exceeded', __( 'You have used your free AI generation for this site.', 'betterdocs' ), 429, [ 'upstream' => 429 ] );
311 620 }
312 621
313 - if ( $status >= 400 || empty( $parsed['categories'] ) || ! is_array( $parsed['categories'] ) ) {
622 + if ( $status >= 400 || ! is_array( $parsed ) ) {
314 623 $message = isset( $parsed['message'] ) ? $parsed['message'] : __( 'The AI service returned an unexpected response.', 'betterdocs' );
315 - return $this->error( 'proxy_error', $message, 502 );
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 ] );
316 628 }
317 629
318 - // Enforce caps + sanitize defensively on our side too.
319 - $parsed['categories'] = $this->sanitize_categories( $parsed['categories'], true );
320 -
321 630 return $parsed;
322 631 }
323 632
324 633 /**
325 - * HTTP timeout (seconds) for the proxy call, kept safely below PHP's
634 + * HTTP timeout (seconds) for a proxy call, kept safely below PHP's
326 635 * max_execution_time so a hung upstream returns a clean WP_Error (typed
327 636 * JSON + static fallback in the wizard) instead of fataling mid-cURL.
328 637 *
638 + * @param int $base Base timeout in seconds for a single OpenAI call.
329 639 * @return int
330 640 */
331 - protected function request_timeout() {
332 - $timeout = 20;
641 + protected function request_timeout( $base = 20 ) {
642 + $timeout = max( 5, (int) $base );
333 643 $max_exec = (int) ini_get( 'max_execution_time' );
334 644
335 645 if ( $max_exec > 0 ) {
336 646 $timeout = min( $timeout, max( 5, $max_exec - 10 ) );
@@ -339,13 +649,19 @@
339 649 /** Filter the HTTP timeout (seconds) for hosted AI proxy requests. */
340 650 return (int) apply_filters( 'betterdocs_ai_proxy_timeout', $timeout );
341 651 }
342 652
343 - protected function proxy_url() {
653 + /**
654 + * Full proxy endpoint URL for an action ('' | 'outline' | 'article').
655 + *
656 + * @return string
657 + */
658 + protected function proxy_endpoint( $action = '' ) {
344 659 $base = get_option( 'betterdocs_ai_proxy_url', 'https://api.betterdocs.co/ai' );
345 660 /** Filter the hosted proxy base URL. */
346 661 $base = apply_filters( 'betterdocs_ai_proxy_url', $base );
347 - return trailingslashit( $base ) . 'v1/sample-docs';
662 + $url = trailingslashit( $base ) . 'v1/sample-docs';
663 + return '' !== $action ? $url . '/' . ltrim( (string) $action, '/' ) : $url;
348 664 }
349 665
350 666 protected function proxy_secret() {
351 667 $secret = get_option( 'betterdocs_ai_proxy_secret', 'betterdocs-local-dev-secret' );
@@ -376,83 +692,205 @@
376 692
377 693 /**
378 694 * Per-content-type category cap. Product FAQs cover the four store-ops groups
379 695 * (Payments & Billing, Shipping & Delivery, Returns & Refunds, Orders & Account);
380 - * docs and general FAQ keep the default cap.
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).
381 698 *
382 699 * @return int
383 700 */
384 701 protected function max_categories( $content_type ) {
385 - return 'product_faq' === $content_type ? 4 : self::MAX_CATEGORIES;
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;
386 706 }
387 707
388 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 + /**
389 730 * Deterministic, type-aware suggested categories (no AI) for the profile screen.
390 731 *
391 732 * @return array
392 733 */
393 734 protected function suggested_categories( array $profile, $content_type ) {
394 - $type = isset( $profile['type'] ) ? $profile['type'] : 'general';
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 + }
395 741
396 - // WooCommerce Product FAQ: the curated store starter set is the single
397 - // source of truth (StoreFaqContent), so the chips/titles shown here match
398 - // what the deterministic fallback generates.
399 - if ( 'product_faq' === $content_type ) {
400 - $store = $this->store_faq();
401 - $defs = $store ? $store->definition() : [];
402 - $defs = array_slice( $defs, 0, $this->max_categories( $content_type ) );
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 + }
403 752
404 - $cats = [];
405 - foreach ( $defs as $i => $group ) {
406 - $cats[] = [
407 - 'id' => sanitize_key( 'sd_' . $i ),
408 - 'name' => $group['name'],
409 - 'icon' => isset( $group['icon'] ) ? sanitize_key( $group['icon'] ) : 'help',
410 - 'color' => self::PALETTE[ $i % count( self::PALETTE ) ],
411 - 'articles' => array_values( (array) $group['questions'] ),
412 - ];
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'] );
413 800 }
801 + }
414 802
415 - return $cats;
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 + );
416 825 }
417 826
418 - if ( 'faq' === $content_type ) {
419 - // FAQ groups: product-store questions on a WooCommerce/ecommerce
420 - // site, general site questions otherwise.
421 - $names = 'ecommerce' === $type
422 - ? [ 'Shipping & Delivery', 'Returns & Refunds', 'Payments & Orders' ]
423 - : [ 'Getting Started', 'Account & Billing', 'Troubleshooting' ];
424 - } else {
425 - // Documentation categories — article-style guides, NOT FAQ groups (no
426 - // "FAQs" category here; FAQs are a separate content type). The hosted
427 - // proxy refines these names to fit the specific site and writes real
428 - // article titles + bodies for each.
429 - $presets = [
430 - 'ecommerce' => [ 'Getting Started', 'Shipping & Delivery', 'Product Guides' ],
431 - 'course_lms' => [ 'Getting Started', 'Course Access', 'Lessons & Content' ],
432 - 'digital_downloads' => [ 'Getting Started', 'Downloads & Licenses', 'Installation' ],
433 - 'membership' => [ 'Getting Started', 'Membership & Plans', 'Member Benefits' ],
434 - 'community' => [ 'Getting Started', 'Using the Community', 'Your Profile' ],
435 - 'business_site' => [ 'Getting Started', 'Our Services', 'How-to Guides' ],
436 - 'general' => [ 'Getting Started', 'Guides & How-tos', 'Features & Settings' ],
437 - ];
438 - $names = isset( $presets[ $type ] ) ? $presets[ $type ] : $presets['general'];
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'] ) );
439 839 }
440 840
441 - // Never suggest more than the cap, so the preview matches what actually
442 - // gets generated and inserted.
443 - $names = array_slice( $names, 0, $this->max_categories( $content_type ) );
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 + }
444 851
445 - $icons = [ 'book', 'truck', 'help', 'refund' ];
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".
446 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 +
447 883 $cats = [];
448 - foreach ( $names as $i => $name ) {
884 + foreach ( $clean as $i => $name ) {
449 885 $cats[] = [
450 886 'id' => sanitize_key( 'sd_' . $i ),
451 - 'name' => $name,
452 - 'icon' => $icons[ $i % count( $icons ) ],
887 + 'name' => sanitize_text_field( $name ),
888 + 'icon' => 'help',
453 889 'color' => self::PALETTE[ $i % count( self::PALETTE ) ],
454 - 'articles' => $this->seed_articles( $name, $content_type ),
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' => [],
455 893 ];
456 894 }
457 895
458 896 return $cats;
@@ -458,28 +896,67 @@
458 896 return $cats;
459 897 }
460 898
461 899 /**
462 - * Placeholder article/question titles so the profile-step counters render.
463 - * The proxy replaces these with real generated content.
900 + * Pages that are site *plumbing* — a cart, a login form, an index — and so carry no
901 + * subject anyone asks a question about.
464 902 *
465 - * @return array
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
466 911 */
467 - protected function seed_articles( $name, $content_type ) {
468 - if ( 'faq' === $content_type ) {
469 - return [
470 - /* translators: %s: suggested FAQ group / category name. */
471 - sprintf( __( 'What is %s?', 'betterdocs' ), $name ),
472 - __( 'Common questions', 'betterdocs' ),
473 - __( 'Tips & best practices', 'betterdocs' ),
474 - ];
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 + }
475 941 }
476 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 +
477 956 return [
478 - /* translators: %s: suggested category name. */
479 - sprintf( __( '%s — overview', 'betterdocs' ), $name ),
480 - __( 'Key concepts', 'betterdocs' ),
481 - __( 'Step-by-step guide', 'betterdocs' ),
957 + 'include' => array_values( $kept_names ),
958 + 'exclude' => $exclude,
482 959 ];
483 960 }
484 961
485 962 /**
@@ -532,22 +1009,30 @@
532 1009 * Sanitize + cap an incoming categories array (used both for the user's edited
533 1010 * list and the proxy response).
534 1011 *
535 1012 * @param bool $with_content Keep article body HTML (proxy response) vs titles only.
536 - * @param int $max Category cap (defaults to MAX_CATEGORIES; product_faq uses more).
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).
537 1016 * @return array
538 1017 */
539 - protected function sanitize_categories( array $categories, $with_content = false, $max = self::MAX_CATEGORIES ) {
540 - $max = max( 1, (int) $max );
541 - $clean = [];
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 = [];
542 1024 foreach ( array_slice( $categories, 0, $max ) as $i => $cat ) {
543 - if ( empty( $cat['name'] ) ) {
1025 + if ( empty( $cat['name'] ) || ( $max_total > 0 && $total >= $max_total ) ) {
544 1026 continue;
545 1027 }
546 1028
547 1029 $articles = [];
548 1030 $raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : [];
549 - foreach ( array_slice( $raw, 0, self::MAX_ARTICLES_PER_CATEGORY ) as $article ) {
1031 + foreach ( array_slice( $raw, 0, $max_articles ) as $article ) {
1032 + if ( $max_total > 0 && $total >= $max_total ) {
1033 + break;
1034 + }
550 1035 if ( is_array( $article ) ) {
551 1036 $entry = [
552 1037 'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ),
553 1038 'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ),
@@ -556,13 +1041,15 @@
556 1041 $entry['content_html'] = wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' );
557 1042 }
558 1043 if ( '' !== $entry['title'] ) {
559 1044 $articles[] = $entry;
1045 + $total++;
560 1046 }
561 1047 } else {
562 1048 $title = sanitize_text_field( $article );
563 1049 if ( '' !== $title ) {
564 1050 $articles[] = $with_content ? [ 'title' => $title, 'content_html' => '', 'excerpt' => '' ] : $title;
1051 + $total++;
565 1052 }
566 1053 }
567 1054 }
568 1055
@@ -568,8 +1055,147 @@
568 1055
569 1056 $clean[] = [
570 1057 'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ),
571 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'] ),
572 1198 'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '',
573 1199 'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book',
574 1200 'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ],
575 1201 'articles' => $articles,