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

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