Changeset 3734482
- Timestamp:
- 10/08/2026 12:03:11 PM (2 weeks ago)
- Location:
- npcink-workflow-toolbox
- Files:
-
- 8 added
- 22 edited
- 1 copied
-
tags/0.5.3 (copied) (copied from npcink-workflow-toolbox/trunk )
-
tags/0.5.3/assets/admin.css (modified) (1 diff)
-
tags/0.5.3/assets/admin.js (modified) (2 diffs)
-
tags/0.5.3/assets/editor-content-support/text-utils.js (modified) (1 diff)
-
tags/0.5.3/includes/Plugin.php (modified) (1 diff)
-
tags/0.5.3/includes/Rest_Controller.php (modified) (3 diffs)
-
tags/0.5.3/includes/Rest_Editor_Audio_Text.php (added)
-
tags/0.5.3/includes/Rest_Editor_Content_Support.php (modified) (35 diffs)
-
tags/0.5.3/includes/Rest_Editor_Flow_Cache.php (added)
-
tags/0.5.3/includes/Rest_Editor_Paragraph_Check.php (added)
-
tags/0.5.3/includes/Rest_Editor_Taxonomy_Shaping.php (added)
-
tags/0.5.3/includes/Settings.php (modified) (2 diffs)
-
tags/0.5.3/languages/npcink-workflow-toolbox-zh_CN-npcink-toolbox-admin.json (modified) (1 diff)
-
tags/0.5.3/npcink-workflow-toolbox.php (modified) (2 diffs)
-
tags/0.5.3/readme.txt (modified) (2 diffs)
-
tags/0.5.3/uninstall.php (modified) (1 diff)
-
trunk/assets/admin.css (modified) (1 diff)
-
trunk/assets/admin.js (modified) (2 diffs)
-
trunk/assets/editor-content-support/text-utils.js (modified) (1 diff)
-
trunk/includes/Plugin.php (modified) (1 diff)
-
trunk/includes/Rest_Controller.php (modified) (3 diffs)
-
trunk/includes/Rest_Editor_Audio_Text.php (added)
-
trunk/includes/Rest_Editor_Content_Support.php (modified) (35 diffs)
-
trunk/includes/Rest_Editor_Flow_Cache.php (added)
-
trunk/includes/Rest_Editor_Paragraph_Check.php (added)
-
trunk/includes/Rest_Editor_Taxonomy_Shaping.php (added)
-
trunk/includes/Settings.php (modified) (2 diffs)
-
trunk/languages/npcink-workflow-toolbox-zh_CN-npcink-toolbox-admin.json (modified) (1 diff)
-
trunk/npcink-workflow-toolbox.php (modified) (2 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
npcink-workflow-toolbox/tags/0.5.3/assets/admin.css
r3723781 r3734482 4251 4251 color: #b32d2e; 4252 4252 } 4253 4254 .npcink-toolbox__review-tally { 4255 display: flex; 4256 flex-wrap: wrap; 4257 align-items: center; 4258 gap: 6px 8px; 4259 margin: 10px 0 0; 4260 padding: 8px 10px; 4261 background: #fff; 4262 border: 1px solid #dcdcde; 4263 border-left: 4px solid #72aee6; 4264 border-radius: 4px; 4265 font-size: 12px; 4266 } 4267 4268 .npcink-toolbox__review-tally-summary { 4269 font-weight: 600; 4270 } 4271 4272 .npcink-toolbox__review-tally-note { 4273 color: #50575e; 4274 } -
npcink-workflow-toolbox/tags/0.5.3/assets/admin.js
r3733201 r3734482 415 415 } 416 416 417 maybeAttachReviewTally(form, result); 418 417 419 return result; 420 } 421 422 const REVIEW_TALLY_FORM_SETS = [ 423 ['data-toolbox-comment-moderation-review', 'comment_moderation'], 424 ['data-toolbox-taxonomy-tag-review', 'taxonomy_tag'], 425 ['data-toolbox-internal-link-review', 'internal_link'], 426 ['data-toolbox-flagged-media-review', 'flagged_media'], 427 ['data-toolbox-media-alt-caption-review', 'media_alt'], 428 ]; 429 let reviewTallySummaryPromise = null; 430 431 function reviewTallySummary() { 432 if (!reviewTallySummaryPromise) { 433 reviewTallySummaryPromise = fetch(joinRestUrl(config.restUrl, 'review-tally/summary'), { 434 headers: { 'X-WP-Nonce': config.nonce || '' }, 435 }).then((response) => (response.ok ? response.json() : null)).then((summary) => { 436 // Neither a rejected fetch nor a non-ok response is cached: 437 // the next attached bar retries instead of showing a stale 438 // zero count that the decision gate could mistake for data. 439 if (!summary) { 440 reviewTallySummaryPromise = null; 441 } 442 return summary; 443 }).catch(() => { 444 reviewTallySummaryPromise = null; 445 return null; 446 }); 447 } 448 return reviewTallySummaryPromise; 449 } 450 451 function paintReviewTallyBars(summary) { 452 // Paint from live DOM state (no closure registry to leak when 453 // result nodes are re-rendered). 454 document.querySelectorAll('.npcink-toolbox__review-tally[data-review-set]').forEach((bar) => { 455 const line = bar.querySelector('.npcink-toolbox__review-tally-summary'); 456 if (line) { 457 line.textContent = summary ? reviewTallyLine(summary, bar.getAttribute('data-review-set')) : t('Counts unavailable. Try reloading.'); 458 } 459 }); 460 } 461 462 function reviewTallyLine(summary, setKey) { 463 const set = summary && summary.sets && summary.sets[setKey] ? summary.sets[setKey] : { accepted: 0, ignored: 0 }; 464 const windowDays = summary && summary.window_days ? Number(summary.window_days) : 7; 465 const label = 7 === windowDays ? t('Last 7 days') : t('Last %d days').replace('%d', String(windowDays)); 466 return label + ': ' + Number(set.accepted || 0) + ' ' + t('accepted') + ' / ' + Number(set.ignored || 0) + ' ' + t('ignored'); 467 } 468 469 function maybeAttachReviewTally(form, result) { 470 const match = REVIEW_TALLY_FORM_SETS.find(([attr]) => form.hasAttribute(attr)); 471 if (!match || !result) { 472 return; 473 } 474 const setKey = match[1]; 475 if (result.querySelector('.npcink-toolbox__review-tally')) { 476 return; 477 } 478 const bar = el('div', 'npcink-toolbox__review-tally'); 479 bar.setAttribute('data-review-set', setKey); 480 const acceptButton = el('button', 'button button-small'); 481 acceptButton.type = 'button'; 482 acceptButton.textContent = t('Accept +1'); 483 const ignoreButton = el('button', 'button button-small'); 484 ignoreButton.type = 'button'; 485 ignoreButton.textContent = t('Ignore +1'); 486 const line = el('span', 'npcink-toolbox__review-tally-summary'); 487 const note = el('span', 'npcink-toolbox__review-tally-note'); 488 note.textContent = t('Local counts only. No content is written.'); 489 bar.appendChild(acceptButton); 490 bar.appendChild(ignoreButton); 491 bar.appendChild(line); 492 bar.appendChild(note); 493 result.appendChild(bar); 494 495 line.textContent = t('Counts unavailable. Try reloading.'); 496 const initialSummaryPromise = reviewTallySummary(); 497 initialSummaryPromise.then((summary) => { 498 // A mark may have completed while this fetch was pending and 499 // already painted fresh counts; a stale read must not overwrite 500 // them, so only paint if the shared cache still holds this 501 // promise's result. 502 if (reviewTallySummaryPromise === initialSummaryPromise) { 503 line.textContent = summary ? reviewTallyLine(summary, setKey) : t('Counts unavailable. Try reloading.'); 504 } 505 }); 506 507 function mark(decision) { 508 // Both buttons lock for the whole flight: a concurrent click on 509 // the sibling would append a second server-side mark. 510 acceptButton.disabled = true; 511 ignoreButton.disabled = true; 512 line.textContent = ''; 513 fetch(joinRestUrl(config.restUrl, 'review-tally/mark'), { 514 method: 'POST', 515 headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce || '' }, 516 body: JSON.stringify({ review_set: setKey, decision }), 517 }).then((response) => (response.ok ? response.json() : null)).then((summary) => { 518 if (!summary) { 519 line.textContent = t('Could not save the tally mark. Try again.'); 520 return; 521 } 522 reviewTallySummaryPromise = Promise.resolve(summary); 523 paintReviewTallyBars(summary); 524 }).catch(() => { 525 line.textContent = t('Could not save the tally mark. Try again.'); 526 }).finally(() => { 527 acceptButton.disabled = false; 528 ignoreButton.disabled = false; 529 }); 530 } 531 acceptButton.addEventListener('click', () => mark('accepted')); 532 ignoreButton.addEventListener('click', () => mark('ignored')); 418 533 } 419 534 … … 595 710 result.appendChild(createRawDetails(error, 'Error payload')); 596 711 } 712 maybeAttachReviewTally(form, result); 597 713 } 598 714 -
npcink-workflow-toolbox/tags/0.5.3/assets/editor-content-support/text-utils.js
r3730454 r3734482 21 21 return ''; 22 22 } 23 if (typeof window !== 'undefined' && window.document) { 24 const container = window.document.createElement('div'); 25 container.innerHTML = source; 26 return String(container.textContent || container.innerText || '').replace(/\s+/g, ' ').trim(); 23 // DOMParser documents are inert by specification: no script 24 // execution and no resource fetches, unlike innerHTML on a 25 // detached div, where inline handlers and image loads in block 26 // HTML can still fire while the text is extracted. 27 if (typeof window !== 'undefined' && typeof window.DOMParser === 'function') { 28 const parsed = new window.DOMParser().parseFromString(source, 'text/html'); 29 return String(parsed.body.textContent || '').replace(/\s+/g, ' ').trim(); 27 30 } 31 // Best-effort tag stripping for environments without DOMParser: 32 // malformed markup with '>' inside attribute values can leak tag text. 28 33 return source.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); 29 34 } -
npcink-workflow-toolbox/tags/0.5.3/includes/Plugin.php
r3730454 r3734482 13 13 14 14 final class Plugin { 15 public const OPTION_NAME = 'npcink_toolbox_settings'; 16 public const CONTEXT_OPTION_NAME = 'npcink_toolbox_content_context'; 17 public const MEDIA_OPTION_NAME = 'npcink_toolbox_media_optimization_settings'; 18 public const WATERMARK_OPTION_NAME = 'npcink_toolbox_watermark_templates'; 19 public const REST_NAMESPACE = 'npcink-toolbox/v1'; 15 public const OPTION_NAME = 'npcink_toolbox_settings'; 16 public const CONTEXT_OPTION_NAME = 'npcink_toolbox_content_context'; 17 public const MEDIA_OPTION_NAME = 'npcink_toolbox_media_optimization_settings'; 18 public const WATERMARK_OPTION_NAME = 'npcink_toolbox_watermark_templates'; 19 public const REVIEW_TALLY_OPTION_NAME = 'npcink_toolbox_review_tally'; 20 public const REST_NAMESPACE = 'npcink-toolbox/v1'; 20 21 21 22 private static ?Plugin $instance = null; -
npcink-workflow-toolbox/tags/0.5.3/includes/Rest_Controller.php
r3731876 r3734482 95 95 $this->post( '/nightly-inspection/cloud-batch/(?P<run_id>[A-Za-z0-9._:-]+)/result', 'nightly_inspection_cloud_batch_result' ); 96 96 $this->post( '/nightly-inspection/cloud-batch/(?P<run_id>[A-Za-z0-9._:-]+)/retry', 'nightly_inspection_cloud_batch_retry' ); 97 $this->post( '/review-tally/mark', 'review_tally_mark' ); 98 $this->get( '/review-tally/summary', 'review_tally_summary' ); 97 99 98 100 register_rest_route( … … 235 237 '/ai/content-support' => 'cap.toolbox.workflow_suggest', 236 238 '/ai/site-helpers' => 'cap.toolbox.workflow_suggest', 239 '/review-tally/mark' => 'cap.toolbox.workflow_suggest', 240 '/review-tally/summary' => 'cap.toolbox.workflow_suggest', 237 241 '/ai/image-generation' => 'cap.toolbox.image_source', 238 242 '/flows/article-plan' => 'cap.toolbox.workflow_suggest', … … 328 332 public function nightly_inspection_cloud_batch_result( WP_REST_Request $request ) { 329 333 return $this->nightly_bridges->nightly_inspection_cloud_batch_result( $request ); 334 } 335 336 public function review_tally_mark( WP_REST_Request $request ) { 337 $review_set = sanitize_key( (string) $request->get_param( 'review_set' ) ); 338 $decision = sanitize_key( (string) $request->get_param( 'decision' ) ); 339 if ( ! array_key_exists( $review_set, Settings::REVIEW_TALLY_SETS ) ) { 340 return new WP_Error( 341 'npcink_toolbox_review_tally_unknown_set', 342 __( 'A supported review set is required.', 'npcink-workflow-toolbox' ), 343 array( 'status' => 400 ) 344 ); 345 } 346 if ( ! in_array( $decision, array( 'accepted', 'ignored' ), true ) ) { 347 return new WP_Error( 348 'npcink_toolbox_review_tally_invalid_decision', 349 __( 'The tally decision must be accepted or ignored.', 'npcink-workflow-toolbox' ), 350 array( 'status' => 400 ) 351 ); 352 } 353 $this->settings->record_review_tally_mark( $review_set, $decision ); 354 return rest_ensure_response( $this->settings->review_tally_summary() ); 355 } 356 357 public function review_tally_summary( WP_REST_Request $request ) { 358 $requested_days = absint( $request->get_param( 'days' ) ); 359 $days = $requested_days ? max( 1, min( 90, $requested_days ) ) : 7; 360 return rest_ensure_response( $this->settings->review_tally_summary( $days ) ); 330 361 } 331 362 -
npcink-workflow-toolbox/tags/0.5.3/includes/Rest_Editor_Content_Support.php
r3731876 r3734482 23 23 defined( 'ABSPATH' ) || exit; 24 24 25 final class Rest_Editor_Content_Support extends Rest_ Controller_Support{25 final class Rest_Editor_Content_Support extends Rest_Editor_Flow_Cache { 26 26 27 27 private const EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS = 30000; 28 private const EDITOR_AUDIO_TEXT_MAX_CHARS = 5000;29 28 private const EDITOR_SELECTED_TEXT_MAX_CHARS = 2000; 30 29 private const EDITOR_COMMENT_TEXT_MAX_CHARS = 1200; 31 private const EDITOR_FLOW_CACHE_TTL = 300;32 30 private const EDITOR_PROGRESSIVE_TARGET_MS = 2500; 33 31 private const EDITOR_PROGRESSIVE_CANDIDATE_LIMIT = 8; 34 32 35 private Provider_Client $client;36 33 private Publish_Preflight_Service $publish_preflight; 37 34 … … 210 207 private function editor_post_context( WP_REST_Request $request ): array { 211 208 $content_raw = (string) $request->get_param( 'content' ); 212 $audio_preferences = $this->editor_audio_preferences_from_request( $request );209 $audio_preferences = Rest_Editor_Audio_Text::editor_audio_preferences_from_request( $request ); 213 210 $content = trim( wp_strip_all_tags( $content_raw ) ); 214 211 $selected_text = trim( wp_strip_all_tags( (string) $request->get_param( 'selected_text' ) ) ); … … 232 229 'excerpt' => sanitize_textarea_field( (string) $request->get_param( 'excerpt' ) ), 233 230 'content_text' => wp_trim_words( $content, 220, '' ), 234 'content_full_text' => sanitize_textarea_field( $this->editor_trim_chars( $content, self::EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS ) ),235 'content_audio_text' => sanitize_textarea_field( $this->editor_trim_chars( $this->editor_audio_text_from_raw_content( $content_raw, $audio_preferences ), self::EDITOR_AUDIO_TEXT_MAX_CHARS ) ),231 'content_full_text' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( $content, self::EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS ) ), 232 'content_audio_text' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( Rest_Editor_Audio_Text::editor_audio_text_from_raw_content( $content_raw, $audio_preferences ), Rest_Editor_Audio_Text::EDITOR_AUDIO_TEXT_MAX_CHARS ) ), 236 233 'selected_text' => wp_trim_words( sanitize_textarea_field( $selected_text ), 110, '' ), 237 234 'selected_block_text' => wp_trim_words( sanitize_textarea_field( $selected_block_text ), 110, '' ), 238 'selected_text_full' => sanitize_textarea_field( $this->editor_trim_chars( $selected_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ),239 'selected_block_text_full' => sanitize_textarea_field( $this->editor_trim_chars( $selected_block_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ),235 'selected_text_full' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( $selected_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ), 236 'selected_block_text_full' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( $selected_block_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ), 240 237 'selected_block_name' => sanitize_text_field( (string) $request->get_param( 'selected_block_name' ) ), 241 238 'user_instruction' => wp_trim_words( sanitize_textarea_field( $user_instruction ), 60, '' ), … … 255 252 'comment_id' => absint( $request->get_param( 'comment_id' ) ), 256 253 'comment_author' => sanitize_text_field( (string) $request->get_param( 'comment_author' ) ), 257 'comment_text' => sanitize_textarea_field( $this->editor_trim_chars( trim( wp_strip_all_tags( (string) $request->get_param( 'comment_text' ) ) ), self::EDITOR_COMMENT_TEXT_MAX_CHARS ) ), 258 ); 259 } 260 261 private function editor_audio_preferences_from_request( WP_REST_Request $request ): array { 262 $raw = $request->get_param( 'audio_preferences' ); 263 if ( ! is_array( $raw ) ) { 264 $raw = array(); 265 } 266 267 $defaults = array( 268 'tone' => 'calm', 269 'pace' => 'normal', 270 'handling' => 'skip_code', 271 'focus' => 'product_names', 272 ); 273 $allowed = array( 274 'tone' => array( 'calm', 'formal', 'casual', 'expressive' ), 275 'pace' => array( 'normal', 'slow', 'fast' ), 276 'handling' => array( 'skip_code', 'read_code', 'skip_tables' ), 277 'focus' => array( 'product_names', 'numbers', 'headings' ), 278 ); 279 $preferences = array(); 280 foreach ( $defaults as $key => $default ) { 281 $value = sanitize_key( (string) ( $raw[ $key ] ?? $default ) ); 282 $preferences[ $key ] = in_array( $value, $allowed[ $key ], true ) ? $value : $default; 283 } 284 return $preferences; 254 'comment_text' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( trim( wp_strip_all_tags( (string) $request->get_param( 'comment_text' ) ) ), self::EDITOR_COMMENT_TEXT_MAX_CHARS ) ), 255 ); 285 256 } 286 257 … … 296 267 } 297 268 $client_id = sanitize_text_field( (string) ( $block['client_id'] ?? '' ) ); 298 $text = sanitize_textarea_field( $this->editor_trim_chars( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1600 ) );269 $text = sanitize_textarea_field( Rest_Editor_Audio_Text::trim( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1600 ) ); 299 270 if ( '' !== $client_id && '' !== $text ) { 300 271 $items[] = array( … … 306 277 } 307 278 return $items; 308 }309 310 private function editor_audio_text_from_raw_content( string $content, array $audio_preferences ): string {311 $source = $content;312 $handling = sanitize_key( (string) ( $audio_preferences['handling'] ?? 'skip_code' ) );313 if ( 'skip_code' === $handling ) {314 $source = preg_replace( '/<!--\s*wp:code\b.*?<!--\s*\/wp:code\s*-->/is', ' ', $source );315 $source = preg_replace( '/<pre\b[^>]*>.*?<\/pre>/is', ' ', $source );316 $source = preg_replace( '/<code\b[^>]*>.*?<\/code>/is', ' ', $source );317 }318 if ( 'skip_tables' === $handling ) {319 $source = preg_replace( '/<!--\s*wp:table\b.*?<!--\s*\/wp:table\s*-->/is', ' ', $source );320 $source = preg_replace( '/<table\b[^>]*>.*?<\/table>/is', ' ', $source );321 }322 return trim( wp_strip_all_tags( (string) $source ) );323 }324 325 private function editor_trim_chars( string $value, int $max_chars ): string {326 $value = trim( $value );327 $max_chars = max( 1, $max_chars );328 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {329 return mb_strlen( $value, 'UTF-8' ) > $max_chars ? mb_substr( $value, 0, $max_chars, 'UTF-8' ) : $value;330 }331 332 return strlen( $value ) > $max_chars ? substr( $value, 0, $max_chars ) : $value;333 279 } 334 280 … … 555 501 $candidate = trim( preg_replace( '/\s+/u', ' ', (string) $candidate ) ?: '' ); 556 502 if ( '' !== $candidate ) { 557 return $this->editor_trim_chars( $candidate, 120 );503 return Rest_Editor_Audio_Text::trim( $candidate, 120 ); 558 504 } 559 505 } … … 675 621 } 676 622 677 $trimmed = $this->editor_trim_chars( $summary, 120 );623 $trimmed = Rest_Editor_Audio_Text::trim( $summary, 120 ); 678 624 if ( $trimmed !== $summary && preg_match( '/\s/u', $trimmed ) ) { 679 625 $trimmed = preg_replace( '/\s+\S*$/u', '', $trimmed ) ?: $trimmed; … … 770 716 771 717 return wp_trim_words( $query, 80, '' ); 772 }773 774 private function editor_input_scope( array $context ): array {775 $scope = sanitize_key( (string) ( $context['context_scope'] ?? 'auto' ) );776 $selection = trim( (string) ( $context['selected_text'] ?? '' ) . ' ' . (string) ( $context['selected_block_text'] ?? '' ) );777 if ( 'auto' === $scope ) {778 $scope = '' !== $selection ? 'selected_text' : ( '' !== trim( (string) ( $context['content_text'] ?? '' ) ) ? 'full_article' : 'topic_only' );779 }780 781 $labels = array(782 'selected_text' => __( 'Selected text or supplied snippet', 'npcink-workflow-toolbox' ),783 'full_article' => __( 'Full article context', 'npcink-workflow-toolbox' ),784 'topic_only' => __( 'Topic or short brief', 'npcink-workflow-toolbox' ),785 );786 787 $fields = array();788 foreach ( array( 'title', 'excerpt', 'content_text', 'selected_text', 'selected_block_text', 'post_id' ) as $field ) {789 if ( ! empty( $context[ $field ] ) ) {790 $fields[] = $field;791 }792 }793 794 return array(795 'id' => $scope,796 'label' => $labels[ $scope ] ?? __( 'Current context', 'npcink-workflow-toolbox' ),797 'source_fields' => $fields,798 'operator_selected_mode' => sanitize_key( (string) ( $context['context_scope'] ?? 'auto' ) ),799 'detail' => __( 'This scope controls ranking context only. Toolbox still returns suggestions and does not write WordPress data.', 'npcink-workflow-toolbox' ),800 );801 718 } 802 719 … … 1094 1011 $taxonomy_recommendations = '' !== trim( $query ) 1095 1012 ? array_merge( 1096 $this->editor_taxonomy_recommendation_candidates( 'category_suggestions', $category_items, array(), $this->empty_proposed_new_terms_review() ),1097 $this->editor_taxonomy_recommendation_candidates( 'tag_suggestions', array(), $tag_items, $this->empty_proposed_new_terms_review() )1013 Rest_Editor_Taxonomy_Shaping::editor_taxonomy_recommendation_candidates( 'category_suggestions', $category_items, array(), $this->empty_proposed_new_terms_review() ), 1014 Rest_Editor_Taxonomy_Shaping::editor_taxonomy_recommendation_candidates( 'tag_suggestions', array(), $tag_items, $this->empty_proposed_new_terms_review() ) 1098 1015 ) 1099 1016 : array(); … … 1513 1430 $match_score = (int) ( $item['score'] ?? 0 ); 1514 1431 $quality_score = min( 95, 55 + $match_score * 8 ); 1515 $candidates[] = $this->editor_recommendation_candidate(1432 $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate( 1516 1433 array( 1517 1434 'id' => 'media_library_' . $attachment_id, … … 1596 1513 $status = sanitize_key( (string) ( $item['status'] ?? 'warning' ) ); 1597 1514 $score = 'error' === $status ? 35 : 55; 1598 $candidates[] = $this->editor_recommendation_candidate(1515 $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate( 1599 1516 array( 1600 1517 'id' => 'preflight_' . $id, … … 1616 1533 return $candidates; 1617 1534 } 1618 1619 private function editor_cached_site_knowledge( array $input ) {1620 return $this->editor_cached_client_result(1621 'site_knowledge',1622 $input,1623 function () use ( $input ) {1624 return $this->client->search_site_knowledge( $input );1625 }1626 );1627 }1628 1629 private function editor_cached_content_discoverability( array $input ) {1630 return $this->editor_cached_client_result(1631 'content_discoverability',1632 $input,1633 function () use ( $input ) {1634 return $this->client->build_content_discoverability_brief( $input );1635 }1636 );1637 }1638 1639 private function editor_cached_hosted_ai_content_support( array $input, bool $force_refresh = false ) {1640 return $this->editor_cached_client_result(1641 'hosted_ai_content_support',1642 $input,1643 function () use ( $input ) {1644 return $this->client->run_hosted_ai_content_support( $input );1645 },1646 $force_refresh1647 );1648 }1649 1650 private function editor_cached_audio_generation( array $input, bool $force_refresh = false ) {1651 return $this->editor_cached_client_result(1652 'audio_generation',1653 $input,1654 function () use ( $input ) {1655 return $this->client->run_audio_generation( $input );1656 },1657 $force_refresh1658 );1659 }1660 1661 private function editor_cached_cloud_web_search( array $input, bool $force_refresh = false ) {1662 return $this->editor_cached_client_result(1663 'cloud_web_search',1664 $input,1665 function () use ( $input ) {1666 return $this->client->test_cloud_web_search( $input );1667 },1668 $force_refresh,1669 function ( array $result ) use ( $input ): bool {1670 return $this->editor_source_extraction_cacheable( $input, $result );1671 },1672 true1673 );1674 }1675 1676 private function editor_source_extraction_cacheable( array $input, array $result ): bool {1677 if ( 'source_extraction_preview' !== sanitize_key( (string) ( $input['intent'] ?? '' ) ) ) {1678 return true;1679 }1680 1681 return 'ready' === sanitize_key( (string) ( $result['status'] ?? '' ) )1682 && 'matched' === sanitize_key( (string) ( $result['url_match'] ?? '' ) )1683 && ! empty( $result['results'][0]['reader_excerpt'] ?? '' );1684 }1685 1686 private function editor_cached_client_result( string $namespace, array $input, callable $callback, bool $force_refresh = false, ?callable $should_cache = null, bool $replace_cache_on_force = false ) {1687 $cache_key = $this->editor_flow_cache_key( $namespace, $input );1688 $cached = $force_refresh ? false : get_transient( $cache_key );1689 if ( false !== $cached && is_array( $cached ) ) {1690 if ( null === $should_cache || $should_cache( $cached ) ) {1691 $cached['cache_status'] = 'hit';1692 return $cached;1693 }1694 delete_transient( $cache_key );1695 }1696 1697 $result = $callback();1698 if ( ! is_wp_error( $result ) && is_array( $result ) ) {1699 $result['cache_status'] = $force_refresh ? 'bypass' : 'miss';1700 $cacheable = null === $should_cache || $should_cache( $result );1701 if ( $cacheable && ( ! $force_refresh || $replace_cache_on_force ) ) {1702 set_transient( $cache_key, $result, self::EDITOR_FLOW_CACHE_TTL );1703 } elseif ( $force_refresh && $replace_cache_on_force ) {1704 delete_transient( $cache_key );1705 }1706 }1707 1708 return $result;1709 }1710 1711 private function editor_flow_cache_key( string $namespace, array $input ): string {1712 $json = wp_json_encode( $input );1713 if ( ! is_string( $json ) ) {1714 $json = serialize( $input );1715 }1716 1717 return 'npcink_toolbox_editor_' . sanitize_key( $namespace ) . '_' . md5( $json );1718 }1719 1720 /**1721 * Validates one public source URL before Cloud research is requested.1722 *1723 * @param string $value Raw URL.1724 * @return string|WP_Error1725 */1726 1535 1727 1536 private function editor_source_adaptation_url( string $value ) { … … 2556 2365 2557 2366 private function editor_writing_pack_related_articles( array $knowledge ): array { 2558 $items = $this->editor_related_content_items( $knowledge );2367 $items = Rest_Editor_Flow_Cache::editor_related_content_items( $knowledge ); 2559 2368 $result = array(); 2560 2369 foreach ( array_slice( $items, 0, 6 ) as $index => $item ) { … … 2712 2521 'final_write_path' => 'core_proposal_required', 2713 2522 'direct_wordpress_write' => false, 2714 'input_scope' => $this->editor_input_scope( $context ),2523 'input_scope' => Rest_Editor_Flow_Cache::editor_input_scope( $context ), 2715 2524 'summary_candidates' => $summary_ai, 2716 2525 'summary_layers' => $summary_layers, … … 2779 2588 'diagnosis' => array( 2780 2589 'summary_quality' => $this->editor_summary_quality( $context ), 2781 'taxonomy_quality' => $this->editor_taxonomy_quality( $context, $categories, $tags, $proposed_new_terms ),2590 'taxonomy_quality' => Rest_Editor_Taxonomy_Shaping::editor_taxonomy_quality( $context, $categories, $tags, $proposed_new_terms ), 2782 2591 'hypotheses' => array( 2783 2592 __( 'A clearer excerpt can improve archive, social, and answer-summary presentation without rewriting the article body.', 'npcink-workflow-toolbox' ), … … 2875 2684 } 2876 2685 2877 private function editor_taxonomy_quality( array $context, array $categories, array $tags, array $proposed_new_terms ): string {2878 $current_categories = is_array( $context['category_ids'] ?? null ) ? array_filter( array_map( 'absint', $context['category_ids'] ) ) : array();2879 $current_tags = is_array( $context['tag_ids'] ?? null ) ? array_filter( array_map( 'absint', $context['tag_ids'] ) ) : array();2880 if ( empty( $current_categories ) && empty( $current_tags ) ) {2881 return 'missing';2882 }2883 if ( ! empty( $proposed_new_terms['items'] ) || 12 < count( $current_tags ) ) {2884 return 'noisy';2885 }2886 if ( empty( $categories ) && empty( $tags ) ) {2887 return 'acceptable';2888 }2889 2890 return 'weak';2891 }2892 2893 private function editor_related_content_items( array $related_content ): array {2894 $items = is_array( $related_content['results'] ?? null )2895 ? $related_content['results']2896 : ( is_array( $related_content['items'] ?? null ) ? $related_content['items'] : array() );2897 2898 return array_values(2899 array_filter(2900 $items,2901 static fn( $item ): bool => is_array( $item )2902 )2903 );2904 }2905 2906 2686 private function editor_related_content_summary( array $related_content ): array { 2907 $items = $this->editor_related_content_items( $related_content );2687 $items = Rest_Editor_Flow_Cache::editor_related_content_items( $related_content ); 2908 2688 $evidence_ids = array(); 2909 2689 $titles = array(); … … 2928 2708 2929 2709 private function editor_related_content_context_for_ai( array $related_content ): array { 2930 $items = $this->editor_related_content_items( $related_content );2710 $items = Rest_Editor_Flow_Cache::editor_related_content_items( $related_content ); 2931 2711 $context_items = array(); 2932 2712 … … 3018 2798 $items = is_array( $artifact['items'] ?? null ) ? $artifact['items'] : array(); 3019 2799 $items = $this->editor_internal_link_merge_cloud_relevance( $items, $related_content_evidence ); 3020 $artifact['input_scope'] = $this->editor_input_scope( $context );2800 $artifact['input_scope'] = Rest_Editor_Flow_Cache::editor_input_scope( $context ); 3021 2801 $artifact['source_ability_id'] = 'npcink-abilities-toolkit/resolve-internal-link-targets'; 3022 2802 $artifact['source_knowledge'] = $source_knowledge; … … 3109 2889 private function editor_internal_link_related_content_evidence( array $source_knowledge ): array { 3110 2890 $evidence = array(); 3111 foreach ( array_slice( $this->editor_related_content_items( $source_knowledge ), 0, 8 ) as $index => $item ) {2891 foreach ( array_slice( Rest_Editor_Flow_Cache::editor_related_content_items( $source_knowledge ), 0, 8 ) as $index => $item ) { 3112 2892 $post_id = absint( $item['post_id'] ?? ( $item['id'] ?? 0 ) ); 3113 2893 $candidate_relevance = sanitize_key( (string) ( $item['candidate_relevance'] ?? '' ) ); … … 3143 2923 continue; 3144 2924 } 3145 $text = trim( sanitize_textarea_field( $this->editor_trim_chars( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1200 ) ) );2925 $text = trim( sanitize_textarea_field( Rest_Editor_Audio_Text::trim( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1200 ) ) ); 3146 2926 if ( '' === $text ) { 3147 2927 continue; … … 3300 3080 } 3301 3081 3302 $candidates[] = $this->editor_recommendation_candidate(3082 $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate( 3303 3083 array( 3304 3084 'id' => 'internal_link_' . ( $index + 1 ), … … 3401 3181 ) 3402 3182 ); 3403 $script = $this->editor_audio_summary_script_text( $summary_ai );3183 $script = Rest_Editor_Audio_Text::editor_audio_summary_script_text( $summary_ai ); 3404 3184 $script_source['summary_script'] = $summary_ai; 3405 3185 } else { 3406 $script = $this->editor_audio_source_text( $source_text );3186 $script = Rest_Editor_Audio_Text::editor_audio_source_text( $source_text ); 3407 3187 $script_source['source_mode'] = 'article_text'; 3408 3188 } … … 3466 3246 } 3467 3247 3468 private function editor_audio_source_text( string $text ): string {3469 $plain = trim( wp_strip_all_tags( $text ) );3470 if ( '' === $plain ) {3471 return '';3472 }3473 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {3474 return mb_strlen( $plain, 'UTF-8' ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? mb_substr( $plain, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS, 'UTF-8' ) : $plain;3475 }3476 return strlen( $plain ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? substr( $plain, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS ) : $plain;3477 }3478 3479 private function editor_audio_summary_script_text( array $summary_ai ): string {3480 $output_json = is_array( $summary_ai['output_json'] ?? null ) ? $summary_ai['output_json'] : array();3481 $parts = array();3482 foreach ( array( 'opening', 'script', 'closing' ) as $key ) {3483 $value = trim( sanitize_textarea_field( (string) ( $output_json[ $key ] ?? '' ) ) );3484 if ( '' !== $value ) {3485 $parts[] = $value;3486 }3487 }3488 if ( is_array( $output_json['key_points'] ?? null ) ) {3489 foreach ( array_slice( $output_json['key_points'], 0, 5 ) as $point ) {3490 $value = trim( sanitize_textarea_field( (string) $point ) );3491 if ( '' !== $value ) {3492 $parts[] = $value;3493 }3494 }3495 }3496 $script = trim( implode( "\n\n", array_values( array_unique( $parts ) ) ) );3497 if ( '' === $script ) {3498 $script = trim( sanitize_textarea_field( (string) ( $summary_ai['output_text'] ?? '' ) ) );3499 }3500 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {3501 return mb_strlen( $script, 'UTF-8' ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? mb_substr( $script, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS, 'UTF-8' ) : $script;3502 }3503 return strlen( $script ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? substr( $script, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS ) : $script;3504 }3505 3506 3248 private function editor_article_checkup_section( array $context ): array { 3507 3249 $text = trim( (string) ( $context['content_full_text'] ?? $context['content_text'] ?? '' ) ); … … 3680 3422 $signals = array(); 3681 3423 3682 if ( $this->editor_text_has_heading_label_glue( $paragraph ) ) {3424 if ( Rest_Editor_Paragraph_Check::editor_text_has_heading_label_glue( $paragraph ) ) { 3683 3425 $signals[] = __( '标题式标签直接黏在正文前', 'npcink-workflow-toolbox' ); 3684 3426 } 3685 if ( $this->editor_text_has_phrase_cluster_glue( $paragraph ) ) {3427 if ( Rest_Editor_Paragraph_Check::editor_text_has_phrase_cluster_glue( $paragraph ) ) { 3686 3428 $signals[] = __( '短语组之间缺少分隔', 'npcink-workflow-toolbox' ); 3687 3429 } 3688 if ( $this->editor_text_has_alnum_cjk_glue( $paragraph ) ) {3430 if ( Rest_Editor_Paragraph_Check::editor_text_has_alnum_cjk_glue( $paragraph ) ) { 3689 3431 $signals[] = __( '字母、数字或方案标签与中文黏连', 'npcink-workflow-toolbox' ); 3690 3432 } … … 3926 3668 } 3927 3669 3928 private function editor_text_has_heading_label_glue( string $text ): bool {3929 return 1 === preg_match( '/(核心要点|评估维度|主要差异|适用建议|常见问题|方案\s*[A-Za-zA-Z])(?=[\p{Han}A-Za-z0-9])/u', $text );3930 }3931 3932 private function editor_text_has_phrase_cluster_glue( string $text ): bool {3933 return 1 === preg_match( '/可维护性编辑体验响应式表现治理边界/u', $text )3934 || 1 === preg_match( '/(可维护性|编辑体验|响应式表现|治理边界)(可维护性|编辑体验|响应式表现|治理边界)(可维护性|编辑体验|响应式表现|治理边界)/u', $text );3935 }3936 3937 private function editor_text_has_alnum_cjk_glue( string $text ): bool {3938 return 1 === preg_match( '/(?:[A-Za-z0-9][\x{4e00}-\x{9fff}]|[\x{4e00}-\x{9fff}][A-Za-z0-9]{2,})/u', $text );3939 }3940 3941 private function editor_text_has_structural_glue( string $text ): bool {3942 return $this->editor_text_has_heading_label_glue( $text )3943 || $this->editor_text_has_phrase_cluster_glue( $text )3944 || $this->editor_text_has_alnum_cjk_glue( $text );3945 }3946 3947 3670 private function editor_article_checkup_issue( string $id, string $type, string $severity, string $location, string $evidence, string $issue, string $edit_direction ): array { 3948 3671 return array( … … 3951 3674 'severity' => sanitize_key( $severity ), 3952 3675 'location' => sanitize_text_field( $location ), 3953 'evidence' => sanitize_text_field( $this->editor_trim_chars( $evidence, 120 ) ),3676 'evidence' => sanitize_text_field( Rest_Editor_Audio_Text::trim( $evidence, 120 ) ), 3954 3677 'issue' => sanitize_text_field( $issue ), 3955 3678 'edit_direction' => sanitize_textarea_field( $edit_direction ), … … 4007 3730 $section['write_posture'] = 'suggestion_only'; 4008 3731 if ( 'polish_notes' === $provider_intent ) { 4009 if ( ! $this->editor_paragraph_check_has_output( $section ) ) {4010 $section = $this->editor_paragraph_check_local_fallback_section( $section, $content );3732 if ( ! Rest_Editor_Paragraph_Check::editor_paragraph_check_has_output( $section ) ) { 3733 $section = Rest_Editor_Paragraph_Check::editor_paragraph_check_local_fallback_section( $section, $content ); 4011 3734 } else { 4012 $section = $this->editor_paragraph_check_local_overlay_section( $section, $content );3735 $section = Rest_Editor_Paragraph_Check::editor_paragraph_check_local_overlay_section( $section, $content ); 4013 3736 } 4014 3737 } 4015 3738 4016 3739 return $section; 4017 }4018 4019 private function editor_paragraph_check_has_output( array $section ): bool {4020 if ( ! empty( $section['items'] ) && is_array( $section['items'] ) ) {4021 return true;4022 }4023 if ( '' !== trim( sanitize_textarea_field( (string) ( $section['output_text'] ?? '' ) ) ) ) {4024 return true;4025 }4026 $output_json = is_array( $section['output_json'] ?? null ) ? $section['output_json'] : array();4027 foreach ( array( 'clarity_check', 'fact_gaps', 'tone_consistency', 'editing_suggestions', 'assumptions_to_verify' ) as $key ) {4028 if ( ! empty( $output_json[ $key ] ) ) {4029 return true;4030 }4031 }4032 $result = is_array( $section['result'] ?? null ) ? $section['result'] : array();4033 foreach ( array( 'clarity_check', 'fact_gaps', 'tone_consistency', 'editing_suggestions', 'assumptions_to_verify' ) as $key ) {4034 if ( ! empty( $result[ $key ] ) ) {4035 return true;4036 }4037 }4038 return false;4039 }4040 4041 private function editor_paragraph_check_local_fallback_section( array $section, string $selected_text ): array {4042 $output = $this->editor_paragraph_check_local_output( $selected_text );4043 $status = sanitize_key( (string) ( $section['status'] ?? 'unknown' ) );4044 4045 $section['provider_execution'] = 'hosted_ai_with_local_empty_fallback';4046 $section['hosted_ai_status'] = $status;4047 $section['fallback_reason'] = 'local_paragraph_check_after_hosted_ai_empty';4048 $section['fallback_source'] = 'current_selected_paragraph_only';4049 $section['fallback_write_posture'] = 'suggestion_only_no_replacement_text';4050 $section['fallback_signal_profile'] = is_array( $output['signal_profile'] ?? null )4051 ? array_map( 'boolval', $output['signal_profile'] )4052 : array();4053 $section['output_json'] = $output;4054 $section['items'] = array(4055 array(4056 'name' => __( 'Clarity check', 'npcink-workflow-toolbox' ),4057 'detail' => $output['clarity_check'],4058 'action_policy' => 'operator_review_only_no_insert',4059 'evidence_refs' => array( 'current_selection:paragraph' ),4060 ),4061 array(4062 'name' => __( 'Fact gaps', 'npcink-workflow-toolbox' ),4063 'detail' => $output['fact_gaps'],4064 'action_policy' => 'operator_review_only_no_insert',4065 'evidence_refs' => array( 'current_selection:paragraph' ),4066 ),4067 array(4068 'name' => __( 'Tone consistency', 'npcink-workflow-toolbox' ),4069 'detail' => $output['tone_consistency'],4070 'action_policy' => 'operator_review_only_no_insert',4071 'evidence_refs' => array( 'current_selection:paragraph' ),4072 ),4073 array(4074 'name' => __( 'Editing suggestions', 'npcink-workflow-toolbox' ),4075 'detail' => $output['editing_suggestions'],4076 'action_policy' => 'operator_review_only_no_insert',4077 'evidence_refs' => array( 'current_selection:paragraph' ),4078 ),4079 );4080 4081 return $section;4082 }4083 4084 private function editor_paragraph_check_local_overlay_section( array $section, string $selected_text ): array {4085 $output = $this->editor_paragraph_check_local_output( $selected_text, false );4086 $overlay = array(4087 'artifact_type' => 'paragraph_local_review_overlay.v1',4088 'source' => 'current_selected_paragraph_only',4089 'write_posture' => 'suggestion_only_no_replacement_text',4090 'action_policy' => 'operator_review_only_no_insert',4091 'status' => 'ready',4092 'provider_execution' => 'local_signal_overlay_after_hosted_ai_output',4093 'signal_profile' => is_array( $output['signal_profile'] ?? null )4094 ? array_map( 'boolval', $output['signal_profile'] )4095 : array(),4096 'output_json' => $output,4097 'items' => $this->editor_paragraph_check_local_overlay_items( $output ),4098 );4099 4100 $section['local_review_overlay'] = $overlay;4101 $section['local_signal_profile'] = $overlay['signal_profile'];4102 4103 $output_json = is_array( $section['output_json'] ?? null ) ? $section['output_json'] : array();4104 $output_json['local_review_overlay'] = $overlay;4105 $section['output_json'] = $output_json;4106 4107 return $section;4108 }4109 4110 private function editor_paragraph_check_local_overlay_items( array $output ): array {4111 $signals = is_array( $output['signal_profile'] ?? null ) ? $output['signal_profile'] : array();4112 $items = array();4113 4114 if ( ! empty( $signals['has_structural_glue'] ) ) {4115 $items[] = array(4116 'name' => __( 'Local structure cross-check', 'npcink-workflow-toolbox' ),4117 'detail' => (string) ( $output['clarity_check'] ?? '' ),4118 'action_policy' => 'operator_review_only_no_insert',4119 'evidence_refs' => array( 'current_selection:local_overlay' ),4120 );4121 $items[] = array(4122 'name' => __( 'Local fact-boundary check', 'npcink-workflow-toolbox' ),4123 'detail' => (string) ( $output['fact_gaps'] ?? '' ),4124 'action_policy' => 'operator_review_only_no_insert',4125 'evidence_refs' => array( 'current_selection:local_overlay' ),4126 );4127 } elseif ( ! empty( $signals['has_metric_claim'] ) || ! empty( $signals['has_comparison_claim'] ) || ! empty( $signals['long_or_dense'] ) ) {4128 $items[] = array(4129 'name' => __( 'Local fact-boundary check', 'npcink-workflow-toolbox' ),4130 'detail' => (string) ( $output['fact_gaps'] ?? '' ),4131 'action_policy' => 'operator_review_only_no_insert',4132 'evidence_refs' => array( 'current_selection:local_overlay' ),4133 );4134 } elseif ( ! empty( $signals['has_scope_claim'] ) || ! empty( $signals['has_causal_transition'] ) ) {4135 $items[] = array(4136 'name' => __( 'Local scope check', 'npcink-workflow-toolbox' ),4137 'detail' => (string) ( $output['tone_consistency'] ?? '' ),4138 'action_policy' => 'operator_review_only_no_insert',4139 'evidence_refs' => array( 'current_selection:local_overlay' ),4140 );4141 }4142 4143 if ( ! empty( $items ) ) {4144 $items[] = array(4145 'name' => __( 'Local editing guardrail', 'npcink-workflow-toolbox' ),4146 'detail' => (string) ( $output['editing_suggestions'] ?? '' ),4147 'action_policy' => 'operator_review_only_no_insert',4148 'evidence_refs' => array( 'current_selection:local_overlay' ),4149 );4150 }4151 4152 return array_values(4153 array_filter(4154 $items,4155 static function ( $item ): bool {4156 return is_array( $item ) && '' !== trim( (string) ( $item['detail'] ?? '' ) );4157 }4158 )4159 );4160 }4161 4162 private function editor_paragraph_check_signal_profile( string $text, int $length, int $punctuation_count ): array {4163 $has_performance_claim = 1 === preg_match( '/(快|慢|耗时|性能|经测试|同等服务器|相当|无明显|明显性能|读取|保存)/u', $text );4164 $has_metric_claim = 1 === preg_match( '/(\d|万|倍|%|百分|测试|经测试|数量|规模|id|ID|attachment)/u', $text );4165 $has_scope_claim = 1 === preg_match( '/(可用于|适合|场景|条件|范围|限制|边界|因此|所以|由于|因为)/u', $text );4166 $has_comparison_claim = 1 === preg_match( '/(比|相比|对比|相较|优于|弱于|快于|慢于|高于|低于)/u', $text );4167 $has_causal_transition = 1 === preg_match( '/(因此|所以|由于|因为|从而|导致)/u', $text );4168 4169 return array(4170 'long_or_dense' => $length > 150 || $punctuation_count >= 3,4171 'has_performance_claim' => $has_performance_claim,4172 'has_metric_claim' => $has_metric_claim,4173 'has_scope_claim' => $has_scope_claim,4174 'has_comparison_claim' => $has_comparison_claim,4175 'has_causal_transition' => $has_causal_transition,4176 'has_structural_glue' => $this->editor_text_has_structural_glue( $text ),4177 );4178 }4179 4180 private function editor_paragraph_check_local_output( string $selected_text, bool $hosted_ai_empty = true ): array {4181 $text = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $selected_text ) ) ?: '' );4182 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );4183 $punctuation_count = preg_match_all( '/[。!?!?;;]/u', $text );4184 $signals = $this->editor_paragraph_check_signal_profile( $text, $length, (int) $punctuation_count );4185 4186 if ( $signals['has_structural_glue'] ) {4187 $clarity = __( '选中段落疑似存在标题词、选项标签或短语串与正文黏连的问题,建议先检查分隔、标点、列表或小标题结构。', 'npcink-workflow-toolbox' );4188 } elseif ( $signals['long_or_dense'] && $signals['has_metric_claim'] ) {4189 $clarity = __( '段落信息量偏高,建议人工检查测试条件、对比结论和适用边界是否需要拆开呈现,避免读者把多个结论混在一起。', 'npcink-workflow-toolbox' );4190 } elseif ( $signals['long_or_dense'] ) {4191 $clarity = __( '段落信息量偏高,建议人工检查主语、原因、结论和适用边界是否需要拆开呈现,避免读者把多个判断混在一起。', 'npcink-workflow-toolbox' );4192 } else {4193 $clarity = __( '段落结构基本清楚;重点检查判断对象、原因和适用边界是否已经在上下文中交代。', 'npcink-workflow-toolbox' );4194 }4195 4196 $fact_gaps = $signals['has_structural_glue']4197 ? __( '结构黏连会影响读者判断事实边界;发布前需要确认每个标签、维度、方案、ID 或问答对应的正文是否清楚分开。', 'npcink-workflow-toolbox' )4198 : ( $signals['has_performance_claim']4199 ? __( '包含测试、数量、速度或耗时类结论;发布前需要确认测试条件、数据规模、对比对象和结论来源,避免把单次测试写成通用事实。', 'npcink-workflow-toolbox' )4200 : ( $signals['has_metric_claim']4201 ? __( '包含数字、ID、数量或范围类表述;发布前需要确认这些数字对应的对象、条件和来源,避免把局部证据写成通用事实。', 'npcink-workflow-toolbox' )4202 : ( $signals['has_comparison_claim']4203 ? __( '包含比较性判断;发布前需要确认比较对象、比较条件和依据是否在上下文中明确。', 'npcink-workflow-toolbox' )4204 : __( '未发现明显数字或比较结论;仍需人工确认段落中的判断是否有上下文依据。', 'npcink-workflow-toolbox' ) ) ) );4205 4206 $tone = $signals['has_scope_claim']4207 ? __( '语气整体偏说明性;涉及“适合/因此/场景”等判断时,建议保持审慎,不要超过已验证范围。', 'npcink-workflow-toolbox' )4208 : ( $signals['has_causal_transition']4209 ? __( '语气整体偏推论式;建议确认原因和结论之间的关系是否足够明确。', 'npcink-workflow-toolbox' )4210 : __( '语气整体中性;保持事实说明,并避免加入选中段落没有承载的新判断。', 'npcink-workflow-toolbox' ) );4211 4212 $editing_parts = array( __( '不要直接替换正文。', 'npcink-workflow-toolbox' ) );4213 if ( $signals['has_structural_glue'] ) {4214 $editing_parts[] = __( '优先拆开标题词、选项标签、短语串和正文说明。', 'npcink-workflow-toolbox' );4215 } elseif ( $signals['has_performance_claim'] ) {4216 $editing_parts[] = __( '优先核对测试条件、性能口径和对比对象。', 'npcink-workflow-toolbox' );4217 } elseif ( $signals['has_metric_claim'] ) {4218 $editing_parts[] = __( '优先核对数字、ID、数量口径和对应对象。', 'npcink-workflow-toolbox' );4219 } elseif ( $signals['has_comparison_claim'] ) {4220 $editing_parts[] = __( '优先核对比较对象和比较条件。', 'npcink-workflow-toolbox' );4221 } else {4222 $editing_parts[] = __( '优先核对该段判断是否有上下文依据。', 'npcink-workflow-toolbox' );4223 }4224 if ( $signals['has_scope_claim'] ) {4225 $editing_parts[] = __( '必要时缩小适用范围,或把原因和边界分开审阅。', 'npcink-workflow-toolbox' );4226 } else {4227 $editing_parts[] = __( '必要时补充限定条件,或把原因和结论分开审阅。', 'npcink-workflow-toolbox' );4228 }4229 $editing = implode( '', $editing_parts );4230 4231 return array(4232 'clarity_check' => $clarity,4233 'fact_gaps' => $fact_gaps,4234 'tone_consistency' => $tone,4235 'editing_suggestions' => $editing,4236 'assumptions_to_verify' => $hosted_ai_empty4237 ? __( '托管 AI 本次未返回建议,以上为本地兜底检查;仍以人工编辑和原始测试记录为准。', 'npcink-workflow-toolbox' )4238 : __( '本地复核只检查结构、事实口径和语气风险;托管 AI 建议仍需人工审阅。', 'npcink-workflow-toolbox' ),4239 'signal_profile' => $signals,4240 );4241 }4242 4243 private function editor_recommendation_candidate( array $args ): array {4244 $candidate = array(4245 'contract' => 'recommendation_candidate.v1',4246 'id' => sanitize_key( (string) ( $args['id'] ?? 'candidate' ) ),4247 'kind' => sanitize_key( (string) ( $args['kind'] ?? 'generic' ) ),4248 'label' => sanitize_text_field( (string) ( $args['label'] ?? __( 'Recommendation candidate', 'npcink-workflow-toolbox' ) ) ),4249 'value' => sanitize_text_field( (string) ( $args['value'] ?? '' ) ),4250 'reason' => sanitize_text_field( (string) ( $args['reason'] ?? '' ) ),4251 'confidence' => is_numeric( $args['confidence'] ?? null ) ? max( 0, min( 1, (float) $args['confidence'] ) ) : null,4252 'quality_status' => sanitize_key( (string) ( $args['quality_status'] ?? 'review' ) ),4253 'quality_score' => absint( $args['quality_score'] ?? 0 ),4254 'quality_issues' => is_array( $args['quality_issues'] ?? null ) ? array_values( array_map( 'sanitize_text_field', $args['quality_issues'] ) ) : array(),4255 'action_policy' => sanitize_key( (string) ( $args['action_policy'] ?? 'suggestion_only' ) ),4256 'target_field' => sanitize_key( (string) ( $args['target_field'] ?? '' ) ),4257 'write_posture' => 'suggestion_only',4258 'direct_wordpress_write' => false,4259 'evidence_refs' => is_array( $args['evidence_refs'] ?? null ) ? array_values( array_map( 'sanitize_text_field', $args['evidence_refs'] ) ) : array(),4260 );4261 4262 if ( '' !== (string) ( $args['source_candidate_ref'] ?? '' ) ) {4263 $candidate['source_candidate_ref'] = sanitize_text_field( (string) $args['source_candidate_ref'] );4264 }4265 if ( '' !== (string) ( $args['candidate_source'] ?? '' ) ) {4266 $candidate['candidate_source'] = sanitize_key( (string) $args['candidate_source'] );4267 }4268 if ( in_array( (string) ( $args['candidate_relevance'] ?? '' ), array( 'strong', 'review', 'weak' ), true ) ) {4269 $candidate['candidate_relevance'] = (string) $args['candidate_relevance'];4270 }4271 if ( is_array( $args['target_ref'] ?? null ) ) {4272 $target_ref = $args['target_ref'];4273 $candidate['target_ref'] = array(4274 'post_id' => absint( $target_ref['post_id'] ?? 0 ),4275 'title' => sanitize_text_field( (string) ( $target_ref['title'] ?? '' ) ),4276 'url' => esc_url_raw( (string) ( $target_ref['url'] ?? '' ) ),4277 'status' => sanitize_key( (string) ( $target_ref['status'] ?? '' ) ),4278 'post_type' => sanitize_key( (string) ( $target_ref['post_type'] ?? '' ) ),4279 );4280 }4281 if ( '' !== (string) ( $args['anchor_or_context'] ?? '' ) ) {4282 $candidate['anchor_or_context'] = sanitize_text_field( (string) $args['anchor_or_context'] );4283 }4284 if ( array_key_exists( 'can_apply_to_editor', $args ) ) {4285 $candidate['can_apply_to_editor'] = true === $args['can_apply_to_editor'];4286 }4287 if ( '' !== (string) ( $args['anchor_quality_status'] ?? '' ) ) {4288 $candidate['anchor_quality_status'] = sanitize_key( (string) $args['anchor_quality_status'] );4289 }4290 if ( '' !== (string) ( $args['evidence_note'] ?? '' ) ) {4291 $candidate['evidence_note'] = sanitize_text_field( (string) $args['evidence_note'] );4292 }4293 if ( '' !== (string) ( $args['owner_label'] ?? '' ) ) {4294 $candidate['owner_label'] = sanitize_key( (string) $args['owner_label'] );4295 }4296 if ( '' !== (string) ( $args['next_safe_action'] ?? '' ) ) {4297 $candidate['next_safe_action'] = sanitize_key( (string) $args['next_safe_action'] );4298 }4299 if ( is_array( $args['source_match'] ?? null ) ) {4300 $source_match = $args['source_match'];4301 $client_id = sanitize_text_field( (string) ( $source_match['block_client_id'] ?? '' ) );4302 $matched_text = sanitize_text_field( (string) ( $source_match['matched_text'] ?? '' ) );4303 $expected_text = sanitize_textarea_field( $this->editor_trim_chars( wp_strip_all_tags( (string) ( $source_match['expected_text'] ?? '' ) ), 1600 ) );4304 if ( '' !== $client_id && '' !== $matched_text && '' !== $expected_text ) {4305 $candidate['source_match'] = array(4306 'block_client_id' => $client_id,4307 'block_name' => sanitize_text_field( (string) ( $source_match['block_name'] ?? '' ) ),4308 'matched_text' => $matched_text,4309 'text_offset' => absint( $source_match['text_offset'] ?? 0 ),4310 'expected_text' => $expected_text,4311 'match_basis' => sanitize_key( (string) ( $source_match['match_basis'] ?? '' ) ),4312 );4313 }4314 }4315 if ( '' !== (string) ( $args['priority_reason'] ?? '' ) ) {4316 $candidate['priority_reason'] = sanitize_text_field( (string) $args['priority_reason'] );4317 }4318 if ( is_array( $args['link_graph_issues'] ?? null ) ) {4319 $candidate['link_graph_issues'] = array_values( array_filter( array_map( 'sanitize_key', $args['link_graph_issues'] ) ) );4320 }4321 if ( is_array( $args['shared_terms'] ?? null ) ) {4322 $candidate['shared_terms'] = array_slice( array_values( array_filter( array_map( 'sanitize_text_field', $args['shared_terms'] ) ) ), 0, 3 );4323 }4324 $candidate['incoming_count'] = absint( $args['incoming_count'] ?? 0 );4325 4326 return $candidate;4327 3740 } 4328 3741 … … 4337 3750 ); 4338 3751 4339 return $this->editor_taxonomy_only_suggestion_section(3752 return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section( 4340 3753 'category_suggestions', 4341 3754 $categories, … … 4358 3771 $proposed_new_terms = $this->empty_proposed_new_terms_review(); 4359 3772 4360 return $this->editor_taxonomy_only_suggestion_section(3773 return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section( 4361 3774 'tag_suggestions', 4362 3775 array(), … … 4368 3781 } 4369 3782 4370 private function editor_taxonomy_only_suggestion_section( string $candidate_type, array $categories, array $tags, array $proposed_new_terms, array $taxonomy_terms, array $context ): array {4371 return array(4372 'artifact_type' => 'article_taxonomy_suggestions.v1',4373 'composition_role' => 'taxonomy_candidates_only',4374 'candidate_type' => sanitize_key( $candidate_type ),4375 'candidate_contract' => 'recommendation_candidate.v1',4376 'write_posture' => 'suggestion_only',4377 'final_write_path' => 'core_proposal_required',4378 'direct_wordpress_write' => false,4379 'input_scope' => $this->editor_input_scope( $context ),4380 'category_candidates' => array_slice( $categories, 0, 5 ),4381 'tag_candidates' => array_slice( $tags, 0, 8 ),4382 'proposed_new_terms' => $proposed_new_terms,4383 'taxonomy_terms' => $taxonomy_terms,4384 'recommendation_candidates' => $this->editor_taxonomy_recommendation_candidates( $candidate_type, $categories, $tags, $proposed_new_terms ),4385 'quality_gate' => array(4386 'name' => 'runtime_taxonomy_candidate_rerank',4387 'policy' => 'existing_terms_first_current_draft_match_then_related_history',4388 'candidate_sort' => 'score_desc_then_existing_term_order',4389 ),4390 'selection_policy' => array(4391 'prefer_existing_terms' => true,4392 'new_terms_deferred' => true,4393 'no_toolbox_term_creation' => true,4394 'accepted_write_path' => 'core_proposal_required',4395 ),4396 );4397 }4398 4399 private function editor_taxonomy_recommendation_candidates( string $candidate_type, array $categories, array $tags, array $proposed_new_terms ): array {4400 $items = 'category_suggestions' === $candidate_type ? array_slice( $categories, 0, 5 ) : array_slice( $tags, 0, 8 );4401 $result = array();4402 foreach ( $items as $index => $item ) {4403 $taxonomy = sanitize_key( (string) ( $item['taxonomy'] ?? '' ) );4404 $term_id = absint( $item['term_id'] ?? 0 );4405 $name = sanitize_text_field( (string) ( $item['name'] ?? '' ) );4406 if ( '' === $name || 0 >= $term_id ) {4407 continue;4408 }4409 $quality = $this->editor_taxonomy_candidate_quality( $item );4410 $result[] = $this->editor_recommendation_candidate(4411 array(4412 'id' => ( 'category' === $taxonomy ? 'category_' : 'tag_' ) . $term_id,4413 'kind' => 'category' === $taxonomy ? 'category' : 'tag',4414 'label' => 'category' === $taxonomy ? __( 'Existing category', 'npcink-workflow-toolbox' ) : __( 'Existing tag', 'npcink-workflow-toolbox' ),4415 'value' => $name,4416 'reason' => sanitize_text_field( (string) ( $item['reason'] ?? '' ) ),4417 'confidence' => $quality['confidence'],4418 'target_field' => 'category' === $taxonomy ? 'category' : 'post_tag',4419 'action_policy' => 'core_proposal_required',4420 'quality_status' => $quality['status'],4421 'quality_score' => $quality['score'],4422 'quality_issues' => $quality['issues'],4423 'evidence_refs' => is_array( $item['evidence_refs'] ?? null ) ? $item['evidence_refs'] : array(),4424 )4425 );4426 }4427 4428 return $result;4429 }4430 4431 private function editor_taxonomy_candidate_quality( array $item ): array {4432 $score = is_numeric( $item['score'] ?? null ) ? (float) $item['score'] : 0.0;4433 $match_signals = is_array( $item['match_signals'] ?? null ) ? $item['match_signals'] : array();4434 $related_context = is_array( $item['related_context'] ?? null ) ? $item['related_context'] : array();4435 $quality_score = max( 0, min( 100, 45 + (int) round( $score * 10 ) ) );4436 $quality_issues = array();4437 if ( in_array( 'current_draft_match', $match_signals, true ) ) {4438 $quality_issues[] = __( '匹配当前草稿中的标题、摘要或正文词。', 'npcink-workflow-toolbox' );4439 }4440 if ( in_array( 'title_term_name_match', $match_signals, true ) ) {4441 $quality_issues[] = __( '词条名称在标题中完整出现,优先级更高。', 'npcink-workflow-toolbox' );4442 }4443 if ( in_array( 'slug_alias_match', $match_signals, true ) ) {4444 $quality_issues[] = __( '词条 slug 或别名与当前编辑上下文匹配。', 'npcink-workflow-toolbox' );4445 }4446 if ( in_array( 'related_site_knowledge_term', $match_signals, true ) ) {4447 $quality_issues[] = __( '历史相关文章使用过该词汇,可作为站内词库证据。', 'npcink-workflow-toolbox' );4448 }4449 if ( in_array( 'description_only_match', $match_signals, true ) ) {4450 $quality_score -= 20;4451 $quality_issues[] = __( '仅描述字段匹配,避免把弱说明文字当作强分类依据。', 'npcink-workflow-toolbox' );4452 }4453 if ( in_array( 'low_specificity_match', $match_signals, true ) ) {4454 $quality_score -= 15;4455 $quality_issues[] = __( '只有一个较弱 token 匹配,需人工确认是否为标题党或泛化词。', 'npcink-workflow-toolbox' );4456 }4457 if ( empty( $quality_issues ) ) {4458 $quality_issues[] = __( '仅作为现有 WordPress 词条候选,采用人工审查。', 'npcink-workflow-toolbox' );4459 }4460 if ( 0 === absint( $related_context['source_count'] ?? 0 ) && ! in_array( 'current_draft_match', $match_signals, true ) ) {4461 $quality_score -= 15;4462 $quality_issues[] = __( '缺少当前草稿或历史文章的强匹配证据。', 'npcink-workflow-toolbox' );4463 }4464 4465 $status = 'good';4466 if ( $quality_score < 70 ) {4467 $status = 'review';4468 }4469 if ( $quality_score < 55 ) {4470 $status = 'weak';4471 }4472 4473 return array(4474 'score' => max( 0, min( 100, $quality_score ) ),4475 'status' => $status,4476 'confidence' => max( 0.0, min( 1.0, $score / 5 ) ),4477 'issues' => array_values( array_unique( $quality_issues ) ),4478 );4479 }4480 4481 3783 private function editor_related_post_terms_for_context( int $post_id ): array { 4482 3784 if ( 0 >= $post_id || ! function_exists( 'get_the_terms' ) ) { … … 4505 3807 private function editor_related_content_term_evidence( array $related_content ): array { 4506 3808 $evidence = array(); 4507 foreach ( array_slice( $this->editor_related_content_items( $related_content ), 0, 20 ) as $index => $item ) {3809 foreach ( array_slice( Rest_Editor_Flow_Cache::editor_related_content_items( $related_content ), 0, 20 ) as $index => $item ) { 4508 3810 $post_id = absint( $item['post_id'] ?? 0 ); 4509 3811 if ( 0 >= $post_id ) { … … 4947 4249 $review_set_result = $this->editor_toolkit_taxonomy_review_set( $input ); 4948 4250 if ( is_wp_error( $review_set_result ) ) { 4949 $taxonomy_terms['taxonomy_tag_review_set'] = $this->editor_taxonomy_review_set_from_suggestions( $taxonomy_terms, $review_set_result );4251 $taxonomy_terms['taxonomy_tag_review_set'] = Rest_Editor_Taxonomy_Shaping::editor_taxonomy_review_set_from_suggestions( $taxonomy_terms, $review_set_result ); 4950 4252 } else { 4951 4253 $review_set_data = is_array( $review_set_result['data'] ?? null ) ? $review_set_result['data'] : $review_set_result; 4952 4254 $taxonomy_terms['taxonomy_tag_review_set'] = is_array( $review_set_data ) && 'taxonomy_tag_review_set' === (string) ( $review_set_data['artifact_type'] ?? '' ) 4953 4255 ? $review_set_data 4954 : $this->editor_taxonomy_review_set_from_suggestions(4256 : Rest_Editor_Taxonomy_Shaping::editor_taxonomy_review_set_from_suggestions( 4955 4257 $taxonomy_terms, 4956 4258 new WP_Error( … … 5035 4337 5036 4338 return $result; 5037 }5038 5039 private function editor_taxonomy_review_set_from_suggestions( array $taxonomy_terms, WP_Error $fallback_reason ): array {5040 $items = is_array( $taxonomy_terms['items'] ?? null ) ? array_values( array_filter( $taxonomy_terms['items'], 'is_array' ) ) : array();5041 $review_set_limit = 8;5042 $selected = array();5043 $blocked = array();5044 5045 foreach ( $items as $item ) {5046 $quality = $this->editor_taxonomy_candidate_quality( $item );5047 $row = array(5048 'candidate_id' => ( 'category' === (string) ( $item['taxonomy'] ?? '' ) ? 'category_' : 'tag_' ) . absint( $item['term_id'] ?? 0 ),5049 'candidate_contract' => 'taxonomy_tag_review_candidate.v1',5050 'taxonomy' => sanitize_key( (string) ( $item['taxonomy'] ?? '' ) ),5051 'term_id' => absint( $item['term_id'] ?? 0 ),5052 'name' => sanitize_text_field( (string) ( $item['name'] ?? '' ) ),5053 'slug' => sanitize_title( (string) ( $item['slug'] ?? '' ) ),5054 'score' => is_numeric( $item['score'] ?? null ) ? (float) $item['score'] : 0.0,5055 'quality' => $quality,5056 'reason' => sanitize_text_field( (string) ( $item['reason'] ?? '' ) ),5057 'evidence_refs' => is_array( $item['evidence_refs'] ?? null ) ? array_values( array_map( 'sanitize_text_field', $item['evidence_refs'] ) ) : array(),5058 'proposed_action' => 'append_existing_term',5059 'needs_operator_review' => true,5060 'direct_wordpress_write' => false,5061 'term_creation_allowed' => false,5062 'term_assignment_authorized' => false,5063 );5064 if ( '' === $row['name'] || 0 >= $row['term_id'] ) {5065 $row['blocked_reason'] = 'invalid_existing_term_candidate';5066 $blocked[] = $row;5067 continue;5068 }5069 if ( 'weak' === (string) $quality['status'] ) {5070 $row['blocked_reason'] = 'weak_taxonomy_evidence';5071 $blocked[] = $row;5072 continue;5073 }5074 if ( count( $selected ) >= $review_set_limit ) {5075 $row['blocked_reason'] = 'review_set_limit_reached';5076 $blocked[] = $row;5077 continue;5078 }5079 $row['review_status'] = 'good' === (string) $quality['status'] ? 'ready_for_review' : 'review_recommended';5080 $selected[] = $row;5081 }5082 5083 return array(5084 'contract_version' => 'taxonomy_tag_review_set.v1',5085 'artifact_type' => 'taxonomy_tag_review_set',5086 'mode' => 'governed_review_set',5087 'write_posture' => 'suggestion_only',5088 'final_write_path' => 'core_proposal_required',5089 'direct_wordpress_write' => false,5090 'proposal_created' => false,5091 'execution_created' => false,5092 'commit_execution' => false,5093 'source_ability_id' => 'npcink-abilities-toolkit/suggest-post-taxonomy-terms',5094 'preferred_source_ability_id' => 'npcink-abilities-toolkit/build-taxonomy-tag-review-set',5095 'runtime_owner' => 'npcink-workflow-toolbox',5096 'fallback_reason' => sanitize_key( $fallback_reason->get_error_code() ),5097 'fallback_message' => sanitize_text_field( $fallback_reason->get_error_message() ),5098 'review_set_limit' => $review_set_limit,5099 'eligibility_summary' => array(5100 'scanned' => count( $items ),5101 'selected' => count( $selected ),5102 'blocked' => count( $blocked ),5103 ),5104 'selected_items' => $selected,5105 'blocked_items' => $blocked,5106 'safety' => array(5107 'term_creation_allowed' => false,5108 'term_assignment_allowed' => false,5109 'proposal_created' => false,5110 'direct_wordpress_write' => false,5111 'provider_runtime_used' => false,5112 'cloud_runtime_dependency' => false,5113 ),5114 'handoff' => array(5115 'accepted_selection_target' => 'npcink-abilities-toolkit/build-content-metadata-apply-plan',5116 'term_assignment_target' => 'npcink-abilities-toolkit/set-post-terms',5117 'final_write_path' => 'core_proposal_required',5118 'operator_review_required' => true,5119 ),5120 );5121 4339 } 5122 4340 … … 5198 4416 'occurrence_index' => absint( $item['occurrence_index'] ?? 0 ), 5199 4417 'context_heading' => sanitize_text_field( (string) ( $item['context_heading'] ?? '' ) ), 5200 'context_before' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_before'] ?? '' ), 240 ) ),5201 'context_after' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_after'] ?? '' ), 240 ) ),5202 'context_caption' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_caption'] ?? '' ), 220 ) ),5203 'context_summary' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_summary'] ?? '' ), 360 ) ),4418 'context_before' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_before'] ?? '' ), 240 ) ), 4419 'context_after' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_after'] ?? '' ), 240 ) ), 4420 'context_caption' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_caption'] ?? '' ), 220 ) ), 4421 'context_summary' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_summary'] ?? '' ), 360 ) ), 5204 4422 'decorative' => ! empty( $item['decorative'] ), 5205 4423 'target_scope' => 'post_block_alt', -
npcink-workflow-toolbox/tags/0.5.3/includes/Settings.php
r3730454 r3734482 11 11 12 12 final class Settings { 13 14 public const REVIEW_TALLY_SETS = array( 15 'media_alt' => 'media_alt_caption_review_set.v1', 16 'taxonomy_tag' => 'taxonomy_tag_review_set.v1', 17 'internal_link' => 'internal_link_review_set.v1', 18 'comment_moderation' => 'comment_moderation_review_set.v1', 19 'flagged_media' => 'flagged_media_review_set.v1', 20 ); 13 21 public function register(): void { 14 22 register_setting( … … 895 903 return '' !== trim( (string) $value ); 896 904 } 905 906 /** 907 * Review-set adoption tally: the operator's own accept/ignore counts per 908 * review set, feeding the batch-apply decision gate. Counts and bounded 909 * timestamps only - no suggestion content, no Cloud data, no WordPress 910 * content writes. 911 */ 912 public function get_review_tally(): array { 913 $value = get_option( Plugin::REVIEW_TALLY_OPTION_NAME, array() ); 914 return is_array( $value ) ? $value : array(); 915 } 916 917 public function record_review_tally_mark( string $review_set, string $decision ): array { 918 if ( ! array_key_exists( $review_set, self::REVIEW_TALLY_SETS ) ) { 919 return array(); 920 } 921 if ( ! in_array( $decision, array( 'accepted', 'ignored' ), true ) ) { 922 return array(); 923 } 924 $tally = $this->get_review_tally(); 925 $marks = is_array( $tally['marks'] ?? null ) ? $tally['marks'] : array(); 926 $marks[] = array( 927 'set' => $review_set, 928 'decision' => $decision, 929 'timestamp' => time(), 930 ); 931 // Bounded log: 2000 marks covers months of single-operator use; a 932 // 7-day window reaching the cap means 250+ marks/day - far past the 933 // batch-apply gate threshold, where undercounting is immaterial. 934 // 935 // Accepted residual (recorded 2026-10-08, raised four advisory 936 // rounds): the get_option -> append -> update_option sequence has 937 // no cross-request mutual exclusion, so two truly concurrent marks 938 // can lose one count. The WordPress options API offers no compare- 939 // and-swap; single-operator admin usage makes the window negligible 940 // and the tally is advisory decision data, not accounting truth. 941 $tally['marks'] = array_slice( $marks, -2000 ); 942 update_option( Plugin::REVIEW_TALLY_OPTION_NAME, $tally, false ); 943 return $tally; 944 } 945 946 public function review_tally_summary( int $days = 7 ): array { 947 $tally = $this->get_review_tally(); 948 $marks = is_array( $tally['marks'] ?? null ) ? $tally['marks'] : array(); 949 $cutoff = time() - ( $days * DAY_IN_SECONDS ); 950 $summary = array( 951 'window_days' => $days, 952 'total' => array( 953 'accepted' => 0, 954 'ignored' => 0, 955 ), 956 'sets' => array(), 957 ); 958 foreach ( array_keys( self::REVIEW_TALLY_SETS ) as $set_key ) { 959 $summary['sets'][ $set_key ] = array( 960 'accepted' => 0, 961 'ignored' => 0, 962 ); 963 } 964 foreach ( $marks as $mark ) { 965 if ( ! is_array( $mark ) || (int) ( $mark['timestamp'] ?? 0 ) < $cutoff ) { 966 continue; 967 } 968 $set = sanitize_key( (string) ( $mark['set'] ?? '' ) ); 969 $decision = sanitize_key( (string) ( $mark['decision'] ?? '' ) ); 970 if ( ! isset( $summary['sets'][ $set ] ) || ! in_array( $decision, array( 'accepted', 'ignored' ), true ) ) { 971 continue; 972 } 973 ++$summary['sets'][ $set ][ $decision ]; 974 ++$summary['total'][ $decision ]; 975 } 976 return $summary; 977 } 897 978 } -
npcink-workflow-toolbox/tags/0.5.3/languages/npcink-workflow-toolbox-zh_CN-npcink-toolbox-admin.json
r3733201 r3734482 909 909 "Check that Npcink Cloud is connected, then start the optimization again. Already completed images in this batch are kept.": ["请确认 Npcink Cloud 已连接,然后重新开始优化。本批次中已完成的图片会保留。"], 910 910 "Review the failed rows, then choose Retry failed previews again or deselect those rows. Successful previews remain unchanged.": ["请检查失败的行,然后选择再次重试失败预览或取消选中这些行。成功的预览保持不变。"], 911 "Keep the current selection, resolve the reported issue, then use the same preview action again. Toolbox will not retry automatically.": ["请保持当前选择,解决报告的问题后重新执行同一预览操作。Toolbox 不会自动重试。"] 911 "Keep the current selection, resolve the reported issue, then use the same preview action again. Toolbox will not retry automatically.": ["请保持当前选择,解决报告的问题后重新执行同一预览操作。Toolbox 不会自动重试。"], 912 "Accept +1": ["接受 +1"], 913 "Ignore +1": ["忽略 +1"], 914 "Last 7 days": ["近 7 天"], 915 "accepted": ["已接受"], 916 "ignored": ["已忽略"], 917 "Local counts only. No content is written.": ["仅本地计数,不写入任何内容。"], 918 "Could not save the tally mark. Try again.": ["无法保存计数标记,请重试。"], 919 "Counts unavailable. Try reloading.": ["计数不可用,请刷新重试。"], 920 "Last %d days": ["近 %d 天"] 912 921 } 913 922 } -
npcink-workflow-toolbox/tags/0.5.3/npcink-workflow-toolbox.php
r3733528 r3734482 3 3 * Plugin Name: Npcink Workflow Toolbox 4 4 * Description: Fixed AI workflow buttons for WordPress operators, with review-only suggestions and governed handoff plans. 5 * Version: 0.5. 25 * Version: 0.5.3 6 6 * Requires at least: 6.9 7 7 * Requires PHP: 8.0 … … 63 63 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Surface_Bridges.php'; 64 64 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Local_Admin_Consent.php'; 65 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Flow_Cache.php'; 66 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Paragraph_Check.php'; 67 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Audio_Text.php'; 68 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Taxonomy_Shaping.php'; 65 69 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Content_Support.php'; 66 70 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Controller.php'; -
npcink-workflow-toolbox/tags/0.5.3/readme.txt
r3733528 r3734482 5 5 Requires PHP: 8.0 6 6 Tested up to: 7.1 7 Stable tag: 0.5. 27 Stable tag: 0.5.3 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 230 230 == Changelog == 231 231 232 = 0.5.3 = 233 234 * Added the review-set adoption tally: every review-set result panel 235 carries local Accept/Ignore +1 buttons with a Last-7-days line, so the 236 batch-apply decision data builds itself during normal use (Toolbox 237 settings counts only; no suggestion content is stored). 238 * Hardened editor plain-text extraction to the inert DOMParser path 239 (inline handlers and resource fetches in block HTML can no longer 240 execute during text extraction). 241 * Guarded the advisory review workflow against missing LLM configuration 242 and fixed the form that briefly stopped all review rounds. 243 * Restructured the editor content-support service into five cohesive 244 units behind one facade (paragraph check, audio text, flow cache, 245 taxonomy shaping) with unchanged behavior. 246 232 247 = 0.5.2 = 233 248 -
npcink-workflow-toolbox/tags/0.5.3/uninstall.php
r3723781 r3734482 16 16 'npcink_toolbox_media_optimization_settings', 17 17 'npcink_toolbox_watermark_templates', 18 'npcink_toolbox_review_tally', 18 19 'npcink_toolbox_zhihu_hot_topic_pool_backup_v1', 19 20 'npcink_toolbox_media_optimization_batches', -
npcink-workflow-toolbox/trunk/assets/admin.css
r3723781 r3734482 4251 4251 color: #b32d2e; 4252 4252 } 4253 4254 .npcink-toolbox__review-tally { 4255 display: flex; 4256 flex-wrap: wrap; 4257 align-items: center; 4258 gap: 6px 8px; 4259 margin: 10px 0 0; 4260 padding: 8px 10px; 4261 background: #fff; 4262 border: 1px solid #dcdcde; 4263 border-left: 4px solid #72aee6; 4264 border-radius: 4px; 4265 font-size: 12px; 4266 } 4267 4268 .npcink-toolbox__review-tally-summary { 4269 font-weight: 600; 4270 } 4271 4272 .npcink-toolbox__review-tally-note { 4273 color: #50575e; 4274 } -
npcink-workflow-toolbox/trunk/assets/admin.js
r3733201 r3734482 415 415 } 416 416 417 maybeAttachReviewTally(form, result); 418 417 419 return result; 420 } 421 422 const REVIEW_TALLY_FORM_SETS = [ 423 ['data-toolbox-comment-moderation-review', 'comment_moderation'], 424 ['data-toolbox-taxonomy-tag-review', 'taxonomy_tag'], 425 ['data-toolbox-internal-link-review', 'internal_link'], 426 ['data-toolbox-flagged-media-review', 'flagged_media'], 427 ['data-toolbox-media-alt-caption-review', 'media_alt'], 428 ]; 429 let reviewTallySummaryPromise = null; 430 431 function reviewTallySummary() { 432 if (!reviewTallySummaryPromise) { 433 reviewTallySummaryPromise = fetch(joinRestUrl(config.restUrl, 'review-tally/summary'), { 434 headers: { 'X-WP-Nonce': config.nonce || '' }, 435 }).then((response) => (response.ok ? response.json() : null)).then((summary) => { 436 // Neither a rejected fetch nor a non-ok response is cached: 437 // the next attached bar retries instead of showing a stale 438 // zero count that the decision gate could mistake for data. 439 if (!summary) { 440 reviewTallySummaryPromise = null; 441 } 442 return summary; 443 }).catch(() => { 444 reviewTallySummaryPromise = null; 445 return null; 446 }); 447 } 448 return reviewTallySummaryPromise; 449 } 450 451 function paintReviewTallyBars(summary) { 452 // Paint from live DOM state (no closure registry to leak when 453 // result nodes are re-rendered). 454 document.querySelectorAll('.npcink-toolbox__review-tally[data-review-set]').forEach((bar) => { 455 const line = bar.querySelector('.npcink-toolbox__review-tally-summary'); 456 if (line) { 457 line.textContent = summary ? reviewTallyLine(summary, bar.getAttribute('data-review-set')) : t('Counts unavailable. Try reloading.'); 458 } 459 }); 460 } 461 462 function reviewTallyLine(summary, setKey) { 463 const set = summary && summary.sets && summary.sets[setKey] ? summary.sets[setKey] : { accepted: 0, ignored: 0 }; 464 const windowDays = summary && summary.window_days ? Number(summary.window_days) : 7; 465 const label = 7 === windowDays ? t('Last 7 days') : t('Last %d days').replace('%d', String(windowDays)); 466 return label + ': ' + Number(set.accepted || 0) + ' ' + t('accepted') + ' / ' + Number(set.ignored || 0) + ' ' + t('ignored'); 467 } 468 469 function maybeAttachReviewTally(form, result) { 470 const match = REVIEW_TALLY_FORM_SETS.find(([attr]) => form.hasAttribute(attr)); 471 if (!match || !result) { 472 return; 473 } 474 const setKey = match[1]; 475 if (result.querySelector('.npcink-toolbox__review-tally')) { 476 return; 477 } 478 const bar = el('div', 'npcink-toolbox__review-tally'); 479 bar.setAttribute('data-review-set', setKey); 480 const acceptButton = el('button', 'button button-small'); 481 acceptButton.type = 'button'; 482 acceptButton.textContent = t('Accept +1'); 483 const ignoreButton = el('button', 'button button-small'); 484 ignoreButton.type = 'button'; 485 ignoreButton.textContent = t('Ignore +1'); 486 const line = el('span', 'npcink-toolbox__review-tally-summary'); 487 const note = el('span', 'npcink-toolbox__review-tally-note'); 488 note.textContent = t('Local counts only. No content is written.'); 489 bar.appendChild(acceptButton); 490 bar.appendChild(ignoreButton); 491 bar.appendChild(line); 492 bar.appendChild(note); 493 result.appendChild(bar); 494 495 line.textContent = t('Counts unavailable. Try reloading.'); 496 const initialSummaryPromise = reviewTallySummary(); 497 initialSummaryPromise.then((summary) => { 498 // A mark may have completed while this fetch was pending and 499 // already painted fresh counts; a stale read must not overwrite 500 // them, so only paint if the shared cache still holds this 501 // promise's result. 502 if (reviewTallySummaryPromise === initialSummaryPromise) { 503 line.textContent = summary ? reviewTallyLine(summary, setKey) : t('Counts unavailable. Try reloading.'); 504 } 505 }); 506 507 function mark(decision) { 508 // Both buttons lock for the whole flight: a concurrent click on 509 // the sibling would append a second server-side mark. 510 acceptButton.disabled = true; 511 ignoreButton.disabled = true; 512 line.textContent = ''; 513 fetch(joinRestUrl(config.restUrl, 'review-tally/mark'), { 514 method: 'POST', 515 headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce || '' }, 516 body: JSON.stringify({ review_set: setKey, decision }), 517 }).then((response) => (response.ok ? response.json() : null)).then((summary) => { 518 if (!summary) { 519 line.textContent = t('Could not save the tally mark. Try again.'); 520 return; 521 } 522 reviewTallySummaryPromise = Promise.resolve(summary); 523 paintReviewTallyBars(summary); 524 }).catch(() => { 525 line.textContent = t('Could not save the tally mark. Try again.'); 526 }).finally(() => { 527 acceptButton.disabled = false; 528 ignoreButton.disabled = false; 529 }); 530 } 531 acceptButton.addEventListener('click', () => mark('accepted')); 532 ignoreButton.addEventListener('click', () => mark('ignored')); 418 533 } 419 534 … … 595 710 result.appendChild(createRawDetails(error, 'Error payload')); 596 711 } 712 maybeAttachReviewTally(form, result); 597 713 } 598 714 -
npcink-workflow-toolbox/trunk/assets/editor-content-support/text-utils.js
r3730454 r3734482 21 21 return ''; 22 22 } 23 if (typeof window !== 'undefined' && window.document) { 24 const container = window.document.createElement('div'); 25 container.innerHTML = source; 26 return String(container.textContent || container.innerText || '').replace(/\s+/g, ' ').trim(); 23 // DOMParser documents are inert by specification: no script 24 // execution and no resource fetches, unlike innerHTML on a 25 // detached div, where inline handlers and image loads in block 26 // HTML can still fire while the text is extracted. 27 if (typeof window !== 'undefined' && typeof window.DOMParser === 'function') { 28 const parsed = new window.DOMParser().parseFromString(source, 'text/html'); 29 return String(parsed.body.textContent || '').replace(/\s+/g, ' ').trim(); 27 30 } 31 // Best-effort tag stripping for environments without DOMParser: 32 // malformed markup with '>' inside attribute values can leak tag text. 28 33 return source.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); 29 34 } -
npcink-workflow-toolbox/trunk/includes/Plugin.php
r3730454 r3734482 13 13 14 14 final class Plugin { 15 public const OPTION_NAME = 'npcink_toolbox_settings'; 16 public const CONTEXT_OPTION_NAME = 'npcink_toolbox_content_context'; 17 public const MEDIA_OPTION_NAME = 'npcink_toolbox_media_optimization_settings'; 18 public const WATERMARK_OPTION_NAME = 'npcink_toolbox_watermark_templates'; 19 public const REST_NAMESPACE = 'npcink-toolbox/v1'; 15 public const OPTION_NAME = 'npcink_toolbox_settings'; 16 public const CONTEXT_OPTION_NAME = 'npcink_toolbox_content_context'; 17 public const MEDIA_OPTION_NAME = 'npcink_toolbox_media_optimization_settings'; 18 public const WATERMARK_OPTION_NAME = 'npcink_toolbox_watermark_templates'; 19 public const REVIEW_TALLY_OPTION_NAME = 'npcink_toolbox_review_tally'; 20 public const REST_NAMESPACE = 'npcink-toolbox/v1'; 20 21 21 22 private static ?Plugin $instance = null; -
npcink-workflow-toolbox/trunk/includes/Rest_Controller.php
r3731876 r3734482 95 95 $this->post( '/nightly-inspection/cloud-batch/(?P<run_id>[A-Za-z0-9._:-]+)/result', 'nightly_inspection_cloud_batch_result' ); 96 96 $this->post( '/nightly-inspection/cloud-batch/(?P<run_id>[A-Za-z0-9._:-]+)/retry', 'nightly_inspection_cloud_batch_retry' ); 97 $this->post( '/review-tally/mark', 'review_tally_mark' ); 98 $this->get( '/review-tally/summary', 'review_tally_summary' ); 97 99 98 100 register_rest_route( … … 235 237 '/ai/content-support' => 'cap.toolbox.workflow_suggest', 236 238 '/ai/site-helpers' => 'cap.toolbox.workflow_suggest', 239 '/review-tally/mark' => 'cap.toolbox.workflow_suggest', 240 '/review-tally/summary' => 'cap.toolbox.workflow_suggest', 237 241 '/ai/image-generation' => 'cap.toolbox.image_source', 238 242 '/flows/article-plan' => 'cap.toolbox.workflow_suggest', … … 328 332 public function nightly_inspection_cloud_batch_result( WP_REST_Request $request ) { 329 333 return $this->nightly_bridges->nightly_inspection_cloud_batch_result( $request ); 334 } 335 336 public function review_tally_mark( WP_REST_Request $request ) { 337 $review_set = sanitize_key( (string) $request->get_param( 'review_set' ) ); 338 $decision = sanitize_key( (string) $request->get_param( 'decision' ) ); 339 if ( ! array_key_exists( $review_set, Settings::REVIEW_TALLY_SETS ) ) { 340 return new WP_Error( 341 'npcink_toolbox_review_tally_unknown_set', 342 __( 'A supported review set is required.', 'npcink-workflow-toolbox' ), 343 array( 'status' => 400 ) 344 ); 345 } 346 if ( ! in_array( $decision, array( 'accepted', 'ignored' ), true ) ) { 347 return new WP_Error( 348 'npcink_toolbox_review_tally_invalid_decision', 349 __( 'The tally decision must be accepted or ignored.', 'npcink-workflow-toolbox' ), 350 array( 'status' => 400 ) 351 ); 352 } 353 $this->settings->record_review_tally_mark( $review_set, $decision ); 354 return rest_ensure_response( $this->settings->review_tally_summary() ); 355 } 356 357 public function review_tally_summary( WP_REST_Request $request ) { 358 $requested_days = absint( $request->get_param( 'days' ) ); 359 $days = $requested_days ? max( 1, min( 90, $requested_days ) ) : 7; 360 return rest_ensure_response( $this->settings->review_tally_summary( $days ) ); 330 361 } 331 362 -
npcink-workflow-toolbox/trunk/includes/Rest_Editor_Content_Support.php
r3731876 r3734482 23 23 defined( 'ABSPATH' ) || exit; 24 24 25 final class Rest_Editor_Content_Support extends Rest_ Controller_Support{25 final class Rest_Editor_Content_Support extends Rest_Editor_Flow_Cache { 26 26 27 27 private const EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS = 30000; 28 private const EDITOR_AUDIO_TEXT_MAX_CHARS = 5000;29 28 private const EDITOR_SELECTED_TEXT_MAX_CHARS = 2000; 30 29 private const EDITOR_COMMENT_TEXT_MAX_CHARS = 1200; 31 private const EDITOR_FLOW_CACHE_TTL = 300;32 30 private const EDITOR_PROGRESSIVE_TARGET_MS = 2500; 33 31 private const EDITOR_PROGRESSIVE_CANDIDATE_LIMIT = 8; 34 32 35 private Provider_Client $client;36 33 private Publish_Preflight_Service $publish_preflight; 37 34 … … 210 207 private function editor_post_context( WP_REST_Request $request ): array { 211 208 $content_raw = (string) $request->get_param( 'content' ); 212 $audio_preferences = $this->editor_audio_preferences_from_request( $request );209 $audio_preferences = Rest_Editor_Audio_Text::editor_audio_preferences_from_request( $request ); 213 210 $content = trim( wp_strip_all_tags( $content_raw ) ); 214 211 $selected_text = trim( wp_strip_all_tags( (string) $request->get_param( 'selected_text' ) ) ); … … 232 229 'excerpt' => sanitize_textarea_field( (string) $request->get_param( 'excerpt' ) ), 233 230 'content_text' => wp_trim_words( $content, 220, '' ), 234 'content_full_text' => sanitize_textarea_field( $this->editor_trim_chars( $content, self::EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS ) ),235 'content_audio_text' => sanitize_textarea_field( $this->editor_trim_chars( $this->editor_audio_text_from_raw_content( $content_raw, $audio_preferences ), self::EDITOR_AUDIO_TEXT_MAX_CHARS ) ),231 'content_full_text' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( $content, self::EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS ) ), 232 'content_audio_text' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( Rest_Editor_Audio_Text::editor_audio_text_from_raw_content( $content_raw, $audio_preferences ), Rest_Editor_Audio_Text::EDITOR_AUDIO_TEXT_MAX_CHARS ) ), 236 233 'selected_text' => wp_trim_words( sanitize_textarea_field( $selected_text ), 110, '' ), 237 234 'selected_block_text' => wp_trim_words( sanitize_textarea_field( $selected_block_text ), 110, '' ), 238 'selected_text_full' => sanitize_textarea_field( $this->editor_trim_chars( $selected_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ),239 'selected_block_text_full' => sanitize_textarea_field( $this->editor_trim_chars( $selected_block_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ),235 'selected_text_full' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( $selected_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ), 236 'selected_block_text_full' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( $selected_block_text, self::EDITOR_SELECTED_TEXT_MAX_CHARS ) ), 240 237 'selected_block_name' => sanitize_text_field( (string) $request->get_param( 'selected_block_name' ) ), 241 238 'user_instruction' => wp_trim_words( sanitize_textarea_field( $user_instruction ), 60, '' ), … … 255 252 'comment_id' => absint( $request->get_param( 'comment_id' ) ), 256 253 'comment_author' => sanitize_text_field( (string) $request->get_param( 'comment_author' ) ), 257 'comment_text' => sanitize_textarea_field( $this->editor_trim_chars( trim( wp_strip_all_tags( (string) $request->get_param( 'comment_text' ) ) ), self::EDITOR_COMMENT_TEXT_MAX_CHARS ) ), 258 ); 259 } 260 261 private function editor_audio_preferences_from_request( WP_REST_Request $request ): array { 262 $raw = $request->get_param( 'audio_preferences' ); 263 if ( ! is_array( $raw ) ) { 264 $raw = array(); 265 } 266 267 $defaults = array( 268 'tone' => 'calm', 269 'pace' => 'normal', 270 'handling' => 'skip_code', 271 'focus' => 'product_names', 272 ); 273 $allowed = array( 274 'tone' => array( 'calm', 'formal', 'casual', 'expressive' ), 275 'pace' => array( 'normal', 'slow', 'fast' ), 276 'handling' => array( 'skip_code', 'read_code', 'skip_tables' ), 277 'focus' => array( 'product_names', 'numbers', 'headings' ), 278 ); 279 $preferences = array(); 280 foreach ( $defaults as $key => $default ) { 281 $value = sanitize_key( (string) ( $raw[ $key ] ?? $default ) ); 282 $preferences[ $key ] = in_array( $value, $allowed[ $key ], true ) ? $value : $default; 283 } 284 return $preferences; 254 'comment_text' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( trim( wp_strip_all_tags( (string) $request->get_param( 'comment_text' ) ) ), self::EDITOR_COMMENT_TEXT_MAX_CHARS ) ), 255 ); 285 256 } 286 257 … … 296 267 } 297 268 $client_id = sanitize_text_field( (string) ( $block['client_id'] ?? '' ) ); 298 $text = sanitize_textarea_field( $this->editor_trim_chars( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1600 ) );269 $text = sanitize_textarea_field( Rest_Editor_Audio_Text::trim( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1600 ) ); 299 270 if ( '' !== $client_id && '' !== $text ) { 300 271 $items[] = array( … … 306 277 } 307 278 return $items; 308 }309 310 private function editor_audio_text_from_raw_content( string $content, array $audio_preferences ): string {311 $source = $content;312 $handling = sanitize_key( (string) ( $audio_preferences['handling'] ?? 'skip_code' ) );313 if ( 'skip_code' === $handling ) {314 $source = preg_replace( '/<!--\s*wp:code\b.*?<!--\s*\/wp:code\s*-->/is', ' ', $source );315 $source = preg_replace( '/<pre\b[^>]*>.*?<\/pre>/is', ' ', $source );316 $source = preg_replace( '/<code\b[^>]*>.*?<\/code>/is', ' ', $source );317 }318 if ( 'skip_tables' === $handling ) {319 $source = preg_replace( '/<!--\s*wp:table\b.*?<!--\s*\/wp:table\s*-->/is', ' ', $source );320 $source = preg_replace( '/<table\b[^>]*>.*?<\/table>/is', ' ', $source );321 }322 return trim( wp_strip_all_tags( (string) $source ) );323 }324 325 private function editor_trim_chars( string $value, int $max_chars ): string {326 $value = trim( $value );327 $max_chars = max( 1, $max_chars );328 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {329 return mb_strlen( $value, 'UTF-8' ) > $max_chars ? mb_substr( $value, 0, $max_chars, 'UTF-8' ) : $value;330 }331 332 return strlen( $value ) > $max_chars ? substr( $value, 0, $max_chars ) : $value;333 279 } 334 280 … … 555 501 $candidate = trim( preg_replace( '/\s+/u', ' ', (string) $candidate ) ?: '' ); 556 502 if ( '' !== $candidate ) { 557 return $this->editor_trim_chars( $candidate, 120 );503 return Rest_Editor_Audio_Text::trim( $candidate, 120 ); 558 504 } 559 505 } … … 675 621 } 676 622 677 $trimmed = $this->editor_trim_chars( $summary, 120 );623 $trimmed = Rest_Editor_Audio_Text::trim( $summary, 120 ); 678 624 if ( $trimmed !== $summary && preg_match( '/\s/u', $trimmed ) ) { 679 625 $trimmed = preg_replace( '/\s+\S*$/u', '', $trimmed ) ?: $trimmed; … … 770 716 771 717 return wp_trim_words( $query, 80, '' ); 772 }773 774 private function editor_input_scope( array $context ): array {775 $scope = sanitize_key( (string) ( $context['context_scope'] ?? 'auto' ) );776 $selection = trim( (string) ( $context['selected_text'] ?? '' ) . ' ' . (string) ( $context['selected_block_text'] ?? '' ) );777 if ( 'auto' === $scope ) {778 $scope = '' !== $selection ? 'selected_text' : ( '' !== trim( (string) ( $context['content_text'] ?? '' ) ) ? 'full_article' : 'topic_only' );779 }780 781 $labels = array(782 'selected_text' => __( 'Selected text or supplied snippet', 'npcink-workflow-toolbox' ),783 'full_article' => __( 'Full article context', 'npcink-workflow-toolbox' ),784 'topic_only' => __( 'Topic or short brief', 'npcink-workflow-toolbox' ),785 );786 787 $fields = array();788 foreach ( array( 'title', 'excerpt', 'content_text', 'selected_text', 'selected_block_text', 'post_id' ) as $field ) {789 if ( ! empty( $context[ $field ] ) ) {790 $fields[] = $field;791 }792 }793 794 return array(795 'id' => $scope,796 'label' => $labels[ $scope ] ?? __( 'Current context', 'npcink-workflow-toolbox' ),797 'source_fields' => $fields,798 'operator_selected_mode' => sanitize_key( (string) ( $context['context_scope'] ?? 'auto' ) ),799 'detail' => __( 'This scope controls ranking context only. Toolbox still returns suggestions and does not write WordPress data.', 'npcink-workflow-toolbox' ),800 );801 718 } 802 719 … … 1094 1011 $taxonomy_recommendations = '' !== trim( $query ) 1095 1012 ? array_merge( 1096 $this->editor_taxonomy_recommendation_candidates( 'category_suggestions', $category_items, array(), $this->empty_proposed_new_terms_review() ),1097 $this->editor_taxonomy_recommendation_candidates( 'tag_suggestions', array(), $tag_items, $this->empty_proposed_new_terms_review() )1013 Rest_Editor_Taxonomy_Shaping::editor_taxonomy_recommendation_candidates( 'category_suggestions', $category_items, array(), $this->empty_proposed_new_terms_review() ), 1014 Rest_Editor_Taxonomy_Shaping::editor_taxonomy_recommendation_candidates( 'tag_suggestions', array(), $tag_items, $this->empty_proposed_new_terms_review() ) 1098 1015 ) 1099 1016 : array(); … … 1513 1430 $match_score = (int) ( $item['score'] ?? 0 ); 1514 1431 $quality_score = min( 95, 55 + $match_score * 8 ); 1515 $candidates[] = $this->editor_recommendation_candidate(1432 $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate( 1516 1433 array( 1517 1434 'id' => 'media_library_' . $attachment_id, … … 1596 1513 $status = sanitize_key( (string) ( $item['status'] ?? 'warning' ) ); 1597 1514 $score = 'error' === $status ? 35 : 55; 1598 $candidates[] = $this->editor_recommendation_candidate(1515 $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate( 1599 1516 array( 1600 1517 'id' => 'preflight_' . $id, … … 1616 1533 return $candidates; 1617 1534 } 1618 1619 private function editor_cached_site_knowledge( array $input ) {1620 return $this->editor_cached_client_result(1621 'site_knowledge',1622 $input,1623 function () use ( $input ) {1624 return $this->client->search_site_knowledge( $input );1625 }1626 );1627 }1628 1629 private function editor_cached_content_discoverability( array $input ) {1630 return $this->editor_cached_client_result(1631 'content_discoverability',1632 $input,1633 function () use ( $input ) {1634 return $this->client->build_content_discoverability_brief( $input );1635 }1636 );1637 }1638 1639 private function editor_cached_hosted_ai_content_support( array $input, bool $force_refresh = false ) {1640 return $this->editor_cached_client_result(1641 'hosted_ai_content_support',1642 $input,1643 function () use ( $input ) {1644 return $this->client->run_hosted_ai_content_support( $input );1645 },1646 $force_refresh1647 );1648 }1649 1650 private function editor_cached_audio_generation( array $input, bool $force_refresh = false ) {1651 return $this->editor_cached_client_result(1652 'audio_generation',1653 $input,1654 function () use ( $input ) {1655 return $this->client->run_audio_generation( $input );1656 },1657 $force_refresh1658 );1659 }1660 1661 private function editor_cached_cloud_web_search( array $input, bool $force_refresh = false ) {1662 return $this->editor_cached_client_result(1663 'cloud_web_search',1664 $input,1665 function () use ( $input ) {1666 return $this->client->test_cloud_web_search( $input );1667 },1668 $force_refresh,1669 function ( array $result ) use ( $input ): bool {1670 return $this->editor_source_extraction_cacheable( $input, $result );1671 },1672 true1673 );1674 }1675 1676 private function editor_source_extraction_cacheable( array $input, array $result ): bool {1677 if ( 'source_extraction_preview' !== sanitize_key( (string) ( $input['intent'] ?? '' ) ) ) {1678 return true;1679 }1680 1681 return 'ready' === sanitize_key( (string) ( $result['status'] ?? '' ) )1682 && 'matched' === sanitize_key( (string) ( $result['url_match'] ?? '' ) )1683 && ! empty( $result['results'][0]['reader_excerpt'] ?? '' );1684 }1685 1686 private function editor_cached_client_result( string $namespace, array $input, callable $callback, bool $force_refresh = false, ?callable $should_cache = null, bool $replace_cache_on_force = false ) {1687 $cache_key = $this->editor_flow_cache_key( $namespace, $input );1688 $cached = $force_refresh ? false : get_transient( $cache_key );1689 if ( false !== $cached && is_array( $cached ) ) {1690 if ( null === $should_cache || $should_cache( $cached ) ) {1691 $cached['cache_status'] = 'hit';1692 return $cached;1693 }1694 delete_transient( $cache_key );1695 }1696 1697 $result = $callback();1698 if ( ! is_wp_error( $result ) && is_array( $result ) ) {1699 $result['cache_status'] = $force_refresh ? 'bypass' : 'miss';1700 $cacheable = null === $should_cache || $should_cache( $result );1701 if ( $cacheable && ( ! $force_refresh || $replace_cache_on_force ) ) {1702 set_transient( $cache_key, $result, self::EDITOR_FLOW_CACHE_TTL );1703 } elseif ( $force_refresh && $replace_cache_on_force ) {1704 delete_transient( $cache_key );1705 }1706 }1707 1708 return $result;1709 }1710 1711 private function editor_flow_cache_key( string $namespace, array $input ): string {1712 $json = wp_json_encode( $input );1713 if ( ! is_string( $json ) ) {1714 $json = serialize( $input );1715 }1716 1717 return 'npcink_toolbox_editor_' . sanitize_key( $namespace ) . '_' . md5( $json );1718 }1719 1720 /**1721 * Validates one public source URL before Cloud research is requested.1722 *1723 * @param string $value Raw URL.1724 * @return string|WP_Error1725 */1726 1535 1727 1536 private function editor_source_adaptation_url( string $value ) { … … 2556 2365 2557 2366 private function editor_writing_pack_related_articles( array $knowledge ): array { 2558 $items = $this->editor_related_content_items( $knowledge );2367 $items = Rest_Editor_Flow_Cache::editor_related_content_items( $knowledge ); 2559 2368 $result = array(); 2560 2369 foreach ( array_slice( $items, 0, 6 ) as $index => $item ) { … … 2712 2521 'final_write_path' => 'core_proposal_required', 2713 2522 'direct_wordpress_write' => false, 2714 'input_scope' => $this->editor_input_scope( $context ),2523 'input_scope' => Rest_Editor_Flow_Cache::editor_input_scope( $context ), 2715 2524 'summary_candidates' => $summary_ai, 2716 2525 'summary_layers' => $summary_layers, … … 2779 2588 'diagnosis' => array( 2780 2589 'summary_quality' => $this->editor_summary_quality( $context ), 2781 'taxonomy_quality' => $this->editor_taxonomy_quality( $context, $categories, $tags, $proposed_new_terms ),2590 'taxonomy_quality' => Rest_Editor_Taxonomy_Shaping::editor_taxonomy_quality( $context, $categories, $tags, $proposed_new_terms ), 2782 2591 'hypotheses' => array( 2783 2592 __( 'A clearer excerpt can improve archive, social, and answer-summary presentation without rewriting the article body.', 'npcink-workflow-toolbox' ), … … 2875 2684 } 2876 2685 2877 private function editor_taxonomy_quality( array $context, array $categories, array $tags, array $proposed_new_terms ): string {2878 $current_categories = is_array( $context['category_ids'] ?? null ) ? array_filter( array_map( 'absint', $context['category_ids'] ) ) : array();2879 $current_tags = is_array( $context['tag_ids'] ?? null ) ? array_filter( array_map( 'absint', $context['tag_ids'] ) ) : array();2880 if ( empty( $current_categories ) && empty( $current_tags ) ) {2881 return 'missing';2882 }2883 if ( ! empty( $proposed_new_terms['items'] ) || 12 < count( $current_tags ) ) {2884 return 'noisy';2885 }2886 if ( empty( $categories ) && empty( $tags ) ) {2887 return 'acceptable';2888 }2889 2890 return 'weak';2891 }2892 2893 private function editor_related_content_items( array $related_content ): array {2894 $items = is_array( $related_content['results'] ?? null )2895 ? $related_content['results']2896 : ( is_array( $related_content['items'] ?? null ) ? $related_content['items'] : array() );2897 2898 return array_values(2899 array_filter(2900 $items,2901 static fn( $item ): bool => is_array( $item )2902 )2903 );2904 }2905 2906 2686 private function editor_related_content_summary( array $related_content ): array { 2907 $items = $this->editor_related_content_items( $related_content );2687 $items = Rest_Editor_Flow_Cache::editor_related_content_items( $related_content ); 2908 2688 $evidence_ids = array(); 2909 2689 $titles = array(); … … 2928 2708 2929 2709 private function editor_related_content_context_for_ai( array $related_content ): array { 2930 $items = $this->editor_related_content_items( $related_content );2710 $items = Rest_Editor_Flow_Cache::editor_related_content_items( $related_content ); 2931 2711 $context_items = array(); 2932 2712 … … 3018 2798 $items = is_array( $artifact['items'] ?? null ) ? $artifact['items'] : array(); 3019 2799 $items = $this->editor_internal_link_merge_cloud_relevance( $items, $related_content_evidence ); 3020 $artifact['input_scope'] = $this->editor_input_scope( $context );2800 $artifact['input_scope'] = Rest_Editor_Flow_Cache::editor_input_scope( $context ); 3021 2801 $artifact['source_ability_id'] = 'npcink-abilities-toolkit/resolve-internal-link-targets'; 3022 2802 $artifact['source_knowledge'] = $source_knowledge; … … 3109 2889 private function editor_internal_link_related_content_evidence( array $source_knowledge ): array { 3110 2890 $evidence = array(); 3111 foreach ( array_slice( $this->editor_related_content_items( $source_knowledge ), 0, 8 ) as $index => $item ) {2891 foreach ( array_slice( Rest_Editor_Flow_Cache::editor_related_content_items( $source_knowledge ), 0, 8 ) as $index => $item ) { 3112 2892 $post_id = absint( $item['post_id'] ?? ( $item['id'] ?? 0 ) ); 3113 2893 $candidate_relevance = sanitize_key( (string) ( $item['candidate_relevance'] ?? '' ) ); … … 3143 2923 continue; 3144 2924 } 3145 $text = trim( sanitize_textarea_field( $this->editor_trim_chars( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1200 ) ) );2925 $text = trim( sanitize_textarea_field( Rest_Editor_Audio_Text::trim( wp_strip_all_tags( (string) ( $block['text'] ?? '' ) ), 1200 ) ) ); 3146 2926 if ( '' === $text ) { 3147 2927 continue; … … 3300 3080 } 3301 3081 3302 $candidates[] = $this->editor_recommendation_candidate(3082 $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate( 3303 3083 array( 3304 3084 'id' => 'internal_link_' . ( $index + 1 ), … … 3401 3181 ) 3402 3182 ); 3403 $script = $this->editor_audio_summary_script_text( $summary_ai );3183 $script = Rest_Editor_Audio_Text::editor_audio_summary_script_text( $summary_ai ); 3404 3184 $script_source['summary_script'] = $summary_ai; 3405 3185 } else { 3406 $script = $this->editor_audio_source_text( $source_text );3186 $script = Rest_Editor_Audio_Text::editor_audio_source_text( $source_text ); 3407 3187 $script_source['source_mode'] = 'article_text'; 3408 3188 } … … 3466 3246 } 3467 3247 3468 private function editor_audio_source_text( string $text ): string {3469 $plain = trim( wp_strip_all_tags( $text ) );3470 if ( '' === $plain ) {3471 return '';3472 }3473 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {3474 return mb_strlen( $plain, 'UTF-8' ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? mb_substr( $plain, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS, 'UTF-8' ) : $plain;3475 }3476 return strlen( $plain ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? substr( $plain, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS ) : $plain;3477 }3478 3479 private function editor_audio_summary_script_text( array $summary_ai ): string {3480 $output_json = is_array( $summary_ai['output_json'] ?? null ) ? $summary_ai['output_json'] : array();3481 $parts = array();3482 foreach ( array( 'opening', 'script', 'closing' ) as $key ) {3483 $value = trim( sanitize_textarea_field( (string) ( $output_json[ $key ] ?? '' ) ) );3484 if ( '' !== $value ) {3485 $parts[] = $value;3486 }3487 }3488 if ( is_array( $output_json['key_points'] ?? null ) ) {3489 foreach ( array_slice( $output_json['key_points'], 0, 5 ) as $point ) {3490 $value = trim( sanitize_textarea_field( (string) $point ) );3491 if ( '' !== $value ) {3492 $parts[] = $value;3493 }3494 }3495 }3496 $script = trim( implode( "\n\n", array_values( array_unique( $parts ) ) ) );3497 if ( '' === $script ) {3498 $script = trim( sanitize_textarea_field( (string) ( $summary_ai['output_text'] ?? '' ) ) );3499 }3500 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {3501 return mb_strlen( $script, 'UTF-8' ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? mb_substr( $script, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS, 'UTF-8' ) : $script;3502 }3503 return strlen( $script ) > self::EDITOR_AUDIO_TEXT_MAX_CHARS ? substr( $script, 0, self::EDITOR_AUDIO_TEXT_MAX_CHARS ) : $script;3504 }3505 3506 3248 private function editor_article_checkup_section( array $context ): array { 3507 3249 $text = trim( (string) ( $context['content_full_text'] ?? $context['content_text'] ?? '' ) ); … … 3680 3422 $signals = array(); 3681 3423 3682 if ( $this->editor_text_has_heading_label_glue( $paragraph ) ) {3424 if ( Rest_Editor_Paragraph_Check::editor_text_has_heading_label_glue( $paragraph ) ) { 3683 3425 $signals[] = __( '标题式标签直接黏在正文前', 'npcink-workflow-toolbox' ); 3684 3426 } 3685 if ( $this->editor_text_has_phrase_cluster_glue( $paragraph ) ) {3427 if ( Rest_Editor_Paragraph_Check::editor_text_has_phrase_cluster_glue( $paragraph ) ) { 3686 3428 $signals[] = __( '短语组之间缺少分隔', 'npcink-workflow-toolbox' ); 3687 3429 } 3688 if ( $this->editor_text_has_alnum_cjk_glue( $paragraph ) ) {3430 if ( Rest_Editor_Paragraph_Check::editor_text_has_alnum_cjk_glue( $paragraph ) ) { 3689 3431 $signals[] = __( '字母、数字或方案标签与中文黏连', 'npcink-workflow-toolbox' ); 3690 3432 } … … 3926 3668 } 3927 3669 3928 private function editor_text_has_heading_label_glue( string $text ): bool {3929 return 1 === preg_match( '/(核心要点|评估维度|主要差异|适用建议|常见问题|方案\s*[A-Za-zA-Z])(?=[\p{Han}A-Za-z0-9])/u', $text );3930 }3931 3932 private function editor_text_has_phrase_cluster_glue( string $text ): bool {3933 return 1 === preg_match( '/可维护性编辑体验响应式表现治理边界/u', $text )3934 || 1 === preg_match( '/(可维护性|编辑体验|响应式表现|治理边界)(可维护性|编辑体验|响应式表现|治理边界)(可维护性|编辑体验|响应式表现|治理边界)/u', $text );3935 }3936 3937 private function editor_text_has_alnum_cjk_glue( string $text ): bool {3938 return 1 === preg_match( '/(?:[A-Za-z0-9][\x{4e00}-\x{9fff}]|[\x{4e00}-\x{9fff}][A-Za-z0-9]{2,})/u', $text );3939 }3940 3941 private function editor_text_has_structural_glue( string $text ): bool {3942 return $this->editor_text_has_heading_label_glue( $text )3943 || $this->editor_text_has_phrase_cluster_glue( $text )3944 || $this->editor_text_has_alnum_cjk_glue( $text );3945 }3946 3947 3670 private function editor_article_checkup_issue( string $id, string $type, string $severity, string $location, string $evidence, string $issue, string $edit_direction ): array { 3948 3671 return array( … … 3951 3674 'severity' => sanitize_key( $severity ), 3952 3675 'location' => sanitize_text_field( $location ), 3953 'evidence' => sanitize_text_field( $this->editor_trim_chars( $evidence, 120 ) ),3676 'evidence' => sanitize_text_field( Rest_Editor_Audio_Text::trim( $evidence, 120 ) ), 3954 3677 'issue' => sanitize_text_field( $issue ), 3955 3678 'edit_direction' => sanitize_textarea_field( $edit_direction ), … … 4007 3730 $section['write_posture'] = 'suggestion_only'; 4008 3731 if ( 'polish_notes' === $provider_intent ) { 4009 if ( ! $this->editor_paragraph_check_has_output( $section ) ) {4010 $section = $this->editor_paragraph_check_local_fallback_section( $section, $content );3732 if ( ! Rest_Editor_Paragraph_Check::editor_paragraph_check_has_output( $section ) ) { 3733 $section = Rest_Editor_Paragraph_Check::editor_paragraph_check_local_fallback_section( $section, $content ); 4011 3734 } else { 4012 $section = $this->editor_paragraph_check_local_overlay_section( $section, $content );3735 $section = Rest_Editor_Paragraph_Check::editor_paragraph_check_local_overlay_section( $section, $content ); 4013 3736 } 4014 3737 } 4015 3738 4016 3739 return $section; 4017 }4018 4019 private function editor_paragraph_check_has_output( array $section ): bool {4020 if ( ! empty( $section['items'] ) && is_array( $section['items'] ) ) {4021 return true;4022 }4023 if ( '' !== trim( sanitize_textarea_field( (string) ( $section['output_text'] ?? '' ) ) ) ) {4024 return true;4025 }4026 $output_json = is_array( $section['output_json'] ?? null ) ? $section['output_json'] : array();4027 foreach ( array( 'clarity_check', 'fact_gaps', 'tone_consistency', 'editing_suggestions', 'assumptions_to_verify' ) as $key ) {4028 if ( ! empty( $output_json[ $key ] ) ) {4029 return true;4030 }4031 }4032 $result = is_array( $section['result'] ?? null ) ? $section['result'] : array();4033 foreach ( array( 'clarity_check', 'fact_gaps', 'tone_consistency', 'editing_suggestions', 'assumptions_to_verify' ) as $key ) {4034 if ( ! empty( $result[ $key ] ) ) {4035 return true;4036 }4037 }4038 return false;4039 }4040 4041 private function editor_paragraph_check_local_fallback_section( array $section, string $selected_text ): array {4042 $output = $this->editor_paragraph_check_local_output( $selected_text );4043 $status = sanitize_key( (string) ( $section['status'] ?? 'unknown' ) );4044 4045 $section['provider_execution'] = 'hosted_ai_with_local_empty_fallback';4046 $section['hosted_ai_status'] = $status;4047 $section['fallback_reason'] = 'local_paragraph_check_after_hosted_ai_empty';4048 $section['fallback_source'] = 'current_selected_paragraph_only';4049 $section['fallback_write_posture'] = 'suggestion_only_no_replacement_text';4050 $section['fallback_signal_profile'] = is_array( $output['signal_profile'] ?? null )4051 ? array_map( 'boolval', $output['signal_profile'] )4052 : array();4053 $section['output_json'] = $output;4054 $section['items'] = array(4055 array(4056 'name' => __( 'Clarity check', 'npcink-workflow-toolbox' ),4057 'detail' => $output['clarity_check'],4058 'action_policy' => 'operator_review_only_no_insert',4059 'evidence_refs' => array( 'current_selection:paragraph' ),4060 ),4061 array(4062 'name' => __( 'Fact gaps', 'npcink-workflow-toolbox' ),4063 'detail' => $output['fact_gaps'],4064 'action_policy' => 'operator_review_only_no_insert',4065 'evidence_refs' => array( 'current_selection:paragraph' ),4066 ),4067 array(4068 'name' => __( 'Tone consistency', 'npcink-workflow-toolbox' ),4069 'detail' => $output['tone_consistency'],4070 'action_policy' => 'operator_review_only_no_insert',4071 'evidence_refs' => array( 'current_selection:paragraph' ),4072 ),4073 array(4074 'name' => __( 'Editing suggestions', 'npcink-workflow-toolbox' ),4075 'detail' => $output['editing_suggestions'],4076 'action_policy' => 'operator_review_only_no_insert',4077 'evidence_refs' => array( 'current_selection:paragraph' ),4078 ),4079 );4080 4081 return $section;4082 }4083 4084 private function editor_paragraph_check_local_overlay_section( array $section, string $selected_text ): array {4085 $output = $this->editor_paragraph_check_local_output( $selected_text, false );4086 $overlay = array(4087 'artifact_type' => 'paragraph_local_review_overlay.v1',4088 'source' => 'current_selected_paragraph_only',4089 'write_posture' => 'suggestion_only_no_replacement_text',4090 'action_policy' => 'operator_review_only_no_insert',4091 'status' => 'ready',4092 'provider_execution' => 'local_signal_overlay_after_hosted_ai_output',4093 'signal_profile' => is_array( $output['signal_profile'] ?? null )4094 ? array_map( 'boolval', $output['signal_profile'] )4095 : array(),4096 'output_json' => $output,4097 'items' => $this->editor_paragraph_check_local_overlay_items( $output ),4098 );4099 4100 $section['local_review_overlay'] = $overlay;4101 $section['local_signal_profile'] = $overlay['signal_profile'];4102 4103 $output_json = is_array( $section['output_json'] ?? null ) ? $section['output_json'] : array();4104 $output_json['local_review_overlay'] = $overlay;4105 $section['output_json'] = $output_json;4106 4107 return $section;4108 }4109 4110 private function editor_paragraph_check_local_overlay_items( array $output ): array {4111 $signals = is_array( $output['signal_profile'] ?? null ) ? $output['signal_profile'] : array();4112 $items = array();4113 4114 if ( ! empty( $signals['has_structural_glue'] ) ) {4115 $items[] = array(4116 'name' => __( 'Local structure cross-check', 'npcink-workflow-toolbox' ),4117 'detail' => (string) ( $output['clarity_check'] ?? '' ),4118 'action_policy' => 'operator_review_only_no_insert',4119 'evidence_refs' => array( 'current_selection:local_overlay' ),4120 );4121 $items[] = array(4122 'name' => __( 'Local fact-boundary check', 'npcink-workflow-toolbox' ),4123 'detail' => (string) ( $output['fact_gaps'] ?? '' ),4124 'action_policy' => 'operator_review_only_no_insert',4125 'evidence_refs' => array( 'current_selection:local_overlay' ),4126 );4127 } elseif ( ! empty( $signals['has_metric_claim'] ) || ! empty( $signals['has_comparison_claim'] ) || ! empty( $signals['long_or_dense'] ) ) {4128 $items[] = array(4129 'name' => __( 'Local fact-boundary check', 'npcink-workflow-toolbox' ),4130 'detail' => (string) ( $output['fact_gaps'] ?? '' ),4131 'action_policy' => 'operator_review_only_no_insert',4132 'evidence_refs' => array( 'current_selection:local_overlay' ),4133 );4134 } elseif ( ! empty( $signals['has_scope_claim'] ) || ! empty( $signals['has_causal_transition'] ) ) {4135 $items[] = array(4136 'name' => __( 'Local scope check', 'npcink-workflow-toolbox' ),4137 'detail' => (string) ( $output['tone_consistency'] ?? '' ),4138 'action_policy' => 'operator_review_only_no_insert',4139 'evidence_refs' => array( 'current_selection:local_overlay' ),4140 );4141 }4142 4143 if ( ! empty( $items ) ) {4144 $items[] = array(4145 'name' => __( 'Local editing guardrail', 'npcink-workflow-toolbox' ),4146 'detail' => (string) ( $output['editing_suggestions'] ?? '' ),4147 'action_policy' => 'operator_review_only_no_insert',4148 'evidence_refs' => array( 'current_selection:local_overlay' ),4149 );4150 }4151 4152 return array_values(4153 array_filter(4154 $items,4155 static function ( $item ): bool {4156 return is_array( $item ) && '' !== trim( (string) ( $item['detail'] ?? '' ) );4157 }4158 )4159 );4160 }4161 4162 private function editor_paragraph_check_signal_profile( string $text, int $length, int $punctuation_count ): array {4163 $has_performance_claim = 1 === preg_match( '/(快|慢|耗时|性能|经测试|同等服务器|相当|无明显|明显性能|读取|保存)/u', $text );4164 $has_metric_claim = 1 === preg_match( '/(\d|万|倍|%|百分|测试|经测试|数量|规模|id|ID|attachment)/u', $text );4165 $has_scope_claim = 1 === preg_match( '/(可用于|适合|场景|条件|范围|限制|边界|因此|所以|由于|因为)/u', $text );4166 $has_comparison_claim = 1 === preg_match( '/(比|相比|对比|相较|优于|弱于|快于|慢于|高于|低于)/u', $text );4167 $has_causal_transition = 1 === preg_match( '/(因此|所以|由于|因为|从而|导致)/u', $text );4168 4169 return array(4170 'long_or_dense' => $length > 150 || $punctuation_count >= 3,4171 'has_performance_claim' => $has_performance_claim,4172 'has_metric_claim' => $has_metric_claim,4173 'has_scope_claim' => $has_scope_claim,4174 'has_comparison_claim' => $has_comparison_claim,4175 'has_causal_transition' => $has_causal_transition,4176 'has_structural_glue' => $this->editor_text_has_structural_glue( $text ),4177 );4178 }4179 4180 private function editor_paragraph_check_local_output( string $selected_text, bool $hosted_ai_empty = true ): array {4181 $text = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $selected_text ) ) ?: '' );4182 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );4183 $punctuation_count = preg_match_all( '/[。!?!?;;]/u', $text );4184 $signals = $this->editor_paragraph_check_signal_profile( $text, $length, (int) $punctuation_count );4185 4186 if ( $signals['has_structural_glue'] ) {4187 $clarity = __( '选中段落疑似存在标题词、选项标签或短语串与正文黏连的问题,建议先检查分隔、标点、列表或小标题结构。', 'npcink-workflow-toolbox' );4188 } elseif ( $signals['long_or_dense'] && $signals['has_metric_claim'] ) {4189 $clarity = __( '段落信息量偏高,建议人工检查测试条件、对比结论和适用边界是否需要拆开呈现,避免读者把多个结论混在一起。', 'npcink-workflow-toolbox' );4190 } elseif ( $signals['long_or_dense'] ) {4191 $clarity = __( '段落信息量偏高,建议人工检查主语、原因、结论和适用边界是否需要拆开呈现,避免读者把多个判断混在一起。', 'npcink-workflow-toolbox' );4192 } else {4193 $clarity = __( '段落结构基本清楚;重点检查判断对象、原因和适用边界是否已经在上下文中交代。', 'npcink-workflow-toolbox' );4194 }4195 4196 $fact_gaps = $signals['has_structural_glue']4197 ? __( '结构黏连会影响读者判断事实边界;发布前需要确认每个标签、维度、方案、ID 或问答对应的正文是否清楚分开。', 'npcink-workflow-toolbox' )4198 : ( $signals['has_performance_claim']4199 ? __( '包含测试、数量、速度或耗时类结论;发布前需要确认测试条件、数据规模、对比对象和结论来源,避免把单次测试写成通用事实。', 'npcink-workflow-toolbox' )4200 : ( $signals['has_metric_claim']4201 ? __( '包含数字、ID、数量或范围类表述;发布前需要确认这些数字对应的对象、条件和来源,避免把局部证据写成通用事实。', 'npcink-workflow-toolbox' )4202 : ( $signals['has_comparison_claim']4203 ? __( '包含比较性判断;发布前需要确认比较对象、比较条件和依据是否在上下文中明确。', 'npcink-workflow-toolbox' )4204 : __( '未发现明显数字或比较结论;仍需人工确认段落中的判断是否有上下文依据。', 'npcink-workflow-toolbox' ) ) ) );4205 4206 $tone = $signals['has_scope_claim']4207 ? __( '语气整体偏说明性;涉及“适合/因此/场景”等判断时,建议保持审慎,不要超过已验证范围。', 'npcink-workflow-toolbox' )4208 : ( $signals['has_causal_transition']4209 ? __( '语气整体偏推论式;建议确认原因和结论之间的关系是否足够明确。', 'npcink-workflow-toolbox' )4210 : __( '语气整体中性;保持事实说明,并避免加入选中段落没有承载的新判断。', 'npcink-workflow-toolbox' ) );4211 4212 $editing_parts = array( __( '不要直接替换正文。', 'npcink-workflow-toolbox' ) );4213 if ( $signals['has_structural_glue'] ) {4214 $editing_parts[] = __( '优先拆开标题词、选项标签、短语串和正文说明。', 'npcink-workflow-toolbox' );4215 } elseif ( $signals['has_performance_claim'] ) {4216 $editing_parts[] = __( '优先核对测试条件、性能口径和对比对象。', 'npcink-workflow-toolbox' );4217 } elseif ( $signals['has_metric_claim'] ) {4218 $editing_parts[] = __( '优先核对数字、ID、数量口径和对应对象。', 'npcink-workflow-toolbox' );4219 } elseif ( $signals['has_comparison_claim'] ) {4220 $editing_parts[] = __( '优先核对比较对象和比较条件。', 'npcink-workflow-toolbox' );4221 } else {4222 $editing_parts[] = __( '优先核对该段判断是否有上下文依据。', 'npcink-workflow-toolbox' );4223 }4224 if ( $signals['has_scope_claim'] ) {4225 $editing_parts[] = __( '必要时缩小适用范围,或把原因和边界分开审阅。', 'npcink-workflow-toolbox' );4226 } else {4227 $editing_parts[] = __( '必要时补充限定条件,或把原因和结论分开审阅。', 'npcink-workflow-toolbox' );4228 }4229 $editing = implode( '', $editing_parts );4230 4231 return array(4232 'clarity_check' => $clarity,4233 'fact_gaps' => $fact_gaps,4234 'tone_consistency' => $tone,4235 'editing_suggestions' => $editing,4236 'assumptions_to_verify' => $hosted_ai_empty4237 ? __( '托管 AI 本次未返回建议,以上为本地兜底检查;仍以人工编辑和原始测试记录为准。', 'npcink-workflow-toolbox' )4238 : __( '本地复核只检查结构、事实口径和语气风险;托管 AI 建议仍需人工审阅。', 'npcink-workflow-toolbox' ),4239 'signal_profile' => $signals,4240 );4241 }4242 4243 private function editor_recommendation_candidate( array $args ): array {4244 $candidate = array(4245 'contract' => 'recommendation_candidate.v1',4246 'id' => sanitize_key( (string) ( $args['id'] ?? 'candidate' ) ),4247 'kind' => sanitize_key( (string) ( $args['kind'] ?? 'generic' ) ),4248 'label' => sanitize_text_field( (string) ( $args['label'] ?? __( 'Recommendation candidate', 'npcink-workflow-toolbox' ) ) ),4249 'value' => sanitize_text_field( (string) ( $args['value'] ?? '' ) ),4250 'reason' => sanitize_text_field( (string) ( $args['reason'] ?? '' ) ),4251 'confidence' => is_numeric( $args['confidence'] ?? null ) ? max( 0, min( 1, (float) $args['confidence'] ) ) : null,4252 'quality_status' => sanitize_key( (string) ( $args['quality_status'] ?? 'review' ) ),4253 'quality_score' => absint( $args['quality_score'] ?? 0 ),4254 'quality_issues' => is_array( $args['quality_issues'] ?? null ) ? array_values( array_map( 'sanitize_text_field', $args['quality_issues'] ) ) : array(),4255 'action_policy' => sanitize_key( (string) ( $args['action_policy'] ?? 'suggestion_only' ) ),4256 'target_field' => sanitize_key( (string) ( $args['target_field'] ?? '' ) ),4257 'write_posture' => 'suggestion_only',4258 'direct_wordpress_write' => false,4259 'evidence_refs' => is_array( $args['evidence_refs'] ?? null ) ? array_values( array_map( 'sanitize_text_field', $args['evidence_refs'] ) ) : array(),4260 );4261 4262 if ( '' !== (string) ( $args['source_candidate_ref'] ?? '' ) ) {4263 $candidate['source_candidate_ref'] = sanitize_text_field( (string) $args['source_candidate_ref'] );4264 }4265 if ( '' !== (string) ( $args['candidate_source'] ?? '' ) ) {4266 $candidate['candidate_source'] = sanitize_key( (string) $args['candidate_source'] );4267 }4268 if ( in_array( (string) ( $args['candidate_relevance'] ?? '' ), array( 'strong', 'review', 'weak' ), true ) ) {4269 $candidate['candidate_relevance'] = (string) $args['candidate_relevance'];4270 }4271 if ( is_array( $args['target_ref'] ?? null ) ) {4272 $target_ref = $args['target_ref'];4273 $candidate['target_ref'] = array(4274 'post_id' => absint( $target_ref['post_id'] ?? 0 ),4275 'title' => sanitize_text_field( (string) ( $target_ref['title'] ?? '' ) ),4276 'url' => esc_url_raw( (string) ( $target_ref['url'] ?? '' ) ),4277 'status' => sanitize_key( (string) ( $target_ref['status'] ?? '' ) ),4278 'post_type' => sanitize_key( (string) ( $target_ref['post_type'] ?? '' ) ),4279 );4280 }4281 if ( '' !== (string) ( $args['anchor_or_context'] ?? '' ) ) {4282 $candidate['anchor_or_context'] = sanitize_text_field( (string) $args['anchor_or_context'] );4283 }4284 if ( array_key_exists( 'can_apply_to_editor', $args ) ) {4285 $candidate['can_apply_to_editor'] = true === $args['can_apply_to_editor'];4286 }4287 if ( '' !== (string) ( $args['anchor_quality_status'] ?? '' ) ) {4288 $candidate['anchor_quality_status'] = sanitize_key( (string) $args['anchor_quality_status'] );4289 }4290 if ( '' !== (string) ( $args['evidence_note'] ?? '' ) ) {4291 $candidate['evidence_note'] = sanitize_text_field( (string) $args['evidence_note'] );4292 }4293 if ( '' !== (string) ( $args['owner_label'] ?? '' ) ) {4294 $candidate['owner_label'] = sanitize_key( (string) $args['owner_label'] );4295 }4296 if ( '' !== (string) ( $args['next_safe_action'] ?? '' ) ) {4297 $candidate['next_safe_action'] = sanitize_key( (string) $args['next_safe_action'] );4298 }4299 if ( is_array( $args['source_match'] ?? null ) ) {4300 $source_match = $args['source_match'];4301 $client_id = sanitize_text_field( (string) ( $source_match['block_client_id'] ?? '' ) );4302 $matched_text = sanitize_text_field( (string) ( $source_match['matched_text'] ?? '' ) );4303 $expected_text = sanitize_textarea_field( $this->editor_trim_chars( wp_strip_all_tags( (string) ( $source_match['expected_text'] ?? '' ) ), 1600 ) );4304 if ( '' !== $client_id && '' !== $matched_text && '' !== $expected_text ) {4305 $candidate['source_match'] = array(4306 'block_client_id' => $client_id,4307 'block_name' => sanitize_text_field( (string) ( $source_match['block_name'] ?? '' ) ),4308 'matched_text' => $matched_text,4309 'text_offset' => absint( $source_match['text_offset'] ?? 0 ),4310 'expected_text' => $expected_text,4311 'match_basis' => sanitize_key( (string) ( $source_match['match_basis'] ?? '' ) ),4312 );4313 }4314 }4315 if ( '' !== (string) ( $args['priority_reason'] ?? '' ) ) {4316 $candidate['priority_reason'] = sanitize_text_field( (string) $args['priority_reason'] );4317 }4318 if ( is_array( $args['link_graph_issues'] ?? null ) ) {4319 $candidate['link_graph_issues'] = array_values( array_filter( array_map( 'sanitize_key', $args['link_graph_issues'] ) ) );4320 }4321 if ( is_array( $args['shared_terms'] ?? null ) ) {4322 $candidate['shared_terms'] = array_slice( array_values( array_filter( array_map( 'sanitize_text_field', $args['shared_terms'] ) ) ), 0, 3 );4323 }4324 $candidate['incoming_count'] = absint( $args['incoming_count'] ?? 0 );4325 4326 return $candidate;4327 3740 } 4328 3741 … … 4337 3750 ); 4338 3751 4339 return $this->editor_taxonomy_only_suggestion_section(3752 return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section( 4340 3753 'category_suggestions', 4341 3754 $categories, … … 4358 3771 $proposed_new_terms = $this->empty_proposed_new_terms_review(); 4359 3772 4360 return $this->editor_taxonomy_only_suggestion_section(3773 return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section( 4361 3774 'tag_suggestions', 4362 3775 array(), … … 4368 3781 } 4369 3782 4370 private function editor_taxonomy_only_suggestion_section( string $candidate_type, array $categories, array $tags, array $proposed_new_terms, array $taxonomy_terms, array $context ): array {4371 return array(4372 'artifact_type' => 'article_taxonomy_suggestions.v1',4373 'composition_role' => 'taxonomy_candidates_only',4374 'candidate_type' => sanitize_key( $candidate_type ),4375 'candidate_contract' => 'recommendation_candidate.v1',4376 'write_posture' => 'suggestion_only',4377 'final_write_path' => 'core_proposal_required',4378 'direct_wordpress_write' => false,4379 'input_scope' => $this->editor_input_scope( $context ),4380 'category_candidates' => array_slice( $categories, 0, 5 ),4381 'tag_candidates' => array_slice( $tags, 0, 8 ),4382 'proposed_new_terms' => $proposed_new_terms,4383 'taxonomy_terms' => $taxonomy_terms,4384 'recommendation_candidates' => $this->editor_taxonomy_recommendation_candidates( $candidate_type, $categories, $tags, $proposed_new_terms ),4385 'quality_gate' => array(4386 'name' => 'runtime_taxonomy_candidate_rerank',4387 'policy' => 'existing_terms_first_current_draft_match_then_related_history',4388 'candidate_sort' => 'score_desc_then_existing_term_order',4389 ),4390 'selection_policy' => array(4391 'prefer_existing_terms' => true,4392 'new_terms_deferred' => true,4393 'no_toolbox_term_creation' => true,4394 'accepted_write_path' => 'core_proposal_required',4395 ),4396 );4397 }4398 4399 private function editor_taxonomy_recommendation_candidates( string $candidate_type, array $categories, array $tags, array $proposed_new_terms ): array {4400 $items = 'category_suggestions' === $candidate_type ? array_slice( $categories, 0, 5 ) : array_slice( $tags, 0, 8 );4401 $result = array();4402 foreach ( $items as $index => $item ) {4403 $taxonomy = sanitize_key( (string) ( $item['taxonomy'] ?? '' ) );4404 $term_id = absint( $item['term_id'] ?? 0 );4405 $name = sanitize_text_field( (string) ( $item['name'] ?? '' ) );4406 if ( '' === $name || 0 >= $term_id ) {4407 continue;4408 }4409 $quality = $this->editor_taxonomy_candidate_quality( $item );4410 $result[] = $this->editor_recommendation_candidate(4411 array(4412 'id' => ( 'category' === $taxonomy ? 'category_' : 'tag_' ) . $term_id,4413 'kind' => 'category' === $taxonomy ? 'category' : 'tag',4414 'label' => 'category' === $taxonomy ? __( 'Existing category', 'npcink-workflow-toolbox' ) : __( 'Existing tag', 'npcink-workflow-toolbox' ),4415 'value' => $name,4416 'reason' => sanitize_text_field( (string) ( $item['reason'] ?? '' ) ),4417 'confidence' => $quality['confidence'],4418 'target_field' => 'category' === $taxonomy ? 'category' : 'post_tag',4419 'action_policy' => 'core_proposal_required',4420 'quality_status' => $quality['status'],4421 'quality_score' => $quality['score'],4422 'quality_issues' => $quality['issues'],4423 'evidence_refs' => is_array( $item['evidence_refs'] ?? null ) ? $item['evidence_refs'] : array(),4424 )4425 );4426 }4427 4428 return $result;4429 }4430 4431 private function editor_taxonomy_candidate_quality( array $item ): array {4432 $score = is_numeric( $item['score'] ?? null ) ? (float) $item['score'] : 0.0;4433 $match_signals = is_array( $item['match_signals'] ?? null ) ? $item['match_signals'] : array();4434 $related_context = is_array( $item['related_context'] ?? null ) ? $item['related_context'] : array();4435 $quality_score = max( 0, min( 100, 45 + (int) round( $score * 10 ) ) );4436 $quality_issues = array();4437 if ( in_array( 'current_draft_match', $match_signals, true ) ) {4438 $quality_issues[] = __( '匹配当前草稿中的标题、摘要或正文词。', 'npcink-workflow-toolbox' );4439 }4440 if ( in_array( 'title_term_name_match', $match_signals, true ) ) {4441 $quality_issues[] = __( '词条名称在标题中完整出现,优先级更高。', 'npcink-workflow-toolbox' );4442 }4443 if ( in_array( 'slug_alias_match', $match_signals, true ) ) {4444 $quality_issues[] = __( '词条 slug 或别名与当前编辑上下文匹配。', 'npcink-workflow-toolbox' );4445 }4446 if ( in_array( 'related_site_knowledge_term', $match_signals, true ) ) {4447 $quality_issues[] = __( '历史相关文章使用过该词汇,可作为站内词库证据。', 'npcink-workflow-toolbox' );4448 }4449 if ( in_array( 'description_only_match', $match_signals, true ) ) {4450 $quality_score -= 20;4451 $quality_issues[] = __( '仅描述字段匹配,避免把弱说明文字当作强分类依据。', 'npcink-workflow-toolbox' );4452 }4453 if ( in_array( 'low_specificity_match', $match_signals, true ) ) {4454 $quality_score -= 15;4455 $quality_issues[] = __( '只有一个较弱 token 匹配,需人工确认是否为标题党或泛化词。', 'npcink-workflow-toolbox' );4456 }4457 if ( empty( $quality_issues ) ) {4458 $quality_issues[] = __( '仅作为现有 WordPress 词条候选,采用人工审查。', 'npcink-workflow-toolbox' );4459 }4460 if ( 0 === absint( $related_context['source_count'] ?? 0 ) && ! in_array( 'current_draft_match', $match_signals, true ) ) {4461 $quality_score -= 15;4462 $quality_issues[] = __( '缺少当前草稿或历史文章的强匹配证据。', 'npcink-workflow-toolbox' );4463 }4464 4465 $status = 'good';4466 if ( $quality_score < 70 ) {4467 $status = 'review';4468 }4469 if ( $quality_score < 55 ) {4470 $status = 'weak';4471 }4472 4473 return array(4474 'score' => max( 0, min( 100, $quality_score ) ),4475 'status' => $status,4476 'confidence' => max( 0.0, min( 1.0, $score / 5 ) ),4477 'issues' => array_values( array_unique( $quality_issues ) ),4478 );4479 }4480 4481 3783 private function editor_related_post_terms_for_context( int $post_id ): array { 4482 3784 if ( 0 >= $post_id || ! function_exists( 'get_the_terms' ) ) { … … 4505 3807 private function editor_related_content_term_evidence( array $related_content ): array { 4506 3808 $evidence = array(); 4507 foreach ( array_slice( $this->editor_related_content_items( $related_content ), 0, 20 ) as $index => $item ) {3809 foreach ( array_slice( Rest_Editor_Flow_Cache::editor_related_content_items( $related_content ), 0, 20 ) as $index => $item ) { 4508 3810 $post_id = absint( $item['post_id'] ?? 0 ); 4509 3811 if ( 0 >= $post_id ) { … … 4947 4249 $review_set_result = $this->editor_toolkit_taxonomy_review_set( $input ); 4948 4250 if ( is_wp_error( $review_set_result ) ) { 4949 $taxonomy_terms['taxonomy_tag_review_set'] = $this->editor_taxonomy_review_set_from_suggestions( $taxonomy_terms, $review_set_result );4251 $taxonomy_terms['taxonomy_tag_review_set'] = Rest_Editor_Taxonomy_Shaping::editor_taxonomy_review_set_from_suggestions( $taxonomy_terms, $review_set_result ); 4950 4252 } else { 4951 4253 $review_set_data = is_array( $review_set_result['data'] ?? null ) ? $review_set_result['data'] : $review_set_result; 4952 4254 $taxonomy_terms['taxonomy_tag_review_set'] = is_array( $review_set_data ) && 'taxonomy_tag_review_set' === (string) ( $review_set_data['artifact_type'] ?? '' ) 4953 4255 ? $review_set_data 4954 : $this->editor_taxonomy_review_set_from_suggestions(4256 : Rest_Editor_Taxonomy_Shaping::editor_taxonomy_review_set_from_suggestions( 4955 4257 $taxonomy_terms, 4956 4258 new WP_Error( … … 5035 4337 5036 4338 return $result; 5037 }5038 5039 private function editor_taxonomy_review_set_from_suggestions( array $taxonomy_terms, WP_Error $fallback_reason ): array {5040 $items = is_array( $taxonomy_terms['items'] ?? null ) ? array_values( array_filter( $taxonomy_terms['items'], 'is_array' ) ) : array();5041 $review_set_limit = 8;5042 $selected = array();5043 $blocked = array();5044 5045 foreach ( $items as $item ) {5046 $quality = $this->editor_taxonomy_candidate_quality( $item );5047 $row = array(5048 'candidate_id' => ( 'category' === (string) ( $item['taxonomy'] ?? '' ) ? 'category_' : 'tag_' ) . absint( $item['term_id'] ?? 0 ),5049 'candidate_contract' => 'taxonomy_tag_review_candidate.v1',5050 'taxonomy' => sanitize_key( (string) ( $item['taxonomy'] ?? '' ) ),5051 'term_id' => absint( $item['term_id'] ?? 0 ),5052 'name' => sanitize_text_field( (string) ( $item['name'] ?? '' ) ),5053 'slug' => sanitize_title( (string) ( $item['slug'] ?? '' ) ),5054 'score' => is_numeric( $item['score'] ?? null ) ? (float) $item['score'] : 0.0,5055 'quality' => $quality,5056 'reason' => sanitize_text_field( (string) ( $item['reason'] ?? '' ) ),5057 'evidence_refs' => is_array( $item['evidence_refs'] ?? null ) ? array_values( array_map( 'sanitize_text_field', $item['evidence_refs'] ) ) : array(),5058 'proposed_action' => 'append_existing_term',5059 'needs_operator_review' => true,5060 'direct_wordpress_write' => false,5061 'term_creation_allowed' => false,5062 'term_assignment_authorized' => false,5063 );5064 if ( '' === $row['name'] || 0 >= $row['term_id'] ) {5065 $row['blocked_reason'] = 'invalid_existing_term_candidate';5066 $blocked[] = $row;5067 continue;5068 }5069 if ( 'weak' === (string) $quality['status'] ) {5070 $row['blocked_reason'] = 'weak_taxonomy_evidence';5071 $blocked[] = $row;5072 continue;5073 }5074 if ( count( $selected ) >= $review_set_limit ) {5075 $row['blocked_reason'] = 'review_set_limit_reached';5076 $blocked[] = $row;5077 continue;5078 }5079 $row['review_status'] = 'good' === (string) $quality['status'] ? 'ready_for_review' : 'review_recommended';5080 $selected[] = $row;5081 }5082 5083 return array(5084 'contract_version' => 'taxonomy_tag_review_set.v1',5085 'artifact_type' => 'taxonomy_tag_review_set',5086 'mode' => 'governed_review_set',5087 'write_posture' => 'suggestion_only',5088 'final_write_path' => 'core_proposal_required',5089 'direct_wordpress_write' => false,5090 'proposal_created' => false,5091 'execution_created' => false,5092 'commit_execution' => false,5093 'source_ability_id' => 'npcink-abilities-toolkit/suggest-post-taxonomy-terms',5094 'preferred_source_ability_id' => 'npcink-abilities-toolkit/build-taxonomy-tag-review-set',5095 'runtime_owner' => 'npcink-workflow-toolbox',5096 'fallback_reason' => sanitize_key( $fallback_reason->get_error_code() ),5097 'fallback_message' => sanitize_text_field( $fallback_reason->get_error_message() ),5098 'review_set_limit' => $review_set_limit,5099 'eligibility_summary' => array(5100 'scanned' => count( $items ),5101 'selected' => count( $selected ),5102 'blocked' => count( $blocked ),5103 ),5104 'selected_items' => $selected,5105 'blocked_items' => $blocked,5106 'safety' => array(5107 'term_creation_allowed' => false,5108 'term_assignment_allowed' => false,5109 'proposal_created' => false,5110 'direct_wordpress_write' => false,5111 'provider_runtime_used' => false,5112 'cloud_runtime_dependency' => false,5113 ),5114 'handoff' => array(5115 'accepted_selection_target' => 'npcink-abilities-toolkit/build-content-metadata-apply-plan',5116 'term_assignment_target' => 'npcink-abilities-toolkit/set-post-terms',5117 'final_write_path' => 'core_proposal_required',5118 'operator_review_required' => true,5119 ),5120 );5121 4339 } 5122 4340 … … 5198 4416 'occurrence_index' => absint( $item['occurrence_index'] ?? 0 ), 5199 4417 'context_heading' => sanitize_text_field( (string) ( $item['context_heading'] ?? '' ) ), 5200 'context_before' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_before'] ?? '' ), 240 ) ),5201 'context_after' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_after'] ?? '' ), 240 ) ),5202 'context_caption' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_caption'] ?? '' ), 220 ) ),5203 'context_summary' => sanitize_textarea_field( $this->editor_trim_chars( (string) ( $item['context_summary'] ?? '' ), 360 ) ),4418 'context_before' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_before'] ?? '' ), 240 ) ), 4419 'context_after' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_after'] ?? '' ), 240 ) ), 4420 'context_caption' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_caption'] ?? '' ), 220 ) ), 4421 'context_summary' => sanitize_textarea_field( Rest_Editor_Audio_Text::trim( (string) ( $item['context_summary'] ?? '' ), 360 ) ), 5204 4422 'decorative' => ! empty( $item['decorative'] ), 5205 4423 'target_scope' => 'post_block_alt', -
npcink-workflow-toolbox/trunk/includes/Settings.php
r3730454 r3734482 11 11 12 12 final class Settings { 13 14 public const REVIEW_TALLY_SETS = array( 15 'media_alt' => 'media_alt_caption_review_set.v1', 16 'taxonomy_tag' => 'taxonomy_tag_review_set.v1', 17 'internal_link' => 'internal_link_review_set.v1', 18 'comment_moderation' => 'comment_moderation_review_set.v1', 19 'flagged_media' => 'flagged_media_review_set.v1', 20 ); 13 21 public function register(): void { 14 22 register_setting( … … 895 903 return '' !== trim( (string) $value ); 896 904 } 905 906 /** 907 * Review-set adoption tally: the operator's own accept/ignore counts per 908 * review set, feeding the batch-apply decision gate. Counts and bounded 909 * timestamps only - no suggestion content, no Cloud data, no WordPress 910 * content writes. 911 */ 912 public function get_review_tally(): array { 913 $value = get_option( Plugin::REVIEW_TALLY_OPTION_NAME, array() ); 914 return is_array( $value ) ? $value : array(); 915 } 916 917 public function record_review_tally_mark( string $review_set, string $decision ): array { 918 if ( ! array_key_exists( $review_set, self::REVIEW_TALLY_SETS ) ) { 919 return array(); 920 } 921 if ( ! in_array( $decision, array( 'accepted', 'ignored' ), true ) ) { 922 return array(); 923 } 924 $tally = $this->get_review_tally(); 925 $marks = is_array( $tally['marks'] ?? null ) ? $tally['marks'] : array(); 926 $marks[] = array( 927 'set' => $review_set, 928 'decision' => $decision, 929 'timestamp' => time(), 930 ); 931 // Bounded log: 2000 marks covers months of single-operator use; a 932 // 7-day window reaching the cap means 250+ marks/day - far past the 933 // batch-apply gate threshold, where undercounting is immaterial. 934 // 935 // Accepted residual (recorded 2026-10-08, raised four advisory 936 // rounds): the get_option -> append -> update_option sequence has 937 // no cross-request mutual exclusion, so two truly concurrent marks 938 // can lose one count. The WordPress options API offers no compare- 939 // and-swap; single-operator admin usage makes the window negligible 940 // and the tally is advisory decision data, not accounting truth. 941 $tally['marks'] = array_slice( $marks, -2000 ); 942 update_option( Plugin::REVIEW_TALLY_OPTION_NAME, $tally, false ); 943 return $tally; 944 } 945 946 public function review_tally_summary( int $days = 7 ): array { 947 $tally = $this->get_review_tally(); 948 $marks = is_array( $tally['marks'] ?? null ) ? $tally['marks'] : array(); 949 $cutoff = time() - ( $days * DAY_IN_SECONDS ); 950 $summary = array( 951 'window_days' => $days, 952 'total' => array( 953 'accepted' => 0, 954 'ignored' => 0, 955 ), 956 'sets' => array(), 957 ); 958 foreach ( array_keys( self::REVIEW_TALLY_SETS ) as $set_key ) { 959 $summary['sets'][ $set_key ] = array( 960 'accepted' => 0, 961 'ignored' => 0, 962 ); 963 } 964 foreach ( $marks as $mark ) { 965 if ( ! is_array( $mark ) || (int) ( $mark['timestamp'] ?? 0 ) < $cutoff ) { 966 continue; 967 } 968 $set = sanitize_key( (string) ( $mark['set'] ?? '' ) ); 969 $decision = sanitize_key( (string) ( $mark['decision'] ?? '' ) ); 970 if ( ! isset( $summary['sets'][ $set ] ) || ! in_array( $decision, array( 'accepted', 'ignored' ), true ) ) { 971 continue; 972 } 973 ++$summary['sets'][ $set ][ $decision ]; 974 ++$summary['total'][ $decision ]; 975 } 976 return $summary; 977 } 897 978 } -
npcink-workflow-toolbox/trunk/languages/npcink-workflow-toolbox-zh_CN-npcink-toolbox-admin.json
r3733201 r3734482 909 909 "Check that Npcink Cloud is connected, then start the optimization again. Already completed images in this batch are kept.": ["请确认 Npcink Cloud 已连接,然后重新开始优化。本批次中已完成的图片会保留。"], 910 910 "Review the failed rows, then choose Retry failed previews again or deselect those rows. Successful previews remain unchanged.": ["请检查失败的行,然后选择再次重试失败预览或取消选中这些行。成功的预览保持不变。"], 911 "Keep the current selection, resolve the reported issue, then use the same preview action again. Toolbox will not retry automatically.": ["请保持当前选择,解决报告的问题后重新执行同一预览操作。Toolbox 不会自动重试。"] 911 "Keep the current selection, resolve the reported issue, then use the same preview action again. Toolbox will not retry automatically.": ["请保持当前选择,解决报告的问题后重新执行同一预览操作。Toolbox 不会自动重试。"], 912 "Accept +1": ["接受 +1"], 913 "Ignore +1": ["忽略 +1"], 914 "Last 7 days": ["近 7 天"], 915 "accepted": ["已接受"], 916 "ignored": ["已忽略"], 917 "Local counts only. No content is written.": ["仅本地计数,不写入任何内容。"], 918 "Could not save the tally mark. Try again.": ["无法保存计数标记,请重试。"], 919 "Counts unavailable. Try reloading.": ["计数不可用,请刷新重试。"], 920 "Last %d days": ["近 %d 天"] 912 921 } 913 922 } -
npcink-workflow-toolbox/trunk/npcink-workflow-toolbox.php
r3733528 r3734482 3 3 * Plugin Name: Npcink Workflow Toolbox 4 4 * Description: Fixed AI workflow buttons for WordPress operators, with review-only suggestions and governed handoff plans. 5 * Version: 0.5. 25 * Version: 0.5.3 6 6 * Requires at least: 6.9 7 7 * Requires PHP: 8.0 … … 63 63 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Surface_Bridges.php'; 64 64 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Local_Admin_Consent.php'; 65 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Flow_Cache.php'; 66 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Paragraph_Check.php'; 67 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Audio_Text.php'; 68 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Taxonomy_Shaping.php'; 65 69 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Content_Support.php'; 66 70 require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Controller.php'; -
npcink-workflow-toolbox/trunk/readme.txt
r3733528 r3734482 5 5 Requires PHP: 8.0 6 6 Tested up to: 7.1 7 Stable tag: 0.5. 27 Stable tag: 0.5.3 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 230 230 == Changelog == 231 231 232 = 0.5.3 = 233 234 * Added the review-set adoption tally: every review-set result panel 235 carries local Accept/Ignore +1 buttons with a Last-7-days line, so the 236 batch-apply decision data builds itself during normal use (Toolbox 237 settings counts only; no suggestion content is stored). 238 * Hardened editor plain-text extraction to the inert DOMParser path 239 (inline handlers and resource fetches in block HTML can no longer 240 execute during text extraction). 241 * Guarded the advisory review workflow against missing LLM configuration 242 and fixed the form that briefly stopped all review rounds. 243 * Restructured the editor content-support service into five cohesive 244 units behind one facade (paragraph check, audio text, flow cache, 245 taxonomy shaping) with unchanged behavior. 246 232 247 = 0.5.2 = 233 248 -
npcink-workflow-toolbox/trunk/uninstall.php
r3723781 r3734482 16 16 'npcink_toolbox_media_optimization_settings', 17 17 'npcink_toolbox_watermark_templates', 18 'npcink_toolbox_review_tally', 18 19 'npcink_toolbox_zhihu_hot_topic_pool_backup_v1', 19 20 'npcink_toolbox_media_optimization_batches',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)