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 / REST / WriteWithAI.php

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

474 lines 23.5 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\REST;
4
5 use WP_REST_Request;
6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 use WPDeveloper\BetterDocs\Utils\AIUsage;
8
9 /**
10 * REST surface for the redesigned "Write with AI" modal.
11 *
12 * Replaces the legacy admin-ajax `generate_openai_content` handler. Supports the
13 * superset modal's flows: full-doc generation, outline generation, expanding an
14 * approved outline into a doc, and generating a doc from pasted source content.
15 * All generation reuses the WriteWithAI service (same model/key/token settings).
16 *
17 * @see \WPDeveloper\BetterDocs\REST\AIEdit Sibling endpoint this mirrors.
18 */
19 class WriteWithAI extends BaseAPI {
20
21 const MAX_SOURCE_LENGTH = 12000;
22 const MAX_PROMPT_LENGTH = 4000;
23
24 public function register() {
25 $this->post(
26 '/write-with-ai',
27 array( $this, 'generate' ),
28 array(
29 'post_id' => array(
30 'type' => 'integer',
31 'required' => false,
32 'default' => 0,
33 ),
34 'action' => array(
35 'type' => 'string',
36 'required' => true,
37 ),
38 'title' => array(
39 'type' => 'string',
40 'required' => false,
41 'default' => '',
42 ),
43 'keywords' => array(
44 'type' => 'string',
45 'required' => false,
46 'default' => '',
47 ),
48 'prompt' => array(
49 'type' => 'string',
50 'required' => false,
51 'default' => '',
52 ),
53 'source' => array(
54 'type' => 'string',
55 'required' => false,
56 'default' => '',
57 ),
58 'source_type' => array(
59 'type' => 'string',
60 'required' => false,
61 'default' => '',
62 ),
63 'git_url' => array(
64 'type' => 'string',
65 'required' => false,
66 'default' => '',
67 ),
68 'git_action' => array(
69 'type' => 'string',
70 'required' => false,
71 'default' => '',
72 ),
73 // "Browse repository" picker params (git-repos / git-items / git-contents).
74 'repo' => array(
75 'type' => 'string',
76 'required' => false,
77 'default' => '',
78 ),
79 'kind' => array(
80 'type' => 'string',
81 'required' => false,
82 'default' => '',
83 ),
84 'path' => array(
85 'type' => 'string',
86 'required' => false,
87 'default' => '',
88 ),
89 'ref' => array(
90 'type' => 'string',
91 'required' => false,
92 'default' => '',
93 ),
94 'outline' => array(
95 'type' => 'array',
96 'required' => false,
97 'default' => array(),
98 ),
99 'tone' => array(
100 'type' => 'string',
101 'required' => false,
102 'default' => '',
103 ),
104 'doc_size' => array(
105 'type' => 'string',
106 'required' => false,
107 'default' => 'any',
108 ),
109 'generate_title' => array(
110 'type' => 'boolean',
111 'required' => false,
112 'default' => false,
113 ),
114 'instruction_ids' => array(
115 'type' => 'array',
116 'required' => false,
117 'default' => array(),
118 ),
119 )
120 );
121 }
122
123 public function permission_check() {
124 // Gate on edit_others_posts to match the sibling FAQ/Glossary AI endpoints
125 // (AIFaq/AIGlossary). This keeps Author-role users — who can create their own
126 // posts but not others' — from spending the site's OpenAI budget.
127 return current_user_can( 'edit_others_posts' );
128 }
129
130 public function generate( WP_REST_Request $request ) {
131 $write_ai = betterdocs()->ai_autowrtie;
132
133 if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) {
134 return $this->error(
135 'ai_disabled',
136 __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ),
137 400
138 );
139 }
140
141 if ( empty( $write_ai->get_api_key() ) ) {
142 return $this->error(
143 'missing_key',
144 __( 'OpenAI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
145 400
146 );
147 }
148
149 $action = sanitize_key( (string) $request->get_param( 'action' ) );
150 $post_id = (int) $request->get_param( 'post_id' );
151 // NOTE: this text is sent verbatim in the OpenAI request body, not rendered
152 // as HTML, so we must NOT strip tags. sanitize_textarea_field() runs
153 // wp_strip_all_tags(), which would delete <ProductCard>, Array<T>, JSX/XML/HTML
154 // from the prompt before the model ever sees it. wp_check_invalid_utf8() keeps
155 // the angle brackets while still guarding against malformed UTF-8.
156 $prompt = $this->clip( wp_check_invalid_utf8( (string) $request->get_param( 'prompt' ) ), self::MAX_PROMPT_LENGTH );
157 $keywords = sanitize_text_field( (string) $request->get_param( 'keywords' ) );
158
159 // Generation directives assembled server-side from the simplified modal.
160 $tone = sanitize_text_field( (string) $request->get_param( 'tone' ) );
161 $doc_size = sanitize_key( (string) $request->get_param( 'doc_size' ) );
162 $generate_title = (bool) $request->get_param( 'generate_title' );
163
164 // Selected instruction sets → extra system messages layered on the base
165 // (Default) system prompt. Unknown/empty ids are dropped by the resolver.
166 $instruction_ids = (array) $request->get_param( 'instruction_ids' );
167 $extra_system = $write_ai->get_instruction_messages( $instruction_ids );
168
169 switch ( $action ) {
170 case 'generate-outline':
171 // Tone steers an outline; size/title only matter for the full doc.
172 $outline_prompt = $this->wrap_topic( $prompt )
173 . $this->build_directives( $tone, $doc_size, false, false, false );
174 return $this->handle_outline( $write_ai, $post_id, $outline_prompt, $extra_system );
175
176 case 'generate-doc':
177 $doc_prompt = $this->wrap_topic( $prompt )
178 . $this->build_directives( $tone, $doc_size, $generate_title );
179 return $this->handle_doc( $write_ai, $post_id, $doc_prompt, $keywords, $action, $doc_size, $extra_system );
180
181 case 'expand-outline':
182 $outline = $this->sanitize_outline( (array) $request->get_param( 'outline' ) );
183 if ( empty( $outline ) ) {
184 return $this->error( 'ai_empty_outline', __( 'No outline provided to expand.', 'betterdocs' ), 400 );
185 }
186 $expand_prompt = $this->wrap_topic( $prompt ) . "\n\n"
187 . __( 'Write the full documentation following EXACTLY this approved outline. Keep the heading order and levels:', 'betterdocs' )
188 . "\n" . $this->render_outline( $outline )
189 . $this->build_directives( $tone, $doc_size, $generate_title );
190 return $this->handle_doc( $write_ai, $post_id, $expand_prompt, $keywords, $action, $doc_size, $extra_system );
191
192 case 'from-source':
193 // Prompt-bound source text: preserve tags (see the prompt note above).
194 $source = $this->clip( wp_check_invalid_utf8( (string) $request->get_param( 'source' ) ), self::MAX_SOURCE_LENGTH );
195 if ( '' === trim( $source ) ) {
196 return $this->error( 'ai_empty_source', __( 'Please paste some source content.', 'betterdocs' ), 400 );
197 }
198 $src_type = sanitize_text_field( (string) $request->get_param( 'source_type' ) );
199 $src_labels = array(
200 'transcript' => __( 'support transcript', 'betterdocs' ),
201 'forum' => __( 'forum thread', 'betterdocs' ),
202 'notes' => __( 'raw notes', 'betterdocs' ),
203 );
204 $src_label = isset( $src_labels[ $src_type ] ) ? $src_labels[ $src_type ] : __( 'source material', 'betterdocs' );
205
206 // Light per-type framing: a one-line system hint steering how to treat
207 // this kind of material. Auto-detect (empty source_type) sends no hint.
208 $src_frames = array(
209 'transcript' => __( 'The source below is a customer-support conversation. Focus on the user\'s problem and its resolution; ignore greetings and small talk.', 'betterdocs' ),
210 'forum' => __( 'The source below is a forum discussion among multiple people. Treat the accepted or most-supported answer as authoritative and skip off-topic replies.', 'betterdocs' ),
211 'notes' => __( 'The source below is rough notes. Expand them into clear, complete prose.', 'betterdocs' ),
212 );
213 if ( isset( $src_frames[ $src_type ] ) ) {
214 array_unshift( $extra_system, array( 'role' => 'system', 'content' => $src_frames[ $src_type ] ) );
215 }
216
217 $source_prompt = trim( $prompt . "\n\n"
218 . sprintf(
219 /* translators: %s: source content type, e.g. "support transcript". */
220 __( 'Turn the following %s into structured documentation. Use only the information it contains; do not invent details:', 'betterdocs' ),
221 $src_label
222 )
223 . "\n---\n" . $source . "\n---" )
224 . $this->build_directives( $tone, $doc_size, $generate_title );
225 return $this->handle_doc( $write_ai, $post_id, $source_prompt, $keywords, $action, $doc_size, $extra_system );
226
227 case 'git-repos':
228 case 'git-items':
229 case 'git-contents':
230 // "Browse repository" data for the From Git tab. Read-only listing
231 // that delegates to Pro (token + Git API live there). The picker
232 // builds a github.com URL client-side and generation still runs via
233 // the 'from-git' fetch above.
234 if ( ! betterdocs()->is_pro_active() ) {
235 return $this->error( 'pro_required', __( 'Generating from Git is a BetterDocs Pro feature.', 'betterdocs' ), 403 );
236 }
237
238 if ( 'git-repos' === $action ) {
239 $list = apply_filters( 'betterdocs_write_with_ai_git_repos', null );
240 $payload_key = 'repos';
241 } elseif ( 'git-items' === $action ) {
242 $repo = sanitize_text_field( (string) $request->get_param( 'repo' ) );
243 $kind = sanitize_key( (string) $request->get_param( 'kind' ) );
244 if ( '' === $repo ) {
245 return $this->error( 'git_bad_repo', __( 'Please choose a repository.', 'betterdocs' ), 400 );
246 }
247 if ( ! in_array( $kind, array( 'pull', 'issue' ), true ) ) {
248 $kind = 'pull';
249 }
250 $list = apply_filters( 'betterdocs_write_with_ai_git_items', null, $repo, $kind );
251 $payload_key = 'items';
252 } else { // git-contents
253 $repo = sanitize_text_field( (string) $request->get_param( 'repo' ) );
254 // Path segments come from GitHub's contents API verbatim; keep
255 // slashes/spaces (sanitize_text_field trims tags, not slashes).
256 $path = sanitize_text_field( (string) $request->get_param( 'path' ) );
257 $ref = sanitize_text_field( (string) $request->get_param( 'ref' ) );
258 if ( '' === $repo ) {
259 return $this->error( 'git_bad_repo', __( 'Please choose a repository.', 'betterdocs' ), 400 );
260 }
261 $list = apply_filters( 'betterdocs_write_with_ai_git_contents', null, $repo, $path, $ref );
262 $payload_key = null; // return the { ref, path, items } structure as-is
263 }
264
265 if ( is_wp_error( $list ) ) {
266 return $this->error( $list->get_error_code() ?: 'git_list_failed', $list->get_error_message(), 400 );
267 }
268 if ( null === $list ) {
269 return $this->error( 'git_unavailable', __( 'Could not reach Git. Confirm Git Sync is connected.', 'betterdocs' ), 400 );
270 }
271 return $this->success( null === $payload_key ? (array) $list : array( $payload_key => $list ) );
272
273 case 'from-git':
274 // From Git is a Pro feature — the fetch runs in betterdocs-pro. The
275 // modal already blocks this without Pro, but keep the endpoint honest.
276 if ( ! betterdocs()->is_pro_active() ) {
277 return $this->error( 'pro_required', __( 'Generating from Git is a BetterDocs Pro feature.', 'betterdocs' ), 403 );
278 }
279
280 $git_url = esc_url_raw( trim( (string) $request->get_param( 'git_url' ) ) );
281 if ( '' === $git_url ) {
282 return $this->error( 'ai_empty_git', __( 'Please paste a Git URL (a pull request or a repository file).', 'betterdocs' ), 400 );
283 }
284
285 // Delegate the actual fetch to Pro (token + API client live there).
286 $fetched = apply_filters( 'betterdocs_write_with_ai_git_fetch', null, $git_url, array( 'post_id' => $post_id ) );
287
288 if ( is_wp_error( $fetched ) ) {
289 return $this->error( $fetched->get_error_code() ?: 'git_fetch_failed', $fetched->get_error_message(), 400 );
290 }
291 if ( empty( $fetched ) || empty( $fetched['content'] ) ) {
292 return $this->error( 'git_unavailable', __( 'Could not read anything from that Git URL. Check the link, or confirm Git Sync is connected.', 'betterdocs' ), 400 );
293 }
294
295 // Fetched Git content is code/diffs; preserve tags (see the prompt note above).
296 $git_content = $this->clip( wp_check_invalid_utf8( (string) $fetched['content'] ), self::MAX_SOURCE_LENGTH );
297 if ( '' === trim( $git_content ) ) {
298 return $this->error( 'git_unavailable', __( 'The fetched Git content was empty.', 'betterdocs' ), 400 );
299 }
300 $git_label = ! empty( $fetched['source_label'] ) ? sanitize_text_field( (string) $fetched['source_label'] ) : __( 'source material', 'betterdocs' );
301
302 // Optional per-intent framing, mirroring from-source's per-type hints.
303 $git_action = sanitize_key( (string) $request->get_param( 'git_action' ) );
304 $git_frames = array(
305 'document_feature' => __( 'The source below was fetched from a Git pull request or code change. Explain, in end-user documentation terms, what the feature does and how to use it — not the implementation details or code.', 'betterdocs' ),
306 'adapt_doc' => __( 'The source below is an existing documentation file from a Git repository. Rewrite it as a fresh doc for this site, keeping the meaning but improving clarity and structure.', 'betterdocs' ),
307 'howto' => __( 'Turn the source below into a concise, step-by-step how-to guide.', 'betterdocs' ),
308 );
309 if ( isset( $git_frames[ $git_action ] ) ) {
310 array_unshift( $extra_system, array( 'role' => 'system', 'content' => $git_frames[ $git_action ] ) );
311 }
312
313 $git_prompt = trim( $prompt . "\n\n"
314 . sprintf(
315 /* translators: %s: the kind of Git source, e.g. "pull request" or "documentation file". */
316 __( 'Turn the following %s into structured documentation. Use only the information it contains; do not invent details:', 'betterdocs' ),
317 $git_label
318 )
319 . "\n---\n" . $git_content . "\n---" )
320 . $this->build_directives( $tone, $doc_size, $generate_title );
321 return $this->handle_doc( $write_ai, $post_id, $git_prompt, $keywords, $action, $doc_size, $extra_system );
322
323 default:
324 return $this->error( 'ai_bad_action', __( 'Unknown AI action.', 'betterdocs' ), 400 );
325 }
326 }
327
328 /**
329 * Full-doc generation (generate-doc, expand-outline, from-source all land here).
330 */
331 protected function handle_doc( $write_ai, $post_id, $prompt, $keywords, $action, $doc_size = 'any', $extra_system = array() ) {
332 if ( '' === trim( $prompt ) ) {
333 return $this->error( 'ai_empty_prompt', __( 'Please provide a prompt for the AI.', 'betterdocs' ), 400 );
334 }
335
336 // A "long" doc can outrun the default 2500-token cap; give it headroom.
337 $max_tokens = 'long' === $doc_size ? 4000 : null;
338
339 $content = $write_ai->generate_openai_response( $prompt, $keywords, $max_tokens, $extra_system );
340
341 if ( ! is_string( $content ) || '' === trim( $content ) ) {
342 return $this->error( 'empty', __( 'The AI returned no content. Try again or rephrase your prompt.', 'betterdocs' ), 502 );
343 }
344 if ( 0 === strpos( $content, 'Error:' ) ) {
345 return $this->error( 'ai_upstream', $content, 502 );
346 }
347
348 // Sanitize the model-generated HTML before it leaves the server: the editor
349 // renders it via dangerouslySetInnerHTML in the preview and inserts it as
350 // blocks, so strip <script>, event-handler attributes, <iframe> and
351 // javascript: URLs while keeping valid documentation markup. The system
352 // prompt asks the model to avoid these, but that is a soft constraint — this
353 // is the enforcement (a prompt-injected source/Git payload can't inject XSS).
354 $content = wp_kses_post( $content );
355
356 AIUsage::record( 'write_with_ai', $post_id, $action );
357
358 return $this->success( array( 'content' => $content, 'action' => $action ) );
359 }
360
361 /**
362 * Outline-only generation.
363 */
364 protected function handle_outline( $write_ai, $post_id, $prompt, $extra_system = array() ) {
365 if ( '' === trim( $prompt ) ) {
366 return $this->error( 'ai_empty_prompt', __( 'Please provide a prompt for the AI.', 'betterdocs' ), 400 );
367 }
368
369 $result = $write_ai->generate_outline_response( $prompt, $extra_system );
370
371 if ( empty( $result['success'] ) ) {
372 $message = isset( $result['error'] ) ? (string) $result['error'] : __( 'Unknown AI error.', 'betterdocs' );
373 return $this->error( 'ai_upstream', $message, 502 );
374 }
375
376 AIUsage::record( 'write_with_ai', $post_id, 'generate-outline' );
377
378 return $this->success( array( 'outline' => $result['outline'], 'action' => 'generate-outline' ) );
379 }
380
381 /**
382 * Normalize an outline payload into a clean list of { level, text } items.
383 *
384 * @param array $raw
385 * @return array<int,array{level:string,text:string}>
386 */
387 protected function sanitize_outline( $raw ) {
388 $outline = array();
389 foreach ( $raw as $item ) {
390 if ( ! is_array( $item ) || empty( $item['text'] ) ) {
391 continue;
392 }
393 $level = isset( $item['level'] ) && 'h3' === strtolower( (string) $item['level'] ) ? 'h3' : 'h2';
394 $text = sanitize_text_field( (string) $item['text'] );
395 if ( '' === $text ) {
396 continue;
397 }
398 $outline[] = array( 'level' => $level, 'text' => $text );
399 }
400 return $outline;
401 }
402
403 /**
404 * Render an outline array into an indented plain-text list for the prompt.
405 */
406 protected function render_outline( $outline ) {
407 $lines = array();
408 foreach ( $outline as $sec ) {
409 $prefix = 'h3' === $sec['level'] ? ' - ' : '- ';
410 $lines[] = $prefix . $sec['text'];
411 }
412 return implode( "\n", $lines );
413 }
414
415 protected function clip( $value, $max ) {
416 return strlen( $value ) > $max ? substr( $value, 0, $max ) : $value;
417 }
418
419 /**
420 * Frame the user's free-form request as a documentation instruction. Returns
421 * an empty string for an empty request (callers compose their own prompt).
422 */
423 protected function wrap_topic( $prompt ) {
424 $prompt = trim( $prompt );
425 if ( '' === $prompt ) {
426 return '';
427 }
428 return __( 'Write documentation for the following request:', 'betterdocs' ) . "\n\n" . $prompt;
429 }
430
431 /**
432 * Build the tone / size / title directive block appended to the prompt. Tone
433 * applies to every action; size and the title instruction are doc-only.
434 *
435 * @param string $tone Selected tone slug ('' = default, no directive).
436 * @param string $doc_size Selected size slug ('any' = no directive).
437 * @param bool $generate_title Whether the AI should also produce an <h1> title.
438 * @param bool $include_size Include the size directive (false for outlines).
439 * @param bool $include_title Include the title directive (false for outlines).
440 * @return string Leading "\n\n" + directives, or '' when none apply.
441 */
442 protected function build_directives( $tone, $doc_size, $generate_title, $include_size = true, $include_title = true ) {
443 $lines = array();
444
445 $tone_map = array(
446 'friendly' => __( 'Write in a warm, friendly, approachable tone.', 'betterdocs' ),
447 'professional' => __( 'Write in a polished, professional tone.', 'betterdocs' ),
448 'technical' => __( 'Write in a precise, technical tone suited to a technical audience.', 'betterdocs' ),
449 'formal' => __( 'Write in a formal tone.', 'betterdocs' ),
450 'casual' => __( 'Write in a casual, conversational tone.', 'betterdocs' ),
451 );
452 if ( isset( $tone_map[ $tone ] ) ) {
453 $lines[] = $tone_map[ $tone ];
454 }
455
456 if ( $include_size ) {
457 $size_map = array(
458 'short' => __( 'Keep the documentation concise — roughly 300–500 words, covering only the essential points.', 'betterdocs' ),
459 'medium' => __( 'Aim for a moderate length — roughly 600–1000 words.', 'betterdocs' ),
460 'long' => __( 'Be comprehensive and in-depth — roughly 1200 words or more, with thorough coverage and examples.', 'betterdocs' ),
461 );
462 if ( isset( $size_map[ $doc_size ] ) ) {
463 $lines[] = $size_map[ $doc_size ];
464 }
465 }
466
467 if ( $include_title && $generate_title ) {
468 $lines[] = __( 'Begin the output with a single <h1> element containing a concise, descriptive title for this documentation, then continue with the body content.', 'betterdocs' );
469 }
470
471 return empty( $lines ) ? '' : "\n\n" . implode( "\n", $lines );
472 }
473 }
474