| 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 |
|