PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.5.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.5.5
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 / Utils / AIHelper.php

AIHelper.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.5.5, at includes/Utils/AIHelper.php

617 lines 22.7 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\Utils;
4
5 use WPDeveloper\BetterDocs\Core\Settings;
6
7 class AIHelper {
8
9 /**
10 * Settings instance
11 *
12 * @var Settings
13 */
14 private $settings;
15
16 public function __construct( Settings $settings ) {
17 $this->settings = $settings;
18 }
19
20 /**
21 * Get OpenAI API key from settings
22 *
23 * @return string
24 */
25 public function get_api_key() {
26 return $this->settings->get( 'ai_autowrite_api_key', '' );
27 }
28
29 /**
30 * Check if OpenAI API key is configured
31 *
32 * @return bool
33 */
34 public function has_api_key() {
35 $api_key = $this->get_api_key();
36 return ! empty( $api_key );
37 }
38
39 /**
40 * Validate OpenAI API key
41 *
42 * @param string $api_key Optional API key to validate, uses stored key if not provided
43 * @return array Array with 'valid' boolean and 'message' string
44 */
45 public function validate_api_key( $api_key = '' ) {
46 if ( empty( $api_key ) ) {
47 $api_key = $this->get_api_key();
48 }
49
50 if ( empty( $api_key ) ) {
51 return array(
52 'valid' => false,
53 'message' => 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">OpenAI API Key</a> to use AI features.'
54 );
55 }
56
57 $ch = curl_init( 'https://api.openai.com/v1/models' ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
58 curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
59 curl_setopt( //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
60 $ch,
61 CURLOPT_HTTPHEADER,
62 array(
63 'Content-Type: application/json',
64 'Authorization: Bearer ' . $api_key
65 )
66 );
67
68 $response = curl_exec( $ch ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec
69 $httpCode = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
70 curl_close( $ch ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
71
72 if ( 200 == $httpCode ) {
73 return array(
74 'valid' => true,
75 'message' => 'Valid API Key'
76 );
77 } else {
78 $responseData = json_decode( $response, true );
79 $messageData = $responseData[ 'error' ] ?? '';
80 return array(
81 'valid' => false,
82 'message' => $messageData[ 'message' ] ?? 'Invalid API Key'
83 );
84 }
85 }
86
87 /**
88 * Minimum token policy by (feature context, model family). Used as the
89 * single source of truth for:
90 * - server-side save validation (Core/Settings.php)
91 * - field UI props sent to the React notice (Core/Settings.php)
92 * - runtime payload floor in build_openai_payload()
93 *
94 * Override the whole map (or any cell) via the `betterdocs_ai_min_tokens`
95 * filter. Returns 0 when no minimum applies (unknown context or model).
96 *
97 * @param string $context Feature key, e.g. 'write_with_ai' or 'article_summary'.
98 * @param string $model OpenAI model identifier.
99 * @return int Minimum recommended max_tokens for that pair.
100 */
101 public static function get_min_tokens( $context, $model ) {
102 $family = self::token_family( $model );
103 $map = apply_filters( 'betterdocs_ai_min_tokens', array(
104 'write_with_ai' => array( 'gpt-4' => 2500, 'gpt-5' => 4500, 'gpt-5.5' => 10000 ),
105 'article_summary' => array( 'gpt-4' => 1500, 'gpt-5' => 2500, 'gpt-5.5' => 10000 ),
106 ) );
107 return isset( $map[ $context ][ $family ] ) ? (int) $map[ $context ][ $family ] : 0;
108 }
109
110 /**
111 * Map a model id to its token-floor family key.
112 *
113 * gpt-5.x point releases (gpt-5.5, gpt-5.1, ...) generate much larger, slower
114 * responses and need a heavier floor than the base gpt-5 family, so they get
115 * their own 'gpt-5.5' key. Plain gpt-5* stays 'gpt-5'; everything else 'gpt-4'.
116 *
117 * @param string $model OpenAI model identifier.
118 * @return string Family key used in the min-token map.
119 */
120 private static function token_family( $model ) {
121 if ( self::is_gpt5_point_release( $model ) ) {
122 return 'gpt-5.5';
123 }
124 return ( 0 === strpos( (string) $model, 'gpt-5' ) ) ? 'gpt-5' : 'gpt-4';
125 }
126
127 /**
128 * Whether a model is a gpt-5.x point release (gpt-5.5, gpt-5.1, ...), which
129 * use the newer reasoning_effort vocabulary and need a heavier token floor.
130 *
131 * @param string $model OpenAI model identifier.
132 * @return bool
133 */
134 private static function is_gpt5_point_release( $model ) {
135 return (bool) preg_match( '/^gpt-5\.\d/', (string) $model );
136 }
137
138 /**
139 * Return the threshold map for one context, in {family => min} shape, so
140 * Settings.php can serialize it onto a field for the React notice to read.
141 *
142 * @param string $context Feature key.
143 * @return array<string,int>
144 */
145 public static function get_min_tokens_map( $context ) {
146 return array(
147 'gpt-4' => self::get_min_tokens( $context, 'gpt-4o' ),
148 'gpt-5' => self::get_min_tokens( $context, 'gpt-5' ),
149 'gpt-5.5' => self::get_min_tokens( $context, 'gpt-5.5' ),
150 );
151 }
152
153 /**
154 * Build an OpenAI Chat Completions request body, switching parameter shape
155 * for model families that reject the legacy max_tokens / custom temperature.
156 *
157 * GPT-5 family requires max_completion_tokens and rejects any non-default
158 * temperature, so we omit both. It is also a reasoning model: internal
159 * reasoning tokens are billed against max_completion_tokens before any
160 * visible output is produced, so we send a low reasoning_effort by default
161 * (see default_reasoning_effort()). Without that the model can spend the
162 * entire budget on reasoning and return empty content with
163 * finish_reason=length.
164 *
165 * When `$context` is provided we also raise `$max_tokens` to the per-family
166 * minimum from get_min_tokens(), so the request never goes out below the
167 * policy floor regardless of what's stored in settings.
168 *
169 * @param string $model OpenAI model identifier (e.g. 'gpt-4o', 'gpt-5-mini').
170 * @param array $messages Chat messages array.
171 * @param int $max_tokens Token cap (will be raised to feature minimum if $context is set).
172 * @param float|null $temperature Optional sampling temperature; ignored for gpt-5*.
173 * @param string|null $context Feature key for runtime min-token enforcement. Pass null for back-compat.
174 * @return array Request body ready to JSON-encode.
175 */
176 public static function build_openai_payload( $model, $messages, $max_tokens, $temperature = null, $context = null ) {
177 if ( null !== $context ) {
178 $min = self::get_min_tokens( $context, $model );
179 if ( $min > 0 && (int) $max_tokens < $min ) {
180 $max_tokens = $min;
181 }
182 }
183
184 $payload = array(
185 'model' => $model,
186 'messages' => $messages,
187 );
188
189 if ( 0 === strpos( $model, 'gpt-5' ) ) {
190 $payload['max_completion_tokens'] = $max_tokens;
191 $payload['reasoning_effort'] = apply_filters( 'betterdocs_openai_gpt5_reasoning_effort', self::default_reasoning_effort( $model ), $model, $max_tokens );
192 return $payload;
193 }
194
195 $payload['max_tokens'] = $max_tokens;
196 if ( null !== $temperature ) {
197 $payload['temperature'] = $temperature;
198 }
199 return $payload;
200 }
201
202 /**
203 * Default reasoning_effort for a gpt-5* model.
204 *
205 * The original GPT-5 generation (gpt-5, gpt-5-mini, gpt-5-nano) accepts
206 * 'minimal'. The gpt-5.x point releases (e.g. gpt-5.5) dropped 'minimal'
207 * from the API and only accept none|low|medium|high|xhigh; sending
208 * 'minimal' returns a 400 "Unsupported value: 'reasoning_effort'". For
209 * those we default to 'none' — no reasoning tokens, which is the fastest
210 * option and leaves the whole token budget for visible output (the closest
211 * equivalent to the gpt-5 'minimal' behaviour). Override per model via the
212 * betterdocs_openai_gpt5_reasoning_effort filter.
213 *
214 * @param string $model OpenAI model identifier.
215 * @return string reasoning_effort value.
216 */
217 private static function default_reasoning_effort( $model ) {
218 // Point releases like gpt-5.5 use the new vocabulary; plain gpt-5* keep 'minimal'.
219 if ( self::is_gpt5_point_release( $model ) ) {
220 return 'none';
221 }
222 return 'minimal';
223 }
224
225 /**
226 * Make a request to OpenAI API
227 *
228 * @param array $messages Array of messages for the chat completion
229 * @param array $options Optional parameters (model, max_tokens, temperature, etc.)
230 * @return string|\WP_Error API response content or error
231 */
232 public function make_openai_request( $messages, $options = array() ) {
233 $api_key = $this->get_api_key();
234 $max_tokens = $this->settings->get( 'article_summary_max_token', 1500 );
235 $model = $this->settings->get( 'article_summary_model', 'gpt-4o-mini' );
236
237 if ( empty( $api_key ) ) {
238 return new \WP_Error( 'no_api_key', 'OpenAI API key is not configured.' );
239 }
240
241 // Default options
242 $defaults = array(
243 'model' => $model,
244 'max_tokens' => $max_tokens,
245 'temperature' => 0.7,
246 'timeout' => 50
247 );
248
249 $options = wp_parse_args( $options, $defaults );
250
251 $api_endpoint = 'https://api.openai.com/v1/chat/completions';
252
253 $request_body = self::build_openai_payload(
254 $options[ 'model' ],
255 $messages,
256 $options[ 'max_tokens' ],
257 $options[ 'temperature' ],
258 'article_summary'
259 );
260
261 $request_options = array(
262 'headers' => array(
263 'Content-Type' => 'application/json',
264 'Authorization' => 'Bearer ' . $api_key
265 ),
266 'body' => json_encode( $request_body ), //phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
267 'timeout' => $options[ 'timeout' ]
268 );
269
270 $response = wp_remote_post( $api_endpoint, $request_options );
271
272 if ( is_wp_error( $response ) ) {
273 return new \WP_Error( 'api_error', 'Failed to connect to OpenAI API: ' . $response->get_error_message() );
274 }
275
276 $body = wp_remote_retrieve_body( $response );
277 $data = json_decode( $body, true );
278
279 if ( ! empty( $data[ 'error' ] ) ) {
280 return new \WP_Error( 'openai_error', $data[ 'error' ][ 'message' ] );
281 }
282
283 if ( empty( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ) {
284 return new \WP_Error( 'no_content', 'No content received from OpenAI.' );
285 }
286
287 return $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ];
288 }
289
290 /**
291 * Analyze article quality using OpenAI
292 *
293 * @param string $content Article content to analyze
294 * @param string $title Article title (optional)
295 * @return array|\WP_Error Analysis result with score and feedback
296 */
297 public function analyze_article_quality( $content, $title = '' ) {
298 if ( empty( $content ) ) {
299 return new \WP_Error( 'empty_content', 'Article content cannot be empty.' );
300 }
301
302 // Create analysis prompt
303 $prompt = $this->build_quality_analysis_prompt( $content, $title );
304
305 $messages = array(
306 array(
307 'role' => 'system',
308 'content' => 'You are an expert content analyst specializing in documentation quality assessment. Provide detailed, actionable feedback to help improve article quality.'
309 ),
310 array(
311 'role' => 'user',
312 'content' => $prompt
313 )
314 );
315
316 $options = array(
317 'max_tokens' => 2000,
318 'temperature' => 0.3 // Lower temperature for more consistent analysis
319 );
320
321 $response = $this->make_openai_request( $messages, $options );
322
323 if ( is_wp_error( $response ) ) {
324 return $response;
325 }
326
327 // Parse the AI response into structured data
328 return $this->parse_quality_analysis_response( $response );
329 }
330
331 /**
332 * Build the prompt for article quality analysis
333 *
334 * @param string $content Article content
335 * @param string $title Article title
336 * @return string Formatted prompt
337 */
338 private function build_quality_analysis_prompt( $content, $title = '' ) {
339 $title_section = ! empty( $title ) ? "Title: {$title}\n\n" : '';
340
341 $prompt = "Please analyze the following documentation article for quality and provide a comprehensive assessment:\n\n";
342 $prompt .= $title_section;
343 $prompt .= "Content:\n{$content}\n\n";
344 $prompt .= "Please evaluate the article based on these criteria and provide your response in the following JSON format:\n\n";
345 $prompt .= "{\n";
346 $prompt .= ' "overall_score": 91,';
347 $prompt .= ' "scores": {';
348 $prompt .= ' "clarity": 90,';
349 $prompt .= ' "completeness": 92,';
350 $prompt .= ' "relevance": 95,';
351 $prompt .= ' "structure": 87,';
352 $prompt .= ' "readability": 88';
353 $prompt .= ' },';
354 $prompt .= ' "feedback": {';
355 $prompt .= ' "strengths": ["Clear headings", "Good use of examples"],';
356 $prompt .= ' "improvements": ["Add more detailed explanations in section 2", "Include troubleshooting steps"],';
357 $prompt .= ' "suggestions": ["Consider adding screenshots", "Break down complex paragraphs"]';
358 $prompt .= ' },';
359 $prompt .= ' "priority": "medium"';
360 $prompt .= "}\n\n";
361 $prompt .= "Scoring criteria (0-100):\n";
362 $prompt .= "- Clarity: How clear and understandable is the content?\n";
363 $prompt .= "- Completeness: Does it cover the topic thoroughly?\n";
364 $prompt .= "- Relevance: Is the content relevant to the stated purpose?\n";
365 $prompt .= "- Structure: Is the content well-organized with proper headings?\n";
366 $prompt .= "- Readability: Is it easy to read and follow?\n\n";
367 $prompt .= "Priority levels: low (80+), medium (60-79), high (below 60)\n";
368 $prompt .= "Provide specific, actionable feedback that content creators can implement.";
369
370 return $prompt;
371 }
372
373 /**
374 * Parse AI response into structured quality analysis data
375 *
376 * @param string $response Raw AI response
377 * @return array|\WP_Error Parsed analysis data
378 */
379 private function parse_quality_analysis_response( $response ) {
380 // Try to extract JSON from the response
381 $json_start = strpos( $response, '{' );
382 $json_end = strrpos( $response, '}' );
383
384 if ( false === $json_start || false === $json_end ) {
385 return new \WP_Error( 'parse_error', 'Could not find valid JSON in AI response.' );
386 }
387
388 $json_string = substr( $response, $json_start, $json_end - $json_start + 1 );
389 $data = json_decode( $json_string, true );
390
391 if ( json_last_error() !== JSON_ERROR_NONE ) {
392 return new \WP_Error( 'json_error', 'Invalid JSON in AI response: ' . json_last_error_msg() );
393 }
394
395 // Validate required fields
396 $required_fields = array( 'overall_score', 'scores', 'feedback' );
397 foreach ( $required_fields as $field ) {
398 if ( ! isset( $data[ $field ] ) ) {
399 return new \WP_Error( 'missing_field', "Missing required field: {$field}" );
400 }
401 }
402
403 // Ensure scores are within valid range
404 $data[ 'overall_score' ] = max( 0, min( 100, intval( $data[ 'overall_score' ] ) ) );
405
406 if ( isset( $data[ 'scores' ] ) && is_array( $data[ 'scores' ] ) ) {
407 foreach ( $data[ 'scores' ] as $key => $score ) {
408 $data[ 'scores' ][ $key ] = max( 0, min( 100, intval( $score ) ) );
409 }
410 }
411
412 // Set default priority if not provided
413 if ( ! isset( $data[ 'priority' ] ) ) {
414 $overall_score = $data[ 'overall_score' ];
415 if ( $overall_score >= 80 ) {
416 $data[ 'priority' ] = 'low';
417 } elseif ( $overall_score >= 60 ) {
418 $data[ 'priority' ] = 'medium';
419 } else {
420 $data[ 'priority' ] = 'high';
421 }
422 }
423
424 return $data;
425 }
426
427 /**
428 * Save article quality score as post meta
429 *
430 * @param int $post_id Post ID
431 * @param array $quality_data Quality analysis data
432 * @return bool Success status
433 */
434 public function save_article_quality_score( $post_id, $quality_data ) {
435 if ( empty( $post_id ) || ! is_array( $quality_data ) ) {
436 return false;
437 }
438
439 // Save the complete analysis data
440 $saved = update_post_meta( $post_id, '_betterdocs_article_quality_analysis', $quality_data );
441
442 // Save just the overall score for easy querying
443 update_post_meta( $post_id, '_betterdocs_article_quality_score', $quality_data[ 'overall_score' ] );
444
445 // Save analysis timestamp
446 update_post_meta( $post_id, '_betterdocs_article_quality_analyzed_at', current_time( 'mysql' ) );
447
448 return false !== $saved;
449 }
450
451 /**
452 * Get article quality score from post meta
453 *
454 * @param int $post_id Post ID
455 * @return array|false Quality analysis data or false if not found
456 */
457 public function get_article_quality_score( $post_id ) {
458 if ( empty( $post_id ) ) {
459 return false;
460 }
461
462 $quality_data = get_post_meta( $post_id, '_betterdocs_article_quality_analysis', true );
463
464 if ( empty( $quality_data ) ) {
465 return false;
466 }
467
468 // Add timestamp if available
469 $analyzed_at = get_post_meta( $post_id, '_betterdocs_article_quality_analyzed_at', true );
470 if ( $analyzed_at ) {
471 $quality_data[ 'analyzed_at' ] = $analyzed_at;
472 }
473
474 return $quality_data;
475 }
476
477 /**
478 * Check if article needs re-analysis based on last modified date
479 *
480 * @param int $post_id Post ID
481 * @return bool True if re-analysis is needed
482 */
483 public function needs_reanalysis( $post_id ) {
484 $analyzed_at = get_post_meta( $post_id, '_betterdocs_article_quality_analyzed_at', true );
485
486 if ( empty( $analyzed_at ) ) {
487 return true; // Never analyzed
488 }
489
490 $post = get_post( $post_id );
491 if ( ! $post ) {
492 return false;
493 }
494
495 // Check if post was modified after last analysis
496 $post_modified = strtotime( $post->post_modified );
497 $analyzed_timestamp = strtotime( $analyzed_at );
498
499 return $post_modified > $analyzed_timestamp;
500 }
501
502 /**
503 * Create a system message for OpenAI
504 *
505 * @param string $content System message content
506 * @return array Message array
507 */
508 public function create_system_message( $content ) {
509 return array(
510 'role' => 'system',
511 'content' => $content
512 );
513 }
514
515 /**
516 * Create a user message for OpenAI
517 *
518 * @param string $content User message content
519 * @return array Message array
520 */
521 public function create_user_message( $content ) {
522 return array(
523 'role' => 'user',
524 'content' => $content
525 );
526 }
527
528 /**
529 * Create messages array for article summarization
530 *
531 * @param string $title Article title
532 * @param string $content Article content
533 * @return array Messages array
534 */
535 public function create_summary_messages( $title, $content ) {
536 $system_message = $this->create_system_message(
537 'You are a helpful assistant that creates concise, informative summaries of documentation articles. Always format your response in clean HTML with paragraph tags. Do not use markdown formatting, code blocks, or backticks. Return only the HTML content without any wrapper formatting.'
538 );
539
540 $user_prompt = "Please provide a concise summary of the following article titled '{$title}'. The summary should be 2-3 paragraphs long, highlighting the main points and key takeaways. Format the response in HTML with proper paragraph tags. Do not wrap the response in markdown code blocks or use any markdown formatting.\n\nArticle content:\n{$content}";
541
542 $user_message = $this->create_user_message( $user_prompt );
543
544 return array( $system_message, $user_message );
545 }
546
547 /**
548 * Create messages array for content generation
549 *
550 * @param string $prompt User prompt
551 * @param string $keywords Optional keywords
552 * @return array Messages array
553 */
554 public function create_content_messages( $prompt, $keywords = '' ) {
555 $system_message = $this->create_system_message(
556 'You are a helpful assistant who writes documentation for users.'
557 );
558
559 $user_message = $this->create_user_message( $prompt );
560
561 return array( $system_message, $user_message );
562 }
563
564 /**
565 * Sanitize and prepare content for AI processing
566 *
567 * @param string $content Raw content
568 * @param int $max_length Maximum length to keep
569 * @return string Sanitized content
570 */
571 public function prepare_content_for_ai( $content, $max_length = 4000 ) {
572 // Strip HTML tags and decode entities
573 $content = wp_strip_all_tags( $content );
574 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
575
576 // Remove extra whitespace
577 $content = preg_replace( '/\s+/', ' ', $content );
578 $content = trim( $content );
579
580 // Limit length
581 if ( strlen( $content ) > $max_length ) {
582 $content = substr( $content, 0, $max_length );
583 // Try to cut at a word boundary
584 $last_space = strrpos( $content, ' ' );
585 if ( false !== $last_space && $last_space > $max_length * 0.8 ) {
586 $content = substr( $content, 0, $last_space );
587 }
588 $content .= '...';
589 }
590
591 return $content;
592 }
593
594 /**
595 * Check if AI features are enabled
596 *
597 * @return bool
598 */
599 public function is_ai_enabled() {
600 return $this->settings->get( 'enable_write_with_ai', true ) && $this->has_api_key();
601 }
602
603 /**
604 * Get AI usage statistics (placeholder for future implementation)
605 *
606 * @return array Usage statistics
607 */
608 public function get_usage_stats() {
609 // This could be implemented to track API usage, costs, etc.
610 return array(
611 'requests_today' => 0,
612 'tokens_used' => 0,
613 'cost_estimate' => 0
614 );
615 }
616 }
617