PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.7.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.7.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 / Core / WriteWithAI.php

WriteWithAI.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.7.0, at includes/Core/WriteWithAI.php

814 lines 32.1 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\Core;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9
10 use WPDeveloper\BetterDocs\Utils\Base;
11 use WPDeveloper\BetterDocs\Core\Settings;
12 use WPDeveloper\BetterDocs\Core\PostType;
13
14 use WPDeveloper\BetterDocs\Utils\Helper;
15 use WPDeveloper\BetterDocs\Utils\AIHelper;
16 use WPDeveloper\BetterDocs\Utils\AIUsage;
17 use WPDeveloper\BetterDocs\REST\AIEdit;
18
19 class WriteWithAI extends Base {
20
21 public $settings;
22
23 public function __construct( Settings $settings ) {
24 $this->settings = $settings;
25 // Get the post ID from the URL
26 $post_id = isset( $_GET[ 'post' ] ) ? intval( $_GET[ 'post' ] ) : 0; // phpcs:ignore
27
28 if ( ! empty( $_GET[ 'post_type' ] ) ) { // phpcs:ignore
29 $post_type = $_GET[ 'post_type' ]; // phpcs:ignore
30 } elseif ( $post_id > 0 ) {
31 $post_type = get_post_type( $post_id );
32 } else {
33 $post_type = '';
34 }
35
36 if ( ! empty( $this->isEnabledWriteWithAI() ) && 'docs' == $post_type ) {
37 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_ai_edit_assets' ) );
38 }
39 // Legacy AJAX handler kept for back-compat; the redesigned modal uses the REST route.
40 add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
41 }
42
43 public function enqueue_ai_edit_assets( $hook ) {
44 if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) {
45 return;
46 }
47
48 global $post_type;
49 if ( 'docs' !== $post_type ) {
50 return;
51 }
52
53 $api_key = $this->get_api_key();
54 $has_key = ! empty( $api_key );
55
56 // Write with AI — loads even without a key so the modal can show its
57 // "add an API key" banner (matches the legacy inline form behavior).
58 betterdocs()->assets->enqueue( 'betterdocs-write-with-ai', 'blocks/write-with-ai.js' );
59 betterdocs()->assets->enqueue( 'betterdocs-write-with-ai-style', 'blocks/write-with-ai-style.css' );
60
61 wp_localize_script(
62 'betterdocs-write-with-ai',
63 'betterdocsWriteWithAI',
64 array(
65 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/write-with-ai' ) ),
66 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
67 'post_id' => get_the_ID(),
68 'has_key' => $has_key,
69 // Term-suggestion (Docs AI Suite) wiring reused by the Write-with-AI
70 // preview step. Endpoint + gate mirror REST\DocsAISuite / Core\DocsAISuite.
71 'rest_suggest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-suggest-terms' ) ),
72 'suggest_terms_enabled' => (bool) $this->settings->get( 'enable_docs_ai_suite', true ),
73 'model' => $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' ),
74 'max_token' => (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
75 'settings_url' => esc_url( admin_url( 'admin.php?page=betterdocs-settings#betterdocs-ai' ) ),
76 'woo_active' => class_exists( 'WooCommerce' ),
77 'is_multilingual_active' => Helper::is_multilingual_active(),
78 'language_options' => Helper::get_active_languages(),
79 'instructions' => $this->get_instruction_choices(),
80 // From Git is a Pro feature. Pro is detected here; Git enabled/auth
81 // status is filled in by Pro via the filter below (default: off).
82 'is_pro_active' => betterdocs()->is_pro_active(),
83 'git' => apply_filters(
84 'betterdocs_write_with_ai_git',
85 array(
86 'enabled' => false,
87 'connected' => false,
88 'settings_url' => admin_url( 'admin.php?page=betterdocs-settings#git-sync' ),
89 )
90 ),
91 )
92 );
93
94 // AI Edit — only meaningful with a key configured.
95 if ( ! $has_key ) {
96 return;
97 }
98
99 betterdocs()->assets->enqueue( 'betterdocs-ai-edit', 'blocks/ai-edit.js' );
100 betterdocs()->assets->enqueue( 'betterdocs-ai-edit-style', 'blocks/ai-edit-style.css' );
101
102 wp_localize_script(
103 'betterdocs-ai-edit',
104 'betterdocsAIEdit',
105 array(
106 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-edit' ) ),
107 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
108 'post_id' => get_the_ID(),
109 'instructions' => $this->get_instruction_choices(),
110 'actions' => AIEdit::get_localized_actions()
111 )
112 );
113 }
114
115 public function isEnabledWriteWithAI() {
116 $isEnableAutoWrite = $this->settings->get( 'enable_write_with_ai', true );
117 return $isEnableAutoWrite;
118 }
119
120 public function isValidAPIKey( $apiKey ) {
121 if ( empty( $apiKey ) ) {
122 $api_response[ 'valid' ] = false;
123 $api_response[ 'message' ] = 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">OpenAI API Key</a> to use this Write with AI feature.';
124
125 return $api_response;
126 }
127
128 $api_response = array();
129
130 $response = wp_safe_remote_get(
131 'https://api.openai.com/v1/engines',
132 array(
133 'headers' => array(
134 'Content-Type' => 'application/json',
135 'Authorization' => 'Bearer ' . $apiKey,
136 ),
137 'timeout' => 15,
138 )
139 );
140
141 if ( is_wp_error( $response ) ) {
142 $api_response[ 'valid' ] = false;
143 $api_response[ 'message' ] = $response->get_error_message();
144 return $api_response;
145 }
146
147 $httpCode = (int) wp_remote_retrieve_response_code( $response );
148 $body = wp_remote_retrieve_body( $response );
149
150 if ( 200 === $httpCode ) {
151 $api_response[ 'valid' ] = true;
152 $api_response[ 'message' ] = 'Valid API Key';
153 } else {
154 $responseData = json_decode( $body, true );
155 $messageData = ! empty( $responseData[ 'error' ] ) ? $responseData[ 'error' ] : array();
156 $api_response[ 'valid' ] = false;
157 $api_response[ 'message' ] = ! empty( $messageData[ 'message' ] ) ? $messageData[ 'message' ] : 'Invalid API Key';
158 }
159
160 // print_r($response);
161
162 return $api_response;
163 }
164
165 public function get_api_key() {
166 $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
167 return $api_key;
168 }
169
170 /**
171 * The built-in "Default" instruction body.
172 *
173 * Single source of truth shared by {@see Settings::get_default()} (which seeds
174 * the editable "Default" instruction set) and {@see self::get_system_prompt()}
175 * (the fallback when no saved Default content exists).
176 *
177 * @return string
178 */
179 public static function default_instruction_content() {
180 return <<<'PROMPT'
181 You are a Senior Technical Writer specializing in comprehensive, high-quality documentation. Your goal is to produce documentation that scores 100/100 on clarity, completeness, and structure.
182
183 ## Output format
184
185 Return only HTML body content. Never wrap output in `<!doctype>`, `<html>`, `<head>`, `<body>`, or markdown code fences (no ```html ... ```).
186
187 Use semantic, Gutenberg-friendly tags only:
188 - Headings: `<h2>`, `<h3>`, `<h4>` (do not emit `<h1>` — it is reserved for the document title)
189 - Paragraphs: `<p>` for prose
190 - Lists: `<ul>`/`<ol>` with `<li>` for any list of items, steps, or bullet points — never fake a list with paragraphs or `<br>`
191 - Links: `<a href="https://...">link text</a>` with absolute URLs
192 - Inline emphasis: `<strong>`, `<em>`, `<code>`
193 - Code blocks: `<pre><code>...</code></pre>` for multi-line code or commands
194 - Quotes: `<blockquote>`
195 - Tables: `<table>` with `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`
196 - Images: `<img src="..." alt="...">`
197
198 Apply `<span class="highlight">key term</span>` to important topic terms inside headings and to the first occurrence of each keyword in body text. Use it sparingly — never wrap whole sentences or wrap text inside `href`, `src`, `alt`, or other attributes.
199
200 Do not emit `<style>`, `<script>`, `<iframe>`, `<form>`, or inline `style=""` / `class=""` attributes (other than the `highlight` class above).
201
202 If — and only if — the user's prompt explicitly asks for raw/custom HTML, embed code, or a non-standard structure, follow that request instead of these defaults.
203 PROMPT;
204 }
205
206 /**
207 * The saved instruction sets, normalized to a list of { id, title, content }.
208 *
209 * Stored in the `write_with_ai_instructions` setting. Always returns at least
210 * the built-in "Default" set so callers never have to special-case an empty
211 * option (e.g. a site whose stored value predates this feature).
212 *
213 * @return array<int,array{id:string,title:string,content:string}>
214 */
215 public function get_instructions() {
216 $stored = $this->settings->get( 'write_with_ai_instructions', array() );
217
218 $instructions = array();
219 if ( is_array( $stored ) ) {
220 foreach ( $stored as $item ) {
221 if ( ! is_array( $item ) || empty( $item['id'] ) ) {
222 continue;
223 }
224 $instructions[] = array(
225 'id' => sanitize_key( (string) $item['id'] ),
226 'title' => isset( $item['title'] ) ? (string) $item['title'] : '',
227 'content' => isset( $item['content'] ) ? (string) $item['content'] : '',
228 );
229 }
230 }
231
232 // Guarantee a Default set is always present.
233 $has_default = false;
234 foreach ( $instructions as $item ) {
235 if ( 'default' === $item['id'] ) {
236 $has_default = true;
237 break;
238 }
239 }
240 if ( ! $has_default ) {
241 array_unshift(
242 $instructions,
243 array(
244 'id' => 'default',
245 'title' => __( 'Default/Core', 'betterdocs' ),
246 'content' => self::default_instruction_content(),
247 )
248 );
249 }
250
251 return $instructions;
252 }
253
254 /**
255 * The selectable (non-default) instruction sets exposed to the editor UI.
256 *
257 * Only id + title are sent to the browser; the prompt body stays server-side
258 * and is resolved at request time by {@see self::get_instruction_messages()}.
259 *
260 * @return array<int,array{id:string,title:string}>
261 */
262 public function get_instruction_choices() {
263 $choices = array();
264 foreach ( $this->get_instructions() as $item ) {
265 if ( 'default' === $item['id'] ) {
266 continue;
267 }
268 if ( '' === trim( $item['content'] ) ) {
269 continue; // skip empty sets — they'd inject nothing.
270 }
271 $choices[] = array(
272 'id' => $item['id'],
273 'title' => '' !== trim( $item['title'] ) ? $item['title'] : $item['id'],
274 );
275 }
276 return $choices;
277 }
278
279 /**
280 * Resolve selected instruction ids into extra `system` messages.
281 *
282 * The "Default" set is intentionally excluded — it is always sent as the base
283 * system prompt by {@see self::get_system_prompt()}. Unknown or empty ids are
284 * silently ignored. Output order follows the requested $ids order.
285 *
286 * @param array $ids
287 * @return array<int,array{role:string,content:string}>
288 */
289 public function get_instruction_messages( $ids ) {
290 if ( empty( $ids ) || ! is_array( $ids ) ) {
291 return array();
292 }
293
294 $by_id = array();
295 foreach ( $this->get_instructions() as $item ) {
296 $by_id[ $item['id'] ] = $item;
297 }
298
299 $messages = array();
300 $seen = array();
301 foreach ( $ids as $id ) {
302 $id = sanitize_key( (string) $id );
303 if ( 'default' === $id || isset( $seen[ $id ] ) || ! isset( $by_id[ $id ] ) ) {
304 continue;
305 }
306 $content = trim( $by_id[ $id ]['content'] );
307 if ( '' === $content ) {
308 continue;
309 }
310 $seen[ $id ] = true;
311 $messages[] = array(
312 'role' => 'system',
313 'content' => $content,
314 );
315 }
316
317 return $messages;
318 }
319
320 /**
321 * Coerce a list of extra messages into well-formed `system` chat messages.
322 *
323 * Accepts either pre-built [ 'role'=>'system', 'content'=>… ] entries (as
324 * returned by {@see self::get_instruction_messages()}) or bare strings, and
325 * drops anything empty. Keeps the OpenAI payload builders tolerant of either
326 * shape so callers can pass instruction ids or ready-made messages.
327 *
328 * @param array $extra_system
329 * @return array<int,array{role:string,content:string}>
330 */
331 protected function normalize_extra_system( $extra_system ) {
332 if ( empty( $extra_system ) || ! is_array( $extra_system ) ) {
333 return array();
334 }
335
336 $messages = array();
337 foreach ( $extra_system as $entry ) {
338 if ( is_string( $entry ) ) {
339 $content = trim( $entry );
340 $role = 'system';
341 } elseif ( is_array( $entry ) && isset( $entry['content'] ) ) {
342 $content = trim( (string) $entry['content'] );
343 $role = isset( $entry['role'] ) ? (string) $entry['role'] : 'system';
344 } else {
345 continue;
346 }
347
348 if ( '' === $content ) {
349 continue;
350 }
351
352 $messages[] = array(
353 'role' => $role,
354 'content' => $content,
355 );
356 }
357
358 return $messages;
359 }
360
361 public function get_system_prompt() {
362 // Prefer the saved (editable) "Default" instruction set; fall back to the
363 // built-in body when it's missing or has been cleared.
364 $prompt = self::default_instruction_content();
365 foreach ( $this->get_instructions() as $item ) {
366 if ( 'default' === $item['id'] && '' !== trim( $item['content'] ) ) {
367 $prompt = $item['content'];
368 break;
369 }
370 }
371
372 return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
373 }
374
375 public function get_outline_system_prompt() {
376 $prompt = <<<'PROMPT'
377 You are a Senior Technical Writer. Produce a documentation OUTLINE only — not the full article.
378
379 Return ONLY a JSON array (no prose, no markdown, no code fences). Each element is an object:
380 { "level": "h2" | "h3", "text": "Section heading" }
381
382 Rules:
383 - 5 to 9 top-level "h2" sections that comprehensively cover the topic.
384 - Add "h3" sub-sections only where they genuinely help; keep each one directly after its parent h2.
385 - Headings are concise, descriptive, and free of numbering.
386 - Do not include an h1/title and do not include any text outside the JSON array.
387 PROMPT;
388
389 return apply_filters( 'betterdocs_write_with_ai_outline_system_prompt', $prompt );
390 }
391
392 /**
393 * Generate a documentation outline as a structured array of sections.
394 *
395 * @param string $user_prompt The composed prompt (title/keywords/instructions).
396 * @return array { success:bool, outline?:array<int,array{level:string,text:string}>, error?:string }
397 */
398 public function generate_outline_response( $user_prompt, $extra_system = array() ) {
399 $result = $this->generate_text( $user_prompt, $this->get_outline_system_prompt(), $extra_system );
400
401 if ( empty( $result['success'] ) ) {
402 return array(
403 'success' => false,
404 'error' => isset( $result['error'] ) ? $result['error'] : 'OpenAI error',
405 );
406 }
407
408 $outline = $this->parse_outline( (string) $result['content'] );
409
410 if ( empty( $outline ) ) {
411 return array(
412 'success' => false,
413 'error' => 'The AI returned an outline we could not read. Please try again.',
414 );
415 }
416
417 return array(
418 'success' => true,
419 'outline' => $outline,
420 );
421 }
422
423 /**
424 * Parse a model outline response (ideally a JSON array) into a clean list of
425 * { level, text } sections. Tolerates code fences and stray prose.
426 *
427 * @param string $content
428 * @return array<int,array{level:string,text:string}>
429 */
430 protected function parse_outline( $content ) {
431 $content = trim( $content );
432 $content = preg_replace( '/^```(?:json)?\s*/i', '', $content );
433 $content = preg_replace( '/```\s*$/', '', $content );
434
435 // Grab the first JSON array if the model wrapped it in prose.
436 if ( preg_match( '/\[[\s\S]*\]/', $content, $m ) ) {
437 $content = $m[0];
438 }
439
440 $decoded = json_decode( $content, true );
441 if ( ! is_array( $decoded ) ) {
442 return array();
443 }
444
445 $outline = array();
446 foreach ( $decoded as $item ) {
447 if ( ! is_array( $item ) || empty( $item['text'] ) ) {
448 continue;
449 }
450 $level = isset( $item['level'] ) && 'h3' === strtolower( (string) $item['level'] ) ? 'h3' : 'h2';
451 $text = sanitize_text_field( (string) $item['text'] );
452 if ( '' === $text ) {
453 continue;
454 }
455 $outline[] = array( 'level' => $level, 'text' => $text );
456 }
457
458 return $outline;
459 }
460
461 public function generate_openai_response( $prompt, $keywords, $max_tokens = null, $extra_system = array() ) {
462 try {
463 $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
464 // Caller may raise the cap (e.g. a "large" doc) above the saved default.
465 $max_tokens = null !== $max_tokens ? (int) $max_tokens : $this->settings->get( 'ai_autowrite_max_token', 2500 );
466 $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
467
468 $api_endpoint = 'https://api.openai.com/v1/chat/completions'; // Update the endpoint based on OpenAI API version
469
470 $messages = array_merge(
471 array(
472 array(
473 'role' => 'system',
474 'content' => $this->get_system_prompt()
475 )
476 ),
477 $this->normalize_extra_system( $extra_system ),
478 array(
479 array(
480 'role' => 'user',
481 'content' => $prompt
482 )
483 )
484 );
485
486 $request_body = AIHelper::build_openai_payload(
487 $model,
488 $messages,
489 $max_tokens,
490 null,
491 'write_with_ai'
492 );
493
494 $request_options = array(
495 'headers' => array(
496 'Content-Type' => 'application/json',
497 'Authorization' => 'Bearer ' . $api_key
498 ),
499 'body' => json_encode( $request_body ),
500 'timeout' => 300
501 );
502
503 // GPT-5.5 reasoning can run well past the default limits; give PHP and
504 // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
505 if ( function_exists( 'set_time_limit' ) ) {
506 set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running AI generation needs an extended limit; still bounded by server fpm/nginx timeouts.
507 }
508
509 $response = wp_remote_post( $api_endpoint, $request_options );
510
511 if ( is_wp_error( $response ) ) {
512 return 'Error: ' . $response->get_error_message();
513 } else {
514 $body = wp_remote_retrieve_body( $response );
515
516 $data = json_decode( $body, true );
517
518 if ( ! empty( $data[ 'error' ] ) ) {
519 return $data[ 'error' ][ 'message' ];
520 }
521
522 return $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ]; // Update this line to get the assistant's message
523 }
524 } catch ( Exception $error ) {
525 return 'Error: ' . $error->getMessage();
526 }
527 }
528
529 public function generate_openai_response_ai_edit( $prompt, $extra_system = array() ) {
530 $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
531 $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
532 $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
533
534 $api_endpoint = 'https://api.openai.com/v1/chat/completions';
535
536 $messages = array_merge(
537 array(
538 array(
539 'role' => 'system',
540 'content' => $this->get_system_prompt()
541 )
542 ),
543 $this->normalize_extra_system( $extra_system ),
544 array(
545 array(
546 'role' => 'user',
547 'content' => $prompt
548 )
549 )
550 );
551
552 $payload = AIHelper::build_openai_payload(
553 $model,
554 $messages,
555 $max_tokens,
556 null,
557 'write_with_ai'
558 );
559
560 $request_options = array(
561 'headers' => array(
562 'Content-Type' => 'application/json',
563 'Authorization' => 'Bearer ' . $api_key
564 ),
565 'body' => wp_json_encode( $payload ),
566 'timeout' => 300
567 );
568
569 // GPT-5.5 reasoning can run well past the default limits; give PHP and
570 // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
571 if ( function_exists( 'set_time_limit' ) ) {
572 set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running AI generation needs an extended limit; still bounded by server fpm/nginx timeouts.
573 }
574
575 $response = wp_remote_post( $api_endpoint, $request_options );
576
577 if ( is_wp_error( $response ) ) {
578 return array(
579 'success' => false,
580 'error' => $response->get_error_message(),
581 'model' => $model
582 );
583 }
584
585 $body = wp_remote_retrieve_body( $response );
586 $data = json_decode( $body, true );
587
588 if ( ! empty( $data[ 'error' ] ) ) {
589 return array(
590 'success' => false,
591 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
592 'model' => $model,
593 'raw' => $data
594 );
595 }
596
597 $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
598 $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
599
600 return array(
601 'success' => true,
602 'content' => $content,
603 'model' => $model,
604 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
605 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
606 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
607 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
608 );
609 }
610
611 /**
612 * Generic chat completion using the Write-with-AI model/token/key settings.
613 *
614 * Shared by lightweight, plain-text generators (glossary definitions, FAQ answers)
615 * that need the same dynamic model as Write with AI / AI Edit but a caller-supplied
616 * system prompt instead of the doc-authoring HTML prompt. Returns the same structured
617 * array shape as {@see self::generate_openai_response_ai_edit()}.
618 *
619 * @param string $user_prompt The user message.
620 * @param string $system_prompt Optional system message (omitted when empty).
621 * @return array { success:bool, content?:string, error?:string, model:string, *_tokens?:int }
622 */
623 public function generate_text( $user_prompt, $system_prompt = '', $extra_system = array() ) {
624 $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
625 $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
626 $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
627
628 $messages = array();
629 if ( $system_prompt !== '' ) {
630 $messages[] = array(
631 'role' => 'system',
632 'content' => $system_prompt
633 );
634 }
635 foreach ( $this->normalize_extra_system( $extra_system ) as $extra ) {
636 $messages[] = $extra;
637 }
638 $messages[] = array(
639 'role' => 'user',
640 'content' => $user_prompt
641 );
642
643 $api_endpoint = 'https://api.openai.com/v1/chat/completions';
644
645 $payload = AIHelper::build_openai_payload( $model, $messages, $max_tokens, null, 'write_with_ai' );
646
647 $request_options = array(
648 'headers' => array(
649 'Content-Type' => 'application/json',
650 'Authorization' => 'Bearer ' . $api_key
651 ),
652 'body' => wp_json_encode( $payload ),
653 'timeout' => 300
654 );
655
656 // GPT-5.5 reasoning can run well past the default limits; give PHP and
657 // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
658 if ( function_exists( 'set_time_limit' ) ) {
659 set_time_limit( 300 );
660 }
661
662 $response = wp_remote_post( $api_endpoint, $request_options );
663
664 if ( is_wp_error( $response ) ) {
665 return array(
666 'success' => false,
667 'error' => $response->get_error_message(),
668 'model' => $model
669 );
670 }
671
672 $body = wp_remote_retrieve_body( $response );
673 $data = json_decode( $body, true );
674
675 if ( ! empty( $data[ 'error' ] ) ) {
676 return array(
677 'success' => false,
678 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
679 'model' => $model,
680 'raw' => $data
681 );
682 }
683
684 $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
685 $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
686
687 return array(
688 'success' => true,
689 'content' => $content,
690 'model' => $model,
691 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
692 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
693 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
694 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
695 );
696 }
697
698 /**
699 * Generate an OpenAI chat completion with a caller-supplied system + user
700 * prompt. Mirrors generate_openai_response_ai_edit() (same key/model/token
701 * floor/timeout handling and return shape) but does NOT force the
702 * documentation-writer system prompt, so callers such as the Docs AI Suite
703 * (taxonomy suggestions, excerpts) can supply task-appropriate instructions.
704 *
705 * @param string $system_prompt System instruction for the model.
706 * @param string $user_prompt User message / content payload.
707 * @param float|null $temperature Optional sampling temperature (ignored for gpt-5*).
708 * @return array{success:bool,content?:string,error?:string,model:string,...}
709 */
710 public function generate_openai_response_raw( $system_prompt, $user_prompt, $temperature = null ) {
711 $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
712 $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
713 $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
714
715 $api_endpoint = 'https://api.openai.com/v1/chat/completions';
716
717 $payload = AIHelper::build_openai_payload(
718 $model,
719 array(
720 array(
721 'role' => 'system',
722 'content' => $system_prompt
723 ),
724 array(
725 'role' => 'user',
726 'content' => $user_prompt
727 )
728 ),
729 $max_tokens,
730 $temperature,
731 'write_with_ai'
732 );
733
734 $request_options = array(
735 'headers' => array(
736 'Content-Type' => 'application/json',
737 'Authorization' => 'Bearer ' . $api_key
738 ),
739 'body' => wp_json_encode( $payload ),
740 'timeout' => 300
741 );
742
743 if ( function_exists( 'set_time_limit' ) ) {
744 set_time_limit( 300 );
745 }
746
747 $response = wp_remote_post( $api_endpoint, $request_options );
748
749 if ( is_wp_error( $response ) ) {
750 return array(
751 'success' => false,
752 'error' => $response->get_error_message(),
753 'model' => $model
754 );
755 }
756
757 $body = wp_remote_retrieve_body( $response );
758 $data = json_decode( $body, true );
759
760 if ( ! empty( $data[ 'error' ] ) ) {
761 return array(
762 'success' => false,
763 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
764 'model' => $model,
765 'raw' => $data
766 );
767 }
768
769 $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
770 $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
771
772 return array(
773 'success' => true,
774 'content' => $content,
775 'model' => $model,
776 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
777 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
778 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
779 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
780 );
781 }
782
783 public function generate_openai_content_callback() {
784 // Verify the nonce
785 $ai_nonce = isset( $_POST['ai_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_nonce'] ) ) : '';
786 if ( ! wp_verify_nonce( $ai_nonce, 'generate_openai_content_nonce' ) ) {
787 wp_send_json_error( 'Invalid nonce' );
788 wp_die();
789 }
790
791 if ( ! current_user_can( 'edit_posts' ) ) {
792 wp_send_json_error( 'Insufficient permissions' );
793 wp_die();
794 }
795
796 $prompt = isset( $_POST['prompt'] ) ? sanitize_text_field( wp_unslash( $_POST['prompt'] ) ) : '';
797 $keywords = isset( $_POST['keywords'] ) ? sanitize_text_field( wp_unslash( $_POST['keywords'] ) ) : '';
798
799 $ai_instance = new WriteWithAI( $this->settings );
800
801 $generated_content = $ai_instance->generate_openai_response( $prompt, $keywords );
802
803 // Count a successful generation (skip obvious upstream errors).
804 if ( is_string( $generated_content ) && $generated_content !== '' && strpos( $generated_content, 'Error:' ) !== 0 ) {
805 $post_id = isset( $_POST[ 'post_id' ] ) ? intval( $_POST[ 'post_id' ] ) : 0; //phpcs:ignore
806 AIUsage::record( 'write_with_ai', $post_id );
807 }
808
809 // Send the generated content as the AJAX response
810 wp_send_json_success( $generated_content );
811 wp_die();
812 }
813 }
814