PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.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 All 202 releases
betterdocs / includes / Core / WriteWithAI.php

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

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