Plugin Directory

Changeset 3734482


Ignore:
Timestamp:
10/08/2026 12:03:11 PM (2 weeks ago)
Author:
muze233
Message:

Release 0.5.3: review-set adoption tally, inert DOMParser text extraction, review-workflow configuration guard, editor content-support restructure

Location:
npcink-workflow-toolbox
Files:
8 added
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • npcink-workflow-toolbox/tags/0.5.3/assets/admin.css

    r3723781 r3734482  
    42514251        color: #b32d2e;
    42524252}
     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  
    415415                }
    416416
     417                maybeAttachReviewTally(form, result);
     418
    417419                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'));
    418533        }
    419534
    … …  
    595710                        result.appendChild(createRawDetails(error, 'Error payload'));
    596711                }
     712                maybeAttachReviewTally(form, result);
    597713        }
    598714
  • npcink-workflow-toolbox/tags/0.5.3/assets/editor-content-support/text-utils.js

    r3730454 r3734482  
    2121                        return '';
    2222                }
    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();
    2730                }
     31                // Best-effort tag stripping for environments without DOMParser:
     32                // malformed markup with '>' inside attribute values can leak tag text.
    2833                return source.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
    2934        }
  • npcink-workflow-toolbox/tags/0.5.3/includes/Plugin.php

    r3730454 r3734482  
    1313
    1414final 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';
    2021
    2122        private static ?Plugin $instance = null;
  • npcink-workflow-toolbox/tags/0.5.3/includes/Rest_Controller.php

    r3731876 r3734482  
    9595                $this->post( '/nightly-inspection/cloud-batch/(?P<run_id>[A-Za-z0-9._:-]+)/result', 'nightly_inspection_cloud_batch_result' );
    9696                $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' );
    9799
    98100                register_rest_route(
    … …  
    235237                        '/ai/content-support'                      => 'cap.toolbox.workflow_suggest',
    236238                        '/ai/site-helpers'                         => 'cap.toolbox.workflow_suggest',
     239                        '/review-tally/mark'                       => 'cap.toolbox.workflow_suggest',
     240                        '/review-tally/summary'                    => 'cap.toolbox.workflow_suggest',
    237241                        '/ai/image-generation'                     => 'cap.toolbox.image_source',
    238242                        '/flows/article-plan'                      => 'cap.toolbox.workflow_suggest',
    … …  
    328332        public function nightly_inspection_cloud_batch_result( WP_REST_Request $request ) {
    329333                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 ) );
    330361        }
    331362
  • npcink-workflow-toolbox/tags/0.5.3/includes/Rest_Editor_Content_Support.php

    r3731876 r3734482  
    2323defined( 'ABSPATH' ) || exit;
    2424
    25 final class Rest_Editor_Content_Support extends Rest_Controller_Support {
     25final class Rest_Editor_Content_Support extends Rest_Editor_Flow_Cache {
    2626
    2727        private const EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS = 30000;
    28         private const EDITOR_AUDIO_TEXT_MAX_CHARS           = 5000;
    2928        private const EDITOR_SELECTED_TEXT_MAX_CHARS        = 2000;
    3029        private const EDITOR_COMMENT_TEXT_MAX_CHARS         = 1200;
    31         private const EDITOR_FLOW_CACHE_TTL                 = 300;
    3230        private const EDITOR_PROGRESSIVE_TARGET_MS          = 2500;
    3331        private const EDITOR_PROGRESSIVE_CANDIDATE_LIMIT    = 8;
    3432
    35         private Provider_Client $client;
    3633        private Publish_Preflight_Service $publish_preflight;
    3734
    … …  
    210207        private function editor_post_context( WP_REST_Request $request ): array {
    211208                $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 );
    213210                $content             = trim( wp_strip_all_tags( $content_raw ) );
    214211                $selected_text       = trim( wp_strip_all_tags( (string) $request->get_param( 'selected_text' ) ) );
    … …  
    232229                        'excerpt'                    => sanitize_textarea_field( (string) $request->get_param( 'excerpt' ) ),
    233230                        '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 ) ),
    236233                        'selected_text'              => wp_trim_words( sanitize_textarea_field( $selected_text ), 110, '' ),
    237234                        '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 ) ),
    240237                        'selected_block_name'        => sanitize_text_field( (string) $request->get_param( 'selected_block_name' ) ),
    241238                        'user_instruction'           => wp_trim_words( sanitize_textarea_field( $user_instruction ), 60, '' ),
    … …  
    255252                        'comment_id'                 => absint( $request->get_param( 'comment_id' ) ),
    256253                        '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                );
    285256        }
    286257
    … …  
    296267                        }
    297268                        $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 ) );
    299270                        if ( '' !== $client_id && '' !== $text ) {
    300271                                $items[] = array(
    … …  
    306277                }
    307278                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;
    333279        }
    334280
    … …  
    555501                        $candidate = trim( preg_replace( '/\s+/u', ' ', (string) $candidate ) ?: '' );
    556502                        if ( '' !== $candidate ) {
    557                                 return $this->editor_trim_chars( $candidate, 120 );
     503                                return Rest_Editor_Audio_Text::trim( $candidate, 120 );
    558504                        }
    559505                }
    … …  
    675621                }
    676622
    677                 $trimmed = $this->editor_trim_chars( $summary, 120 );
     623                $trimmed = Rest_Editor_Audio_Text::trim( $summary, 120 );
    678624                if ( $trimmed !== $summary && preg_match( '/\s/u', $trimmed ) ) {
    679625                        $trimmed = preg_replace( '/\s+\S*$/u', '', $trimmed ) ?: $trimmed;
    … …  
    770716
    771717                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                 );
    801718        }
    802719
    … …  
    10941011                $taxonomy_recommendations = '' !== trim( $query )
    10951012                        ? 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() )
    10981015                        )
    10991016                        : array();
    … …  
    15131430                        $match_score   = (int) ( $item['score'] ?? 0 );
    15141431                        $quality_score = min( 95, 55 + $match_score * 8 );
    1515                         $candidates[]  = $this->editor_recommendation_candidate(
     1432                        $candidates[]  = Rest_Editor_Flow_Cache::editor_recommendation_candidate(
    15161433                                array(
    15171434                                        'id'                   => 'media_library_' . $attachment_id,
    … …  
    15961513                        $status       = sanitize_key( (string) ( $item['status'] ?? 'warning' ) );
    15971514                        $score        = 'error' === $status ? 35 : 55;
    1598                         $candidates[] = $this->editor_recommendation_candidate(
     1515                        $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate(
    15991516                                array(
    16001517                                        'id'             => 'preflight_' . $id,
    … …  
    16161533                return $candidates;
    16171534        }
    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_refresh
    1647                 );
    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_refresh
    1658                 );
    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                         true
    1673                 );
    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_Error
    1725          */
    17261535
    17271536        private function editor_source_adaptation_url( string $value ) {
    … …  
    25562365
    25572366        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 );
    25592368                $result = array();
    25602369                foreach ( array_slice( $items, 0, 6 ) as $index => $item ) {
    … …  
    27122521                        'final_write_path'       => 'core_proposal_required',
    27132522                        'direct_wordpress_write' => false,
    2714                         'input_scope'            => $this->editor_input_scope( $context ),
     2523                        'input_scope'            => Rest_Editor_Flow_Cache::editor_input_scope( $context ),
    27152524                        'summary_candidates'     => $summary_ai,
    27162525                        'summary_layers'         => $summary_layers,
    … …  
    27792588                        'diagnosis'              => array(
    27802589                                '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 ),
    27822591                                'hypotheses'        => array(
    27832592                                        __( 'A clearer excerpt can improve archive, social, and answer-summary presentation without rewriting the article body.', 'npcink-workflow-toolbox' ),
    … …  
    28752684        }
    28762685
    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 
    29062686        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 );
    29082688                $evidence_ids = array();
    29092689                $titles       = array();
    … …  
    29282708
    29292709        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 );
    29312711                $context_items = array();
    29322712
    … …  
    30182798                $items                                 = is_array( $artifact['items'] ?? null ) ? $artifact['items'] : array();
    30192799                $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 );
    30212801                $artifact['source_ability_id']         = 'npcink-abilities-toolkit/resolve-internal-link-targets';
    30222802                $artifact['source_knowledge']          = $source_knowledge;
    … …  
    31092889        private function editor_internal_link_related_content_evidence( array $source_knowledge ): array {
    31102890                $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 ) {
    31122892                        $post_id             = absint( $item['post_id'] ?? ( $item['id'] ?? 0 ) );
    31132893                        $candidate_relevance = sanitize_key( (string) ( $item['candidate_relevance'] ?? '' ) );
    … …  
    31432923                                continue;
    31442924                        }
    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 ) ) );
    31462926                        if ( '' === $text ) {
    31472927                                continue;
    … …  
    33003080                        }
    33013081
    3302                         $candidates[] = $this->editor_recommendation_candidate(
     3082                        $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate(
    33033083                                array(
    33043084                                        'id'                    => 'internal_link_' . ( $index + 1 ),
    … …  
    34013181                                )
    34023182                        );
    3403                         $script                          = $this->editor_audio_summary_script_text( $summary_ai );
     3183                        $script                          = Rest_Editor_Audio_Text::editor_audio_summary_script_text( $summary_ai );
    34043184                        $script_source['summary_script'] = $summary_ai;
    34053185                } else {
    3406                         $script                       = $this->editor_audio_source_text( $source_text );
     3186                        $script                       = Rest_Editor_Audio_Text::editor_audio_source_text( $source_text );
    34073187                        $script_source['source_mode'] = 'article_text';
    34083188                }
    … …  
    34663246        }
    34673247
    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 
    35063248        private function editor_article_checkup_section( array $context ): array {
    35073249                $text       = trim( (string) ( $context['content_full_text'] ?? $context['content_text'] ?? '' ) );
    … …  
    36803422                $signals = array();
    36813423
    3682                 if ( $this->editor_text_has_heading_label_glue( $paragraph ) ) {
     3424                if ( Rest_Editor_Paragraph_Check::editor_text_has_heading_label_glue( $paragraph ) ) {
    36833425                        $signals[] = __( '标题式标签直接黏在正文前', 'npcink-workflow-toolbox' );
    36843426                }
    3685                 if ( $this->editor_text_has_phrase_cluster_glue( $paragraph ) ) {
     3427                if ( Rest_Editor_Paragraph_Check::editor_text_has_phrase_cluster_glue( $paragraph ) ) {
    36863428                        $signals[] = __( '短语组之间缺少分隔', 'npcink-workflow-toolbox' );
    36873429                }
    3688                 if ( $this->editor_text_has_alnum_cjk_glue( $paragraph ) ) {
     3430                if ( Rest_Editor_Paragraph_Check::editor_text_has_alnum_cjk_glue( $paragraph ) ) {
    36893431                        $signals[] = __( '字母、数字或方案标签与中文黏连', 'npcink-workflow-toolbox' );
    36903432                }
    … …  
    39263668        }
    39273669
    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 
    39473670        private function editor_article_checkup_issue( string $id, string $type, string $severity, string $location, string $evidence, string $issue, string $edit_direction ): array {
    39483671                return array(
    … …  
    39513674                        'severity'       => sanitize_key( $severity ),
    39523675                        '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 ) ),
    39543677                        'issue'          => sanitize_text_field( $issue ),
    39553678                        'edit_direction' => sanitize_textarea_field( $edit_direction ),
    … …  
    40073730                $section['write_posture']      = 'suggestion_only';
    40083731                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 );
    40113734                        } 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 );
    40133736                        }
    40143737                }
    40153738
    40163739                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_empty
    4237                                 ? __( '托管 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;
    43273740        }
    43283741
    … …  
    43373750                );
    43383751
    4339                 return $this->editor_taxonomy_only_suggestion_section(
     3752                return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section(
    43403753                        'category_suggestions',
    43413754                        $categories,
    … …  
    43583771                $proposed_new_terms = $this->empty_proposed_new_terms_review();
    43593772
    4360                 return $this->editor_taxonomy_only_suggestion_section(
     3773                return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section(
    43613774                        'tag_suggestions',
    43623775                        array(),
    … …  
    43683781        }
    43693782
    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 
    44813783        private function editor_related_post_terms_for_context( int $post_id ): array {
    44823784                if ( 0 >= $post_id || ! function_exists( 'get_the_terms' ) ) {
    … …  
    45053807        private function editor_related_content_term_evidence( array $related_content ): array {
    45063808                $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 ) {
    45083810                        $post_id = absint( $item['post_id'] ?? 0 );
    45093811                        if ( 0 >= $post_id ) {
    … …  
    49474249                $review_set_result                                        = $this->editor_toolkit_taxonomy_review_set( $input );
    49484250                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 );
    49504252                } else {
    49514253                        $review_set_data                           = is_array( $review_set_result['data'] ?? null ) ? $review_set_result['data'] : $review_set_result;
    49524254                        $taxonomy_terms['taxonomy_tag_review_set'] = is_array( $review_set_data ) && 'taxonomy_tag_review_set' === (string) ( $review_set_data['artifact_type'] ?? '' )
    49534255                                ? $review_set_data
    4954                                 : $this->editor_taxonomy_review_set_from_suggestions(
     4256                                : Rest_Editor_Taxonomy_Shaping::editor_taxonomy_review_set_from_suggestions(
    49554257                                        $taxonomy_terms,
    49564258                                        new WP_Error(
    … …  
    50354337
    50364338                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                 );
    51214339        }
    51224340
    … …  
    51984416                        'occurrence_index' => absint( $item['occurrence_index'] ?? 0 ),
    51994417                        '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 ) ),
    52044422                        'decorative'       => ! empty( $item['decorative'] ),
    52054423                        'target_scope'     => 'post_block_alt',
  • npcink-workflow-toolbox/tags/0.5.3/includes/Settings.php

    r3730454 r3734482  
    1111
    1212final 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        );
    1321        public function register(): void {
    1422                register_setting(
    … …  
    895903                return '' !== trim( (string) $value );
    896904        }
     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        }
    897978}
  • npcink-workflow-toolbox/tags/0.5.3/languages/npcink-workflow-toolbox-zh_CN-npcink-toolbox-admin.json

    r3733201 r3734482  
    909909      "Check that Npcink Cloud is connected, then start the optimization again. Already completed images in this batch are kept.": ["请确认 Npcink Cloud 已连接,然后重新开始优化。本批次中已完成的图片会保留。"],
    910910      "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 天"]
    912921    }
    913922  }
  • npcink-workflow-toolbox/tags/0.5.3/npcink-workflow-toolbox.php

    r3733528 r3734482  
    33 * Plugin Name: Npcink Workflow Toolbox
    44 * Description: Fixed AI workflow buttons for WordPress operators, with review-only suggestions and governed handoff plans.
    5  * Version: 0.5.2
     5 * Version: 0.5.3
    66 * Requires at least: 6.9
    77 * Requires PHP: 8.0
    … …  
    6363require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Surface_Bridges.php';
    6464require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Local_Admin_Consent.php';
     65require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Flow_Cache.php';
     66require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Paragraph_Check.php';
     67require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Audio_Text.php';
     68require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Taxonomy_Shaping.php';
    6569require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Content_Support.php';
    6670require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Controller.php';
  • npcink-workflow-toolbox/tags/0.5.3/readme.txt

    r3733528 r3734482  
    55Requires PHP: 8.0
    66Tested up to: 7.1
    7 Stable tag: 0.5.2
     7Stable tag: 0.5.3
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    230230== Changelog ==
    231231
     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
    232247= 0.5.2 =
    233248
  • npcink-workflow-toolbox/tags/0.5.3/uninstall.php

    r3723781 r3734482  
    1616                        'npcink_toolbox_media_optimization_settings',
    1717                        'npcink_toolbox_watermark_templates',
     18                        'npcink_toolbox_review_tally',
    1819                        'npcink_toolbox_zhihu_hot_topic_pool_backup_v1',
    1920                        'npcink_toolbox_media_optimization_batches',
  • npcink-workflow-toolbox/trunk/assets/admin.css

    r3723781 r3734482  
    42514251        color: #b32d2e;
    42524252}
     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  
    415415                }
    416416
     417                maybeAttachReviewTally(form, result);
     418
    417419                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'));
    418533        }
    419534
    … …  
    595710                        result.appendChild(createRawDetails(error, 'Error payload'));
    596711                }
     712                maybeAttachReviewTally(form, result);
    597713        }
    598714
  • npcink-workflow-toolbox/trunk/assets/editor-content-support/text-utils.js

    r3730454 r3734482  
    2121                        return '';
    2222                }
    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();
    2730                }
     31                // Best-effort tag stripping for environments without DOMParser:
     32                // malformed markup with '>' inside attribute values can leak tag text.
    2833                return source.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
    2934        }
  • npcink-workflow-toolbox/trunk/includes/Plugin.php

    r3730454 r3734482  
    1313
    1414final 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';
    2021
    2122        private static ?Plugin $instance = null;
  • npcink-workflow-toolbox/trunk/includes/Rest_Controller.php

    r3731876 r3734482  
    9595                $this->post( '/nightly-inspection/cloud-batch/(?P<run_id>[A-Za-z0-9._:-]+)/result', 'nightly_inspection_cloud_batch_result' );
    9696                $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' );
    9799
    98100                register_rest_route(
    … …  
    235237                        '/ai/content-support'                      => 'cap.toolbox.workflow_suggest',
    236238                        '/ai/site-helpers'                         => 'cap.toolbox.workflow_suggest',
     239                        '/review-tally/mark'                       => 'cap.toolbox.workflow_suggest',
     240                        '/review-tally/summary'                    => 'cap.toolbox.workflow_suggest',
    237241                        '/ai/image-generation'                     => 'cap.toolbox.image_source',
    238242                        '/flows/article-plan'                      => 'cap.toolbox.workflow_suggest',
    … …  
    328332        public function nightly_inspection_cloud_batch_result( WP_REST_Request $request ) {
    329333                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 ) );
    330361        }
    331362
  • npcink-workflow-toolbox/trunk/includes/Rest_Editor_Content_Support.php

    r3731876 r3734482  
    2323defined( 'ABSPATH' ) || exit;
    2424
    25 final class Rest_Editor_Content_Support extends Rest_Controller_Support {
     25final class Rest_Editor_Content_Support extends Rest_Editor_Flow_Cache {
    2626
    2727        private const EDITOR_SUMMARY_FULL_CONTENT_MAX_CHARS = 30000;
    28         private const EDITOR_AUDIO_TEXT_MAX_CHARS           = 5000;
    2928        private const EDITOR_SELECTED_TEXT_MAX_CHARS        = 2000;
    3029        private const EDITOR_COMMENT_TEXT_MAX_CHARS         = 1200;
    31         private const EDITOR_FLOW_CACHE_TTL                 = 300;
    3230        private const EDITOR_PROGRESSIVE_TARGET_MS          = 2500;
    3331        private const EDITOR_PROGRESSIVE_CANDIDATE_LIMIT    = 8;
    3432
    35         private Provider_Client $client;
    3633        private Publish_Preflight_Service $publish_preflight;
    3734
    … …  
    210207        private function editor_post_context( WP_REST_Request $request ): array {
    211208                $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 );
    213210                $content             = trim( wp_strip_all_tags( $content_raw ) );
    214211                $selected_text       = trim( wp_strip_all_tags( (string) $request->get_param( 'selected_text' ) ) );
    … …  
    232229                        'excerpt'                    => sanitize_textarea_field( (string) $request->get_param( 'excerpt' ) ),
    233230                        '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 ) ),
    236233                        'selected_text'              => wp_trim_words( sanitize_textarea_field( $selected_text ), 110, '' ),
    237234                        '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 ) ),
    240237                        'selected_block_name'        => sanitize_text_field( (string) $request->get_param( 'selected_block_name' ) ),
    241238                        'user_instruction'           => wp_trim_words( sanitize_textarea_field( $user_instruction ), 60, '' ),
    … …  
    255252                        'comment_id'                 => absint( $request->get_param( 'comment_id' ) ),
    256253                        '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                );
    285256        }
    286257
    … …  
    296267                        }
    297268                        $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 ) );
    299270                        if ( '' !== $client_id && '' !== $text ) {
    300271                                $items[] = array(
    … …  
    306277                }
    307278                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;
    333279        }
    334280
    … …  
    555501                        $candidate = trim( preg_replace( '/\s+/u', ' ', (string) $candidate ) ?: '' );
    556502                        if ( '' !== $candidate ) {
    557                                 return $this->editor_trim_chars( $candidate, 120 );
     503                                return Rest_Editor_Audio_Text::trim( $candidate, 120 );
    558504                        }
    559505                }
    … …  
    675621                }
    676622
    677                 $trimmed = $this->editor_trim_chars( $summary, 120 );
     623                $trimmed = Rest_Editor_Audio_Text::trim( $summary, 120 );
    678624                if ( $trimmed !== $summary && preg_match( '/\s/u', $trimmed ) ) {
    679625                        $trimmed = preg_replace( '/\s+\S*$/u', '', $trimmed ) ?: $trimmed;
    … …  
    770716
    771717                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                 );
    801718        }
    802719
    … …  
    10941011                $taxonomy_recommendations = '' !== trim( $query )
    10951012                        ? 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() )
    10981015                        )
    10991016                        : array();
    … …  
    15131430                        $match_score   = (int) ( $item['score'] ?? 0 );
    15141431                        $quality_score = min( 95, 55 + $match_score * 8 );
    1515                         $candidates[]  = $this->editor_recommendation_candidate(
     1432                        $candidates[]  = Rest_Editor_Flow_Cache::editor_recommendation_candidate(
    15161433                                array(
    15171434                                        'id'                   => 'media_library_' . $attachment_id,
    … …  
    15961513                        $status       = sanitize_key( (string) ( $item['status'] ?? 'warning' ) );
    15971514                        $score        = 'error' === $status ? 35 : 55;
    1598                         $candidates[] = $this->editor_recommendation_candidate(
     1515                        $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate(
    15991516                                array(
    16001517                                        'id'             => 'preflight_' . $id,
    … …  
    16161533                return $candidates;
    16171534        }
    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_refresh
    1647                 );
    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_refresh
    1658                 );
    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                         true
    1673                 );
    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_Error
    1725          */
    17261535
    17271536        private function editor_source_adaptation_url( string $value ) {
    … …  
    25562365
    25572366        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 );
    25592368                $result = array();
    25602369                foreach ( array_slice( $items, 0, 6 ) as $index => $item ) {
    … …  
    27122521                        'final_write_path'       => 'core_proposal_required',
    27132522                        'direct_wordpress_write' => false,
    2714                         'input_scope'            => $this->editor_input_scope( $context ),
     2523                        'input_scope'            => Rest_Editor_Flow_Cache::editor_input_scope( $context ),
    27152524                        'summary_candidates'     => $summary_ai,
    27162525                        'summary_layers'         => $summary_layers,
    … …  
    27792588                        'diagnosis'              => array(
    27802589                                '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 ),
    27822591                                'hypotheses'        => array(
    27832592                                        __( 'A clearer excerpt can improve archive, social, and answer-summary presentation without rewriting the article body.', 'npcink-workflow-toolbox' ),
    … …  
    28752684        }
    28762685
    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 
    29062686        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 );
    29082688                $evidence_ids = array();
    29092689                $titles       = array();
    … …  
    29282708
    29292709        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 );
    29312711                $context_items = array();
    29322712
    … …  
    30182798                $items                                 = is_array( $artifact['items'] ?? null ) ? $artifact['items'] : array();
    30192799                $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 );
    30212801                $artifact['source_ability_id']         = 'npcink-abilities-toolkit/resolve-internal-link-targets';
    30222802                $artifact['source_knowledge']          = $source_knowledge;
    … …  
    31092889        private function editor_internal_link_related_content_evidence( array $source_knowledge ): array {
    31102890                $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 ) {
    31122892                        $post_id             = absint( $item['post_id'] ?? ( $item['id'] ?? 0 ) );
    31132893                        $candidate_relevance = sanitize_key( (string) ( $item['candidate_relevance'] ?? '' ) );
    … …  
    31432923                                continue;
    31442924                        }
    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 ) ) );
    31462926                        if ( '' === $text ) {
    31472927                                continue;
    … …  
    33003080                        }
    33013081
    3302                         $candidates[] = $this->editor_recommendation_candidate(
     3082                        $candidates[] = Rest_Editor_Flow_Cache::editor_recommendation_candidate(
    33033083                                array(
    33043084                                        'id'                    => 'internal_link_' . ( $index + 1 ),
    … …  
    34013181                                )
    34023182                        );
    3403                         $script                          = $this->editor_audio_summary_script_text( $summary_ai );
     3183                        $script                          = Rest_Editor_Audio_Text::editor_audio_summary_script_text( $summary_ai );
    34043184                        $script_source['summary_script'] = $summary_ai;
    34053185                } else {
    3406                         $script                       = $this->editor_audio_source_text( $source_text );
     3186                        $script                       = Rest_Editor_Audio_Text::editor_audio_source_text( $source_text );
    34073187                        $script_source['source_mode'] = 'article_text';
    34083188                }
    … …  
    34663246        }
    34673247
    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 
    35063248        private function editor_article_checkup_section( array $context ): array {
    35073249                $text       = trim( (string) ( $context['content_full_text'] ?? $context['content_text'] ?? '' ) );
    … …  
    36803422                $signals = array();
    36813423
    3682                 if ( $this->editor_text_has_heading_label_glue( $paragraph ) ) {
     3424                if ( Rest_Editor_Paragraph_Check::editor_text_has_heading_label_glue( $paragraph ) ) {
    36833425                        $signals[] = __( '标题式标签直接黏在正文前', 'npcink-workflow-toolbox' );
    36843426                }
    3685                 if ( $this->editor_text_has_phrase_cluster_glue( $paragraph ) ) {
     3427                if ( Rest_Editor_Paragraph_Check::editor_text_has_phrase_cluster_glue( $paragraph ) ) {
    36863428                        $signals[] = __( '短语组之间缺少分隔', 'npcink-workflow-toolbox' );
    36873429                }
    3688                 if ( $this->editor_text_has_alnum_cjk_glue( $paragraph ) ) {
     3430                if ( Rest_Editor_Paragraph_Check::editor_text_has_alnum_cjk_glue( $paragraph ) ) {
    36893431                        $signals[] = __( '字母、数字或方案标签与中文黏连', 'npcink-workflow-toolbox' );
    36903432                }
    … …  
    39263668        }
    39273669
    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 
    39473670        private function editor_article_checkup_issue( string $id, string $type, string $severity, string $location, string $evidence, string $issue, string $edit_direction ): array {
    39483671                return array(
    … …  
    39513674                        'severity'       => sanitize_key( $severity ),
    39523675                        '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 ) ),
    39543677                        'issue'          => sanitize_text_field( $issue ),
    39553678                        'edit_direction' => sanitize_textarea_field( $edit_direction ),
    … …  
    40073730                $section['write_posture']      = 'suggestion_only';
    40083731                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 );
    40113734                        } 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 );
    40133736                        }
    40143737                }
    40153738
    40163739                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_empty
    4237                                 ? __( '托管 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;
    43273740        }
    43283741
    … …  
    43373750                );
    43383751
    4339                 return $this->editor_taxonomy_only_suggestion_section(
     3752                return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section(
    43403753                        'category_suggestions',
    43413754                        $categories,
    … …  
    43583771                $proposed_new_terms = $this->empty_proposed_new_terms_review();
    43593772
    4360                 return $this->editor_taxonomy_only_suggestion_section(
     3773                return Rest_Editor_Taxonomy_Shaping::editor_taxonomy_only_suggestion_section(
    43613774                        'tag_suggestions',
    43623775                        array(),
    … …  
    43683781        }
    43693782
    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 
    44813783        private function editor_related_post_terms_for_context( int $post_id ): array {
    44823784                if ( 0 >= $post_id || ! function_exists( 'get_the_terms' ) ) {
    … …  
    45053807        private function editor_related_content_term_evidence( array $related_content ): array {
    45063808                $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 ) {
    45083810                        $post_id = absint( $item['post_id'] ?? 0 );
    45093811                        if ( 0 >= $post_id ) {
    … …  
    49474249                $review_set_result                                        = $this->editor_toolkit_taxonomy_review_set( $input );
    49484250                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 );
    49504252                } else {
    49514253                        $review_set_data                           = is_array( $review_set_result['data'] ?? null ) ? $review_set_result['data'] : $review_set_result;
    49524254                        $taxonomy_terms['taxonomy_tag_review_set'] = is_array( $review_set_data ) && 'taxonomy_tag_review_set' === (string) ( $review_set_data['artifact_type'] ?? '' )
    49534255                                ? $review_set_data
    4954                                 : $this->editor_taxonomy_review_set_from_suggestions(
     4256                                : Rest_Editor_Taxonomy_Shaping::editor_taxonomy_review_set_from_suggestions(
    49554257                                        $taxonomy_terms,
    49564258                                        new WP_Error(
    … …  
    50354337
    50364338                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                 );
    51214339        }
    51224340
    … …  
    51984416                        'occurrence_index' => absint( $item['occurrence_index'] ?? 0 ),
    51994417                        '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 ) ),
    52044422                        'decorative'       => ! empty( $item['decorative'] ),
    52054423                        'target_scope'     => 'post_block_alt',
  • npcink-workflow-toolbox/trunk/includes/Settings.php

    r3730454 r3734482  
    1111
    1212final 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        );
    1321        public function register(): void {
    1422                register_setting(
    … …  
    895903                return '' !== trim( (string) $value );
    896904        }
     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        }
    897978}
  • npcink-workflow-toolbox/trunk/languages/npcink-workflow-toolbox-zh_CN-npcink-toolbox-admin.json

    r3733201 r3734482  
    909909      "Check that Npcink Cloud is connected, then start the optimization again. Already completed images in this batch are kept.": ["请确认 Npcink Cloud 已连接,然后重新开始优化。本批次中已完成的图片会保留。"],
    910910      "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 天"]
    912921    }
    913922  }
  • npcink-workflow-toolbox/trunk/npcink-workflow-toolbox.php

    r3733528 r3734482  
    33 * Plugin Name: Npcink Workflow Toolbox
    44 * Description: Fixed AI workflow buttons for WordPress operators, with review-only suggestions and governed handoff plans.
    5  * Version: 0.5.2
     5 * Version: 0.5.3
    66 * Requires at least: 6.9
    77 * Requires PHP: 8.0
    … …  
    6363require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Surface_Bridges.php';
    6464require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Local_Admin_Consent.php';
     65require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Flow_Cache.php';
     66require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Paragraph_Check.php';
     67require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Audio_Text.php';
     68require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Taxonomy_Shaping.php';
    6569require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Editor_Content_Support.php';
    6670require_once NPCINK_TOOLBOX_DIR . 'includes/Rest_Controller.php';
  • npcink-workflow-toolbox/trunk/readme.txt

    r3733528 r3734482  
    55Requires PHP: 8.0
    66Tested up to: 7.1
    7 Stable tag: 0.5.2
     7Stable tag: 0.5.3
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    230230== Changelog ==
    231231
     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
    232247= 0.5.2 =
    233248
  • npcink-workflow-toolbox/trunk/uninstall.php

    r3723781 r3734482  
    1616                        'npcink_toolbox_media_optimization_settings',
    1717                        'npcink_toolbox_watermark_templates',
     18                        'npcink_toolbox_review_tally',
    1819                        'npcink_toolbox_zhihu_hot_topic_pool_backup_v1',
    1920                        'npcink_toolbox_media_optimization_batches',
Note: See TracChangeset for help on using the changeset viewer.