| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\REST; |
| 4 |
|
| 5 |
use WP_REST_Request; |
| 6 |
use WPDeveloper\BetterDocs\Core\BaseAPI; |
| 7 |
use WPDeveloper\BetterDocs\Utils\AIUsage; |
| 8 |
|
| 9 |
/** |
| 10 |
* Server-side proxy for the "Write FAQ with BetterDocs AI" feature. |
| 11 |
* |
| 12 |
* The FAQ builder used to call OpenAI directly from the browser with the secret API key — |
| 13 |
* this endpoint moves that call server-side so the key never leaves the site. Uses the |
| 14 |
* same dynamic model/token settings as Write with AI (write_with_ai_model). |
| 15 |
*/ |
| 16 |
class AIFaq extends BaseAPI { |
| 17 |
|
| 18 |
const MAX_QUESTION_LENGTH = 500; |
| 19 |
const MAX_KEYWORDS_LENGTH = 300; |
| 20 |
|
| 21 |
public function register() { |
| 22 |
$this->post( |
| 23 |
'/ai-faq', |
| 24 |
array( $this, 'generate' ), |
| 25 |
array( |
| 26 |
'question' => array( |
| 27 |
'type' => 'string', |
| 28 |
'required' => true |
| 29 |
), |
| 30 |
'keywords' => array( |
| 31 |
'type' => 'string', |
| 32 |
'required' => false, |
| 33 |
'default' => '' |
| 34 |
) |
| 35 |
) |
| 36 |
); |
| 37 |
} |
| 38 |
|
| 39 |
public function permission_check() { |
| 40 |
return current_user_can( 'edit_others_posts' ); |
| 41 |
} |
| 42 |
|
| 43 |
public function generate( WP_REST_Request $request ) { |
| 44 |
$settings = betterdocs()->settings; |
| 45 |
|
| 46 |
if ( ! $settings->get( 'enable_faq_write_with_ai', true ) ) { |
| 47 |
return $this->error( |
| 48 |
'ai_disabled', |
| 49 |
__( 'Write FAQ with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ), |
| 50 |
400 |
| 51 |
); |
| 52 |
} |
| 53 |
|
| 54 |
$write_ai = betterdocs()->ai_autowrtie; |
| 55 |
if ( empty( $write_ai ) || empty( $write_ai->get_api_key() ) ) { |
| 56 |
return $this->error( |
| 57 |
'ai_no_key', |
| 58 |
__( 'OpenAI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ), |
| 59 |
400 |
| 60 |
); |
| 61 |
} |
| 62 |
|
| 63 |
$question = trim( wp_strip_all_tags( (string) $request->get_param( 'question' ) ) ); |
| 64 |
$keywords = trim( wp_strip_all_tags( (string) $request->get_param( 'keywords' ) ) ); |
| 65 |
|
| 66 |
if ( $question === '' ) { |
| 67 |
return $this->error( |
| 68 |
'ai_empty_question', |
| 69 |
__( 'Please provide a question for the AI.', 'betterdocs' ), |
| 70 |
400 |
| 71 |
); |
| 72 |
} |
| 73 |
|
| 74 |
if ( strlen( $question ) > self::MAX_QUESTION_LENGTH ) { |
| 75 |
$question = substr( $question, 0, self::MAX_QUESTION_LENGTH ); |
| 76 |
} |
| 77 |
if ( strlen( $keywords ) > self::MAX_KEYWORDS_LENGTH ) { |
| 78 |
$keywords = substr( $keywords, 0, self::MAX_KEYWORDS_LENGTH ); |
| 79 |
} |
| 80 |
|
| 81 |
$prompt = sprintf( |
| 82 |
'Write a clear, concise answer in plain text for the following FAQ question related to %s. The question: %s', |
| 83 |
$keywords, |
| 84 |
$question |
| 85 |
); |
| 86 |
$system = __( 'You are a helpful assistant that writes clear, concise FAQ answers in plain text. Do not use any Markdown or special formatting — no asterisks, bold, italics, headings, bullet points, backticks or code fences. Reply with a direct answer written in plain sentences.', 'betterdocs' ); |
| 87 |
|
| 88 |
$result = $write_ai->generate_text( $prompt, $system ); |
| 89 |
|
| 90 |
if ( empty( $result['success'] ) ) { |
| 91 |
$message = isset( $result['error'] ) ? (string) $result['error'] : __( 'Unknown AI error.', 'betterdocs' ); |
| 92 |
return $this->error( 'ai_upstream', $message, 502 ); |
| 93 |
} |
| 94 |
|
| 95 |
$answer = $this->clean_response( (string) $result['content'] ); |
| 96 |
|
| 97 |
if ( $answer === '' ) { |
| 98 |
return $this->error( |
| 99 |
'ai_empty_response', |
| 100 |
__( 'The AI returned no answer. Try again or rephrase your question.', 'betterdocs' ), |
| 101 |
502 |
| 102 |
); |
| 103 |
} |
| 104 |
|
| 105 |
// FAQ answers are generated before the FAQ post exists — no reliable post id. |
| 106 |
AIUsage::record( 'faq_write_with_ai' ); |
| 107 |
|
| 108 |
return $this->success( |
| 109 |
array( |
| 110 |
'answer' => $answer, |
| 111 |
'model' => isset( $result['model'] ) ? $result['model'] : null |
| 112 |
) |
| 113 |
); |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Strip the leading "Answer:" label / stray leading "?" the browser-side code removed. |
| 118 |
*/ |
| 119 |
protected function clean_response( $text ) { |
| 120 |
$text = trim( $text ); |
| 121 |
$text = preg_replace( '/^\s*answer\s*:\s*/i', '', $text ); |
| 122 |
$text = preg_replace( '/^\s*\?/', '', $text ); |
| 123 |
$text = $this->strip_markdown( $text ); |
| 124 |
|
| 125 |
return trim( $text ); |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Safety net: strip the common Markdown the model may still emit despite the |
| 130 |
* plain-text instruction, so FAQ answers never render literal asterisks. |
| 131 |
*/ |
| 132 |
protected function strip_markdown( $text ) { |
| 133 |
$text = preg_replace( '/```.*?```/s', '', $text ); // fenced code blocks |
| 134 |
$text = preg_replace( '/\*\*(.+?)\*\*/s', '$1', $text ); // **bold** |
| 135 |
$text = preg_replace( '/__(.+?)__/s', '$1', $text ); // __bold__ |
| 136 |
$text = preg_replace( '/`([^`]*)`/', '$1', $text ); // `inline code` |
| 137 |
$text = preg_replace( '/^\s{0,3}#{1,6}\s+/m', '', $text ); // # headings |
| 138 |
$text = preg_replace( '/^\s{0,3}>\s?/m', '', $text ); // > blockquotes |
| 139 |
$text = preg_replace( '/^\s{0,3}[-*+]\s+/m', '', $text ); // -, *, + bullet markers |
| 140 |
|
| 141 |
return trim( $text ); |
| 142 |
} |
| 143 |
} |
| 144 |
|