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

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

327 lines 11.9 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\AIHelper;
8 use WPDeveloper\BetterDocs\Utils\AIUsage;
9
10 /**
11 * REST endpoints powering the BetterDocs "Enhance Docs with AI" editor actions
12 * for the `docs` post type: AI-suggested taxonomy terms (doc_category / doc_tag)
13 * and AI-generated post excerpts. Both reuse the existing OpenAI integration in
14 * WriteWithAI / AIHelper (key `ai_autowrite_api_key`, model `write_with_ai_model`).
15 *
16 * Auto-discovered + registered under the `betterdocs/v1` namespace by Plugin.php.
17 */
18 class DocsAISuite extends BaseAPI {
19
20 /**
21 * Taxonomies eligible for AI term suggestions.
22 */
23 const ALLOWED_TAXONOMIES = array( 'doc_category', 'doc_tag', 'glossaries' );
24
25 /**
26 * Generic, catch-all / placeholder names that are never useful suggestions
27 * for any taxonomy. Compared case-insensitively against the trimmed name.
28 */
29 const BLOCKED_TERMS = array(
30 'uncategorized',
31 'uncategorised',
32 'general',
33 'miscellaneous',
34 'misc',
35 'other',
36 'others',
37 'none',
38 'n/a',
39 'untagged'
40 );
41
42 public function register() {
43 $this->post(
44 '/ai-suggest-terms',
45 array( $this, 'suggest_terms' ),
46 array(
47 'post_id' => array(
48 'type' => 'integer',
49 'required' => true
50 ),
51 'taxonomy' => array(
52 'type' => 'string',
53 'required' => true
54 ),
55 'content' => array(
56 'type' => 'string',
57 'required' => false,
58 'default' => ''
59 ),
60 'title' => array(
61 'type' => 'string',
62 'required' => false,
63 'default' => ''
64 ),
65 'max_suggestions' => array(
66 'type' => 'integer',
67 'required' => false,
68 'default' => 6
69 )
70 )
71 );
72 }
73
74 public function permission_check() {
75 // Gate on edit_others_posts to match the sibling FAQ/Glossary AI endpoints
76 // (AIFaq/AIGlossary) and keep Author-role users from spending the AI budget.
77 return current_user_can( 'edit_others_posts' );
78 }
79
80 /**
81 * Shared gate: feature toggle + Write-with-AI enabled + API key present.
82 *
83 * @return \WPDeveloper\BetterDocs\Core\WriteWithAI|\WP_Error
84 */
85 protected function ai_guard() {
86 if ( ! $this->settings->get( 'enable_docs_ai_suite', true ) ) {
87 return $this->error(
88 'ai_disabled',
89 __( 'Docs AI features are disabled. Enable "Enhance Docs with AI" in BetterDocs settings.', 'betterdocs' ),
90 400
91 );
92 }
93
94 $write_ai = betterdocs()->ai_autowrtie;
95
96 if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) {
97 return $this->error(
98 'ai_disabled',
99 __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ),
100 400
101 );
102 }
103
104 if ( empty( $write_ai->get_api_key() ) ) {
105 return $this->error(
106 'ai_no_key',
107 __( 'OpenAI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
108 400
109 );
110 }
111
112 return $write_ai;
113 }
114
115 public function suggest_terms( WP_REST_Request $request ) {
116 $write_ai = $this->ai_guard();
117 if ( is_wp_error( $write_ai ) ) {
118 return $write_ai;
119 }
120
121 $post_id = (int) $request->get_param( 'post_id' );
122 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
123 $max = (int) $request->get_param( 'max_suggestions' );
124 $max = max( 1, min( 15, $max > 0 ? $max : 6 ) );
125
126 if ( ! in_array( $taxonomy, self::ALLOWED_TAXONOMIES, true ) ) {
127 return $this->error(
128 'invalid_taxonomy',
129 __( 'Unsupported taxonomy for AI suggestions.', 'betterdocs' ),
130 400
131 );
132 }
133
134 list( $title, $content ) = $this->resolve_content( $request, $post_id );
135
136 if ( '' === trim( $content ) ) {
137 return $this->error(
138 'empty_content',
139 __( 'Add some content before requesting suggestions.', 'betterdocs' ),
140 400
141 );
142 }
143
144 $labels_map = array(
145 'doc_category' => 'categories',
146 'doc_tag' => 'tags',
147 'glossaries' => 'glossary terms'
148 );
149 $label = isset( $labels_map[ $taxonomy ] ) ? $labels_map[ $taxonomy ] : 'terms';
150
151 // Bias the model toward reusing terms that already exist on the site.
152 $existing = get_terms( array(
153 'taxonomy' => $taxonomy,
154 'hide_empty' => false,
155 'number' => 200,
156 'fields' => 'names'
157 ) );
158 $existing_list = ( is_array( $existing ) && ! empty( $existing ) ) ? implode( ', ', $existing ) : '';
159
160 $system = sprintf(
161 'You are a documentation taxonomist. Suggest up to %1$d concise, relevant %2$s for the documentation article. Prefer reusing terms from the provided existing list when they fit. Each term should be 1-3 words in Title Case. Avoid generic, catch-all, or placeholder terms (for example "Uncategorized", "General", "Miscellaneous", "Other") — only suggest terms that genuinely describe the article. Return ONLY a JSON array of strings, for example ["Getting Started","Billing"]. No prose, no object keys, no code fences.',
162 $max,
163 $label
164 );
165
166 // Glossary terms are a curated, defined set — never invent new ones.
167 if ( 'glossaries' === $taxonomy ) {
168 $system .= ' IMPORTANT: Choose ONLY from the provided existing list of glossary terms. Do not invent or suggest any term that is not in that list. If none of the existing terms fit, return an empty array [].';
169 }
170
171 $user = '';
172 if ( '' !== $title ) {
173 $user .= "Title: {$title}\n\n";
174 }
175 if ( '' !== $existing_list ) {
176 $user .= "Existing {$label}: {$existing_list}\n\n";
177 }
178 $user .= "Article content:\n{$content}";
179
180 $result = $write_ai->generate_openai_response_raw( $system, $user, 0.3 );
181
182 if ( empty( $result[ 'success' ] ) ) {
183 $message = isset( $result[ 'error' ] ) ? (string) $result[ 'error' ] : __( 'Unknown AI error.', 'betterdocs' );
184 return $this->error( 'ai_upstream', $message, 502 );
185 }
186
187 // An empty list is a VALID outcome, not an error: for glossaries the model
188 // is explicitly told to return [] when no curated term applies, and for
189 // category/tag the article may simply not match anything worth suggesting.
190 // We return success with an empty `suggestions` array and let the client
191 // show a calm "nothing matched" notice instead of a scary "try again".
192 $names = $this->parse_string_list( (string) $result[ 'content' ], $max );
193
194 $suggestions = array();
195 $seen = array();
196 foreach ( $names as $name ) {
197 $key = strtolower( trim( $name ) );
198 if ( isset( $seen[ $key ] ) ) {
199 continue;
200 }
201 $seen[ $key ] = true;
202
203 // Drop generic, catch-all / placeholder names for every taxonomy.
204 if ( in_array( $key, self::BLOCKED_TERMS, true ) ) {
205 continue;
206 }
207
208 $term = get_term_by( 'name', $name, $taxonomy );
209
210 // Glossaries must come from the existing, curated set only — never
211 // surface a term the AI invented that has no glossary definition.
212 if ( 'glossaries' === $taxonomy && ! $term ) {
213 continue;
214 }
215
216 $suggestions[] = array(
217 'name' => $name,
218 'term_id' => $term ? (int) $term->term_id : null,
219 'is_new' => $term ? false : true
220 );
221 }
222
223 // Usage telemetry: a successful AI suggestion run, bucketed by taxonomy
224 // (doc_category/doc_tag/glossaries). An empty result is still a use of the
225 // feature, so record on any successful upstream response.
226 AIUsage::record( 'ai_suggest_terms', $post_id, $taxonomy );
227
228 return $this->success( array(
229 'taxonomy' => $taxonomy,
230 'suggestions' => $suggestions,
231 'model' => isset( $result[ 'model' ] ) ? $result[ 'model' ] : null
232 ) );
233 }
234
235 /**
236 * Resolve the title + AI-ready content for a request, preferring the live
237 * editor content sent by the client and falling back to the saved post.
238 *
239 * @return array{0:string,1:string} [ $title, $content ]
240 */
241 protected function resolve_content( WP_REST_Request $request, $post_id ) {
242 $title = sanitize_text_field( (string) $request->get_param( 'title' ) );
243 $content = (string) $request->get_param( 'content' );
244
245 if ( '' === trim( wp_strip_all_tags( $content ) ) && $post_id > 0 ) {
246 $post = get_post( $post_id );
247 if ( $post ) {
248 $content = $post->post_content;
249 if ( '' === $title ) {
250 $title = $post->post_title;
251 }
252 }
253 }
254
255 if ( '' === $title && $post_id > 0 ) {
256 $title = get_the_title( $post_id );
257 }
258
259 $ai_helper = $this->container->get( AIHelper::class );
260 $content = $ai_helper->prepare_content_for_ai( $content, 4000 );
261
262 return array( $title, $content );
263 }
264
265 /**
266 * Tolerant parse of the model output into a clean list of term-name strings.
267 * Handles JSON arrays (of strings or simple objects), code fences, and a
268 * comma/newline fallback when JSON can't be found.
269 *
270 * @return string[]
271 */
272 protected function parse_string_list( $raw, $max ) {
273 $raw = trim( $raw );
274 $raw = preg_replace( '/^```(?:json)?\s*/i', '', $raw );
275 $raw = preg_replace( '/```\s*$/', '', $raw );
276
277 $names = array();
278
279 $start = strpos( $raw, '[' );
280 $end = strrpos( $raw, ']' );
281 if ( false !== $start && false !== $end && $end > $start ) {
282 $json = substr( $raw, $start, $end - $start + 1 );
283 $decoded = json_decode( $json, true );
284 if ( is_array( $decoded ) ) {
285 foreach ( $decoded as $item ) {
286 if ( is_string( $item ) ) {
287 $names[] = $item;
288 } elseif ( is_array( $item ) ) {
289 if ( isset( $item[ 'name' ] ) ) {
290 $names[] = $item[ 'name' ];
291 } elseif ( isset( $item[ 'term' ] ) ) {
292 $names[] = $item[ 'term' ];
293 }
294 }
295 }
296 }
297 }
298
299 // Fallback: split on newlines/commas and strip bullets/numbering/quotes.
300 if ( empty( $names ) ) {
301 $parts = preg_split( '/[\r\n,]+/', $raw );
302 if ( is_array( $parts ) ) {
303 foreach ( $parts as $part ) {
304 $part = trim( $part, " \t\"'`-•*0123456789.()[]" );
305 if ( '' !== $part ) {
306 $names[] = $part;
307 }
308 }
309 }
310 }
311
312 $clean = array();
313 foreach ( $names as $name ) {
314 $name = trim( sanitize_text_field( $name ) );
315 if ( '' === $name || mb_strlen( $name ) > 60 ) {
316 continue;
317 }
318 $clean[] = $name;
319 if ( count( $clean ) >= $max ) {
320 break;
321 }
322 }
323
324 return $clean;
325 }
326 }
327