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

549 lines 19.2 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
262 $args = [
263 'timeout' => 30,
264 'headers' => [
265 'Content-Type' => 'application/json',
266 'Accept' => 'application/json',
267 'X-BetterDocs-Site' => esc_url_raw( home_url() ),
268 'X-BetterDocs-License' => $this->license_key(),
269 'X-BetterDocs-Signature' => hash_hmac( 'sha256', $body, $secret ),
270 ],
271 'body' => $body,
272 ];
273
274 $attempts = 0;
275 $response = null;
276 while ( $attempts < 2 ) {
277 $attempts++;
278 $response = wp_remote_post( $url, $args );
279
280 // Retry ONLY on a genuine transport failure (no HTTP response). A 5xx may
281 // mean the proxy already called OpenAI and spent tokens, so re-POSTing
282 // would risk double-billing — treat any received status as final.
283 if ( ! is_wp_error( $response ) ) {
284 break;
285 }
286 }
287
288 if ( is_wp_error( $response ) ) {
289 return $this->error( 'proxy_unreachable', __( 'Could not reach the BetterDocs AI service. Please try again.', 'betterdocs' ), 502 );
290 }
291
292 $status = wp_remote_retrieve_response_code( $response );
293 $parsed = json_decode( wp_remote_retrieve_body( $response ), true );
294
295 if ( 429 === $status || ( isset( $parsed['status'] ) && 'quota_exceeded' === $parsed['status'] ) ) {
296 return $this->error( 'quota_exceeded', __( 'You have used your free AI generation for this site.', 'betterdocs' ), 429 );
297 }
298
299 if ( $status >= 400 || empty( $parsed['categories'] ) || ! is_array( $parsed['categories'] ) ) {
300 $message = isset( $parsed['message'] ) ? $parsed['message'] : __( 'The AI service returned an unexpected response.', 'betterdocs' );
301 return $this->error( 'proxy_error', $message, 502 );
302 }
303
304 // Enforce caps + sanitize defensively on our side too.
305 $parsed['categories'] = $this->sanitize_categories( $parsed['categories'], true );
306
307 return $parsed;
308 }
309
310 protected function proxy_url() {
311 $base = get_option( 'betterdocs_ai_proxy_url', 'https://api.betterdocs.co/ai' );
312 /** Filter the hosted proxy base URL. */
313 $base = apply_filters( 'betterdocs_ai_proxy_url', $base );
314 return trailingslashit( $base ) . 'v1/sample-docs';
315 }
316
317 protected function proxy_secret() {
318 $secret = get_option( 'betterdocs_ai_proxy_secret', 'betterdocs-local-dev-secret' );
319 /** Filter the HMAC shared secret used to sign proxy requests. */
320 return apply_filters( 'betterdocs_ai_proxy_secret', $secret );
321 }
322
323 protected function license_key() {
324 /** Filter the license key sent to the proxy for per-site identity. */
325 return apply_filters( 'betterdocs_ai_proxy_license', (string) get_option( 'betterdocs_pro_licenses', '' ) );
326 }
327
328 /* --------------------------------------------------------------------- */
329 /* Helpers */
330 /* --------------------------------------------------------------------- */
331
332 protected function is_enabled() {
333 return (bool) $this->settings->get( 'enable_ai_sample_docs', true );
334 }
335
336 protected function content_type( WP_REST_Request $request ) {
337 $type = $request->get_param( 'content_type' );
338 if ( 'faq' === $type || 'product_faq' === $type ) {
339 return $type;
340 }
341 return 'docs';
342 }
343
344 /**
345 * Per-content-type category cap. Product FAQs cover the four store-ops groups
346 * (Payments & Billing, Shipping & Delivery, Returns & Refunds, Orders & Account);
347 * docs and general FAQ keep the default cap.
348 *
349 * @return int
350 */
351 protected function max_categories( $content_type ) {
352 return 'product_faq' === $content_type ? 4 : self::MAX_CATEGORIES;
353 }
354
355 /**
356 * Deterministic, type-aware suggested categories (no AI) for the profile screen.
357 *
358 * @return array
359 */
360 protected function suggested_categories( array $profile, $content_type ) {
361 $type = isset( $profile['type'] ) ? $profile['type'] : 'general';
362
363 // WooCommerce Product FAQ: the curated store starter set is the single
364 // source of truth (StoreFaqContent), so the chips/titles shown here match
365 // what the deterministic fallback generates.
366 if ( 'product_faq' === $content_type ) {
367 $store = $this->store_faq();
368 $defs = $store ? $store->definition() : [];
369 $defs = array_slice( $defs, 0, $this->max_categories( $content_type ) );
370
371 $cats = [];
372 foreach ( $defs as $i => $group ) {
373 $cats[] = [
374 'id' => sanitize_key( 'sd_' . $i ),
375 'name' => $group['name'],
376 'icon' => isset( $group['icon'] ) ? sanitize_key( $group['icon'] ) : 'help',
377 'color' => self::PALETTE[ $i % count( self::PALETTE ) ],
378 'articles' => array_values( (array) $group['questions'] ),
379 ];
380 }
381
382 return $cats;
383 }
384
385 if ( 'faq' === $content_type ) {
386 // FAQ groups: product-store questions on a WooCommerce/ecommerce
387 // site, general site questions otherwise.
388 $names = 'ecommerce' === $type
389 ? [ 'Shipping & Delivery', 'Returns & Refunds', 'Payments & Orders' ]
390 : [ 'Getting Started', 'Account & Billing', 'Troubleshooting' ];
391 } else {
392 // Documentation categories — article-style guides, NOT FAQ groups (no
393 // "FAQs" category here; FAQs are a separate content type). The hosted
394 // proxy refines these names to fit the specific site and writes real
395 // article titles + bodies for each.
396 $presets = [
397 'ecommerce' => [ 'Getting Started', 'Shipping & Delivery', 'Product Guides' ],
398 'course_lms' => [ 'Getting Started', 'Course Access', 'Lessons & Content' ],
399 'digital_downloads' => [ 'Getting Started', 'Downloads & Licenses', 'Installation' ],
400 'membership' => [ 'Getting Started', 'Membership & Plans', 'Member Benefits' ],
401 'community' => [ 'Getting Started', 'Using the Community', 'Your Profile' ],
402 'business_site' => [ 'Getting Started', 'Our Services', 'How-to Guides' ],
403 'general' => [ 'Getting Started', 'Guides & How-tos', 'Features & Settings' ],
404 ];
405 $names = isset( $presets[ $type ] ) ? $presets[ $type ] : $presets['general'];
406 }
407
408 // Never suggest more than the cap, so the preview matches what actually
409 // gets generated and inserted.
410 $names = array_slice( $names, 0, $this->max_categories( $content_type ) );
411
412 $icons = [ 'book', 'truck', 'help', 'refund' ];
413
414 $cats = [];
415 foreach ( $names as $i => $name ) {
416 $cats[] = [
417 'id' => sanitize_key( 'sd_' . $i ),
418 'name' => $name,
419 'icon' => $icons[ $i % count( $icons ) ],
420 'color' => self::PALETTE[ $i % count( self::PALETTE ) ],
421 'articles' => $this->seed_articles( $name, $content_type ),
422 ];
423 }
424
425 return $cats;
426 }
427
428 /**
429 * Placeholder article/question titles so the profile-step counters render.
430 * The proxy replaces these with real generated content.
431 *
432 * @return array
433 */
434 protected function seed_articles( $name, $content_type ) {
435 if ( 'faq' === $content_type ) {
436 return [
437 /* translators: %s: suggested FAQ group / category name. */
438 sprintf( __( 'What is %s?', 'betterdocs' ), $name ),
439 __( 'Common questions', 'betterdocs' ),
440 __( 'Tips & best practices', 'betterdocs' ),
441 ];
442 }
443
444 return [
445 /* translators: %s: suggested category name. */
446 sprintf( __( '%s — overview', 'betterdocs' ), $name ),
447 __( 'Key concepts', 'betterdocs' ),
448 __( 'Step-by-step guide', 'betterdocs' ),
449 ];
450 }
451
452 /**
453 * Keep only the generated categories the user left selected on the profile
454 * screen, matched by normalized name. Falls back to the full generated set when
455 * the selection is empty or nothing matches, so we never return zero categories.
456 *
457 * @param array $generated Categories produced by the deterministic generator.
458 * @param array $selected The user's kept categories (each with a 'name').
459 * @return array
460 */
461 protected function filter_selected_categories( array $generated, array $selected ) {
462 if ( empty( $selected ) ) {
463 return $generated;
464 }
465
466 $wanted = [];
467 foreach ( $selected as $cat ) {
468 if ( is_array( $cat ) && ! empty( $cat['name'] ) ) {
469 $wanted[ $this->normalize_category_name( $cat['name'] ) ] = true;
470 }
471 }
472 if ( empty( $wanted ) ) {
473 return $generated;
474 }
475
476 $filtered = array_values(
477 array_filter(
478 $generated,
479 function ( $group ) use ( $wanted ) {
480 return ! empty( $group['name'] ) && isset( $wanted[ $this->normalize_category_name( $group['name'] ) ] );
481 }
482 )
483 );
484
485 return ! empty( $filtered ) ? $filtered : $generated;
486 }
487
488 /**
489 * Normalize a category name for loose matching between the user's selection and
490 * the generated set (case/whitespace/tag-insensitive).
491 *
492 * @return string
493 */
494 protected function normalize_category_name( $name ) {
495 return strtolower( trim( wp_strip_all_tags( (string) $name ) ) );
496 }
497
498 /**
499 * Sanitize + cap an incoming categories array (used both for the user's edited
500 * list and the proxy response).
501 *
502 * @param bool $with_content Keep article body HTML (proxy response) vs titles only.
503 * @param int $max Category cap (defaults to MAX_CATEGORIES; product_faq uses more).
504 * @return array
505 */
506 protected function sanitize_categories( array $categories, $with_content = false, $max = self::MAX_CATEGORIES ) {
507 $max = max( 1, (int) $max );
508 $clean = [];
509 foreach ( array_slice( $categories, 0, $max ) as $i => $cat ) {
510 if ( empty( $cat['name'] ) ) {
511 continue;
512 }
513
514 $articles = [];
515 $raw = isset( $cat['articles'] ) && is_array( $cat['articles'] ) ? $cat['articles'] : [];
516 foreach ( array_slice( $raw, 0, self::MAX_ARTICLES_PER_CATEGORY ) as $article ) {
517 if ( is_array( $article ) ) {
518 $entry = [
519 'title' => sanitize_text_field( isset( $article['title'] ) ? $article['title'] : '' ),
520 'excerpt' => sanitize_text_field( isset( $article['excerpt'] ) ? $article['excerpt'] : '' ),
521 ];
522 if ( $with_content ) {
523 $entry['content_html'] = wp_kses_post( isset( $article['content_html'] ) ? $article['content_html'] : '' );
524 }
525 if ( '' !== $entry['title'] ) {
526 $articles[] = $entry;
527 }
528 } else {
529 $title = sanitize_text_field( $article );
530 if ( '' !== $title ) {
531 $articles[] = $with_content ? [ 'title' => $title, 'content_html' => '', 'excerpt' => '' ] : $title;
532 }
533 }
534 }
535
536 $clean[] = [
537 'id' => sanitize_key( isset( $cat['id'] ) ? $cat['id'] : 'sd_' . $i ),
538 'name' => sanitize_text_field( $cat['name'] ),
539 'description' => isset( $cat['description'] ) ? sanitize_text_field( $cat['description'] ) : '',
540 'icon' => isset( $cat['icon'] ) ? sanitize_key( $cat['icon'] ) : 'book',
541 'color' => sanitize_hex_color( isset( $cat['color'] ) ? $cat['color'] : '' ) ?: self::PALETTE[ $i % count( self::PALETTE ) ],
542 'articles' => $articles,
543 ];
544 }
545
546 return $clean;
547 }
548 }
549