PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.1
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.6.1, at includes/REST/SampleDocs.php

582 lines 20.4 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\Dependencies\DI\Container;
10
11 /**
12 * REST surface for the AI "Generate Sample Docs" feature (Phase 2).
13 *
14 * Routes (namespace betterdocs/v1):
15 * POST sample-docs/detect → SiteProfiler profile + locally suggested categories
16 * POST sample-docs/generate → signs + forwards to the hosted proxy, returns articles
17 * POST sample-docs/insert → DocBuilder creates the terms/posts (Phase 4)
18 * POST sample-docs/undo → DocBuilder removes the flagged sample content (Phase 4)
19 *
20 * @since 4.5.3
21 */
22 class SampleDocs extends BaseAPI {
23 /** Hard caps mirrored on the proxy (Phase 0 contract). */
24 const MAX_CATEGORIES = 3;
25 const MAX_ARTICLES_PER_CATEGORY = 3;
26
27 /** Default per-category palette/icons (matches the design mockup). */
28 const PALETTE = [ '#00B884', '#3B82F6', '#8B5CF6', '#F59E0B', '#0EA5E9', '#EF4444' ];
29
30 /**
31 * @var SiteProfiler
32 */
33 protected $profiler;
34
35 public function __construct( Settings $settings, Container $container, SiteProfiler $profiler ) {
36 parent::__construct( $settings, $container );
37 $this->profiler = $profiler;
38 }
39
40 public function permission_check(): bool {
41 return current_user_can( 'edit_docs_settings' );
42 }
43
44 public function register() {
45 $this->post( 'sample-docs/detect', [ $this, 'detect' ] );
46 $this->post( 'sample-docs/generate', [ $this, 'generate' ] );
47 $this->post( 'sample-docs/insert', [ $this, 'insert' ] );
48 $this->post( 'sample-docs/undo', [ $this, 'undo' ] );
49 }
50
51 /**
52 * Detection step — returns the site profile and locally-derived suggested
53 * categories (no AI). Feeds the design's "detecting" + "detected profile" screens.
54 */
55 public function detect( WP_REST_Request $request ) {
56 if ( ! $this->is_enabled() ) {
57 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
58 }
59
60 $content_type = $this->content_type( $request );
61 $profile = $this->profiler->build( (bool) $request->get_param( 'fresh' ) );
62
63 /**
64 * Telemetry: site detection ran.
65 *
66 * @param string $type Detected site type.
67 * @param string $content_type docs|faq
68 */
69 do_action( 'betterdocs_sample_docs_detected', $profile['type'] ?? 'general', $content_type );
70
71 return $this->success(
72 [
73 'profile' => $profile,
74 'categories' => $this->suggested_categories( $profile, $content_type ),
75 ]
76 );
77 }
78
79 /**
80 * Generation step — signs the request and forwards it to the hosted proxy,
81 * which calls OpenAI and returns structured categories + articles.
82 */
83 public function generate( WP_REST_Request $request ) {
84 if ( ! $this->is_enabled() ) {
85 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
86 }
87
88 $content_type = $this->content_type( $request );
89 $profile = (array) $request->get_param( 'profile' );
90 $categories = $this->sanitize_categories( (array) $request->get_param( 'categories' ), false, $this->max_categories( $content_type ) );
91
92 if ( empty( $profile ) ) {
93 $profile = $this->profiler->build();
94 }
95 if ( empty( $categories ) ) {
96 $categories = $this->suggested_categories( $profile, $content_type );
97 }
98
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.
102 if ( 'product_faq' === $content_type ) {
103 return $this->success( $this->build_store_faqs( $profile, $content_type, $categories ) );
104 }
105
106 $payload = [
107 'profile' => $profile,
108 'categories' => $categories,
109 'options' => [
110 'content_type' => $content_type,
111 'maxCategories' => self::MAX_CATEGORIES,
112 'maxArticlesPerCategory' => self::MAX_ARTICLES_PER_CATEGORY,
113 'locale' => isset( $profile['site']['locale'] ) ? $profile['site']['locale'] : get_locale(),
114 ],
115 ];
116
117 $response = $this->call_proxy( $payload );
118
119 if ( is_wp_error( $response ) ) {
120 $code = $response->get_error_code();
121
122 /**
123 * Telemetry: generation failed (e.g. quota_exceeded, proxy_error).
124 *
125 * @param string $content_type docs|faq
126 * @param string $code Error code.
127 */
128 do_action( 'betterdocs_sample_docs_generation_failed', $content_type, $code );
129
130 // Typed errors the UI maps to the quota / fallback screens.
131 $data = $response->get_error_data();
132 $status = is_array( $data ) && isset( $data['status'] ) ? $data['status'] : 502;
133
134 return $this->error( $code, $response->get_error_message(), $status, [ 'fallback' => 'static' ] );
135 }
136
137 /**
138 * Telemetry: generation succeeded.
139 *
140 * @param string $content_type docs|faq
141 * @param int $count Number of categories returned.
142 */
143 do_action( 'betterdocs_sample_docs_generated', $content_type, count( $response['categories'] ) );
144
145 return $this->success(
146 [
147 'content_type' => $content_type,
148 'categories' => $response['categories'],
149 'meta' => isset( $response['meta'] ) ? $response['meta'] : [],
150 ]
151 );
152 }
153
154 /**
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.
158 *
159 * @return array { content_type, categories, meta }
160 */
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 ) );
169
170 do_action( 'betterdocs_sample_docs_generated', $content_type, count( $categories ) );
171
172 return [
173 'content_type' => $content_type,
174 'categories' => $categories,
175 'meta' => [ 'model' => 'betterdocs-local', 'source' => 'deterministic' ],
176 ];
177 }
178
179 /**
180 * Lazily resolve the deterministic store-FAQ generator.
181 *
182 * @return \WPDeveloper\BetterDocs\Core\StoreFaqContent|null
183 */
184 protected function store_faq() {
185 $class = 'WPDeveloper\\BetterDocs\\Core\\StoreFaqContent';
186 if ( ! class_exists( $class ) ) {
187 return null;
188 }
189 return $this->container->get( $class );
190 }
191
192 /**
193 * Insert step — DocBuilder creates the terms/posts. Wired in Phase 4.
194 */
195 public function insert( WP_REST_Request $request ) {
196 if ( ! $this->is_enabled() ) {
197 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
198 }
199
200 $builder = $this->builder();
201 if ( null === $builder ) {
202 return $this->error( 'not_implemented', __( 'Inserting sample docs is not available yet.', 'betterdocs' ), 501 );
203 }
204
205 $content_type = $this->content_type( $request );
206 $categories = $this->sanitize_categories( (array) $request->get_param( 'categories' ), true, $this->max_categories( $content_type ) );
207
208 $result = $builder->build( $categories, $content_type );
209
210 if ( is_wp_error( $result ) ) {
211 return $this->error( $result->get_error_code(), $result->get_error_message(), 400 );
212 }
213
214 return $this->success( $result );
215 }
216
217 /**
218 * Undo step — remove exactly the sample content we created. Wired in Phase 4.
219 */
220 public function undo( WP_REST_Request $request ) {
221 if ( ! $this->is_enabled() ) {
222 return $this->error( 'feature_disabled', __( 'AI sample docs is disabled.', 'betterdocs' ), 403 );
223 }
224
225 $builder = $this->builder();
226 if ( null === $builder ) {
227 return $this->error( 'not_implemented', __( 'Removing sample docs is not available yet.', 'betterdocs' ), 501 );
228 }
229
230 $content_type = $this->content_type( $request );
231 return $this->success( $builder->undo( $content_type ) );
232 }
233
234 /**
235 * Lazily resolve the DocBuilder (added in Phase 4) so this class loads even
236 * before the builder exists.
237 *
238 * @return \WPDeveloper\BetterDocs\Core\SampleDocBuilder|null
239 */
240 protected function builder() {
241 $class = 'WPDeveloper\\BetterDocs\\Core\\SampleDocBuilder';
242 if ( ! class_exists( $class ) ) {
243 return null;
244 }
245 return $this->container->get( $class );
246 }
247
248 /* --------------------------------------------------------------------- */
249 /* Proxy plumbing */
250 /* --------------------------------------------------------------------- */
251
252 /**
253 * Sign + POST the payload to the hosted proxy, with one retry.
254 *
255 * @return array|\WP_Error Parsed { categories, meta } on success.
256 */
257 protected function call_proxy( array $payload ) {
258 $url = $this->proxy_url();
259 $secret = $this->proxy_secret();
260 $body = wp_json_encode( $payload );
261 $timeout = $this->request_timeout();
262
263 $args = [
264 'timeout' => $timeout,
265 '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 ),
271 ],
272 'body' => $body,
273 ];
274
275 $attempts = 0;
276 $response = null;
277 while ( $attempts < 2 ) {
278 $attempts++;
279
280 // Give this attempt a fresh execution budget: without it a hung upstream
281 // trips PHP's max_execution_time mid-cURL and the route dies with a raw
282 // 500 critical error instead of the typed JSON the modal understands.
283 $reset = function_exists( 'set_time_limit' ) && @set_time_limit( $timeout + 15 );
284
285 $response = wp_remote_post( $url, $args );
286
287 // Retry ONLY on a genuine transport failure (no HTTP response). A 5xx may
288 // mean the proxy already called OpenAI and spent tokens, so re-POSTing
289 // would risk double-billing — treat any received status as final.
290 if ( ! is_wp_error( $response ) ) {
291 break;
292 }
293
294 // If the time limit could not be reset (disabled by the host), a second
295 // full-length attempt could still fatal mid-cURL — surface the transport
296 // error instead of risking the retry.
297 if ( ! $reset ) {
298 break;
299 }
300 }
301
302 if ( is_wp_error( $response ) ) {
303 return $this->error( 'proxy_unreachable', __( 'Could not reach the BetterDocs AI service. Please try again.', 'betterdocs' ), 502 );
304 }
305
306 $status = wp_remote_retrieve_response_code( $response );
307 $parsed = json_decode( wp_remote_retrieve_body( $response ), true );
308
309 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 );
311 }
312
313 if ( $status >= 400 || empty( $parsed['categories'] ) || ! is_array( $parsed['categories'] ) ) {
314 $message = isset( $parsed['message'] ) ? $parsed['message'] : __( 'The AI service returned an unexpected response.', 'betterdocs' );
315 return $this->error( 'proxy_error', $message, 502 );
316 }
317
318 // Enforce caps + sanitize defensively on our side too.
319 $parsed['categories'] = $this->sanitize_categories( $parsed['categories'], true );
320
321 return $parsed;
322 }
323
324 /**
325 * HTTP timeout (seconds) for the proxy call, kept safely below PHP's
326 * max_execution_time so a hung upstream returns a clean WP_Error (typed
327 * JSON + static fallback in the wizard) instead of fataling mid-cURL.
328 *
329 * @return int
330 */
331 protected function request_timeout() {
332 $timeout = 20;
333 $max_exec = (int) ini_get( 'max_execution_time' );
334
335 if ( $max_exec > 0 ) {
336 $timeout = min( $timeout, max( 5, $max_exec - 10 ) );
337 }
338
339 /** Filter the HTTP timeout (seconds) for hosted AI proxy requests. */
340 return (int) apply_filters( 'betterdocs_ai_proxy_timeout', $timeout );
341 }
342
343 protected function proxy_url() {
344 $base = get_option( 'betterdocs_ai_proxy_url', 'https://api.betterdocs.co/ai' );
345 /** Filter the hosted proxy base URL. */
346 $base = apply_filters( 'betterdocs_ai_proxy_url', $base );
347 return trailingslashit( $base ) . 'v1/sample-docs';
348 }
349
350 protected function proxy_secret() {
351 $secret = get_option( 'betterdocs_ai_proxy_secret', 'betterdocs-local-dev-secret' );
352 /** Filter the HMAC shared secret used to sign proxy requests. */
353 return apply_filters( 'betterdocs_ai_proxy_secret', $secret );
354 }
355
356 protected function license_key() {
357 /** Filter the license key sent to the proxy for per-site identity. */
358 return apply_filters( 'betterdocs_ai_proxy_license', (string) get_option( 'betterdocs_pro_licenses', '' ) );
359 }
360
361 /* --------------------------------------------------------------------- */
362 /* Helpers */
363 /* --------------------------------------------------------------------- */
364
365 protected function is_enabled() {
366 return (bool) $this->settings->get( 'enable_ai_sample_docs', true );
367 }
368
369 protected function content_type( WP_REST_Request $request ) {
370 $type = $request->get_param( 'content_type' );
371 if ( 'faq' === $type || 'product_faq' === $type ) {
372 return $type;
373 }
374 return 'docs';
375 }
376
377 /**
378 * Per-content-type category cap. Product FAQs cover the four store-ops groups
379 * (Payments & Billing, Shipping & Delivery, Returns & Refunds, Orders & Account);
380 * docs and general FAQ keep the default cap.
381 *
382 * @return int
383 */
384 protected function max_categories( $content_type ) {
385 return 'product_faq' === $content_type ? 4 : self::MAX_CATEGORIES;
386 }
387
388 /**
389 * Deterministic, type-aware suggested categories (no AI) for the profile screen.
390 *
391 * @return array
392 */
393 protected function suggested_categories( array $profile, $content_type ) {
394 $type = isset( $profile['type'] ) ? $profile['type'] : 'general';
395
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 ) );
403
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 ];
413 }
414
415 return $cats;
416 }
417
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'];
439 }
440
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 ) );
444
445 $icons = [ 'book', 'truck', 'help', 'refund' ];
446
447 $cats = [];
448 foreach ( $names as $i => $name ) {
449 $cats[] = [
450 'id' => sanitize_key( 'sd_' . $i ),
451 'name' => $name,
452 'icon' => $icons[ $i % count( $icons ) ],
453 'color' => self::PALETTE[ $i % count( self::PALETTE ) ],
454 'articles' => $this->seed_articles( $name, $content_type ),
455 ];
456 }
457
458 return $cats;
459 }
460
461 /**
462 * Placeholder article/question titles so the profile-step counters render.
463 * The proxy replaces these with real generated content.
464 *
465 * @return array
466 */
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 ];
475 }
476
477 return [
478 /* translators: %s: suggested category name. */
479 sprintf( __( '%s — overview', 'betterdocs' ), $name ),
480 __( 'Key concepts', 'betterdocs' ),
481 __( 'Step-by-step guide', 'betterdocs' ),
482 ];
483 }
484
485 /**
486 * Keep only the generated categories the user left selected on the profile
487 * screen, matched by normalized name. Falls back to the full generated set when
488 * the selection is empty or nothing matches, so we never return zero categories.
489 *
490 * @param array $generated Categories produced by the deterministic generator.
491 * @param array $selected The user's kept categories (each with a 'name').
492 * @return array
493 */
494 protected function filter_selected_categories( array $generated, array $selected ) {
495 if ( empty( $selected ) ) {
496 return $generated;
497 }
498
499 $wanted = [];
500 foreach ( $selected as $cat ) {
501 if ( is_array( $cat ) && ! empty( $cat['name'] ) ) {
502 $wanted[ $this->normalize_category_name( $cat['name'] ) ] = true;
503 }
504 }
505 if ( empty( $wanted ) ) {
506 return $generated;
507 }
508
509 $filtered = array_values(
510 array_filter(
511 $generated,
512 function ( $group ) use ( $wanted ) {
513 return ! empty( $group['name'] ) && isset( $wanted[ $this->normalize_category_name( $group['name'] ) ] );
514 }
515 )
516 );
517
518 return ! empty( $filtered ) ? $filtered : $generated;
519 }
520
521 /**
522 * Normalize a category name for loose matching between the user's selection and
523 * the generated set (case/whitespace/tag-insensitive).
524 *
525 * @return string
526 */
527 protected function normalize_category_name( $name ) {
528 return strtolower( trim( wp_strip_all_tags( (string) $name ) ) );
529 }
530
531 /**
532 * Sanitize + cap an incoming categories array (used both for the user's edited
533 * list and the proxy response).
534 *
535 * @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).
537 * @return array
538 */
539 protected function sanitize_categories( array $categories, $with_content = false, $max = self::MAX_CATEGORIES ) {
540 $max = max( 1, (int) $max );
541 $clean = [];
542 foreach ( array_slice( $categories, 0, $max ) as $i => $cat ) {
543 if ( empty( $cat['name'] ) ) {
544 continue;
545 }
546
547 $articles = [];
548 $raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : [];
549 foreach ( array_slice( $raw, 0, self::MAX_ARTICLES_PER_CATEGORY ) as $article ) {
550 if ( is_array( $article ) ) {
551 $entry = [
552 'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ),
553 'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ),
554 ];
555 if ( $with_content ) {
556 $entry['content_html'] = wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' );
557 }
558 if ( '' !== $entry['title'] ) {
559 $articles[] = $entry;
560 }
561 } else {
562 $title = sanitize_text_field( $article );
563 if ( '' !== $title ) {
564 $articles[] = $with_content ? [ 'title' => $title, 'content_html' => '', 'excerpt' => '' ] : $title;
565 }
566 }
567 }
568
569 $clean[] = [
570 'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ),
571 'name' => sanitize_text_field( $cat['name'] ),
572 'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '',
573 'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book',
574 'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ],
575 'articles' => $articles,
576 ];
577 }
578
579 return $clean;
580 }
581 }
582