PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.0
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 3.5.2 All 199 releases
betterdocs / includes / REST / SampleDocs.php

SampleDocs.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.8.0, at includes/REST/SampleDocs.php

1,208 lines 46.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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