diff --git a/docs/experiments/refine-notes.md b/docs/experiments/refine-notes.md new file mode 100644 index 00000000..b5caf0ee --- /dev/null +++ b/docs/experiments/refine-notes.md @@ -0,0 +1,177 @@ +# AI Refine from Notes + +## Summary + +The AI Refine from Notes experiment enables users to automatically apply pending editorial feedback/notes to their WordPress post content using AI. Clicking "Refine from Notes" in the post sidebar triggers the AI to contextually examine all blocks with pending Notes and modify the text according to the provided suggestions. + +## Overview + +### For End Users + +When enabled, a "Refine from Notes" button appears in the post status info panel (the sidebar area below the post status) assuming there is at least one Note pending on any block in the post. Clicking it triggers the refinement process: + +1. The button label updates to show progression across blocks (`Refining block (2 of 4)…`) +2. Each block that has a pending Note attached is sent to the AI alongside the Note's content. +3. The AI precisely updates the block content resolving the note feedback. +4. An autosave checkpoint is established. +5. After completion, a success snackbar with a "Review in Revisions" action lets the user review the diff safely and rollback if necessary. + +**Key Features:** + +- Precise block-level content refinement guided strictly by user/editorial feedback comments. +- Asynchronous batched block processing for improved performance and reliability. +- Robust state rollback using native WordPress Revisions viewer so users can diff the changes smoothly. + +### For Developers + +The experiment consists of: + +1. **Experiment Class** (`WordPress\AI\Experiments\Refine_Notes\Refine_Notes`): Registers the ability and enqueues the block editor asset. +2. **Ability Class** (`WordPress\AI\Abilities\Refine_Notes\Refine_Notes`): Receives a single block's content, surrounding context, and associated notes, parsing the resulting AI output back into plain string replacements. +3. **React Plugin** (`src/experiments/refine-notes/`): Drives the sidebar UI, discovers threaded Notes via WordPress data stores, processes block attributes iteratively, and manages Editor saving workflows. + +## Architecture & Implementation + +### Key Hooks & Entry Points + +`WordPress\AI\Experiments\Refine_Notes\Refine_Notes::register()` wires everything once the experiment is enabled: + +- `wp_abilities_api_init` → registers the `ai/refine-notes` ability +- `enqueue_block_editor_assets` → enqueues the React bundle whenever the block editor loads + +### Assets & Data Flow + +1. **PHP Side:** + + - `enqueue_assets()` loads `experiments/refine-notes` and localizes `window.RefineNotesData`: + - `enabled`: Whether the experiment is currently enabled + +2. **React Side:** + + - `index.tsx` registers the `ai-refine-notes` plugin. + - `RefineNotesPlugin.tsx` conditionally renders the button inside `PluginPostStatusInfo`. + - `useRefineNotes.ts` hook manages all state and orchestration: + - Flattens the active block tree. + - Fetches active pending Notes via `GET /wp/v2/comments?type=note&status=hold&post=&per_page=100`. + - Maps notes and child threaded-replies directly to their parent `blockClientId`. + - Skips any blocks that do not have active pending notes attached. + - Processes qualifying blocks in parallel batches of 4. + - Dispatches an `updateBlockAttributes` directly to the `core/block-editor` store with the returned refactored content. + - Triggers `wp.data.dispatch( 'core/editor' ).autosave()` to freeze the revision. + +3. **Ability Execution:** + - Receives target block type, current content, note texts, and optionally surrounding text context. + - Builds a standard prompt matching against the system instruction. + - Extracts plain string response from the AI and returns the direct replacement to the block content. + +### Block Types Supported + +Can safely run against any block. Output targets formatting of standard block markup (e.g. inner wrappers). + +### Input Schema + +```php +array( + 'block_type' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The block type, e.g. core/paragraph, core/heading.', + ), + 'block_content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The content of the block to refine.', + ), + 'notes' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => 'The editorial feedback notes to apply to the block.', + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Optional surrounding content for context.', + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => 'ID of the post being modified.', + ), +) +``` + +### Output Schema + +```php +array( + 'type' => 'string', + 'description' => 'The updated block content after applying feedback.', +) +``` + +### Permissions + +The ability's `permission_callback` operates via two paths: + +- **With a numeric `post_id` (post ID):** Validates that the post exists, the current user has `edit_post` capability for that specific post, and the post type is registered with `show_in_rest => true`. Returns `false` if the post type is not REST-accessible. +- **Without a post ID:** Requires `current_user_can( 'edit_posts' )`. + +In both cases, users without the required capability receive an `insufficient_capabilities` WP_Error. + +## Using the Ability via REST API + +### Endpoint + +```text +POST /wp-json/wp-abilities/v1/abilities/ai/refine-notes/run +``` + +### Authentication + +See [TESTING_REST_API.md](../TESTING_REST_API.md) for authentication details (application passwords or cookie + nonce). + +### Request Examples + +#### Refine a Paragraph Block + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/refine-notes/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "block_type": "core/paragraph", + "block_content": "We shuld try an fix up this stuff.", + "notes": ["Fix typos and grammar."], + "post_id": 42 + } + }' +``` + +**Response:** + +```json +"We should try and fix up this stuff." +``` + +### Error Responses + +| Code | Meaning | +| --------------------------- | -------------------------------------------------------------- | +| `block_content_required` | `block_content` was empty | +| `notes_required` | No valid notes were supplied in the array | +| `post_not_found` | The post ID passed does not exist | +| `insufficient_capabilities` | User lacks `edit_posts` (or `edit_post` for the specific post) | + +## Related Files + +- **Experiment:** `includes/Experiments/Refine_Notes/Refine_Notes.php` +- **Ability:** `includes/Abilities/Refine_Notes/Refine_Notes.php` +- **System Instruction:** `includes/Abilities/Refine_Notes/system-instruction.php` +- **React Entry:** `src/experiments/refine-notes/index.tsx` +- **React Plugin Component:** `src/experiments/refine-notes/components/RefineNotesPlugin.tsx` +- **React Hook:** `src/experiments/refine-notes/hooks/useRefineNotes.ts` +- **PHPUnit Tests (Ability):** `tests/Integration/Includes/Abilities/Refine_NotesTest.php` +- **PHPUnit Tests (Experiment):** `tests/Integration/Includes/Experiments/Refine_Notes/Refine_NotesTest.php` +- **E2E Tests:** `tests/e2e/specs/experiments/refine-notes.spec.js` +- **Mock Fixtures:** `tests/e2e-request-mocking/responses/OpenAI/refine-notes-completions.json` and `tests/e2e-request-mocking/responses/OpenAI/refine-notes-responses.json` diff --git a/includes/Abilities/Refine_Notes/Refine_Notes.php b/includes/Abilities/Refine_Notes/Refine_Notes.php new file mode 100644 index 00000000..54a01228 --- /dev/null +++ b/includes/Abilities/Refine_Notes/Refine_Notes.php @@ -0,0 +1,267 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'block_type' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The block type, e.g. core/paragraph, core/heading.', 'ai' ), + ), + 'block_content' => array( + 'type' => 'string', + 'sanitize_callback' => 'wp_kses_post', + 'description' => esc_html__( 'The content of the block to refine.', 'ai' ), + ), + 'notes' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'description' => esc_html__( 'The feedback Notes to apply to the block.', 'ai' ), + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Optional surrounding content for context.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'ID of the post being modified.', 'ai' ), + ), + ), + 'required' => array( 'block_type', 'block_content', 'notes' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'string', + 'description' => esc_html__( 'The updated block content after applying feedback.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @param mixed $input The input arguments to the ability. + * @return string|\WP_Error + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'block_type' => '', + 'block_content' => '', + 'notes' => array(), + 'context' => '', + 'post_id' => null, + ) + ); + + if ( empty( $args['block_content'] ) ) { + return new WP_Error( + 'block_content_required', + esc_html__( 'Block content is required to perform refinement.', 'ai' ) + ); + } + + /** @var list $notes */ + $notes = array_values( + array_filter( + is_array( $args['notes'] ) ? $args['notes'] : array(), + 'is_string' + ) + ); + + if ( empty( $notes ) ) { + return new WP_Error( + 'notes_required', + esc_html__( 'At least one note is required to perform refinement.', 'ai' ) + ); + } + + $result = $this->generate_refinement( $args['block_type'], $args['block_content'], $notes, $args['context'] ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return $result; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @param mixed $input The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $input ) { + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : null; + + if ( $post_id ) { + $post = get_post( $post_id ); + + // Ensure the post exists. + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $post_id ) ) + ); + } + + // Ensure the user has permission to edit this particular post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to run AI refinements on this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + // Ensure the user has permission to edit posts in general. + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to run AI refinements.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates refined content for a single block based on notes. + * + * @since x.x.x + * + * @param string $block_type The block type identifier. + * @param string $block_content The plain-text block content. + * @param list $notes Editorial feedback notes to apply. + * @param string $context Optional context to improve refinement relevance. + * @return string|\WP_Error Refined text or WP_Error. + */ + protected function generate_refinement( + string $block_type, + string $block_content, + array $notes, + string $context + ) { + $prompt = $this->create_prompt( $block_type, $block_content, $notes, $context ); + + $raw = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + + if ( is_wp_error( $raw ) ) { + return $raw; + } + + if ( empty( $raw ) ) { + return $block_content; + } + + return (string) $raw; + } + + /** + * Creates the prompt for the refinement. + * + * @since x.x.x + * + * @param string $block_type The block type identifier. + * @param string $block_content The plain-text block content. + * @param list $notes Feedback notes. + * @param string $context Optional context. + * @return string The generated prompt. + */ + private function create_prompt( string $block_type, string $block_content, array $notes, string $context ): string { + $prompt_parts = array(); + + $prompt_parts[] = '' . sanitize_text_field( $block_type ) . ''; + $prompt_parts[] = '' . wp_kses_post( $block_content ) . ''; + + if ( ! empty( $notes ) ) { + $prompt_parts[] = '' . implode( "\n\n", array_map( 'sanitize_text_field', $notes ) ) . ''; + } + + if ( $context ) { + $prompt_parts[] = '' . normalize_content( $context ) . ''; + } + + return implode( "\n", $prompt_parts ); + } +} diff --git a/includes/Abilities/Refine_Notes/system-instruction.php b/includes/Abilities/Refine_Notes/system-instruction.php new file mode 100644 index 00000000..3ebc8e62 --- /dev/null +++ b/includes/Abilities/Refine_Notes/system-instruction.php @@ -0,0 +1,30 @@ + tags. +The type of block is provided in tags. +The editorial feedback is provided in tags. +Surrounding context may be provided in tags to help you understand the block's role in the full article. + +Your goal is to read the notes and carefully apply the requested changes to the block content. + +## Rules: +- Only apply changes directly requested in the notes. Do not rewrite or optimize other parts of the text unless specified. +- Return ONLY the updated block content. Do not include any explanations, pleasantries, or markdown formatting around the output. +- If the block type is structured (like a table, pullquote, or list), maintain the appropriate formatting within the content. +- Do not output the block wrapper comments (like ). You are only returning the inner content. +- Be concise and precise in applying the feedback. +INSTRUCTION; diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 27c8058f..6cf41874 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -32,6 +32,7 @@ final class Experiments { \WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Review_Notes\Review_Notes::class, + \WordPress\AI\Experiments\Refine_Notes\Refine_Notes::class, \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, ); diff --git a/includes/Experiments/Refine_Notes/Refine_Notes.php b/includes/Experiments/Refine_Notes/Refine_Notes.php new file mode 100644 index 00000000..6b652e30 --- /dev/null +++ b/includes/Experiments/Refine_Notes/Refine_Notes.php @@ -0,0 +1,101 @@ + __( 'Refine from Notes', 'ai' ), + 'description' => __( 'Analyze feedback that has been left via Notes and apply edits where needed.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers any needed abilities. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Refine_Notes_Ability::class, + ), + ); + } + + /** + * Enqueues and localizes the block editor script. + * + * @since x.x.x + */ + public function enqueue_assets(): void { + Asset_Loader::enqueue_script( 'refine_notes', 'experiments/refine-notes' ); + + $post_type = get_post_type(); + $post_type_object = $post_type ? get_post_type_object( $post_type ) : null; + $rest_base = $post_type_object && $post_type_object->rest_base + ? $post_type_object->rest_base + : null; + + Asset_Loader::localize_script( + 'refine_notes', + 'RefineNotesData', + array( + 'enabled' => $this->is_enabled(), + 'rest_base' => $rest_base, + ) + ); + } +} diff --git a/src/experiments/refine-notes/components/RefineNotesPlugin.tsx b/src/experiments/refine-notes/components/RefineNotesPlugin.tsx new file mode 100644 index 00000000..6726fb15 --- /dev/null +++ b/src/experiments/refine-notes/components/RefineNotesPlugin.tsx @@ -0,0 +1,76 @@ +/** + * AI Refine Notes plugin component. + */ + +/** + * WordPress dependencies + */ +import { Button, Flex, FlexItem } from '@wordpress/components'; +import { PluginPostStatusInfo } from '@wordpress/editor'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useRefineNotes } from '../hooks/useRefineNotes'; + +/** + * RefineNotesPlugin component. + * + * Renders a "Refine from Notes" button in the post status info panel + * when unresolved Notes exist. + */ +export default function RefineNotesPlugin() { + const { isRefining, progress, total, hasPendingNotes, runRefinement } = + useRefineNotes(); + + if ( ! hasPendingNotes && ! isRefining ) { + return null; + } + + const buttonLabel = isRefining + ? sprintf( + /* translators: 1: Current block number, 2: Total number of blocks. */ + __( 'Refining block (%1$s of %2$s)…', 'ai' ), + progress, + total + ) + : __( 'Refine from Notes', 'ai' ); + + const buttonDescription = __( + 'Automatically updates blocks using unresolved feedback Notes.', + 'ai' + ); + + return ( + + + + + + + + { buttonDescription } + + + + + ); +} diff --git a/src/experiments/refine-notes/hooks/useRefineNotes.ts b/src/experiments/refine-notes/hooks/useRefineNotes.ts new file mode 100644 index 00000000..445c2323 --- /dev/null +++ b/src/experiments/refine-notes/hooks/useRefineNotes.ts @@ -0,0 +1,388 @@ +/** + * Custom hook for AI Refine from Notes functionality. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { dispatch, select, useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { + flattenBlocks, + getBlockText, + replaceBlockWithPlaceholder, +} from '../../../utils/blocks'; +import { + REVIEWABLE_BLOCK_TYPES, + fetchAllNotesByStatus, + buildContextWindow, +} from '../../../utils/notes'; +import { runAbility } from '../../../utils/run-ability'; + +const BLOCK_PLACEHOLDER = '[[BLOCK_GOES_HERE]]'; + +interface BlockAttributes { + content?: string; + value?: string; + alt?: string; + caption?: string; + metadata?: { + noteId?: number; + [ key: string ]: unknown; + }; + [ key: string ]: unknown; +} + +interface Block { + clientId: string; + name: string; + attributes: BlockAttributes; + innerBlocks: Block[]; +} + +/** + * Updates a Note status to approved. + * + * @param noteId Note ID. + */ +async function resolveNote( noteId: number ): Promise< void > { + return apiFetch< void >( { + path: `/wp/v2/comments/${ noteId }`, + method: 'PUT', + data: { status: 'approve' }, + } ); +} + +/** + * Hook for refining blocks based on existing notes with AI. + * + * @return {Object} Object with refining state and functions. + * @property {boolean} isRefining Whether a refine operation is in progress. + * @property {number} progress The number of blocks processed so far. + * @property {number} total The total number of blocks to process. + * @property {boolean} hasPendingNotes Whether there are pending notes to process. + * @property {Function} runRefinement Function to trigger the refinement process. + */ +export function useRefineNotes(): { + isRefining: boolean; + progress: number; + total: number; + hasPendingNotes: boolean; + runRefinement: () => Promise< void >; +} { + const [ isRefining, setIsRefining ] = useState< boolean >( false ); + const [ progress, setProgress ] = useState< number >( 0 ); + const [ total, setTotal ] = useState< number >( 0 ); + + const postId = useSelect( + ( sel ) => ( sel( editorStore ) as any ).getCurrentPostId() as number, + [] + ); + + // Reactively derived from the coreStore so the button appears/disappears + // automatically whenever Review Notes creates notes (via saveEntityRecord + + // invalidateResolutionForStoreSelector) or Refine Notes resolves them. + const hasPendingNotes = useSelect( + ( sel ) => { + if ( ! postId ) { + return false; + } + const notes = ( sel( coreStore ) as any ).getEntityRecords( + 'root', + 'comment', + { + type: 'note', + status: 'hold', + post: postId, + per_page: 1, + _fields: 'id', + } + ) as Array< { id: number } > | null; + // null means the fetch is still in flight; treat as false until resolved. + return notes !== null && notes.length > 0; + }, + [ postId ] + ); + + const runRefinement = async () => { + setIsRefining( true ); + setProgress( 0 ); + setTotal( 0 ); + + ( dispatch( noticesStore ) as any ).removeNotice( + 'ai_refine_notes_error' + ); + + try { + const content = ( + select( editorStore ) as any + ).getEditedPostContent() as string; + + // Get all blocks and flatten the tree. + const allBlocks = ( + select( blockEditorStore ) as any + ).getBlocks() as Block[]; + const flatBlocks = flattenBlocks( allBlocks ); + + // Fetch pending Notes for this post. + const pendingNotes = await fetchAllNotesByStatus( postId, 'hold' ); + + if ( pendingNotes.length === 0 ) { + ( dispatch( noticesStore ) as any ).createNotice( + 'info', + __( 'No pending Notes found to refine.', 'ai' ), + { type: 'snackbar' } + ); + return; + } + + // Build a lookup: noteId -> Note content text + const noteContentById = new Map< number, string >(); + for ( const note of pendingNotes ) { + noteContentById.set( note.id, note.content?.rendered ?? '' ); + } + + // Find which blocks have matching notes + const refineableBlocks = flatBlocks.filter( ( block ) => { + if ( ! REVIEWABLE_BLOCK_TYPES.includes( block.name ) ) { + return false; + } + const existingNoteId = + block.attributes.metadata?.noteId ?? null; + if ( + ! existingNoteId || + ! noteContentById.has( existingNoteId ) + ) { + return false; + } + const blockText = getBlockText( block ); + return blockText.length > 0; + } ); + + if ( refineableBlocks.length === 0 ) { + ( dispatch( noticesStore ) as any ).createNotice( + 'info', + __( 'No blocks found matching the existing Notes.', 'ai' ), + { type: 'snackbar' } + ); + return; + } + + setTotal( refineableBlocks.length ); + + let refinedBlocksCount = 0; + let processedBlocksCount = 0; + const notesToResolve: number[] = []; + + // Process in batches of 4 (similar to Review Notes) + const BATCH_SIZE = 4; + for ( + let batchStart = 0; + batchStart < refineableBlocks.length; + batchStart += BATCH_SIZE + ) { + const batch = refineableBlocks.slice( + batchStart, + batchStart + BATCH_SIZE + ); + + await Promise.all( + batch.map( async ( block ) => { + const existingNoteId = block.attributes.metadata + ?.noteId as number; + + const blockText = getBlockText( block ); + + // Collect notes logic + const existingNoteTexts: string[] = []; + const rootText = noteContentById.get( existingNoteId ); + if ( rootText ) { + existingNoteTexts.push( rootText ); + } + + for ( const note of pendingNotes ) { + if ( note.parent === existingNoteId ) { + const replyText = noteContentById.get( + note.id + ); + if ( replyText ) { + existingNoteTexts.push( replyText ); + } + } + } + + // Replace the block with the placeholder. + const contentWithPlaceholder = + replaceBlockWithPlaceholder( + content, + block.clientId, + BLOCK_PLACEHOLDER + ); + + const contextWindow = buildContextWindow( + contentWithPlaceholder, + BLOCK_PLACEHOLDER + ); + const refinementContext = `What follows is surrounding article content, where the block being refined has been replaced with the placeholder ${ BLOCK_PLACEHOLDER }. Use the nearby text to better understand the context of the block within the article. CONTENT: \n\n${ contextWindow }`; + + // Execute refinement + try { + const refinedContent = await runAbility< string >( + 'ai/refine-notes', + { + block_type: block.name, + block_content: blockText, + context: refinementContext, + post_id: postId, + notes: existingNoteTexts, + } + ); + + if ( + refinedContent && + refinedContent !== blockText + ) { + // For heading and paragraph it's content, image is alt + const attributeToUpdate = + block.name === 'core/image' + ? 'alt' + : 'content'; + + ( + dispatch( blockEditorStore ) as any + ).updateBlockAttributes( block.clientId, { + [ attributeToUpdate ]: refinedContent, + } ); + + refinedBlocksCount++; + + // Add notes for resolution + notesToResolve.push( existingNoteId ); + for ( const note of pendingNotes ) { + if ( note.parent === existingNoteId ) { + notesToResolve.push( note.id ); + } + } + } + } catch ( e ) { + // eslint-disable-next-line no-console + console.warn( + `[AI Refine Notes] Failed to refine block ${ block.clientId }`, + e + ); + throw e; + } finally { + processedBlocksCount++; + setProgress( processedBlocksCount ); + } + } ) + ); + } + + // Resolve applied notes + if ( notesToResolve.length > 0 ) { + await Promise.all( + notesToResolve.map( ( id ) => + resolveNote( id ).catch( () => null ) + ) + ); + + ( + dispatch( coreStore ) as any + ).invalidateResolutionForStoreSelector( 'getEntityRecords' ); + } + + if ( refinedBlocksCount > 0 ) { + // We trigger autosave to ensure the DB state has the refinements + // as a distinct revision boundary. + await ( dispatch( editorStore ) as any ).autosave(); + + // Fetch the latest revision ID directly from the REST API. + // The autosave endpoint only updates the autosave record (not the main + // post entity), so the editor store's revision data is stale after autosave. + const { aiRefineNotesData } = window as any; + const restBase = aiRefineNotesData?.rest_base as + | string + | undefined; + + let lastRevisionId: number | null = null; + try { + const revisions = await apiFetch< Array< { id: number } > >( + { + path: `/wp/v2/${ restBase }/${ postId }/revisions?per_page=1`, + method: 'GET', + } + ); + lastRevisionId = revisions[ 0 ]?.id ?? null; + } catch ( e ) { + lastRevisionId = ( + select( editorStore ) as any + ).getCurrentPostLastRevisionId() as number | null; + } + + const noticeActions = lastRevisionId + ? [ + { + label: __( 'Review in Revisions', 'ai' ), + url: `/wp-admin/revision.php?revision=${ lastRevisionId }`, + }, + ] + : []; + + ( dispatch( noticesStore ) as any ).createSuccessNotice( + sprintf( + /* translators: %d: number of blocks refined. */ + _n( + '%d block refined with AI.', + '%d blocks refined with AI.', + refinedBlocksCount, + 'ai' + ), + refinedBlocksCount + ), + { + type: 'snackbar', + actions: noticeActions, + } + ); + } else { + ( dispatch( noticesStore ) as any ).createNotice( + 'info', + __( + 'No content changes were needed based on the existing Notes.', + 'ai' + ), + { type: 'snackbar' } + ); + } + } catch ( error: any ) { + ( dispatch( noticesStore ) as any ).createErrorNotice( + error?.message ?? String( error ), + { + id: 'ai_refine_notes_error', + isDismissible: true, + } + ); + } finally { + setIsRefining( false ); + } + }; + + return { + isRefining, + progress, + total, + hasPendingNotes, + runRefinement, + }; +} diff --git a/src/experiments/refine-notes/index.tsx b/src/experiments/refine-notes/index.tsx new file mode 100644 index 00000000..8d0cde05 --- /dev/null +++ b/src/experiments/refine-notes/index.tsx @@ -0,0 +1,19 @@ +/** + * AI Refine Notes Experiment. + */ + +/** + * WordPress dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import RefineNotesPlugin from './components/RefineNotesPlugin'; + +if ( ( window as any ).aiRefineNotesData?.enabled ) { + registerPlugin( 'ai-refine-notes', { + render: RefineNotesPlugin, + } ); +} diff --git a/src/experiments/review-notes/components/ReviewNotesPlugin.tsx b/src/experiments/review-notes/components/ReviewNotesPlugin.tsx index 9ee320c7..2d45dc33 100644 --- a/src/experiments/review-notes/components/ReviewNotesPlugin.tsx +++ b/src/experiments/review-notes/components/ReviewNotesPlugin.tsx @@ -23,11 +23,8 @@ import { commentContent } from '@wordpress/icons'; /** * Internal dependencies */ -import { - REVIEWABLE_BLOCK_TYPES, - useReviewBlock, - useReviewNotes, -} from '../hooks/useReviewNotes'; +import { REVIEWABLE_BLOCK_TYPES } from '../../../utils/notes'; +import { useReviewBlock, useReviewNotes } from '../hooks/useReviewNotes'; /** * ReviewNotesPlugin component. diff --git a/src/experiments/review-notes/hooks/useReviewNotes.ts b/src/experiments/review-notes/hooks/useReviewNotes.ts index 1ccb9650..99ca4477 100644 --- a/src/experiments/review-notes/hooks/useReviewNotes.ts +++ b/src/experiments/review-notes/hooks/useReviewNotes.ts @@ -5,7 +5,6 @@ /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; import { dispatch, select } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; @@ -23,26 +22,17 @@ import { getBlockText, replaceBlockWithPlaceholder, } from '../../../utils/blocks'; +import type { ExistingNote } from '../../../utils/notes'; +import { + REVIEWABLE_BLOCK_TYPES, + fetchAllNotesByStatus, + buildContextWindow, +} from '../../../utils/notes'; import { runAbility } from '../../../utils/run-ability'; -export const REVIEWABLE_BLOCK_TYPES = [ - 'core/paragraph', - 'core/heading', - 'core/list-item', - 'core/verse', - 'core/image', - 'core/table', - 'core/preformatted', - 'core/pullquote', -]; - const BLOCK_PLACEHOLDER = '[[BLOCK_GOES_HERE]]'; const BATCH_SIZE = 4; -const NOTES_PAGE_SIZE = 100; -const CONTEXT_WINDOW_SIZE = 2000; -const TRUNCATED_BEFORE_MARKER = '[TRUNCATED BEFORE]'; -const TRUNCATED_AFTER_MARKER = '[TRUNCATED AFTER]'; const NOTES_SIDEBAR_ID = 'edit-post/collab-sidebar'; interface BlockAttributes { @@ -78,15 +68,6 @@ interface NoteRecord { [ key: string ]: unknown; } -interface ExistingNote { - id: number; - parent: number; - content: { rendered: string }; - [ key: string ]: unknown; -} - -type NoteStatus = 'hold' | 'approve'; - /** * Reviews a single block and creates/updates a Note if suggestions are found. * @@ -398,94 +379,6 @@ export function useReviewBlock(): { return { isReviewing, reviewBlock }; } -/** - * Fetches all Notes by status for a given post. - * - * @param postId The ID of the post to fetch Notes for. - * @param status The status of the Notes to fetch. - * @return An array of Notes. - */ -async function fetchAllNotesByStatus( - postId: number, - status: NoteStatus -): Promise< ExistingNote[] > { - const notes: ExistingNote[] = []; - let page = 1; - - while ( true ) { - try { - const pageNotes = await apiFetch< ExistingNote[] >( { - path: `/wp/v2/comments?type=note&status=${ status }&post=${ postId }&per_page=${ NOTES_PAGE_SIZE }&page=${ page }`, - method: 'GET', - } ); - - notes.push( ...pageNotes ); - - if ( pageNotes.length < NOTES_PAGE_SIZE ) { - return notes; - } - - page += 1; - } catch ( error ) { - // eslint-disable-next-line no-console - console.warn( - `[AI Review Notes] Failed to fetch ${ status } Notes page ${ page }:`, - error - ); - return notes; - } - } -} - -/** - * Returns a bounded context window around a placeholder token. - * - * @param content The full content with placeholder. - * @param placeholder The placeholder token. - * @return A truncated content window centered around the placeholder. - */ -function buildContextWindow( content: string, placeholder: string ): string { - const placeholderIndex = content.indexOf( placeholder ); - - if ( - placeholderIndex === -1 || - content.length <= CONTEXT_WINDOW_SIZE * 2 - ) { - return content; - } - - const roughStart = Math.max( 0, placeholderIndex - CONTEXT_WINDOW_SIZE ); - const roughEnd = Math.min( - content.length, - placeholderIndex + placeholder.length + CONTEXT_WINDOW_SIZE - ); - - const isBoundaryChar = ( char: string ) => /\s/.test( char ); - - // Move inward to the nearest word boundary so we don't cut mid-word. - let start = roughStart; - if ( start > 0 && ! isBoundaryChar( content.charAt( start - 1 ) ) ) { - while ( - start < roughEnd && - ! isBoundaryChar( content.charAt( start ) ) - ) { - start += 1; - } - } - - let end = roughEnd; - if ( end < content.length && ! isBoundaryChar( content.charAt( end ) ) ) { - while ( end > start && ! isBoundaryChar( content.charAt( end - 1 ) ) ) { - end -= 1; - } - } - - const prefix = start > 0 ? `${ TRUNCATED_BEFORE_MARKER }\n` : ''; - const suffix = end < content.length ? `\n${ TRUNCATED_AFTER_MARKER }` : ''; - - return `${ prefix }${ content.slice( start, end ) }${ suffix }`; -} - /** * Creates a Note (or appends a reply) for a reviewed block. * diff --git a/src/utils/notes.ts b/src/utils/notes.ts new file mode 100644 index 00000000..86488adf --- /dev/null +++ b/src/utils/notes.ts @@ -0,0 +1,130 @@ +/** + * Shared utilities for Notes-related experiments (Review Notes, Refine Notes). + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Block types that can be reviewed and refined. + */ +export const REVIEWABLE_BLOCK_TYPES = [ + 'core/paragraph', + 'core/heading', + 'core/list-item', + 'core/verse', + 'core/image', + 'core/table', + 'core/preformatted', + 'core/pullquote', +]; + +/** Number of Notes to fetch per page when paginating. */ +export const NOTES_PAGE_SIZE = 100; +const CONTEXT_WINDOW_SIZE = 2000; +const TRUNCATED_BEFORE_MARKER = '[TRUNCATED BEFORE]'; +const TRUNCATED_AFTER_MARKER = '[TRUNCATED AFTER]'; + +/** A WordPress comment of type "note" as returned by the REST API. */ +export interface ExistingNote { + id: number; + parent: number; + content: { rendered: string }; + [ key: string ]: unknown; +} + +/** WordPress comment status used when querying Notes. */ +export type NoteStatus = 'hold' | 'approve'; + +/** + * Fetches all Notes by status for a given post. + * + * @param postId The ID of the post to fetch Notes for. + * @param status The status of the Notes to fetch. + * @return An array of Notes. + */ +export async function fetchAllNotesByStatus( + postId: number, + status: NoteStatus +): Promise< ExistingNote[] > { + const notes: ExistingNote[] = []; + let page = 1; + + while ( true ) { + try { + const pageNotes = await apiFetch< ExistingNote[] >( { + path: `/wp/v2/comments?type=note&status=${ status }&post=${ postId }&per_page=${ NOTES_PAGE_SIZE }&page=${ page }`, + method: 'GET', + } ); + + notes.push( ...pageNotes ); + + if ( pageNotes.length < NOTES_PAGE_SIZE ) { + return notes; + } + + page += 1; + } catch ( error ) { + // eslint-disable-next-line no-console + console.warn( + `[AI Notes] Failed to fetch ${ status } Notes page ${ page }:`, + error + ); + return notes; + } + } +} + +/** + * Returns a bounded context window around a placeholder token. + * + * @param content The full content with placeholder. + * @param placeholder The placeholder token. + * @return A truncated content window centered around the placeholder. + */ +export function buildContextWindow( + content: string, + placeholder: string +): string { + const placeholderIndex = content.indexOf( placeholder ); + + if ( + placeholderIndex === -1 || + content.length <= CONTEXT_WINDOW_SIZE * 2 + ) { + return content; + } + + const roughStart = Math.max( 0, placeholderIndex - CONTEXT_WINDOW_SIZE ); + const roughEnd = Math.min( + content.length, + placeholderIndex + placeholder.length + CONTEXT_WINDOW_SIZE + ); + + const isBoundaryChar = ( char: string ) => /\s/.test( char ); + + // Move inward to the nearest word boundary so we don't cut mid-word. + let start = roughStart; + if ( start > 0 && ! isBoundaryChar( content.charAt( start - 1 ) ) ) { + while ( + start < roughEnd && + ! isBoundaryChar( content.charAt( start ) ) + ) { + start += 1; + } + } + + let end = roughEnd; + if ( end < content.length && ! isBoundaryChar( content.charAt( end ) ) ) { + while ( end > start && ! isBoundaryChar( content.charAt( end - 1 ) ) ) { + end -= 1; + } + } + + const prefix = start > 0 ? `${ TRUNCATED_BEFORE_MARKER }\n` : ''; + const suffix = end < content.length ? `\n${ TRUNCATED_AFTER_MARKER }` : ''; + + return `${ prefix }${ content.slice( start, end ) }${ suffix }`; +} diff --git a/tests/Integration/Includes/Abilities/Refine_NotesTest.php b/tests/Integration/Includes/Abilities/Refine_NotesTest.php new file mode 100644 index 00000000..4e75e8b7 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Refine_NotesTest.php @@ -0,0 +1,661 @@ + 'Refine from Notes', + 'description' => 'Refines block content based on editorial notes.', + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Refine_Notes Ability test case. + * + * @since x.x.x + * + * @group abilities + * @group refine-notes + */ +class Refine_NotesTest extends WP_UnitTestCase { + + /** + * Refine_Notes Ability instance. + * + * @since x.x.x + * + * @var Refine_Notes + */ + private $ability; + + /** + * Test experiment instance. + * + * @since x.x.x + * + * @var Test_Refine_Notes_Experiment + */ + private $experiment; + + /** + * Sets up the test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Refine_Notes_Experiment(); + $this->ability = new Refine_Notes( + 'ai/refine-notes', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tears down the test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // input_schema() + // ------------------------------------------------------------------------- + + /** + * Tests that input_schema() returns the expected structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'block_type', $schema['properties'], 'Schema should have block_type property' ); + $this->assertArrayHasKey( 'block_content', $schema['properties'], 'Schema should have block_content property' ); + $this->assertArrayHasKey( 'notes', $schema['properties'], 'Schema should have notes property' ); + $this->assertArrayHasKey( 'context', $schema['properties'], 'Schema should have context property' ); + $this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' ); + } + + /** + * Tests that input_schema() marks the expected fields as required. + * + * @since x.x.x + */ + public function test_input_schema_marks_required_fields() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertArrayHasKey( 'required', $schema, 'Schema should define required keys' ); + $this->assertContains( 'block_type', $schema['required'], 'block_type should be required' ); + $this->assertContains( 'block_content', $schema['required'], 'block_content should be required' ); + $this->assertContains( 'notes', $schema['required'], 'notes should be required' ); + } + + // ------------------------------------------------------------------------- + // output_schema() + // ------------------------------------------------------------------------- + + /** + * Tests that output_schema() returns the expected structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'string', $schema['type'], 'Schema type should be string (refined text)' ); + $this->assertArrayHasKey( 'description', $schema, 'Schema should have a description' ); + } + + // ------------------------------------------------------------------------- + // meta() + // ------------------------------------------------------------------------- + + /** + * Tests that meta() includes show_in_rest. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } + + // ------------------------------------------------------------------------- + // get_system_instruction() + // ------------------------------------------------------------------------- + + /** + * Tests that get_system_instruction() returns a non-empty string. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_string() { + $instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $instruction, 'System instruction should not be empty' ); + } + + // ------------------------------------------------------------------------- + // permission_callback() — no post_id path + // ------------------------------------------------------------------------- + + /** + * Tests that permission_callback() returns true for users with edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for editor role' ); + } + + /** + * Tests that permission_callback() returns WP_Error for users without edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error for subscriber' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Tests that permission_callback() returns WP_Error for logged-out users. + * + * @since x.x.x + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error for logged-out user' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code() ); + } + + // ------------------------------------------------------------------------- + // permission_callback() — numeric context (post_id) path + // ------------------------------------------------------------------------- + + /** + * Tests that permission_callback() returns true for an editor with a valid post. + * + * @since x.x.x + */ + public function test_permission_callback_returns_true_for_editor_with_valid_post() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( $result, 'Permission should be granted for editor with a valid post' ); + } + + /** + * Tests that permission_callback() returns WP_Error when the context post does not exist. + * + * @since x.x.x + */ + public function test_permission_callback_returns_error_when_context_post_not_found() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => 999999 ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error for missing post' ); + $this->assertEquals( 'post_not_found', $result->get_error_code() ); + } + + /** + * Tests that permission_callback() returns WP_Error when the user cannot edit the specific post. + * + * @since x.x.x + */ + public function test_permission_callback_returns_error_when_user_cannot_edit_post() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error when user cannot edit the post' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Tests that permission_callback() returns false for a post type not shown in REST. + * + * @since x.x.x + */ + public function test_permission_callback_returns_false_for_non_rest_post_type() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + register_post_type( + 'ai_test_private', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $post_id = $this->factory->post->create( array( 'post_type' => 'ai_test_private' ) ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + unregister_post_type( 'ai_test_private' ); + + $this->assertFalse( $result, 'Permission should be false for post types not shown in REST' ); + } + + // ------------------------------------------------------------------------- + // execute_callback() — validation + // ------------------------------------------------------------------------- + + /** + * Tests that execute_callback() returns an error when block_content is empty. + * + * @since x.x.x + */ + public function test_execute_callback_returns_error_when_block_content_empty() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'block_type' => 'core/paragraph', + 'block_content' => '', + 'notes' => array( 'Fix grammar' ), + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error for empty content' ); + $this->assertEquals( 'block_content_required', $result->get_error_code(), 'Error code should be block_content_required' ); + } + + /** + * Tests that execute_callback() returns an error when notes array is empty. + * + * @since x.x.x + */ + public function test_execute_callback_returns_error_when_notes_empty() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'block_type' => 'core/paragraph', + 'block_content' => 'Some content here.', + 'notes' => array(), + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error for empty notes' ); + $this->assertEquals( 'notes_required', $result->get_error_code(), 'Error code should be notes_required' ); + } + + /** + * Tests that execute_callback() filters out non-string notes and returns error when no valid notes remain. + * + * @since x.x.x + */ + public function test_execute_callback_filters_non_string_notes() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'block_type' => 'core/paragraph', + 'block_content' => 'Some content.', + 'notes' => array( 123, null, true ), + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Should reject notes with only non-string values' ); + $this->assertEquals( 'notes_required', $result->get_error_code() ); + } + + // ------------------------------------------------------------------------- + // execute_callback() — mocked generate_refinement + // ------------------------------------------------------------------------- + + /** + * Tests that execute_callback() returns refined text from generate_refinement. + * + * @since x.x.x + */ + public function test_execute_callback_returns_refined_text() { + $mock = $this->getMockBuilder( Refine_Notes::class ) + ->setConstructorArgs( + array( + 'ai/refine-notes', + array( + 'label' => 'Refine from Notes', + 'description' => 'Test', + ), + ) + ) + ->onlyMethods( array( 'generate_refinement' ) ) + ->getMock(); + + $mock->method( 'generate_refinement' )->willReturn( 'This is the refined content.' ); + + $reflection = new \ReflectionClass( $mock ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'block_type' => 'core/paragraph', + 'block_content' => 'Original content with issues.', + 'notes' => array( 'Fix the grammar issue' ), + ); + $result = $method->invoke( $mock, $input ); + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertEquals( 'This is the refined content.', $result, 'Should return the refined text' ); + } + + /** + * Tests that execute_callback() propagates WP_Error from generate_refinement. + * + * @since x.x.x + */ + public function test_execute_callback_propagates_wp_error_from_generate_refinement() { + $mock = $this->getMockBuilder( Refine_Notes::class ) + ->setConstructorArgs( + array( + 'ai/refine-notes', + array( + 'label' => 'Refine from Notes', + 'description' => 'Test', + ), + ) + ) + ->onlyMethods( array( 'generate_refinement' ) ) + ->getMock(); + + $mock->method( 'generate_refinement' )->willReturn( + new WP_Error( 'ai_error', 'AI service unavailable' ) + ); + + $reflection = new \ReflectionClass( $mock ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'block_type' => 'core/paragraph', + 'block_content' => 'Some content.', + 'notes' => array( 'Improve clarity' ), + ); + $result = $method->invoke( $mock, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Should propagate WP_Error from generate_refinement' ); + $this->assertEquals( 'ai_error', $result->get_error_code() ); + } + + /** + * Tests that execute_callback() attempts AI call with valid input. + * + * The AI call may fail in test environments without credentials, so the + * test accepts both a valid result and an AI-related WP_Error. + * + * @since x.x.x + */ + public function test_execute_callback_with_valid_input() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'block_type' => 'core/paragraph', + 'block_content' => 'This is a sentence that needs improvement based on feedback.', + 'notes' => array( 'Make this more concise' ), + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // AI client unavailable in test environment — that's expected. + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + // ------------------------------------------------------------------------- + // create_prompt() — tested via reflection (private method) + // ------------------------------------------------------------------------- + + /** + * Tests that create_prompt() builds the expected XML structure. + * + * @since x.x.x + */ + public function test_create_prompt_includes_xml_tags() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'create_prompt' ); + $method->setAccessible( true ); + + $prompt = $method->invoke( + $this->ability, + 'core/paragraph', + 'Hello world content.', + array( 'Fix spelling' ), + '' + ); + + $this->assertIsString( $prompt, 'Prompt should be a string' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain block-type tag' ); + $this->assertStringContainsString( 'core/paragraph', $prompt, 'Prompt should contain the block type' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain block-content tag' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain notes tag' ); + $this->assertStringContainsString( 'Fix spelling', $prompt, 'Prompt should contain the note text' ); + } + + /** + * Tests that create_prompt() sanitizes script injection in block_content. + * + * @since x.x.x + */ + public function test_create_prompt_sanitizes_block_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'create_prompt' ); + $method->setAccessible( true ); + + $prompt = $method->invoke( + $this->ability, + 'core/paragraph', + 'This is legitimate content.', + array( 'Review this' ), + '' + ); + + $this->assertIsString( $prompt, 'Prompt should be a string' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain the block-content section' ); + $this->assertStringNotContainsString( '