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

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