Files
wp-agentic-writer/includes/class-gutenberg-sidebar.php
Dwindi Ramadhana 379a72e52d fix: writing stuck - handle empty model response + no-divider fallback + timeline cleanup
Root causes of writing getting stuck:
1. Model returns empty response for a section → now detected early with
   actionable error message including model name
2. Model responds but without ~~~ARTICLE~~~ divider (happens with fallback
   models like Gemini) → now treats entire response as markdown content
3. Stream ends without 'complete' event (error/exit in PHP) → JS timeline
   entries lingered as 'active' forever. Now deactivated on stream close.
4. Error messages in execution flow now use structured formatAiErrorMessage
   with retry button instead of raw text

Also: deactivateActiveTimelineEntries called in catch block so errors
properly clear the 'Writing section X' status indicator.
2026-06-06 05:30:12 +07:00

10172 lines
312 KiB
PHP

<?php
/**
* Gutenberg Sidebar
*
* Registers the plugin sidebar in Gutenberg editor.
*
* @package WP_Agentic_Writer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Debug logging helper - logs only when SCRIPT_DEBUG is enabled.
*
* @param string $message Log message.
* @param mixed $data Optional data to log.
*/
function wpaw_debug_log( $message, $data = null ) {
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
$prefix = '[WPAW Debug] ';
if ( null === $data ) {
error_log( $prefix . $message );
} else {
error_log( $prefix . $message . ' ' . wp_json_encode( $data ) );
}
}
}
/**
* Class WP_Agentic_Writer_Gutenberg_Sidebar
*
* @since 0.1.0
*/
class WP_Agentic_Writer_Gutenberg_Sidebar {
/**
* Get singleton instance.
*
* @since 0.1.0
* @return WP_Agentic_Writer_Gutenberg_Sidebar
*/
public static function get_instance() {
static $instance = null;
if ( null === $instance ) {
$instance = new self();
}
return $instance;
}
/**
* Constructor.
*
* @since 0.1.0
*/
private function __construct() {
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
}
/**
* Enqueue sidebar assets.
*
* @since 0.1.0
*/
public function enqueue_assets() {
// Check if Gutenberg is available.
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
// Check if we're in the block editor.
$current_screen = get_current_screen();
if ( ! $current_screen || ! $current_screen->is_block_editor ) {
return;
}
// Build script URL.
$script_url = WP_AGENTIC_WRITER_URL . 'assets/js/sidebar.js';
$style_url = WP_AGENTIC_WRITER_URL . 'assets/css/sidebar.css';
$editor_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/editor.css';
$markdown_it_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it.min.js';
$dompurify_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/purify.min.js';
$markdown_task_lists_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it-task-lists.min.js';
// Enqueue markdown renderer and sanitizer.
wp_enqueue_script(
'wp-agentic-writer-markdown-it',
$markdown_it_url,
array(),
'13.0.2',
true
);
wp_enqueue_script(
'wp-agentic-writer-dompurify',
$dompurify_url,
array(),
'3.0.8',
true
);
wp_enqueue_script(
'wp-agentic-writer-markdown-task-lists',
$markdown_task_lists_url,
array( 'wp-agentic-writer-markdown-it' ),
'2.1.1',
true
);
// Enqueue utility functions (loaded before main sidebar).
$utils_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar-utils.js';
wp_enqueue_script(
'wp-agentic-writer-sidebar-utils',
WP_AGENTIC_WRITER_URL . 'assets/js/sidebar-utils.js',
array(),
file_exists( $utils_script_path ) ? filemtime( $utils_script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
// Enqueue sidebar script.
$script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js';
wp_enqueue_script(
'wp-agentic-writer-sidebar',
$script_url,
array(
'wp-plugins',
'wp-edit-post',
'wp-element',
'wp-components',
'wp-compose',
'wp-data',
'wp-i18n',
'wp-blocks',
'wp-agentic-writer-markdown-it',
'wp-agentic-writer-dompurify',
'wp-agentic-writer-markdown-task-lists',
'wp-agentic-writer-sidebar-utils',
),
file_exists( $script_path ) ? filemtime( $script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
$block_toolbar_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/block-refine.js';
wp_enqueue_script(
'wp-agentic-writer-block-chat-mention',
WP_AGENTIC_WRITER_URL . 'assets/js/block-refine.js',
array(
'wp-block-editor',
'wp-components',
'wp-compose',
'wp-data',
'wp-element',
'wp-hooks',
'wp-i18n',
),
file_exists( $block_toolbar_script_path ) ? filemtime( $block_toolbar_script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
// Enqueue image block toolbar script.
$block_image_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/block-image-generate.js';
wp_enqueue_script(
'wp-agentic-writer-block-image-generate',
WP_AGENTIC_WRITER_URL . 'assets/js/block-image-generate.js',
array(
'wp-block-editor',
'wp-components',
'wp-compose',
'wp-data',
'wp-element',
'wp-hooks',
'wp-i18n',
),
file_exists( $block_image_script_path ) ? filemtime( $block_image_script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
// Enqueue image modal script.
$image_modal_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/image-modal.js';
wp_enqueue_script(
'wp-agentic-writer-image-modal',
WP_AGENTIC_WRITER_URL . 'assets/js/image-modal.js',
array(
'wp-components',
'wp-element',
'wp-data',
'wp-block-editor',
),
file_exists( $image_modal_script_path ) ? filemtime( $image_modal_script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
// Enqueue sidebar styles.
$style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/sidebar.css';
wp_enqueue_style(
'wp-agentic-writer-sidebar',
$style_url,
array(),
file_exists( $style_path ) ? filemtime( $style_path ) : WP_AGENTIC_WRITER_VERSION
);
// Enqueue agentic components styles.
$components_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/agentic-components.css';
$components_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css';
wp_enqueue_style(
'wp-agentic-writer-components',
$components_style_url,
array(),
file_exists( $components_style_path ) ? filemtime( $components_style_path ) : WP_AGENTIC_WRITER_VERSION
);
// Enqueue workflow styles.
$workflow_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/agentic-workflow.css';
$workflow_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/agentic-workflow.css';
wp_enqueue_style(
'wp-agentic-writer-workflow',
$workflow_style_url,
array(),
file_exists( $workflow_style_path ) ? filemtime( $workflow_style_path ) : WP_AGENTIC_WRITER_VERSION
);
// Enqueue editor styles for image placeholders.
$editor_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/editor.css';
wp_enqueue_style(
'wp-agentic-writer-editor',
$editor_style_url,
array(),
file_exists( $editor_style_path ) ? filemtime( $editor_style_path ) : WP_AGENTIC_WRITER_VERSION
);
// Get current post ID.
$post_id = isset( $_GET['post'] ) ? intval( $_GET['post'] ) : 0;
if ( ! $post_id ) {
$post_id = get_the_ID();
}
if ( ! $post_id ) {
$post_id = 0;
}
// Get settings for JS.
$settings = $this->get_settings_for_js();
// Health check: verify DB table and API key exist
$health = $this->run_health_check();
// Localize script with data.
$data = array(
'apiUrl' => rest_url( 'wp-agentic-writer/v1' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'postId' => $post_id,
'settings' => $settings,
'version' => WP_AGENTIC_WRITER_VERSION,
'debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG,
'pluginUrl' => plugin_dir_url( dirname( __FILE__ ) ),
'health' => $health,
);
wp_localize_script( 'wp-agentic-writer-sidebar', 'wpAgenticWriter', $data );
}
/**
* Run health check for sidebar initialization.
*
* @since 0.2.4
* @return array Health status.
*/
private function run_health_check() {
global $wpdb;
$table_name = $wpdb->prefix . 'wpaw_conversations';
$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name;
$settings = get_option( 'wp_agentic_writer_settings', array() );
$has_api_key = ! empty( $settings['openrouter_api_key'] );
$issues = array();
if ( ! $table_exists ) {
$issues[] = array(
'type' => 'db_table_missing',
'message' => 'Conversation table not found. Please deactivate and reactivate the plugin.',
);
}
if ( ! $has_api_key ) {
$issues[] = array(
'type' => 'no_api_key',
'message' => 'API key not configured. Add your OpenRouter key in settings.',
'actionUrl' => admin_url( 'options-general.php?page=wp-agentic-writer-settings' ),
'actionLabel' => 'Open Settings',
);
}
return array(
'ok' => empty( $issues ),
'issues' => $issues,
);
}
/**
* Get settings for JavaScript.
*
* @since 0.1.0
* @return array Settings.
*/
private function get_settings_for_js() {
$settings = get_option( 'wp_agentic_writer_settings', array() );
// Don't expose API key to frontend.
unset( $settings['openrouter_api_key'] );
// Ensure all required keys exist with defaults from model registry.
$defaults = array(
'chat_model' => WPAW_Model_Registry::get_default_model( 'chat' ),
'clarity_model' => WPAW_Model_Registry::get_default_model( 'clarity' ),
'planning_model' => WPAW_Model_Registry::get_default_model( 'planning' ),
'writing_model' => WPAW_Model_Registry::get_default_model( 'writing' ),
'refinement_model' => WPAW_Model_Registry::get_default_model( 'refinement' ),
'image_model' => WPAW_Model_Registry::get_default_model( 'image' ),
'web_search_enabled' => false,
'search_engine' => 'auto',
'search_depth' => 'medium',
'cost_tracking_enabled' => true,
'monthly_budget' => 600,
'settings_url' => admin_url( 'options-general.php?page=wp-agentic-writer-settings' ),
'preferred_languages' => array( 'auto', 'English', 'Indonesian' ),
'custom_languages' => array(),
);
return wp_parse_args( $settings, $defaults );
}
/**
* Register REST API routes.
*
* @since 0.1.0
*/
public function register_rest_routes() {
// Get models endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/models',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_models' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Refresh models endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/models/refresh',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refresh_models' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Chat endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/chat',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_chat_request' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Clear chat context endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/clear-context',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_clear_context' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Chat history endpoint (deprecated - for backward compatibility only).
register_rest_route(
'wp-agentic-writer/v1',
'/chat-history/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_chat_history' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Conversation session endpoint (canonical for chat hydration).
register_rest_route(
'wp-agentic-writer/v1',
'/conversation/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_conversation_by_post' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Post config endpoints.
register_rest_route(
'wp-agentic-writer/v1',
'/post-config/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_post_config' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/post-config/(?P<post_id>\d+)',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_update_post_config' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Generate plan endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/generate-plan',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_plan' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Revise plan endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/revise-plan',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_revise_plan' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Execute article endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/execute-article',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_execute_article' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Reformat blocks endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/reformat-blocks',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_reformat_blocks' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Regenerate block endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/regenerate-block',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_regenerate_block' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Check clarity endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/check-clarity',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_check_clarity' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Block refine endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/refine-block',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_block_refine' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Chat-based block refinement endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/refine-from-chat',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refine_from_chat' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Section block mapping endpoints.
register_rest_route(
'wp-agentic-writer/v1',
'/section-blocks',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_save_section_blocks' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/section-blocks/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_section_blocks' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Get cost tracking data endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/cost-tracking/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_cost_tracking' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// SEO audit endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/seo-audit/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_seo_audit' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Generate meta description endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/generate-meta',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_meta' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Suggest keywords endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/suggest-keywords',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_suggest_keywords' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Summarize context endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/summarize-context',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_summarize_context' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Multi-pass refinement endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/refine-multi-pass',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refine_multi_pass' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Article-wide refinement endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/refine-article',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refine_article' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// GEO scoring endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/geo-score/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_geo_score' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Proactive suggestions endpoint (idle analysis)
register_rest_route(
'wp-agentic-writer/v1',
'/suggest-improvements',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_suggest_improvements' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Detect intent endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/detect-intent',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_detect_intent' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Image generation endpoints.
register_rest_route(
'wp-agentic-writer/v1',
'/image-recommendations/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_image_recommendations' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/generate-image',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_image' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/commit-image',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_commit_image' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Writing state persistence endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/writing-state/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_writing_state' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/writing-state/(?P<post_id>\d+)',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_save_writing_state' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Generate title endpoint (uses WP 7.0 AI Client when available).
register_rest_route(
'wp-agentic-writer/v1',
'/generate-title',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_title' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Refine title endpoint (instruction-driven rewrite from chat mention @title).
register_rest_route(
'wp-agentic-writer/v1',
'/refine-title',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refine_title' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Generate excerpt endpoint (uses WP 7.0 AI Client when available).
register_rest_route(
'wp-agentic-writer/v1',
'/generate-excerpt',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_excerpt' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// AI capabilities status endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/ai-capabilities',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_ai_capabilities' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Brave Search endpoint for research.
register_rest_route(
'wp-agentic-writer/v1',
'/search',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_search' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Fetch web content endpoint for research.
register_rest_route(
'wp-agentic-writer/v1',
'/fetch-content',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_fetch_content' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Research summary endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/research-summary',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_research_summary' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Conversation sessions endpoints.
register_rest_route(
'wp-agentic-writer/v1',
'/conversations',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_conversations' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations/post/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_conversations' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_create_conversation' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations/(?P<session_id>[a-zA-Z0-9]+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_conversation' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations/(?P<session_id>[a-zA-Z0-9]+)',
array(
'methods' => 'PUT',
'callback' => array( $this, 'handle_update_conversation' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations/(?P<session_id>[a-zA-Z0-9]+)',
array(
'methods' => 'DELETE',
'callback' => array( $this, 'handle_delete_conversation' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations/(?P<session_id>[a-zA-Z0-9]+)/messages',
array(
'methods' => array( 'POST', 'PUT' ),
'callback' => array( $this, 'handle_update_conversation_messages' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/conversations/(?P<session_id>[a-zA-Z0-9]+)/link-post',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_link_conversation_to_post' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Legacy migration endpoint for converting post meta chat history to sessions
register_rest_route(
'wp-agentic-writer/v1',
'/migrate-chat-history/(?P<post_id>\d+)',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_migrate_chat_history' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// User preferences endpoints (per-user settings)
register_rest_route(
'wp-agentic-writer/v1',
'/user-preferences',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_user_preferences' ),
'permission_callback' => '__return_true',
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/user-preferences',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_save_user_preferences' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
}
/**
* Handle get writing state request.
*
* @since 0.2.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_get_writing_state( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Authorization: Check if user can edit this specific post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$state = array(
'status' => get_post_meta( $post_id, '_wpaw_writing_status', true ) ?: 'idle',
'current_section_index' => (int) get_post_meta( $post_id, '_wpaw_current_section', true ) ?: 0,
'sections_written' => get_post_meta( $post_id, '_wpaw_sections_written', true ) ?: array(),
'last_updated' => get_post_meta( $post_id, '_wpaw_writing_state_updated', true ) ?: null,
'plan_id' => get_post_meta( $post_id, '_wpaw_plan_id', true ) ?: null,
'resume_token' => get_post_meta( $post_id, '_wpaw_resume_token', true ) ?: null,
);
return new WP_REST_Response( $state, 200 );
}
/**
* Handle save writing state request.
*
* @since 0.2.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_save_writing_state( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Authorization: Check if user can edit this specific post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to modify this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$params = $request->get_json_params();
// Validate status against allowed values.
$allowed_statuses = array( 'idle', 'in_progress', 'paused', 'completed', 'failed' );
$status = sanitize_text_field( $params['status'] ?? 'idle' );
if ( ! in_array( $status, $allowed_statuses, true ) ) {
$status = 'idle';
}
// Save writing status
update_post_meta( $post_id, '_wpaw_writing_status', $status );
// Save current section index
$section_index = (int) ( $params['current_section_index'] ?? 0 );
update_post_meta( $post_id, '_wpaw_current_section', $section_index );
// Save sections written array
$sections_written = is_array( $params['sections_written'] ?? null )
? array_map( 'sanitize_text_field', $params['sections_written'] )
: array();
update_post_meta( $post_id, '_wpaw_sections_written', $sections_written );
// Save plan ID
$plan_id = sanitize_text_field( $params['plan_id'] ?? '' );
update_post_meta( $post_id, '_wpaw_plan_id', $plan_id );
// Save resume token
$resume_token = sanitize_text_field( $params['resume_token'] ?? '' );
update_post_meta( $post_id, '_wpaw_resume_token', $resume_token );
// Update timestamp
update_post_meta( $post_id, '_wpaw_writing_state_updated', current_time( 'mysql' ) );
$state = array(
'status' => $status,
'current_section_index' => $section_index,
'sections_written' => $sections_written,
'last_updated' => current_time( 'mysql' ),
'plan_id' => $plan_id,
);
return new WP_REST_Response( $state, 200 );
}
/**
* Check permissions.
*
* @since 0.1.0
* @return bool True if user has permission.
*/
public function check_permissions() {
return current_user_can( 'edit_posts' );
}
/**
* Check post-specific edit permissions.
*
* @since 0.1.3
* @param int $post_id Post ID to check.
* @return bool True if user can edit the post.
*/
public function check_post_permission( $post_id ) {
if ( $post_id <= 0 ) {
return false;
}
return current_user_can( 'edit_post', $post_id );
}
/**
* Resolve session ID from request, or auto-create a post-linked session.
*
* @param string $session_id Existing session ID from request.
* @param int $post_id Post ID.
* @return string
*/
private function resolve_or_create_session_id( $session_id, $post_id ) {
$session_id = sanitize_text_field( (string) $session_id );
if ( '' !== $session_id ) {
return $session_id;
}
$post_id = (int) $post_id;
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
if ( $post_id <= 0 ) {
$created_unassigned = $manager->create_session(
array(
'post_id' => 0,
'title' => 'Unassigned Session - ' . current_time( 'Y-m-d H:i' ),
)
);
return is_wp_error( $created_unassigned ) ? '' : (string) $created_unassigned;
}
$existing = $manager->get_session_by_post_id( $post_id );
if ( $existing && ! empty( $existing['session_id'] ) ) {
return (string) $existing['session_id'];
}
$created = $manager->create_session(
array(
'post_id' => $post_id,
'title' => 'Post ' . $post_id . ' Session',
)
);
return is_wp_error( $created ) ? '' : (string) $created;
}
/**
* Build provider metadata for responses.
*
* @since 0.1.4
* @param WPAW_Provider_Selection_Result $provider_result Provider selection result.
* @param string $model Model identifier used.
* @return array Provider metadata.
*/
private function build_provider_metadata( $provider_result, $model = '' ) {
$actual_provider = $provider_result->actual_provider ?? 'unknown';
return array(
'provider' => $actual_provider,
'selected_provider' => $provider_result->selected_provider ?? $actual_provider,
'fallback_used' => ! empty( $provider_result->fallback_used ),
'warnings' => $provider_result->warnings ?? array(),
'model' => $model,
'byok_managed_by' => 'openrouter' === $actual_provider ? 'openrouter' : '',
);
}
/**
* Get a provider model label without assuming provider-specific helpers exist.
*
* @since 0.2.2
* @param object $provider Provider instance.
* @param string $fallback Fallback model label.
* @return string
*/
private function get_provider_execution_model( $provider, $fallback = 'execution' ) {
if ( is_object( $provider ) && method_exists( $provider, 'get_execution_model' ) ) {
return (string) $provider->get_execution_model();
}
return $fallback;
}
/**
* Track AI cost with full metadata.
*
* This helper ensures all cost tracking includes provider, session, and status
* metadata consistently. Use this instead of raw do_action calls.
*
* @since 0.2.0
* @param int $post_id Post ID.
* @param string $model Model used.
* @param string $action Action type (chat, planning, execution, etc).
* @param int $input_tokens Input token count.
* @param int $output_tokens Output token count.
* @param float $cost Cost in USD.
* @param mixed $provider_result Provider selection result or provider name string.
* @param string $session_id Session ID (optional).
* @param string $status Status (success, error) (optional, defaults to 'success').
*/
private function track_ai_cost( $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $provider_result, $session_id = '', $status = 'success' ) {
// Handle both provider result objects and plain strings
if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) {
$actual_provider = $provider_result->actual_provider;
} elseif ( is_string( $provider_result ) ) {
$actual_provider = $provider_result;
} else {
$actual_provider = 'unknown';
}
do_action(
'wp_aw_after_api_request',
$post_id,
$model,
$action,
$input_tokens,
$output_tokens,
$cost,
$actual_provider,
$session_id,
$status
);
}
/**
* Handle chat request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_chat_request( $request ) {
$params = $request->get_json_params();
$messages = $params['messages'] ?? array();
$post_id = $params['postId'] ?? 0;
$type = $params['type'] ?? 'planning';
$stream = ! empty( $params['stream'] );
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
// Check post permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
// Detect language from user's last message for real-time response matching
$last_user_message = $this->get_last_user_message( $messages );
$detected_from_message = $this->detect_language_from_text( $last_user_message );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $detected_from_message ?: $stored_language );
// Extract focus keyword for context anchoring
$focus_keyword = '';
if ( ! empty( $post_config['focus_keyword'] ) ) {
$focus_keyword = sanitize_text_field( $post_config['focus_keyword'] );
} elseif ( ! empty( $post_config['seo_focus_keyword'] ) ) {
$focus_keyword = sanitize_text_field( $post_config['seo_focus_keyword'] );
} elseif ( $post_id > 0 ) {
$focus_keyword = get_post_meta( $post_id, '_wpaw_focus_keyword', true );
}
// Build focus keyword instruction for chat
$focus_keyword_instruction = '';
if ( ! empty( $focus_keyword ) ) {
$focus_keyword_instruction = "
CONTEXT ANCHOR: The user is working on an article about \"{$focus_keyword}\".
Keep your responses relevant to this primary topic. If the conversation drifts, gently guide it back to \"{$focus_keyword}\".
At the END of your response, if you identify a good focus keyword from the discussion, suggest it in this format:
**Focus Keyword Suggestion:** [your suggested keyword]
";
}
$language_instruction = $this->build_language_instruction( $effective_language, 'chat responses' );
$system_prompt = "You are a helpful writing assistant. Answer clearly, with concise structure and practical suggestions.
{$focus_keyword_instruction}
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}";
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
$context_package = $context_builder->build_system_message(
'chat',
$session_id,
$post_id,
array_merge(
$params,
array(
'messages' => $messages,
'postConfig' => $post_config,
'latestUserMessage' => $last_user_message,
)
)
);
// OpenRouter is stateless; send only compact saved context plus the latest turn.
$messages = array();
if ( '' !== trim( (string) $last_user_message ) ) {
$messages[] = array(
'role' => 'user',
'content' => $last_user_message,
);
}
$messages = $this->prepend_system_prompt( $messages, $system_prompt );
if ( ! empty( $context_package['message'] ) ) {
array_splice( $messages, 1, 0, array( $context_package['message'] ) );
}
// Get provider for this task type with selection metadata.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type );
$provider = $provider_result->provider;
$provider_warnings = $provider_result->warnings;
if ( $stream ) {
$web_search_options = $this->get_web_search_options( $post_config );
$this->stream_chat_request( $messages, $post_id, $type, $web_search_options, $session_id );
exit;
}
// Send chat request.
$response = $provider->chat( $messages, array(), $type );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'chat_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Track cost with provider and session metadata.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'chat',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id,
'success'
);
// Include provider metadata in response (DoD Provider Transparency contract).
$response['provider'] = $provider_result->actual_provider;
$response['selected_provider'] = $provider_result->selected_provider;
$response['fallback_used'] = $provider_result->fallback_used;
$response['warnings'] = $provider_warnings;
$response['session_id'] = $session_id;
$response['context_audit'] = $context_package['audit'] ?? array();
// Also include nested form for consistency with other AI endpoints
$response['provider_metadata'] = $this->build_provider_metadata( $provider_result, $response['model'] ?? '' );
if ( ! empty( $response['content'] ) ) {
// Storage: Persist to session table via Context Service only.
// Legacy _wpaw_chat_history post meta is deprecated and no longer written.
if ( ! empty( $session_id ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->add_message(
$session_id,
array(
'role' => 'user',
'content' => $last_user_message,
'timestamp' => current_time( 'c' ),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'assistant',
'content' => $response['content'],
'timestamp' => current_time( 'c' ),
)
);
}
}
return new WP_REST_Response( $response, 200 );
}
/**
* Stream chat request response.
*
* @since 0.1.0
* @param array $messages Chat messages.
* @param int $post_id Post ID.
* @param string $type Chat type.
* @param array $web_search_options Web search options.
* @param string $session_id Session ID for context persistence.
* @return void
*/
private function stream_chat_request( $messages, $post_id, $type, $web_search_options = array(), $session_id = '' ) {
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' );
// Aggressively disable ALL output buffering layers (WordPress nests multiple)
@ini_set( 'output_buffering', 'Off' );
@ini_set( 'zlib.output_compression', false );
while ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
// Initialize streaming state variables.
$accumulated_content = '';
$chunks_emitted = 0;
$total_cost = 0;
$last_user_message = $this->get_last_user_message( $messages );
// Get provider with selection metadata for transparency.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type );
$provider = $provider_result->provider;
$provider_warnings = $provider_result->warnings;
echo "data: " . wp_json_encode(
array(
'type' => 'provider',
'provider' => $provider_result->actual_provider,
'selectedProvider' => $provider_result->selected_provider,
'fallback_used' => $provider_result->fallback_used,
'byok_managed_by' => 'openrouter' === $provider_result->actual_provider ? 'openrouter' : '',
)
) . "\n\n";
flush();
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat_stream(
$messages,
$web_search_options,
$type,
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content, &$chunks_emitted ) {
$accumulated_content = $full_content;
if ( '' !== $chunk ) {
$chunks_emitted++;
echo "data: " . wp_json_encode(
array(
'type' => 'conversational_stream',
'content' => $accumulated_content,
)
) . "\n\n";
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
}
}
);
// Fallback: if streaming produced no chunks but we have accumulated content, emit it now
if ( 0 === $chunks_emitted && ! is_wp_error( $response ) && ! empty( $response['content'] ) ) {
$accumulated_content = $response['content'];
echo "data: " . wp_json_encode(
array(
'type' => 'conversational_stream',
'content' => $accumulated_content,
)
) . "\n\n";
flush();
}
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$total_cost = $response['cost'] ?? 0;
// Debug: Log chat cost tracking (only when WP_DEBUG is on)
wpaw_debug_log( 'Tracking chat cost', array(
'post_id' => $post_id,
'model' => $response['model'] ?? 'unknown',
'type' => $type,
'cost' => $total_cost
) );
// Track cost with provider and session metadata.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'chat',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$total_cost,
$provider_result,
$session_id,
'success'
);
if ( ! empty( $accumulated_content ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => $accumulated_content,
)
) . "\n\n";
flush();
// Storage: Persist to session table via Context Service only.
// Legacy _wpaw_chat_history post meta is deprecated and no longer written.
if ( ! empty( $session_id ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->add_message(
$session_id,
array(
'role' => 'user',
'content' => $last_user_message,
'timestamp' => current_time( 'c' ),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'assistant',
'content' => $accumulated_content,
'timestamp' => current_time( 'c' ),
)
);
}
}
// Send provider transparency metadata in completion event.
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
'session_id' => $session_id,
'provider' => $provider_result->actual_provider,
'fallback_used' => $provider_result->fallback_used,
'warnings' => $provider_warnings,
)
) . "\n\n";
flush();
}
/**
* Clear chat context for a post.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_clear_context( $request ) {
$params = $request->get_json_params();
$post_id = intval( $params['postId'] ?? 0 );
$session_id = sanitize_text_field( $params['sessionId'] ?? '' );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission before clearing context.
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Use the context service to clear the session and post meta consistently.
$this->context_service->clear_context( $session_id, $post_id );
return new WP_REST_Response(
array(
'success' => true,
),
200
);
}
/**
* Get chat history for a post (deprecated compatibility endpoint).
*
* @since 0.1.0
* @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead.
* This endpoint reads from conversation sessions via migration.
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_chat_history( $request ) {
$post_id = intval( $request['post_id'] ?? 0 );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$history = $this->get_post_chat_history( $post_id );
return new WP_REST_Response(
array(
'messages' => $history,
'deprecated' => true,
'message' => 'This endpoint is deprecated. Use conversation sessions instead.',
),
200
);
}
/**
* Handle get conversation by post ID request (canonical endpoint).
*
* @since 0.2.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_conversation_by_post( $request ) {
$post_id = intval( $request['post_id'] ?? 0 );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$session = $manager->get_session_by_post_id( $post_id );
if ( ! $session ) {
// Check for legacy post-meta chat history and migrate if present.
$legacy_history = get_post_meta( $post_id, '_wpaw_chat_history', true );
if ( ! empty( $legacy_history ) && is_array( $legacy_history ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id );
// Fetch the newly created session after migration.
$session = $manager->get_session_by_post_id( $post_id );
if ( $session ) {
return new WP_REST_Response(
array(
'messages' => $session['messages'],
'has_session' => true,
'session_id' => $session['session_id'],
'post_id' => $session['post_id'],
'migrated' => true,
'deprecated' => false,
),
200
);
}
}
return new WP_REST_Response(
array(
'messages' => array(),
'has_session' => false,
),
200
);
}
return new WP_REST_Response(
array(
'messages' => $session['messages'],
'has_session' => true,
'session_id' => $session['session_id'],
'post_id' => $session['post_id'],
'deprecated' => false,
),
200
);
}
/**
* Update per-post chat history.
*
* @since 0.1.0
* @deprecated 0.1.4 Use conversation sessions instead. This method no longer writes
* to post meta; it exists only for backward compatibility.
* @param int $post_id Post ID.
* @param string $user_message User message.
* @param string $assistant_message Assistant message.
* @return void
*/
private function update_post_chat_history( $post_id, $user_message, $assistant_message ) {
// Deprecated - now only used for migration reads. Do not write.
// New code should use conversation sessions.
return;
}
/**
* Get per-post chat history.
*
* @since 0.1.0
* @deprecated 0.1.4 Use conversation sessions instead.
* @param int $post_id Post ID.
* @return array
*/
private function get_post_chat_history( $post_id ) {
if ( $post_id <= 0 ) {
return array();
}
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$sessions = $manager->get_sessions_for_post( $post_id );
// If we have active sessions, return messages from the most recent one
if ( ! empty( $sessions ) ) {
// Sort by last activity, most recent first
usort( $sessions, function( $a, $b ) {
return strtotime( $b['last_activity'] ?? '' ) - strtotime( $a['last_activity'] ?? '' );
} );
$active_session = $sessions[0];
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context = $context_service->get_context( $active_session['session_id'], $post_id );
return $context['messages'] ?? array();
}
// No sessions found - check for legacy history and migrate
$history = get_post_meta( $post_id, '_wpaw_chat_history', true );
if ( ! is_array( $history ) || empty( $history ) ) {
return array();
}
// Legacy data exists - trigger migration
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id );
// Return migrated data using the returned session id
if ( ! empty( $migrated_session_id ) ) {
$context = $context_service->get_context( $migrated_session_id, $post_id );
return $context['messages'] ?? array();
}
return array();
}
/**
* Get default per-post configuration values.
*
* @since 0.1.0
* @return array
*/
private function get_default_post_config() {
$settings = get_option( 'wp_agentic_writer_settings', array() );
return array(
'article_length' => 'medium',
'language' => 'auto',
'tone' => '',
'audience' => '',
'experience_level'=> 'general',
'include_images' => true,
'web_search' => isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'],
'default_mode' => 'writing',
// SEO fields
'focus_keyword' => '',
'seo_focus_keyword' => '',
'seo_secondary_keywords' => '',
'seo_meta_description' => '',
'seo_enabled' => true,
);
}
/**
* Sanitize post config input.
*
* @since 0.1.0
* @param array $config Post config.
* @return array
*/
private function sanitize_post_config( $config ) {
$defaults = $this->get_default_post_config();
$config = is_array( $config ) ? $config : array();
$sanitized = array();
$allowed_lengths = array( 'short', 'medium', 'long' );
$length = $config['article_length'] ?? $defaults['article_length'];
$sanitized['article_length'] = in_array( $length, $allowed_lengths, true ) ? $length : $defaults['article_length'];
// Validate language - normalize to lowercase for comparison
$settings = get_option( 'wp_agentic_writer_settings', array() );
$allowed_languages = array_merge(
$settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ),
$settings['custom_languages'] ?? array()
);
// Normalize allowed languages to lowercase
$allowed_languages_lower = array_map( 'strtolower', $allowed_languages );
$language = strtolower( $config['language'] ?? $defaults['language'] );
$sanitized['language'] = in_array( $language, $allowed_languages_lower, true ) ? $language : 'auto';
$sanitized['tone'] = sanitize_text_field( $config['tone'] ?? $defaults['tone'] );
$sanitized['audience'] = sanitize_text_field( $config['audience'] ?? $defaults['audience'] );
$sanitized['experience_level'] = sanitize_text_field( $config['experience_level'] ?? $defaults['experience_level'] );
$sanitized['include_images'] = isset( $config['include_images'] )
? (bool) $config['include_images']
: (bool) $defaults['include_images'];
$sanitized['web_search'] = isset( $config['web_search'] )
? (bool) $config['web_search']
: (bool) $defaults['web_search'];
$allowed_modes = array( 'writing', 'planning', 'chat' );
$mode = $config['default_mode'] ?? $defaults['default_mode'];
$sanitized['default_mode'] = in_array( $mode, $allowed_modes, true ) ? $mode : $defaults['default_mode'];
// SEO fields
$sanitized['seo_focus_keyword'] = sanitize_text_field( $config['seo_focus_keyword'] ?? $defaults['seo_focus_keyword'] );
$sanitized['focus_keyword'] = sanitize_text_field( $config['focus_keyword'] ?? $defaults['focus_keyword'] );
if ( '' === $sanitized['focus_keyword'] && '' !== $sanitized['seo_focus_keyword'] ) {
$sanitized['focus_keyword'] = $sanitized['seo_focus_keyword'];
}
if ( '' === $sanitized['seo_focus_keyword'] && '' !== $sanitized['focus_keyword'] ) {
$sanitized['seo_focus_keyword'] = $sanitized['focus_keyword'];
}
$sanitized['seo_secondary_keywords'] = sanitize_text_field( $config['seo_secondary_keywords'] ?? $defaults['seo_secondary_keywords'] );
$sanitized['seo_meta_description'] = sanitize_textarea_field( $config['seo_meta_description'] ?? $defaults['seo_meta_description'] );
$sanitized['seo_enabled'] = isset( $config['seo_enabled'] )
? (bool) $config['seo_enabled']
: (bool) $defaults['seo_enabled'];
return $sanitized;
}
/**
* Get post config (merged with defaults).
*
* @since 0.1.0
* @param int $post_id Post ID.
* @return array
*/
private function get_post_config( $post_id ) {
$defaults = $this->get_default_post_config();
if ( $post_id <= 0 ) {
return $defaults;
}
$stored = get_post_meta( $post_id, '_wpaw_post_config', true );
$stored = is_array( $stored ) ? $stored : array();
return $this->sanitize_post_config( wp_parse_args( $stored, $defaults ) );
}
/**
* Resolve post config from request, falling back to stored config.
*
* @since 0.1.0
* @param array $params Request params.
* @param int $post_id Post ID.
* @return array
*/
private function resolve_post_config_from_request( $params, $post_id ) {
if ( isset( $params['postConfig'] ) && is_array( $params['postConfig'] ) ) {
$merged = wp_parse_args( $params['postConfig'], $this->get_post_config( $post_id ) );
return $this->sanitize_post_config( $merged );
}
return $this->get_post_config( $post_id );
}
/**
* Build a short configuration context string for prompts.
*
* @since 0.1.0
* @param array $post_config Post config.
* @return string
*/
private function build_post_config_context( $post_config ) {
$lines = array();
if ( ! empty( $post_config['tone'] ) ) {
$lines[] = 'Tone: ' . $post_config['tone'];
}
if ( ! empty( $post_config['audience'] ) ) {
$lines[] = 'Target audience: ' . $post_config['audience'];
}
if ( ! empty( $post_config['experience_level'] ) && 'general' !== $post_config['experience_level'] ) {
$lines[] = 'Expertise level: ' . $post_config['experience_level'];
}
// Add SEO context if enabled
$seo_context = $this->build_seo_context( $post_config );
if ( empty( $lines ) && empty( $seo_context ) ) {
return '';
}
$result = '';
if ( ! empty( $lines ) ) {
$result .= "\nPOST CONFIG:\n- " . implode( "\n- ", $lines ) . "\n";
}
if ( ! empty( $seo_context ) ) {
$result .= $seo_context;
}
return $result;
}
/**
* Build SEO context for prompts.
*
* @since 0.1.0
* @param array $post_config Post config.
* @return string SEO context string.
*/
private function build_seo_context( $post_config ) {
if ( empty( $post_config['seo_enabled'] ) ) {
return '';
}
$seo_lines = array();
if ( ! empty( $post_config['seo_focus_keyword'] ) ) {
$seo_lines[] = 'Focus keyword: "' . $post_config['seo_focus_keyword'] . '" - Include this keyword naturally in: title, first paragraph, at least 2-3 subheadings, and throughout the content (aim for 1-2% density)';
}
if ( ! empty( $post_config['seo_secondary_keywords'] ) ) {
$seo_lines[] = 'Secondary keywords: ' . $post_config['seo_secondary_keywords'] . ' - Sprinkle these throughout the content naturally';
}
if ( empty( $seo_lines ) ) {
return '';
}
return "\nSEO OPTIMIZATION:\n- " . implode( "\n- ", $seo_lines ) . "\n- Use descriptive, keyword-rich subheadings (H2, H3)\n- Write compelling meta-description-worthy opening paragraph\n- Include internal linking opportunities where relevant\n";
}
/**
* Detect language from text using common word patterns.
*
* @since 0.1.0
* @param string $text Text to analyze.
* @return string Detected language code.
*/
private function detect_language_from_text( $text ) {
$text = strtolower( $text );
// Indonesian indicators
$indonesian_words = array( 'yang', 'dan', 'untuk', 'dengan', 'ini', 'itu', 'dari', 'ke', 'di', 'pada', 'adalah', 'akan', 'sudah', 'bisa', 'harus', 'tidak', 'juga', 'atau', 'saya', 'apa', 'bagaimana', 'mengapa', 'kenapa', 'gimana', 'tolong', 'mohon', 'silakan', 'terima', 'kasih', 'selamat', 'pagi', 'siang', 'malam', 'artikel', 'tentang', 'topik', 'pembahasan', 'cara', 'membuat', 'menulis' );
$indonesian_count = 0;
foreach ( $indonesian_words as $word ) {
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) {
$indonesian_count++;
}
}
// Spanish indicators
$spanish_words = array( 'que', 'de', 'no', 'es', 'el', 'la', 'los', 'las', 'un', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'este', 'esta', 'todo', 'también', 'puede', 'hacer', 'tiene', 'cuando', 'sobre', 'entre', 'después', 'antes', 'porque', 'cómo', 'qué', 'cuál' );
$spanish_count = 0;
foreach ( $spanish_words as $word ) {
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) {
$spanish_count++;
}
}
// French indicators
$french_words = array( 'le', 'la', 'les', 'de', 'du', 'des', 'un', 'une', 'et', 'est', 'que', 'qui', 'dans', 'pour', 'pas', 'sur', 'avec', 'ce', 'cette', 'sont', 'être', 'avoir', 'faire', 'comme', 'mais', 'ou', 'où', 'plus', 'tout', 'bien', 'aussi', 'peut', 'très', 'comment', 'pourquoi', 'quoi' );
$french_count = 0;
foreach ( $french_words as $word ) {
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) {
$french_count++;
}
}
// Determine language with threshold
$threshold = 2;
if ( $indonesian_count >= $threshold && $indonesian_count > $spanish_count && $indonesian_count > $french_count ) {
return 'indonesian';
}
if ( $spanish_count >= $threshold && $spanish_count > $indonesian_count && $spanish_count > $french_count ) {
return 'spanish';
}
if ( $french_count >= $threshold && $french_count > $indonesian_count && $french_count > $spanish_count ) {
return 'french';
}
// Return empty string instead of 'auto' to allow fallback to stored language
return '';
}
/**
* Resolve effective language preference.
*
* @since 0.1.0
* @param array $post_config Post config.
* @param string $fallback Language to fall back to.
* @return string
*/
private function resolve_language_preference( $post_config, $fallback ) {
$language = strtolower( (string) ( $post_config['language'] ?? 'auto' ) );
if ( 'auto' !== $language && '' !== $language ) {
return $language;
}
// If fallback is provided and not empty, use it
if ( ! empty( $fallback ) && 'auto' !== strtolower( $fallback ) ) {
return strtolower( $fallback );
}
// Default to 'auto' instead of 'english' to let AI detect from context
return 'auto';
}
/**
* Build language instruction for prompts.
*
* @since 0.1.0
* @param string $language Language code.
* @param string $context Context label.
* @return string
*/
private function build_language_instruction( $language, $context = 'content' ) {
$language = trim( (string) $language );
// If auto or empty, let AI detect from context
if ( empty( $language ) || 'auto' === strtolower( $language ) ) {
return "CRITICAL: Detect the language from the conversation history and topic. Write ALL {$context} in the SAME language as the user's input. If the user wrote in Indonesian, write in Indonesian. If English, write in English. Match the user's language exactly.";
}
// Pass any language name directly to AI - AI models understand all languages
return "You MUST write the {$context} in {$language}. Use native {$language} vocabulary, grammar, and style.";
}
/**
* Prepend a system prompt to messages.
*
* @since 0.1.0
* @param array $messages Messages list.
* @param string $prompt System prompt.
* @return array
*/
private function prepend_system_prompt( $messages, $prompt ) {
if ( empty( $prompt ) ) {
return $messages;
}
$messages = is_array( $messages ) ? $messages : array();
array_unshift(
$messages,
array(
'role' => 'system',
'content' => $prompt,
)
);
return $messages;
}
/**
* Physically scrapes the web and injects the results as a system prompt if applicable.
*
* @since 0.1.0
* @param array &$messages Chat messages (passed by reference).
* @param object $provider AI Provider instance.
* @param array $web_search_options Web search options.
* @return void
*/
private function maybe_inject_brave_search( &$messages, $provider, $web_search_options ) {
if ( empty( $web_search_options['web_search_enabled'] ) ) {
return;
}
// Check if Brave API key is configured
$settings = get_option( 'wp_agentic_writer_settings', array() );
$brave_api_key = $settings['brave_search_api_key'] ?? '';
// Determine search strategy:
// 1. If Brave API key is set -> Use Brave (regardless of provider)
// 2. If using OpenRouter without Brave key -> Let OpenRouter's online models handle it
// 3. If using Local Backend without Brave key -> No search available
if ( empty( $brave_api_key ) && $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) {
// No Brave API key with OpenRouter - let the model's built-in search handle it
// OpenRouter's online models (e.g., gemini-2.5-flash-online) have search tools built-in
return;
}
if ( empty( $brave_api_key ) ) {
// Local Backend or other providers without Brave API key
return;
}
$last_query = '';
foreach ( array_reverse( $messages ) as $msg ) {
if ( 'user' === $msg['role'] ) {
$last_query = (string) $msg['content'];
break;
}
}
if ( empty( $last_query ) ) {
return;
}
$brave_search = WP_Agentic_Writer_Brave_Search_API::get_instance();
$results = $brave_search->search( $last_query, 3 );
if ( ! is_wp_error( $results ) && ! empty( $results ) ) {
$context_markdown = $brave_search->format_results_for_llm( $results, $last_query );
$injection_message = array(
'role' => 'system',
'content' => $context_markdown
);
$injected = false;
for( $i = count( $messages ) - 1; $i >= 0; $i-- ) {
if ( 'user' === $messages[ $i ]['role'] ) {
array_splice( $messages, $i, 0, array( $injection_message ) );
$injected = true;
break;
}
}
if ( ! $injected ) {
array_unshift( $messages, $injection_message );
}
}
}
/**
* Build web search option overrides.
*
* @since 0.1.0
* @param array $post_config Post config.
* @return array
*/
private function get_web_search_options( $post_config ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
return array(
'web_search_enabled' => isset( $post_config['web_search'] ) ? (bool) $post_config['web_search'] : false,
'search_depth' => $settings['search_depth'] ?? 'medium',
'search_engine' => $settings['search_engine'] ?? 'auto',
);
}
/**
* Handle get post config request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_get_post_config( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
return new WP_REST_Response( $this->get_post_config( $post_id ), 200 );
}
/**
* Handle update post config request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_update_post_config( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$params = $request->get_json_params();
$config = $this->sanitize_post_config( $params['postConfig'] ?? array() );
update_post_meta( $post_id, '_wpaw_post_config', $config );
return new WP_REST_Response( $config, 200 );
}
/**
* Get the last user message from a message list.
*
* @since 0.1.0
* @param array $messages Message list.
* @return string
*/
private function get_last_user_message( $messages ) {
if ( empty( $messages ) || ! is_array( $messages ) ) {
return '';
}
for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) {
$message = $messages[ $i ];
if ( isset( $message['role'] ) && 'user' === $message['role'] && ! empty( $message['content'] ) ) {
return sanitize_text_field( $message['content'] );
}
}
return '';
}
/**
* Handle generate plan request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_generate_plan( $request ) {
$params = $request->get_json_params();
$topic = $params['topic'] ?? '';
$context = $params['context'] ?? '';
$post_id = $params['postId'] ?? 0;
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
$auto_execute = $params['autoExecute'] ?? false;
$stream = $params['stream'] ?? false;
$chat_history = $params['chatHistory'] ?? array();
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$article_length = $post_config['article_length'] ?? ( $params['articleLength'] ?? 'medium' );
$clarification_answers = $params['clarificationAnswers'] ?? array(); // Get clarification answers
$detected_language = $params['detectedLanguage'] ?? 'english'; // Get detected language from clarity check
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
$post_config_context = $this->build_post_config_context( $post_config );
$web_search_options = $this->get_web_search_options( $post_config );
if ( empty( $topic ) ) {
return new WP_Error(
'no_topic',
__( 'Topic is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
$context_package = $context_builder->build_for_task(
'planning',
$session_id,
$post_id,
array_merge(
$params,
array(
'chatHistory' => $chat_history,
'postConfig' => $post_config,
)
)
);
// If streaming is requested, use streaming response.
if ( $stream ) {
return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history, $session_id );
}
// Get provider for planning task.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
$provider = $provider_result->provider;
// Build prompt for plan generation.
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
$system_prompt = "You are an Information Architect and SEO/GEO Strategist. Your task is to outline a high-information-density article based on the user's topic and context.
ANTI-ROBOT RULES:
- Never use generic intros or 'throat-clearing' fluff.
- Avoid academic, pompous, or 'expert' posturing.
- Headings must provide direct value or ask specific questions the article will answer.
GEO/SEO STRATEGY:
- Design the outline for Generative Engine Optimization (GEO): sections must flow logically to answer the user's core intent comprehensively.
- Suggest strategic use of tables, bullet points, and Q&A formats where they maximize information density.
- Incorporate secondary entities and related concepts naturally to show topical depth.
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
{$post_config_context}
Generate a JSON outline with the following structure:
{
\"title\": \"Article title\",
\"meta\": {
\"reading_time\": \"5 min\",
\"difficulty\": \"intermediate\",
\"cost_estimate\": 0.70
},
\"sections\": [
{
\"id\": \"unique-section-id\",
\"status\": \"pending\",
\"type\": \"section\",
\"heading\": \"Section heading\",
\"content\": [
{
\"type\": \"paragraph\",
\"content\": \"Brief description of what this section should cover\"
}
]
}
]
}
Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text.
Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nContext:\n{$context_package['working_context']}\n\n{$context_package['research_context']}",
),
);
// Generate plan.
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat(
$messages,
array_merge(
array(
'temperature' => 0.7,
'max_tokens' => 2200,
),
$web_search_options
),
'planning'
);
// Debug: log the provider type and response
$provider_class = get_class( $provider );
wpaw_debug_log( 'Plan generation using provider: ' . $provider_class );
if ( is_wp_error( $response ) ) {
wpaw_debug_log( 'Plan generation error: ' . $response->get_error_message() );
return new WP_Error(
'plan_generation_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Extract JSON from response.
$content = $response['content'] ?? '';
$plan_json = $this->extract_plan_from_response( $content, $topic );
// Debug: log the raw response
wpaw_debug_log( 'Plan generation raw response length: ' . strlen( $content ) );
if ( empty( trim( (string) $content ) ) ) {
$model_used = $response['model'] ?? 'unknown';
return new WP_Error(
'empty_response',
sprintf(
__( 'The AI model (%s) returned an empty response. Try a different planning model or simplify your topic.', 'wp-agentic-writer' ),
$model_used
),
array( 'status' => 500 )
);
}
if ( null === $plan_json ) {
wpaw_debug_log( 'extract_plan_from_response returned null. Content preview: ' . substr( $content, 0, 500 ) );
return new WP_Error(
'invalid_json',
sprintf(
/* translators: %s: model output preview */
__( 'The AI responded but the outline couldn\'t be parsed as JSON. Try again — this is usually a one-time formatting issue. Preview: %s', 'wp-agentic-writer' ),
$this->build_model_output_preview( $content )
),
array( 'status' => 500 )
);
}
$plan_json = $this->ensure_plan_sections_with_tasks( $plan_json );
// Persist planning exchange into session history.
if ( ! empty( $session_id ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->update_session_context(
$session_id,
array(
'working_summary' => array(
'text' => $this->build_memory_summary_from_plan( $plan_json ),
'updated_at' => current_time( 'c' ),
'source_message_count' => 0,
),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'user',
'content' => trim( (string) $topic ),
'timestamp' => current_time( 'c' ),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'assistant',
'type' => 'plan',
'plan' => $plan_json,
'content' => $this->build_plan_summary_for_session( $plan_json, $post_config ),
'timestamp' => current_time( 'c' ),
)
);
}
// Store plan in post meta.
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
update_post_meta( $post_id, '_wpaw_detected_language', $effective_language );
$summary = $this->build_memory_summary_from_plan( $plan_json );
$this->update_post_memory(
$post_id,
array(
'summary' => $summary,
'last_prompt' => $topic,
'last_intent' => 'generate',
)
);
}
// Track cost with provider metadata.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'planning',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id,
'success'
);
return new WP_REST_Response(
array(
'plan' => $plan_json,
'cost' => $response['cost'] ?? 0,
'web_search_results' => $response['web_search_results'] ?? array(),
'context_audit' => $context_package['audit'] ?? array(),
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle revise plan request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_revise_plan( $request ) {
$params = $request->get_json_params();
$instruction = $params['instruction'] ?? '';
$plan = $params['plan'] ?? array();
$post_id = $params['postId'] ?? 0;
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
if ( empty( $instruction ) ) {
return new WP_Error(
'no_instruction',
__( 'Instruction is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( empty( $plan ) || ! is_array( $plan ) ) {
return new WP_Error(
'no_plan',
__( 'Plan is required to revise.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission BEFORE reading post data.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Only read post config/meta after permission check.
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
$effective_language = $this->resolve_language_preference( $post_config, get_post_meta( $post_id, '_wpaw_detected_language', true ) );
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
$web_search_options = $this->get_web_search_options( $post_config );
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
$provider = $provider_result->provider;
$memory_context = $this->get_post_memory_context( $post_id );
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
$context_package = $context_builder->build_for_task(
'plan_revision',
$session_id,
$post_id,
array_merge(
$params,
array(
'plan' => $plan,
'postConfig' => $post_config,
)
)
);
$system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction.
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
{$post_config_context}
Return ONLY valid JSON in this structure:
{
\"title\": \"Article title\",
\"meta\": {
\"reading_time\": \"5 min\",
\"difficulty\": \"intermediate\",
\"cost_estimate\": 0.70
},
\"sections\": [
{
\"id\": \"unique-section-id\",
\"status\": \"pending\",
\"type\": \"section\",
\"heading\": \"Section heading\",
\"content\": [
{
\"type\": \"paragraph\",
\"content\": \"Brief description of what this section should cover\"
}
]
}
]
}
Rules:
- Preserve the JSON schema exactly.
- Preserve existing section id and status values when possible.
- New sections must include a new id and default status \"pending\".
- Edit only what is needed to satisfy the instruction.
- Keep headings as H2-level topics.
- No markdown, no explanation, JSON only.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Instruction: {$instruction}\n\nContinuity Context:\n{$context_package['working_context']}\n\nCurrent Outline JSON:\n" . wp_json_encode( $plan ) . $memory_context,
),
);
// Generate revised plan.
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.6 ), $web_search_options ), 'planning' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'plan_revision_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
$plan_json = $this->extract_plan_from_response( $response['content'], $instruction, $plan );
if ( null === $plan_json ) {
return new WP_Error(
'plan_revision_invalid',
__( 'Failed to generate valid plan JSON.', 'wp-agentic-writer' ),
array( 'status' => 500 )
);
}
$plan_json = $this->ensure_plan_sections_with_tasks( $plan_json, $plan );
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
if ( ! empty( $effective_language ) ) {
update_post_meta( $post_id, '_wpaw_detected_language', $effective_language );
}
$summary = $this->build_memory_summary_from_plan( $plan_json );
$this->update_post_memory(
$post_id,
array(
'summary' => $summary,
'last_prompt' => $instruction,
'last_intent' => 'plan',
)
);
}
if ( ! empty( $session_id ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->append_session_context_item(
$session_id,
'plan_versions',
array(
'instruction' => sanitize_text_field( $instruction ),
'plan' => $plan,
),
10
);
$context_service->update_session_context(
$session_id,
array(
'working_summary' => array(
'text' => $this->build_memory_summary_from_plan( $plan_json ),
'updated_at' => current_time( 'c' ),
'source_message_count' => 0,
),
)
);
}
// Track cost with provider metadata.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'planning',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id,
'success'
);
return new WP_REST_Response(
array(
'plan' => $plan_json,
'cost' => $response['cost'] ?? 0,
'context_audit' => $context_package['audit'] ?? array(),
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Ensure plan sections have stable ids and task statuses.
*
* @since 0.1.0
* @param array $plan Plan data.
* @param array $previous_plan Previous plan for matching.
* @return array
*/
private function ensure_plan_sections_with_tasks( $plan, $previous_plan = array() ) {
if ( empty( $plan ) || ! is_array( $plan ) ) {
return $plan;
}
$sections = $plan['sections'] ?? array();
if ( ! is_array( $sections ) ) {
$sections = array();
}
$previous_sections = array();
if ( is_array( $previous_plan ) ) {
$previous_sections = $previous_plan['sections'] ?? array();
if ( ! is_array( $previous_sections ) ) {
$previous_sections = array();
}
}
$previous_by_id = array();
$previous_by_title = array();
foreach ( $previous_sections as $previous_section ) {
if ( ! is_array( $previous_section ) ) {
continue;
}
$previous_id = $previous_section['id'] ?? '';
$previous_title = $this->normalize_plan_section_title( $previous_section );
if ( $previous_id ) {
$previous_by_id[ $previous_id ] = $previous_section;
}
if ( $previous_title ) {
$previous_by_title[ $previous_title ] = $previous_section;
}
}
$normalized_sections = array();
foreach ( $sections as $section ) {
if ( ! is_array( $section ) ) {
continue;
}
$section_title = $this->normalize_plan_section_title( $section );
$section_id = $section['id'] ?? '';
$status = $section['status'] ?? '';
if ( $section_id && isset( $previous_by_id[ $section_id ] ) ) {
$matched = $previous_by_id[ $section_id ];
$status = $matched['status'] ?? $status;
} elseif ( $section_title && isset( $previous_by_title[ $section_title ] ) ) {
$matched = $previous_by_title[ $section_title ];
$section_id = $matched['id'] ?? $section_id;
$status = $matched['status'] ?? $status;
}
if ( empty( $section_id ) ) {
$section_id = wp_generate_uuid4();
}
if ( empty( $status ) ) {
$status = 'pending';
}
$section['id'] = $section_id;
$section['status'] = $status;
$normalized_sections[] = $section;
}
$plan['sections'] = $normalized_sections;
return $plan;
}
/**
* Normalize section title for matching.
*
* @since 0.1.0
* @param array $section Section data.
* @return string
*/
private function normalize_plan_section_title( $section ) {
$title = '';
if ( is_array( $section ) ) {
$title = $section['heading'] ?? $section['title'] ?? '';
}
$title = trim( wp_strip_all_tags( (string) $title ) );
return strtolower( $title );
}
/**
* Stream generate plan with optional auto-execution.
*
* @since 0.1.0
* @param string $topic Topic.
* @param string $context Context.
* @param int $post_id Post ID.
* @param bool $auto_execute Whether to auto-execute the article.
* @param string $article_length Article length (short, medium, or long).
* @return void Streams response to client.
*/
private function stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length = 'medium', $clarification_answers = array(), $detected_language = 'english', $post_config = array(), $chat_history = array(), $session_id = '' ) {
// Set headers for streaming.
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering.
// Flush output buffer to ensure immediate streaming.
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
$provider = $provider_result->provider;
$total_cost = 0;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$web_search_options = $this->get_web_search_options( $post_config );
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
// Extract focus keyword for context anchoring
$focus_keyword = '';
if ( ! empty( $post_config['focus_keyword'] ) ) {
$focus_keyword = sanitize_text_field( $post_config['focus_keyword'] );
} elseif ( ! empty( $post_config['seo_focus_keyword'] ) ) {
$focus_keyword = sanitize_text_field( $post_config['seo_focus_keyword'] );
}
// Save focus keyword to post meta for persistence
if ( $post_id > 0 && ! empty( $focus_keyword ) ) {
update_post_meta( $post_id, '_wpaw_focus_keyword', $focus_keyword );
}
try {
// Note: Clarity check should be done BEFORE calling this streaming endpoint
// The frontend is responsible for checking clarity first via /check-clarity
// This endpoint only handles the actual streaming generation
echo "data: " . wp_json_encode(
array(
'type' => 'provider',
'provider' => $provider_result->actual_provider,
'selectedProvider' => $provider_result->selected_provider,
'fallback_used' => $provider_result->fallback_used,
'byok_managed_by' => 'openrouter' === $provider_result->actual_provider ? 'openrouter' : '',
)
) . "\n\n";
flush();
// Send starting status
$this->send_status( 'starting', 'Connecting to AI...' );
// Step 1: Generate plan.
$this->send_status( 'planning', 'Creating article outline...' );
// Build clarification context if available.
$clarity_context = '';
if ( ! empty( $clarification_answers ) && is_array( $clarification_answers ) ) {
$clarity_context = "\n\n=== CONTEXT FROM CLARIFICATION QUIZ ===\n";
// Group by category.
$grouped = array();
foreach ( $clarification_answers as $answer ) {
$category = $answer['category'] ?? 'other';
$value = $answer['value'] ?? $answer['answer'] ?? '';
$skipped = $answer['skipped'] ?? false;
if ( ! $skipped && ! empty( $value ) ) {
$grouped[ $category ] = $value;
}
}
// Format for prompt.
$category_labels = array(
'target_outcome' => 'Primary Goal',
'target_audience' => 'Target Audience',
'tone' => 'Tone of Voice',
'content_depth' => 'Content Depth',
'expertise_level' => 'Expertise Level',
'content_type' => 'Content Type',
'pov' => 'Point of View',
);
foreach ( $grouped as $category => $value ) {
$label = $category_labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) );
$clarity_context .= "- {$label}: {$value}\n";
}
$clarity_context .= "=== END CONTEXT ===\n";
}
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
$context_package = $context_builder->build_for_task(
'planning',
$session_id,
$post_id,
array(
'topic' => $topic,
'context' => $context,
'chatHistory' => $chat_history,
'postConfig' => $post_config,
'clarificationAnswers' => $clarification_answers,
'detectedLanguage' => $detected_language,
)
);
$chat_history_context = "\n\n" . $context_package['working_context'] . "\n\n" . $context_package['research_context'];
// Add section limits based on article length.
$length_section_limits = array(
'short' => 'Create exactly 2-3 sections maximum.',
'medium' => 'Create 4-5 sections maximum.',
'long' => 'Create 6-8 sections maximum.',
);
$section_limit = $length_section_limits[ $article_length ];
// Determine language instruction for plan generation
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
// Build focus keyword anchor instruction
$focus_keyword_instruction = '';
if ( ! empty( $focus_keyword ) ) {
$focus_keyword_instruction = "
PRIMARY TOPIC ANCHOR: \"{$focus_keyword}\"
CRITICAL: This article MUST be about \"{$focus_keyword}\".
- The title MUST include or clearly relate to \"{$focus_keyword}\"
- All sections MUST support this primary topic
- Recent conversation refinements are meant to ENHANCE this topic, not REPLACE it
- If user discussed sub-topics, treat them as ASPECTS of the primary topic \"{$focus_keyword}\"
";
}
$system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
{$focus_keyword_instruction}
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
IMPORTANT CONSTRAINT: {$section_limit}
{$post_config_context}
Generate a JSON outline with the following structure:
{
\"title\": \"Article title\",
\"meta\": {
\"reading_time\": \"5 min\",
\"difficulty\": \"intermediate\",
\"cost_estimate\": 0.70
},
\"sections\": [
{
\"id\": \"unique-section-id\",
\"status\": \"pending\",
\"type\": \"section\",
\"heading\": \"Section heading\",
\"content\": [
{
\"type\": \"paragraph\",
\"content\": \"Brief description of what this section should cover\"
}
]
}
]
}
Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text.
Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks.";
$memory_context = $this->get_post_memory_context( $post_id );
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nContext: {$context}{$chat_history_context}{$clarity_context}{$post_config_context}{$memory_context}",
),
);
// Log the request for debugging (only when WP_DEBUG is on)
wpaw_debug_log( 'Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) );
wpaw_debug_log( 'Detected language: ' . $detected_language );
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
$response = $provider->chat(
$messages,
array_merge(
array(
'temperature' => 0.7,
'max_tokens' => 2200,
),
$web_search_options
),
'planning'
);
wpaw_debug_log( 'OpenRouter API response received' );
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$content = $response['content'];
wpaw_debug_log( 'stream_generatePlan content length: ' . strlen( $content ) );
// Handle empty response gracefully
if ( empty( trim( (string) $content ) ) ) {
$model_used = $response['model'] ?? 'unknown';
$input_tokens = $response['input_tokens'] ?? 0;
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => sprintf(
'The AI model (%s) returned an empty response. This usually means the model couldn\'t process the request. Try: 1) Use a different planning model in Settings, 2) Simplify your topic, or 3) Try again. (Tokens sent: %d)',
$model_used,
$input_tokens
),
)
) . "\n\n";
flush();
exit;
}
$plan_json = $this->extract_plan_from_response( $content, $topic );
if ( null === $plan_json ) {
wpaw_debug_log( 'extract_plan_from_response failed in streaming. Content preview: ' . substr( $content, 0, 500 ) );
$preview = $this->build_model_output_preview( $content );
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => 'The AI responded but the outline couldn\'t be parsed as JSON. This sometimes happens when the model adds extra text. Trying again usually fixes this. Preview: ' . $preview,
)
) . "\n\n";
flush();
exit;
}
$plan_json = $this->ensure_plan_sections_with_tasks( $plan_json );
// Persist planning exchange into session history.
if ( ! empty( $session_id ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->update_session_context(
$session_id,
array(
'working_summary' => array(
'text' => $this->build_memory_summary_from_plan( $plan_json ),
'updated_at' => current_time( 'c' ),
'source_message_count' => 0,
),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'user',
'content' => trim( (string) $topic ),
'timestamp' => current_time( 'c' ),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'assistant',
'type' => 'plan',
'plan' => $plan_json,
'content' => $this->build_plan_summary_for_session( $plan_json, $post_config ),
'timestamp' => current_time( 'c' ),
)
);
}
// Store plan in post meta.
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
update_post_meta( $post_id, '_wpaw_detected_language', $effective_language );
$summary = $this->build_memory_summary_from_plan( $plan_json );
$this->update_post_memory(
$post_id,
array(
'summary' => $summary,
'last_prompt' => $topic,
'last_intent' => 'generate',
)
);
}
$total_cost += $response['cost'];
// Track plan cost.
$this->track_ai_cost(
$post_id,
$response['model'],
'planning',
$response['input_tokens'],
$response['output_tokens'],
$response['cost'],
$provider_result,
$session_id,
'success'
);
// Send plan data.
echo "data: " . wp_json_encode(
array(
'type' => 'plan',
'plan' => $plan_json,
'cost' => $response['cost'],
'web_search_results' => $response['web_search_results'] ?? array(),
'context_audit' => $context_package['audit'] ?? array(),
)
) . "\n\n";
flush();
// Send plan complete status
if ( $auto_execute ) {
$this->send_status( 'plan_complete', 'Outline created! Starting to write...' );
} else {
$this->send_status( 'plan_complete', 'Outline ready.' );
echo "data: " . wp_json_encode(
array_merge(
array(
'type' => 'complete',
'totalCost' => $total_cost,
),
$this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
)
)
) . "\n\n";
flush();
}
// Step 2: Auto-execute if requested.
if ( $auto_execute && ! empty( $plan_json['sections'] ) ) {
// Define length constraints with section counts
$length_constraints = array(
'short' => 'Write exactly 2-3 main sections. Each section should have 3-4 substantial paragraphs (4-6 sentences each). Go deep into each point with examples and explanations. Total: ~400 words.',
'medium' => 'Write 4-5 main sections. Each section should have 2-3 meaningful paragraphs (3-5 sentences each). Balance breadth with adequate depth. Total: ~750 words.',
'long' => 'Write 6-8 main sections. Each section should have 2-3 paragraphs (3-4 sentences each) with detailed examples and comprehensive coverage. Total: ~1500 words.',
);
$depth_instruction = array(
'short' => 'CRITICAL: Fewer sections, more depth per section. Avoid skimming. Each section should feel complete and comprehensive.',
'medium' => 'Balance: Moderate sections with good paragraph development. Each point should be explained with at least one example.',
'long' => 'Comprehensive: More sections covering all aspects, but still maintain substance in each paragraph.',
);
$length_instruction = $length_constraints[ $article_length ];
// Set post title from plan title with validation
if ( $post_id > 0 && ! empty( $plan_json['title'] ) ) {
// Verify post exists and user can edit
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) ) {
// Disable revisions during this update
add_filter( 'wp_revisions_to_keep', '__return_zero', 999 );
// Update post title
$update_result = wp_update_post(
array(
'ID' => $post_id,
'post_title' => sanitize_text_field( $plan_json['title'] ),
),
true // Return WP_Error on failure
);
if ( is_wp_error( $update_result ) ) {
wpaw_debug_log( 'Failed to update post title: ' . $update_result->get_error_message() );
}
// Restore filters
remove_filter( 'wp_revisions_to_keep', '__return_zero', 999 );
// Send title update to frontend for immediate sync
echo "data: " . wp_json_encode(
array(
'type' => 'title_update',
'title' => $plan_json['title'],
)
) . "\n\n";
flush();
}
}
// Determine language instruction based on detected language
$language_instruction = $this->build_language_instruction( $effective_language, 'ENTIRE article (conversational responses and article text)' );
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Maximum 1-2 image suggestions per section";
if ( empty( $post_config['include_images'] ) ) {
$image_instruction = "IMAGE SUGGESTIONS:
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
}
$system_prompt = "You are an expert content writer and technical consultant. Your task is to provide helpful conversational feedback AND write the article content based on the provided plan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
ARTICLE LENGTH CONSTRAINT: {$length_instruction}
DEPTH GUIDELINE: {$depth_instruction[$article_length]}
{$post_config_context}
CRITICAL WRITING RULES:
1. LANGUAGE: Strictly follow the language requirement above. This is NON-NEGOTIABLE.
2. Section Count: Strictly follow the section count specified above
3. Paragraph Quality: Each paragraph must be 4-6 sentences with substance
4. No \"fluff\" - every sentence must add value
5. Examples: Include at least 1 concrete example per section
6. Avoid: Short 2-sentence paragraphs, bullet point lists without explanation
7. Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
8. Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
OUTPUT FORMAT (FOLLOW THIS EXACT STRUCTURE):
First, provide a brief conversational response (2-3 sentences) in the required language about:
- What you're going to write about
- Any suggestions or notes about the content
- Reasoning for your approach
Then, insert this EXACT divider on its own line:
~~~ARTICLE~~~
After the divider, write the article in PURE Markdown format in the required language.
MARKDOWN FORMAT REQUIREMENTS:
- Use H2 (##) for section headings
- Use H3 (###) for subsections if needed
- Write clear, concise paragraphs (2-3 sentences each)
- Use bullet points or numbered lists for clarity
- Use **bold** for emphasis, *italic* for subtle emphasis
- Use `inline code` for technical terms
- Use code blocks with language specification for code examples
{$image_instruction}
EXAMPLE OUTPUT:
I'll write a comprehensive guide on this topic, focusing on practical examples and clear explanations. This approach will help readers understand both the concepts and implementation.
~~~ARTICLE~~~
## Heading Here
Content here...
Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversational response from the article content.";
$sections_to_write = array();
foreach ( $plan_json['sections'] as $index => $section ) {
$status = $section['status'] ?? 'pending';
if ( 'done' === $status ) {
continue;
}
$sections_to_write[ $index ] = $section;
}
$section_index = 0;
$total_sections = count( $sections_to_write );
// Send initial writing status
$this->send_status( 'writing', 'Writing content...' );
foreach ( $sections_to_write as $section_position => $section ) {
$section_index++;
$is_first_section = $section_index === 1;
$heading = $section['heading'] ?? $section['title'] ?? '';
$section_id = $section['id'] ?? wp_generate_uuid4();
$plan_json['sections'][ $section_position ]['id'] = $section_id;
$plan_json['sections'][ $section_position ]['status'] = 'in_progress';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_start',
'sectionId' => $section_id,
'heading' => $heading,
'index' => $section_index,
'total' => $total_sections,
)
) . "\n\n";
flush();
// Send section-specific status
$this->send_status( 'writing_section', "Writing section {$section_index} of {$total_sections}: {$heading}" );
$section_prompt = "Write content for the \"{$heading}\" section.\n\n";
$section_prompt .= "Content requirements:\n";
if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) {
foreach ( $section['content'] as $item ) {
if ( ! empty( $item['content'] ) ) {
$section_prompt .= "- {$item['content']}\n";
}
}
}
$section_prompt .= "\nIMPORTANT: Start with a brief conversational note, then include ~~~ARTICLE~~~ divider, then write the section content in Markdown.\n";
if ( $is_first_section ) {
$section_prompt .= "\nNOTE: This is the first section. Start directly with the section heading as an H2 (##), not an H1. The article title is already set separately.\n";
}
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $section_prompt,
),
);
// Log before calling streaming API
wpaw_debug_log( 'Starting section generation: ' . $heading );
// Send heading block first (but NOT for first section to avoid duplication with post title)
if ( ! $is_first_section && $heading ) {
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'sectionId' => $section_id,
'block' => array(
'type' => 'heading',
'content' => $heading,
'level' => 2,
),
)
) . "\n\n";
flush();
}
// Use streaming for real-time content generation!
$accumulated_content = '';
$section_cost = 0;
$conversational_sent = false;
$divider_found = false;
$markdown_content = ''; // Store complete markdown for later parsing
wpaw_debug_log( 'Calling OpenRouter streaming API' );
$response = $provider->chat_stream(
$messages,
array( 'temperature' => 0.8 ),
'execution',
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content, &$section_cost, &$total_cost, $post_id, $provider, &$conversational_sent, &$divider_found, &$markdown_content ) {
// Accumulate the full content
$accumulated_content = $full_content;
// Check for divider
if ( ! $divider_found && strpos( $accumulated_content, '~~~ARTICLE~~~' ) !== false ) {
$divider_found = true;
// Split content on divider
$parts = explode( '~~~ARTICLE~~~', $accumulated_content, 2 );
$conversational = trim( $parts[0] );
$markdown_content = isset( $parts[1] ) ? trim( $parts[1] ) : '';
// CRITICAL: Remove any remaining divider markers from conversational content
$conversational = str_replace( '~~~ARTICLE~~~', '', $conversational );
$conversational = preg_replace( '/~~~ARTICLE~~~[\r\n]*/', '', $conversational );
$conversational = trim( $conversational );
// Send conversational part as chat message
if ( ! empty( $conversational ) && ! $conversational_sent ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => $conversational,
)
) . "\n\n";
flush();
$conversational_sent = true;
}
// Stream raw markdown for display (no parsing yet)
if ( ! empty( $markdown_content ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'markdown_stream',
'content' => $markdown_content,
)
) . "\n\n";
flush();
}
} elseif ( ! $divider_found ) {
// No divider yet, this is all conversational
// Send conversational updates as they stream
if ( ! $conversational_sent ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational_stream',
'content' => $accumulated_content,
)
) . "\n\n";
flush();
}
} else {
// Divider found, stream markdown content as it comes
$parts = explode( '~~~ARTICLE~~~', $accumulated_content, 2 );
$markdown_content = isset( $parts[1] ) ? trim( $parts[1] ) : '';
// Stream raw markdown for display (no parsing yet)
if ( ! empty( $markdown_content ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'markdown_stream',
'content' => $markdown_content,
)
) . "\n\n";
flush();
}
}
}
);
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
// Handle empty response from model
if ( empty( trim( (string) $accumulated_content ) ) ) {
$model_used = $response['model'] ?? 'unknown';
wpaw_debug_log( "Section writing got empty response from model: {$model_used}" );
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => sprintf( 'Section "%s" got an empty response from the AI model (%s). Please retry.', $heading, $model_used ),
)
) . "\n\n";
flush();
exit;
}
// If divider was never found, treat the entire content as markdown
if ( ! $divider_found ) {
wpaw_debug_log( 'No ~~~ARTICLE~~~ divider found in section response. Using full content as markdown.' );
$markdown_content = $accumulated_content;
// Strip any leading conversational fluff (first line if it looks like a note)
$lines = explode( "\n", $markdown_content );
if ( ! empty( $lines[0] ) && ! preg_match( '/^#{1,3}\s/', $lines[0] ) && strlen( $lines[0] ) < 200 ) {
// First line might be a brief conversational note, skip it
$first_line = array_shift( $lines );
if ( ! empty( $first_line ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => trim( $first_line ),
)
) . "\n\n";
flush();
}
$markdown_content = implode( "\n", $lines );
}
}
$section_cost = $response['cost'] ?? 0;
$total_cost += $section_cost;
// Debug: Log execution cost tracking (only when WP_DEBUG is on)
wpaw_debug_log( 'Tracking execution cost', array(
'post_id' => $post_id,
'model' => $response['model'] ?? 'unknown',
'cost' => $section_cost
) );
// Track execution cost for this section.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'execution',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$section_cost,
$provider_result,
$session_id ?? '',
'success'
);
// NOW parse the complete markdown content and send blocks
if ( ! empty( $markdown_content ) ) {
// Extract image placeholders and generate IDs
$image_placeholders = array();
if ( preg_match_all( '/\[IMAGE:\s*(.+?)\]/i', $markdown_content, $matches ) ) {
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
foreach ( $matches[1] as $index => $description ) {
$agent_image_id = 'img_' . $post_id . '_' . time() . '_' . ( $index + 1 );
$image_placeholders[] = array(
'agent_image_id' => $agent_image_id,
'description' => trim( $description ),
);
// Save to database
$image_manager->save_image_recommendation(
$post_id,
$agent_image_id,
'section_' . $section_id,
$heading,
trim( $description ),
trim( $description )
);
}
}
$markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $markdown_content, $image_placeholders );
foreach ( $markdown_blocks as $block ) {
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block,
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
}
$plan_json['sections'][ $section_position ]['status'] = 'done';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_complete',
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
}
// Send complete status
$this->send_status( 'complete', 'Article finished!' );
// Send conversational completion message before complete signal
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => "✅ Article generation complete! The content has been added to your editor. Feel free to ask for refinements or adjustments to any section.",
)
) . "\n\n";
flush();
// Send completion message.
echo "data: " . wp_json_encode(
array_merge(
array(
'type' => 'complete',
'totalCost' => $total_cost,
),
$this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
)
)
) . "\n\n";
flush();
} catch ( Exception $e ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $e->getMessage(),
)
) . "\n\n";
flush();
}
exit;
}
/**
* Build a compact, persistent outline summary for session history.
*
* @since 0.2.2
* @param array $plan_json Plan data.
* @param array $post_config Post config.
* @return string
*/
private function build_plan_summary_for_session( $plan_json, $post_config = array() ) {
$title = trim( (string) ( $plan_json['title'] ?? 'Outline ready' ) );
$sections = is_array( $plan_json['sections'] ?? null ) ? $plan_json['sections'] : array();
$lines = array();
$lines[] = 'Outline ready.';
$lines[] = '';
$lines[] = $title;
$lines[] = '';
$focus = trim( (string) ( $post_config['seo_focus_keyword'] ?? '' ) );
$secondary = trim( (string) ( $post_config['seo_secondary_keywords'] ?? '' ) );
if ( '' !== $focus || '' !== $secondary ) {
$lines[] = 'SEO Snapshot:';
if ( '' !== $focus ) {
$lines[] = '- Focus: ' . $focus;
}
if ( '' !== $secondary ) {
$lines[] = '- Secondary: ' . $secondary;
}
$lines[] = '';
}
$lines[] = 'Sections:';
$index = 1;
foreach ( $sections as $section ) {
$heading = trim( (string) ( $section['heading'] ?? $section['title'] ?? '' ) );
if ( '' === $heading ) {
continue;
}
$lines[] = $index . '. ' . $heading;
$index++;
}
return implode( "\n", $lines );
}
/**
* Handle execute article request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_execute_article( $request ) {
$params = $request->get_json_params();
$post_id = $params['postId'] ?? 0;
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
$stream = $params['stream'] ?? false;
$recommended_title = '';
$chat_history = $params['chatHistory'] ?? array();
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$detected_language = $params['detectedLanguage'] ?? $stored_language;
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
// Auto-save post and link conversation if needed (only for post_id = 0)
if ( empty( $post_id ) && ! empty( $session_id ) ) {
$post_id = $this->ensure_conversation_linked_to_post( $session_id, $post_id );
}
// Check post permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Get plan from post meta.
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( empty( $plan ) ) {
return new WP_Error(
'no_plan',
__( 'No plan found. Please generate a plan first.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( $stream ) {
// For streaming, link conversation to post BEFORE getting plan from meta
if ( empty( $post_id ) && ! empty( $session_id ) ) {
$post_id = $this->ensure_conversation_linked_to_post( $session_id, $post_id );
}
// Now get plan after potentially having a valid post_id
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( empty( $plan ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => 'No plan found. Please generate a plan first.',
)
) . "\n\n";
flush();
return;
}
$this->stream_execute_article( $plan, $post_id, $post_config, $effective_language, $session_id );
exit;
}
$plan = $this->ensure_plan_sections_with_tasks( $plan );
// Update post title from the plan title when available.
if ( ! empty( $plan['title'] ) ) {
$recommended_title = sanitize_text_field( $plan['title'] );
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) ) {
if ( empty( $post->post_title ) ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $recommended_title,
)
);
}
}
}
}
// Get provider for writing task.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$provider = $provider_result->provider;
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Good places for images: after introductions, before complex explanations, to show examples
- Maximum 1-2 image suggestions per section
- Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]";
if ( empty( $post_config['include_images'] ) ) {
$image_instruction = "IMAGE SUGGESTIONS:
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
}
$language_instruction = $this->build_language_instruction( $effective_language, 'article content' );
// Build chat history context for continuity
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- CONVERSATION CONTEXT ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) && 'system' !== strtolower( $msg['role'] ?? '' ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION CONTEXT ---\n";
$chat_history_context .= "Use the above conversation to understand the user's intent and preferences for this article.";
}
// Build SEO instructions if SEO is enabled
$seo_instruction = '';
$internal_links_instruction = '';
if ( ! empty( $post_config['seo_enabled'] ) && ! empty( $post_config['seo_focus_keyword'] ) ) {
$focus_keyword = $post_config['seo_focus_keyword'];
$seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW):
- Focus Keyword: \"{$focus_keyword}\"
- MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning)
- MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words)
- Use the focus keyword 5-8 times naturally throughout the article
- Include the focus keyword in:
* At least 2 H2 or H3 subheadings
* The conclusion paragraph
- Include 2-3 authoritative outbound links to reputable sources (Wikipedia, official documentation, industry leaders)
- When suggesting images, include the focus keyword or related terms in the alt text
- Keep the article title under 60 characters";
// Get internal link suggestions
$internal_links = $this->suggest_internal_links( $post_id, $focus_keyword, 3 );
if ( ! empty( $internal_links ) ) {
$internal_links_instruction = "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n";
foreach ( $internal_links as $link ) {
$internal_links_instruction .= "- [{$link['title']}]({$link['url']})\n";
}
$internal_links_instruction .= "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'.";
}
}
// Build system prompt for article generation.
$system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
ANTI-ROBOT RULES:
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
GEO/SEO STRATEGY:
- Answer the implicit user intent directly and immediately in the first paragraph.
- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
- Use bullet points or numbered lists where they make data easier to scan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
{$chat_history_context}
Follow these guidelines:
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
- Embed secondary keywords naturally as concepts, without forcing exact matches
- For code blocks, use proper syntax highlighting (e.g., ```php)
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
- Write for the specified difficulty level
{$seo_instruction}
{$internal_links_instruction}
IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding (diagrams, screenshots)
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
- Maximum 1 image per section
{$image_instruction}";
// Generate content for each section.
$blocks = array();
$total_cost = 0;
$sections_to_write = array();
foreach ( $plan['sections'] as $index => $section ) {
$status = $section['status'] ?? 'pending';
if ( 'done' === $status ) {
continue;
}
$sections_to_write[ $index ] = $section;
}
foreach ( $sections_to_write as $section ) {
$heading = $section['heading'] ?? $section['title'] ?? '';
$section_prompt = $heading ? "Write the \"{$heading}\" section.\n\n" : "Write the next section.\n\n";
$section_prompt .= "Content requirements:\n";
if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) {
foreach ( $section['content'] as $item ) {
if ( ! empty( $item['content'] ) ) {
$section_prompt .= "- {$item['content']}\n";
}
}
}
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $section_prompt,
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'execution_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Add section blocks.
if ( $heading ) {
$blocks[] = array(
'type' => 'heading',
'content' => $heading,
'level' => 2,
);
}
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] );
if ( ! empty( $section_blocks ) ) {
$first_block = $section_blocks[0];
if ( isset( $first_block['blockName'] ) && 'core/heading' === $first_block['blockName'] ) {
$first_heading = $first_block['attrs']['content'] ?? '';
if ( $heading && $first_heading && 0 === strcasecmp( trim( $first_heading ), trim( $heading ) ) ) {
array_shift( $section_blocks );
}
}
foreach ( $section_blocks as $block ) {
$blocks[] = $block;
}
} else {
$blocks[] = array(
'type' => 'paragraph',
'content' => $response['content'],
);
}
$total_cost += $response['cost'];
}
if ( ! empty( $sections_to_write ) ) {
foreach ( array_keys( $sections_to_write ) as $section_index ) {
$plan['sections'][ $section_index ]['status'] = 'done';
}
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan );
}
}
// Track total cost.
$this->track_ai_cost(
$post_id,
$this->get_provider_execution_model( $provider, 'execution' ),
'execution',
0,
0,
$total_cost,
$provider_result,
'',
'success'
);
return new WP_REST_Response(
array(
'blocks' => $blocks,
'cost' => $total_cost,
'recommended_title' => $recommended_title,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$this->get_provider_execution_model( $provider, 'execution' )
),
),
200
);
}
/**
* Stream article execution from a stored plan.
*
* @since 0.1.0
* @param array $plan Plan data.
* @param int $post_id Post ID.
* @param array $post_config Post configuration.
* @param string $effective_language Effective language.
* @param string $session_id Session ID for conversation linking.
* @return void
*/
private function stream_execute_article( $plan, $post_id, $post_config = array(), $effective_language = 'english', $session_id = '' ) {
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' );
// Aggressively disable ALL output buffering layers (WordPress nests multiple)
@ini_set( 'output_buffering', 'Off' );
@ini_set( 'zlib.output_compression', false );
while ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$provider = $provider_result->provider;
$settings = get_option( 'wp_agentic_writer_settings', array() );
wpaw_debug_log( 'Using provider', array(
'class' => get_class( $provider ),
'configured' => method_exists( $provider, 'is_configured' ) ? $provider->is_configured() : 'unknown',
) );
wpaw_debug_log( 'Settings check', array(
'local_backend_url' => $settings['local_backend_url'] ?? 'NOT SET',
'task_providers[writing]' => $settings['task_providers']['writing'] ?? 'NOT SET'
) );
$total_cost = 0;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$language_instruction = $this->build_language_instruction( $effective_language, 'article content' );
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Good places for images: after introductions, before complex explanations, to show examples
- Maximum 1-2 image suggestions per section
- Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]";
if ( empty( $post_config['include_images'] ) ) {
$image_instruction = "IMAGE SUGGESTIONS:
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
}
$plan = $this->ensure_plan_sections_with_tasks( $plan );
$sections = isset( $plan['sections'] ) && is_array( $plan['sections'] ) ? $plan['sections'] : array();
$sections_to_write = array();
foreach ( $sections as $index => $section ) {
$status = $section['status'] ?? 'pending';
if ( 'done' === $status ) {
continue;
}
$sections_to_write[ $index ] = $section;
}
$total_sections = count( $sections_to_write );
if ( 0 === $total_sections ) {
$this->send_status( 'complete', 'All outline items are already written.' );
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
return;
}
$this->send_status( 'writing', 'Writing from outline...' );
if ( ! empty( $plan['title'] ) ) {
$plan_title = sanitize_text_field( $plan['title'] );
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) && empty( $post->post_title ) ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $plan_title,
)
);
}
}
echo "data: " . wp_json_encode(
array(
'type' => 'title_update',
'title' => $plan_title,
)
) . "\n\n";
flush();
}
// Build SEO instructions if SEO is enabled
$seo_instruction = '';
$internal_links_instruction = '';
if ( ! empty( $post_config['seo_enabled'] ) && ! empty( $post_config['seo_focus_keyword'] ) ) {
$focus_keyword = $post_config['seo_focus_keyword'];
$seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW):
- Focus Keyword: \"{$focus_keyword}\"
- MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning)
- MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words)
- Use the focus keyword 5-8 times naturally throughout the article
- Include the focus keyword in:
* At least 2 H2 or H3 subheadings
* The conclusion paragraph
- Include 2-3 authoritative outbound links to reputable sources (Wikipedia, official documentation, industry leaders)
- When suggesting images, include the focus keyword or related terms in the alt text
- Keep the article title under 60 characters";
// Get internal link suggestions
$internal_links = $this->suggest_internal_links( $post_id, $focus_keyword, 3 );
if ( ! empty( $internal_links ) ) {
$internal_links_instruction = "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n";
foreach ( $internal_links as $link ) {
$internal_links_instruction .= "- [{$link['title']}]({$link['url']})\n";
}
$internal_links_instruction .= "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'.";
}
}
$system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
ANTI-ROBOT RULES:
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
GEO/SEO STRATEGY:
- Answer the implicit user intent directly and immediately in the first paragraph.
- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
- Use bullet points or numbered lists where they make data easier to scan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
Follow these guidelines:
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
- Embed secondary keywords naturally as concepts, without forcing exact matches
- For code blocks, use proper syntax highlighting (e.g., ```php)
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
- Write for the specified difficulty level
{$seo_instruction}
{$internal_links_instruction}
IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding (diagrams, screenshots)
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
- Maximum 1 image per section
{$image_instruction}";
$section_index = 0;
foreach ( $sections_to_write as $section_position => $section ) {
$section_index++;
$heading = $section['heading'] ?? $section['title'] ?? '';
$status_message = $heading
? sprintf( 'Writing section %d of %d: %s', $section_index, $total_sections, $heading )
: sprintf( 'Writing section %d of %d', $section_index, $total_sections );
$section_id = $section['id'] ?? wp_generate_uuid4();
$plan['sections'][ $section_position ]['id'] = $section_id;
$plan['sections'][ $section_position ]['status'] = 'in_progress';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_start',
'sectionId' => $section_id,
'heading' => $heading,
'index' => $section_index,
'total' => $total_sections,
)
) . "\n\n";
flush();
$this->send_status( 'writing_section', $status_message );
$section_prompt = $heading ? "Write the \"{$heading}\" section.\n\n" : "Write the next section.\n\n";
$section_prompt .= "Content requirements:\n";
if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) {
foreach ( $section['content'] as $item ) {
if ( ! empty( $item['content'] ) ) {
$section_prompt .= "- {$item['content']}\n";
}
}
}
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $section_prompt,
),
);
$accumulated_content = '';
wpaw_debug_log( 'Starting section generation', array(
'heading' => $heading,
'section_position' => $section_position
) );
$response = $provider->chat_stream(
$messages,
array( 'temperature' => 0.8 ),
'execution',
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content ) {
$accumulated_content = $full_content;
}
);
wpaw_debug_log( 'Section generation complete. accumulated_content length: ' . strlen( $accumulated_content ) );
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$section_cost = $response['cost'] ?? 0;
$total_cost += $section_cost;
// Track cost for this section.
if ( $section_cost > 0 ) {
$this->track_ai_cost(
$post_id,
$response['model'] ?? 'unknown',
'execution',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$section_cost,
$provider_result,
$session_id ?? '',
'success'
);
}
if ( ! empty( $accumulated_content ) ) {
error_log( 'WP Agentic Writer: Parsing and sending blocks for section: ' . $heading );
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $accumulated_content );
foreach ( $section_blocks as $block ) {
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block,
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
} else {
error_log( 'WP Agentic Writer: WARNING - No accumulated content for section: ' . $heading );
}
$plan['sections'][ $section_position ]['status'] = 'done';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_complete',
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
$this->send_status( 'complete', 'Article finished!' );
// Suggest meta description generation if SEO is enabled
if ( ! empty( $post_config['seo_enabled'] ) && $post_id > 0 ) {
echo "data: " . wp_json_encode(
array(
'type' => 'assistant_message',
'message' => '✅ Article complete! You can now generate the meta description in config panel.',
)
) . "\n\n";
flush();
}
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$this->get_provider_execution_model( $provider, 'execution' )
),
)
) . "\n\n";
flush();
}
/**
* Handle reformat blocks request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_reformat_blocks( $request ) {
$params = $request->get_json_params();
$blocks = $params['blocks'] ?? array();
$post_id = $params['postId'] ?? 0;
$recommended_title = '';
$title_updated = false;
if ( empty( $blocks ) || ! is_array( $blocks ) ) {
return new WP_Error(
'no_blocks',
__( 'Blocks are required to reformat.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$results = array();
if ( $post_id > 0 ) {
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( is_array( $plan ) && ! empty( $plan['title'] ) ) {
$recommended_title = sanitize_text_field( $plan['title'] );
}
}
foreach ( $blocks as $block ) {
$client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? '';
$block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph';
$block_attrs = $block['attributes'] ?? $block['attrs'] ?? array();
if ( empty( $client_id ) ) {
continue;
}
if ( 'core/paragraph' !== $block_type ) {
continue;
}
$content = $this->extract_block_content_from_attrs( $block_type, $block_attrs );
if ( '' === trim( (string) $content ) ) {
continue;
}
$parsed_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $content );
if ( empty( $parsed_blocks ) ) {
continue;
}
$results[] = array(
'clientId' => $client_id,
'blocks' => $parsed_blocks,
);
}
if ( $post_id > 0 && '' !== $recommended_title ) {
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) ) {
if ( empty( $post->post_title ) ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $recommended_title,
)
);
$title_updated = true;
}
}
}
return new WP_REST_Response(
array(
'results' => $results,
'recommended_title' => $recommended_title,
'title_updated' => $title_updated,
),
200
);
}
/**
* Handle regenerate block request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_regenerate_block( $request ) {
$params = $request->get_json_params();
$block_content = $params['blockContent'] ?? '';
$context = $params['context'] ?? '';
$post_id = $params['postId'] ?? 0;
if ( empty( $block_content ) ) {
return new WP_Error(
'no_content',
__( 'Block content is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Get provider for writing task.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$provider = $provider_result->provider;
$messages = array(
array(
'role' => 'system',
'content' => 'You are an expert technical writer. Rewrite the provided content to improve it while maintaining the same meaning and key information.',
),
array(
'role' => 'user',
'content' => "Context: {$context}\n\nOriginal content:\n\n{$block_content}\n\nPlease rewrite this content.",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' );
if ( is_wp_error( $response ) ) {
// Track failed attempt for observability.
$this->track_ai_cost(
$post_id,
WPAW_Model_Registry::get_default_model( 'writing' ),
'regeneration',
0,
0,
0,
$provider_result,
'',
'error'
);
return new WP_Error(
'regeneration_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Track cost (always track for debugging).
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'regeneration',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
'',
'success'
);
return new WP_REST_Response(
array(
'content' => $response['content'],
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle get cost tracking request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response Response.
*/
public function handle_get_cost_tracking( $request ) {
$post_id = $request->get_param( 'post_id' );
// Check post-specific permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
$data = $cost_tracker->get_frontend_data( $post_id );
return new WP_REST_Response( $data, 200 );
}
/**
* Extract JSON from string.
*
* @since 0.1.0
* @param string $string String containing JSON.
* @return array|null Decoded JSON or null if invalid.
*/
private function extract_json( $string ) {
$string = trim( (string) $string );
if ( '' === $string ) {
return null;
}
// Method 1: JSON wrapped in markdown code block.
if ( preg_match_all( '/```(?:json)?\s*([\s\S]*?)```/i', $string, $matches ) ) {
foreach ( $matches[1] as $candidate ) {
$json = json_decode( trim( $candidate ), true );
if ( json_last_error() === JSON_ERROR_NONE ) {
return $json;
}
}
}
// Method 2: Decode the whole string.
$json = json_decode( $string, true );
if ( json_last_error() === JSON_ERROR_NONE ) {
return $json;
}
// Method 3: Extract balanced JSON object/array candidates. This avoids
// greedy matching across multiple objects or explanatory braces.
$candidates = array_merge(
$this->extract_balanced_json_candidates( $string, '{', '}' ),
$this->extract_balanced_json_candidates( $string, '[', ']' )
);
foreach ( $candidates as $candidate ) {
$json = json_decode( $candidate, true );
if ( json_last_error() === JSON_ERROR_NONE ) {
return $json;
}
}
return null;
}
/**
* Extract balanced JSON object candidates from model text.
*
* @since 0.2.2
* @param string $string Source text.
* @return array
*/
private function extract_balanced_json_candidates( $string, $open_char = '{', $close_char = '}' ) {
$candidates = array();
$length = strlen( $string );
$depth = 0;
$start = null;
$in_string = false;
$escaped = false;
for ( $i = 0; $i < $length; $i++ ) {
$char = $string[ $i ];
if ( $in_string ) {
if ( $escaped ) {
$escaped = false;
} elseif ( '\\' === $char ) {
$escaped = true;
} elseif ( '"' === $char ) {
$in_string = false;
}
continue;
}
if ( '"' === $char ) {
$in_string = true;
continue;
}
if ( $open_char === $char ) {
if ( 0 === $depth ) {
$start = $i;
}
$depth++;
} elseif ( $close_char === $char && $depth > 0 ) {
$depth--;
if ( 0 === $depth && null !== $start ) {
$candidates[] = substr( $string, $start, $i - $start + 1 );
$start = null;
}
}
}
usort(
$candidates,
function( $a, $b ) {
return strlen( $b ) <=> strlen( $a );
}
);
return $candidates;
}
/**
* Extract an article plan from model output, falling back to markdown outlines.
*
* @since 0.2.2
* @param string $content Model response.
* @param string $fallback_title Fallback title/topic.
* @param array $previous_plan Previous plan for revisions.
* @return array|null
*/
private function extract_plan_from_response( $content, $fallback_title = '', $previous_plan = array() ) {
$json = $this->extract_json( $content );
$normalized_json_plan = $this->normalize_extracted_plan_json( $json, $fallback_title );
if ( ! empty( $normalized_json_plan['sections'] ) ) {
return $normalized_json_plan;
}
$markdown_plan = $this->build_plan_from_markdown_outline( $content, $fallback_title, $previous_plan );
if ( ! empty( $markdown_plan['sections'] ) ) {
return $markdown_plan;
}
return null;
}
/**
* Build a short, safe preview of unparseable model output.
*
* @since 0.2.3
* @param string $content Model response.
* @return string
*/
private function build_model_output_preview( $content ) {
$preview = trim( wp_strip_all_tags( (string) $content ) );
$preview = preg_replace( '/\s+/', ' ', $preview );
if ( function_exists( 'mb_substr' ) ) {
$preview = mb_substr( $preview, 0, 240 );
} else {
$preview = substr( $preview, 0, 240 );
}
return '' !== $preview ? $preview : '(empty response)';
}
/**
* Normalize common model outline JSON variants into the required plan schema.
*
* @since 0.2.3
* @param mixed $json Decoded model JSON.
* @param string $fallback_title Fallback title/topic.
* @return array|null
*/
private function normalize_extracted_plan_json( $json, $fallback_title = '' ) {
if ( ! is_array( $json ) ) {
return null;
}
// Some models return the sections array directly.
if ( array_is_list( $json ) ) {
$json = array(
'title' => $fallback_title,
'sections' => $json,
);
}
// Some models nest the outline under a descriptive top-level key.
foreach ( array( 'plan', 'outline', 'article_plan', 'articlePlan', 'data' ) as $key ) {
if ( empty( $json['sections'] ) && isset( $json[ $key ] ) && is_array( $json[ $key ] ) ) {
$nested = $this->normalize_extracted_plan_json( $json[ $key ], $fallback_title );
if ( ! empty( $nested['sections'] ) ) {
return $nested;
}
}
}
$section_keys = array( 'sections', 'outline', 'items', 'chapters', 'headings', 'bagian' );
$sections = array();
foreach ( $section_keys as $key ) {
if ( ! empty( $json[ $key ] ) && is_array( $json[ $key ] ) ) {
$sections = $json[ $key ];
break;
}
}
if ( empty( $sections ) ) {
return null;
}
$title = $json['title'] ?? $json['judul'] ?? $json['headline'] ?? $fallback_title;
$title = $this->clean_outline_heading( $title );
if ( '' === $title ) {
$title = __( 'Article Outline', 'wp-agentic-writer' );
}
$normalized_sections = array();
foreach ( $sections as $index => $section ) {
if ( is_string( $section ) ) {
$section = array( 'heading' => $section );
}
if ( ! is_array( $section ) ) {
continue;
}
$heading = $section['heading']
?? $section['title']
?? $section['judul']
?? $section['name']
?? $section['h2']
?? sprintf( 'Section %d', $index + 1 );
$heading = $this->clean_outline_heading( $heading );
if ( '' === $heading ) {
continue;
}
$content_items = $section['content']
?? $section['description']
?? $section['summary']
?? $section['points']
?? $section['bullets']
?? array();
$content = $this->normalize_plan_section_content_items( $content_items );
if ( empty( $content ) ) {
$content[] = array(
'type' => 'paragraph',
'content' => $heading,
);
}
$normalized_sections[] = array(
'id' => sanitize_key( $section['id'] ?? '' ),
'status' => sanitize_key( $section['status'] ?? 'pending' ),
'type' => sanitize_key( $section['type'] ?? 'section' ),
'heading' => $heading,
'content' => $content,
);
}
if ( empty( $normalized_sections ) ) {
return null;
}
$meta = isset( $json['meta'] ) && is_array( $json['meta'] ) ? $json['meta'] : array();
return array(
'title' => $title,
'meta' => wp_parse_args(
$meta,
array(
'reading_time' => '5 min',
'difficulty' => 'intermediate',
'cost_estimate' => 0.70,
)
),
'sections' => $normalized_sections,
);
}
/**
* Normalize varied model section content into plan content items.
*
* @since 0.2.3
* @param mixed $items Section content candidate.
* @return array
*/
private function normalize_plan_section_content_items( $items ) {
if ( is_string( $items ) ) {
$items = array( $items );
}
if ( ! is_array( $items ) ) {
return array();
}
$normalized = array();
foreach ( $items as $item ) {
if ( is_string( $item ) ) {
$text = trim( wp_strip_all_tags( $item ) );
if ( '' !== $text ) {
$normalized[] = array(
'type' => 'paragraph',
'content' => $text,
);
}
continue;
}
if ( ! is_array( $item ) ) {
continue;
}
$text = $item['content'] ?? $item['text'] ?? $item['description'] ?? $item['point'] ?? '';
$text = trim( wp_strip_all_tags( (string) $text ) );
if ( '' === $text ) {
continue;
}
$normalized[] = array(
'type' => sanitize_key( $item['type'] ?? 'paragraph' ),
'content' => $text,
);
}
return $normalized;
}
/**
* Build a plan schema from markdown/numbered outline output.
*
* @since 0.2.2
* @param string $content Model response.
* @param string $fallback_title Fallback title/topic.
* @param array $previous_plan Previous plan for revisions.
* @return array|null
*/
private function build_plan_from_markdown_outline( $content, $fallback_title = '', $previous_plan = array() ) {
$lines = preg_split( '/\r\n|\r|\n/', (string) $content );
if ( ! is_array( $lines ) ) {
return null;
}
$title = '';
$sections = array();
$current = null;
foreach ( $lines as $raw_line ) {
$line = trim( wp_strip_all_tags( (string) $raw_line ) );
if ( '' === $line ) {
continue;
}
$line = preg_replace( '/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/', '$1', $line );
$heading = '';
if ( preg_match( '/^#{1,2}\s+(.+)$/', $line, $matches ) ) {
$text = $this->clean_outline_heading( $matches[1] );
if ( '' === $title ) {
$title = $text;
continue;
}
$heading = $text;
} elseif ( preg_match( '/^\d+[\.)]\s+(.+)$/', $line, $matches ) ) {
$heading = $this->clean_outline_heading( $matches[1] );
} elseif ( preg_match( '/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i', $line, $matches ) ) {
$heading = $this->clean_outline_heading( $matches[1] );
} elseif ( '' === $title && ! preg_match( '/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i', $line ) ) {
$title = $this->clean_outline_heading( $line );
continue;
}
if ( '' !== $heading && ! preg_match( '/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i', $heading ) ) {
if ( null !== $current ) {
$sections[] = $current;
}
$current = array(
'id' => wp_generate_uuid4(),
'status' => 'pending',
'type' => 'section',
'heading' => $heading,
'content' => array(),
);
continue;
}
if ( null !== $current ) {
$detail = preg_replace( '/^[-*]\s+/', '', $line );
if ( '' !== $detail && ! preg_match( '/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i', $detail ) ) {
$current['content'][] = array(
'type' => 'paragraph',
'content' => $detail,
);
}
}
}
if ( null !== $current ) {
$sections[] = $current;
}
if ( empty( $sections ) ) {
return null;
}
if ( '' === $title ) {
$title = $this->clean_outline_heading( $fallback_title );
}
if ( '' === $title && ! empty( $previous_plan['title'] ) ) {
$title = (string) $previous_plan['title'];
}
if ( '' === $title ) {
$title = __( 'Article Outline', 'wp-agentic-writer' );
}
return array(
'title' => $title,
'meta' => array(
'reading_time' => '5 min',
'difficulty' => 'intermediate',
'cost_estimate' => 0.70,
),
'sections' => $sections,
);
}
/**
* Clean markdown decoration from an outline heading.
*
* @since 0.2.2
* @param string $heading Heading text.
* @return string
*/
private function clean_outline_heading( $heading ) {
$heading = trim( (string) $heading );
$heading = preg_replace( '/^\s*["\'`]+|["\'`]+\s*$/', '', $heading );
$heading = preg_replace( '/\*\*(.*?)\*\*/', '$1', $heading );
$heading = preg_replace( '/\s+/', ' ', $heading );
return trim( $heading );
}
/**
* Handle get models request.
*
* @since 0.1.0
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_models() {
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->get_cached_models();
if ( is_wp_error( $models ) ) {
return $models;
}
return new WP_REST_Response( $models, 200 );
}
/**
* Handle refresh models request.
*
* @since 0.1.0
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_refresh_models() {
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->fetch_and_cache_models( true );
if ( is_wp_error( $models ) ) {
return $models;
}
return new WP_REST_Response(
array(
'models' => $models,
'message' => __( 'Models refreshed successfully.', 'wp-agentic-writer' ),
),
200
);
}
/**
* Handle check clarity request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_check_clarity( $request ) {
$params = $request->get_json_params();
$topic = $params['topic'] ?? '';
$answers = $params['answers'] ?? array();
$post_id = $params['postId'] ?? 0;
$mode = $params['mode'] ?? 'generation';
$chat_history = $params['chatHistory'] ?? array();
if ( empty( $topic ) ) {
return new WP_Error(
'no_topic',
__( 'Topic is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission BEFORE reading post data.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Only read post config after permission check.
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
$preferred_language = $this->resolve_language_preference( $post_config, '' );
$language_hint = '';
if ( 'auto' !== ( $post_config['language'] ?? 'auto' ) ) {
$language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language.";
}
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$provider = $provider_result->provider;
// Get settings.
$settings = get_option( 'wp_agentic_writer_settings', array() );
$enabled = $settings['enable_clarification_quiz'] ?? true;
$threshold = $settings['clarity_confidence_threshold'] ?? '0.6';
$required_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
'target_audience',
'tone',
'content_depth',
'expertise_level',
'content_type',
'pov',
);
// If quiz is disabled, skip AI questions but still add MANDATORY config questions
if ( ! $enabled ) {
$result = array(
'is_clear' => true,
'confidence' => 1.0,
'questions' => array(),
);
// MANDATORY: Always add config questions (language, focus keyword)
$result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
if ( ! empty( $result['questions'] ) ) {
$result['is_clear'] = false; // Force quiz for config questions
}
return new WP_REST_Response(
array(
'result' => $result,
'cost' => 0,
),
200
);
}
// Build context from answers if available.
$context = '';
if ( ! empty( $answers ) ) {
$context = "\n\nPrevious answers:\n";
foreach ( $answers as $answer ) {
$context .= "- {$answer['question']}: {$answer['answer']}\n";
}
}
// Build chat history context for continuity.
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- CONVERSATION HISTORY (IMPORTANT - use this context!) ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION HISTORY ---\n";
$chat_history_context .= "\nIMPORTANT: The user's current request \"" . $topic . "\" is a CONTINUATION of the above conversation. Extract topic/context from the chat history. If the conversation already discussed a specific topic, the user likely wants to create an outline for THAT topic. Do NOT ask \"what topic?\" if it's already clear from the conversation.";
}
$memory_context = $this->get_post_memory_context( $post_id );
$followup_hint = '';
if ( 'refinement' === $mode && ! empty( $memory_context ) ) {
$followup_hint = "\n\nThis is a follow-up request to an existing article. Use the post memory below to avoid asking generic questions already covered unless the request is ambiguous within that context.";
}
// Also treat chat history as follow-up context.
if ( ! empty( $chat_history_context ) ) {
$followup_hint .= "\n\nThis request continues from a previous chat conversation. Use the conversation history to understand what the user wants.";
}
$system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively.
IMPORTANT RULES:
1. DETECT LANGUAGE: Identify the user's language (Indonesian, English, etc.) and write ALL questions in that SAME language
2. PRIORITIZE TOPIC CONTEXT: Ask about the topic/scope/platform FIRST before writing-style questions
3. USE OPEN-TEXT for complex questions that need detailed explanations
4. USE MULTIPLE CHOICE only for simple binary/selection questions
EVALUATION CATEGORIES (in priority order):
**TOPIC-SPECIFIC CONTEXT (Most Important):**
1. topic_scope - What specific aspects should be covered? (e.g., for \"page builder\": platforms covered? WordPress only? Comparison? Features? Use cases?)
2. target_platform - Which platform/tool? (WordPress/Shopify/Webflow/Generic/Multiple/etc)
3. specific_focus - What angle? (Technical tutorial/Business benefits/Comparison/Getting started/Best practices)
4. missing_info - What key details are unclear?
**WRITING CONTEXT (Secondary):**
5. target_outcome - What should this achieve? (Education/Marketing/Sales/Comparison/Tutorial/Opinion)
6. target_audience - Who reads this? (Beginners/Developers/Business owners/Marketers/General audience)
7. content_depth - How detailed? (Quick overview/Standard guide/Comprehensive/Technical deep-dive)
QUESTION TYPES:
1. **single_choice** - For simple selections (one answer):
Use for: platform, outcome, audience type, depth level
Example:
{
'id': 'q1',
'category': 'target_platform',
'question': 'Platform apa yang ingin dibahas? (What platform to focus on?)',
'type': 'single_choice',
'options': [
{ 'value': 'WordPress only', 'default': true },
{ 'value': 'Comparison: WordPress vs others', 'default': false },
{ 'value': 'General page builders (multiple platforms)', 'default': false },
{ 'value': 'Specific platform (mention below)', 'default': false }
]
}
2. **multiple_choice** - For selecting multiple items:
Use for: topics to cover, platforms to compare
Example:
{
'id': 'q2',
'category': 'topic_scope',
'question': 'Apa yang harus dibahas? (What to cover?)',
'type': 'multiple_choice',
'options': [
{ 'value': 'Benefits/advantages', 'default': true },
{ 'value': 'How to choose', 'default': true },
{ 'value': 'Popular plugins/platforms', 'default': false },
{ 'value': 'Use cases/examples', 'default': false },
{ 'value': 'Technical details', 'default': false }
]
}
3. **open_text** - For detailed explanations (RECOMMENDED for complex topics):
Use for: scope clarification, specific requirements, custom details
Example:
{
'id': 'q3',
'category': 'topic_scope',
'question': 'Jelaskan lebih detail apa yang ingin Anda bahas tentang page builder. Apakah ada platform spesifik? Fokus ke mana? (Explain in detail what you want to cover about page builders. Any specific platform? What focus?)',
'type': 'open_text',
'placeholder': 'Contoh: Fokus ke WordPress plugin seperti Elementor, Divi, Brizy. Jelaskan kelebihan masing-masing...',
'max_length': 500
}
CONFIDENCE CALCULATION:
- Start at 100% (1.0)
- Subtract 15% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
- Subtract 10% for each missing SECONDARY category (target_outcome, target_audience, content_depth)
- CRITICAL: If chat history exists with detailed discussion, ADD 20% confidence bonus (user already provided context)
- If confidence < {$threshold}, generate questions starting with HIGH-PRIORITY
- MANDATORY: Even if confidence >= threshold, ALWAYS ask at least 1-2 system config questions (language, SEO settings)
QUESTION GENERATION STRATEGY:
1. Always detect user language first and match it
2. If topic is vague (e.g., \"page builder\" without platform), ask open_text about scope
3. If multiple possible platforms, ask single_choice to narrow down
4. If multiple topics could apply, ask multiple_choice
5. Only ask writing-style questions if topic context is already clear
Return ONLY valid JSON:
{
'is_clear': true/false,
'confidence': 0.0-1.0,
'detected_language': 'indonesian'|'english'|'other',
'missing_categories': ['topic_scope', 'target_platform'],
'questions': [ ... ]
}
No markdown, no explanation - just JSON.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nRequired Categories: " . implode( ', ', $required_categories ) . "\n\nEvaluate this request and determine which context is missing.{$chat_history_context}{$context}{$post_config_context}{$memory_context}{$followup_hint}{$language_hint}",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
if ( is_wp_error( $response ) ) {
// Track failed attempt for observability.
$this->track_ai_cost(
$post_id,
WPAW_Model_Registry::get_default_model( 'clarity' ),
'clarity_check',
0,
0,
0,
$provider_result,
'',
'error'
);
// Log error and use default questions instead of failing.
error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() );
$result = $this->get_default_clarification_questions( $topic );
// MANDATORY: Always add config questions
$result['questions'] = $this->append_config_questions( $result['questions'] ?? array(), $post_config );
if ( ! empty( $result['questions'] ) ) {
$result['is_clear'] = false;
}
return new WP_REST_Response(
array(
'result' => $result,
'cost' => 0,
),
200
);
}
// Extract JSON from response.
$content = $response['content'];
$result = $this->extract_json( $content );
if ( null === $result ) {
// Track parse failure for observability.
$this->track_ai_cost(
$post_id,
$response['model'] ?? 'unknown',
'clarity_check',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
'',
'error'
);
// Log parse error and use default questions instead of failing.
error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' );
$result = $this->get_default_clarification_questions( $topic );
// MANDATORY: Always add config questions
$result['questions'] = $this->append_config_questions( $result['questions'] ?? array(), $post_config );
if ( ! empty( $result['questions'] ) ) {
$result['is_clear'] = false;
}
return new WP_REST_Response(
array(
'result' => $result,
'cost' => 0,
),
200
);
}
// Track cost (always track for debugging).
$post_id = $params['postId'] ?? 0;
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'clarity_check',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
'',
'success'
);
// MANDATORY: Always add configuration questions
if ( ! isset( $result['questions'] ) || ! is_array( $result['questions'] ) ) {
$result['questions'] = array();
}
$result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
// CRITICAL: Always show quiz if config questions exist (system questions are MANDATORY)
if ( ! empty( $result['questions'] ) ) {
$result['is_clear'] = false; // Force quiz to show - config questions are mandatory
}
return new WP_REST_Response(
array(
'result' => $result,
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Append configuration questions to clarity quiz.
*
* @since 0.1.0
* @param array $questions Existing questions.
* @param array $post_config Post configuration.
* @return array Updated questions with config prompts.
*/
private function append_config_questions( $questions, $post_config ) {
$detected_language = $post_config['language'] ?? 'auto';
$is_indonesian = ( 'Indonesian' === $detected_language );
// Get preferred languages from settings
$settings = get_option( 'wp_agentic_writer_settings', array() );
$preferred_languages = array_merge(
$settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ),
$settings['custom_languages'] ?? array()
);
// Build language options from site preferences
$language_options = array();
foreach ( $preferred_languages as $lang ) {
$language_options[] = array(
'value' => $lang,
'default' => ( 'auto' === $lang ),
);
}
// Language selection question (FIRST)
$questions[] = array(
'id' => 'config_language',
'category' => 'config',
'question' => $is_indonesian
? '🌍 Pilih Bahasa Artikel (Select Article Language)'
: '🌍 Select Article Language',
'type' => 'single_choice',
'options' => $language_options,
);
// Single consolidated config question with all fields
$questions[] = array(
'id' => 'config_all',
'category' => 'config',
'question' => $is_indonesian
? '⚙️ Konfigurasi Artikel (Article Configuration)'
: '⚙️ Article Configuration',
'type' => 'config_form',
'fields' => array(
array(
'id' => 'web_search',
'label' => $is_indonesian
? '🔍 Pencarian Web (Web Search)'
: '🔍 Web Search',
'description' => $is_indonesian
? 'Aktifkan untuk data terkini (~$0.02/pencarian)'
: 'Enable for current data (~$0.02/search)',
'type' => 'toggle',
'default' => false,
),
array(
'id' => 'seo',
'label' => $is_indonesian
? '📊 Optimasi SEO (SEO Optimization)'
: '📊 SEO Optimization',
'description' => $is_indonesian
? 'Optimalkan artikel untuk mesin pencari'
: 'Optimize article for search engines',
'type' => 'toggle',
'default' => true,
),
array(
'id' => 'focus_keyword',
'label' => $is_indonesian
? '🎯 Kata Kunci Fokus (Focus Keyword)'
: '🎯 Focus Keyword',
'placeholder' => $is_indonesian ? 'Contoh: wordpress plugin' : 'Example: wordpress plugin',
'type' => 'text',
'max_length' => 100,
'conditional' => 'seo',
'default' => $post_config['seo_focus_keyword'] ?? '',
'description' => ! empty( $post_config['seo_focus_keyword'] )
? ( $is_indonesian ? '💡 Disarankan AI - edit jika perlu' : '💡 AI-suggested - edit if needed' )
: '',
),
array(
'id' => 'secondary_keywords',
'label' => $is_indonesian
? '🔑 Kata Kunci Sekunder (Secondary Keywords)'
: '🔑 Secondary Keywords',
'placeholder' => $is_indonesian ? 'Pisahkan dengan koma' : 'Comma-separated',
'type' => 'text',
'max_length' => 200,
'conditional' => 'seo',
'default' => $post_config['seo_secondary_keywords'] ?? '',
'description' => ! empty( $post_config['seo_secondary_keywords'] )
? ( $is_indonesian ? '💡 Disarankan AI - edit jika perlu' : '💡 AI-suggested - edit if needed' )
: '',
),
),
);
return $questions;
}
/**
* Handle block refine request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_block_refine( $request ) {
$params = $request->get_json_params();
$block_id = $params['blockId'] ?? '';
$block_type = $params['blockType'] ?? '';
$block_content = $params['blockContent'] ?? '';
$refinement_request = $params['refinementRequest'] ?? '';
$article_context = $params['articleContext'] ?? array();
$post_id = $params['postId'] ?? 0;
$stream = $params['stream'] ?? false;
$chat_history = $params['chatHistory'] ?? array();
if ( empty( $block_content ) || empty( $refinement_request ) ) {
return new WP_Error(
'missing_data',
__( 'Block content and refinement request are required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission BEFORE reading post data.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Only read post config after permission check.
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
// If streaming is requested, use streaming response.
if ( $stream ) {
return $this->stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config );
}
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
$provider = $provider_result->provider;
// Build context from article structure.
$context_str = "\n\nArticle Context:\n";
$context_str .= "Title: " . ( $article_context['title'] ?? 'Unknown' ) . "\n";
if ( ! empty( $article_context['previousBlock'] ) ) {
$context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n";
}
$context_str .= "Current block type: " . $block_type . "\n";
$context_str .= "Current content:\n" . $block_content . "\n";
if ( ! empty( $article_context['nextBlock'] ) ) {
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
// Add chat history context if available
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- ORIGINAL CONVERSATION ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) && 'system' !== strtolower( $msg['role'] ?? '' ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION ---\n";
$chat_history_context .= "This shows the original discussion that led to this article.";
}
// Add plan context if available
$plan_context = '';
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( ! empty( $plan ) && is_array( $plan ) ) {
$plan_context = "\n\nOriginal Article Outline:\n";
if ( ! empty( $plan['title'] ) ) {
$plan_context .= "Title: {$plan['title']}\n";
}
if ( ! empty( $plan['sections'] ) && is_array( $plan['sections'] ) ) {
foreach ( $plan['sections'] as $section ) {
$heading = $section['heading'] ?? $section['title'] ?? '';
if ( ! empty( $heading ) ) {
$plan_context .= "- {$heading}\n";
}
}
}
}
$system_prompt = "You are an expert editor helping refine a specific section of an article.
{$context_str}
{$plan_context}
{$chat_history_context}
USER REQUEST: {$refinement_request}
TASK:
Refine the current section content considering:
1. How it fits into the overall article flow
2. Consistency with surrounding sections
3. The original intent from the conversation and outline
4. The user's specific refinement request
5. Maintaining the original key information
Provide the refined content in Markdown format.
Keep the same block type (paragraph, heading, list, etc.).";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Please refine this content.",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'execution' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'refinement_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Parse refined content as Gutenberg blocks.
$blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] );
// Track cost (always track for debugging).
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'block_refinement',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id,
'success'
);
return new WP_REST_Response(
array(
'blocks' => $blocks,
'blockId' => $block_id,
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Stream block refinement response.
*
* @since 0.1.0
* @param string $block_id Block ID.
* @param string $block_type Block type.
* @param string $block_content Block content.
* @param string $refinement_request Refinement request.
* @param array $article_context Article context.
* @param int $post_id Post ID.
* @return void Streams response to client.
*/
private function stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config = array() ) {
// Set headers for streaming.
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering.
// Flush output buffer to ensure immediate streaming.
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
$provider = $provider_result->provider;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'refined content' );
try {
// Build context from article structure.
$context_str = "\n\nArticle Context:\n";
$context_str .= "Title: " . ( $article_context['title'] ?? 'Unknown' ) . "\n";
if ( ! empty( $article_context['previousBlock'] ) ) {
$context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n";
}
$context_str .= "Current block type: " . $block_type . "\n";
$context_str .= "Current content:\n" . $block_content . "\n";
if ( ! empty( $article_context['nextBlock'] ) ) {
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
$system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request.
ANTI-ROBOT RULES:
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
- Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested.
- Increase specificity and information density. Do not just increase word count with conversational filler.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
{$context_str}
USER REQUEST: {$refinement_request}
IMPORTANT RULES:
1. Rewrite the content to fulfill the refinement request
2. Maintain the core meaning and key information
3. Ensure it flows well with surrounding sections
4. Match the article's overall tone and style
5. Return ONLY the refined content, no explanations or conversational text
Output format:
- If paragraph: Return the refined text only
- If heading: Return the refined heading text only
- If list: Return the list items, one per line
- No markdown formatting like ```text`` wrappers
- No conversational filler
- Start directly with the refined content";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Refine this content.",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'execution' );
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
// Track cost (always track for debugging).
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'block_refinement',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id ?? '',
'success'
);
$payload = $this->parse_refined_payload( $response['content'] );
$refined_content = $this->clean_refined_content( $payload['content'] );
$resolved_block_type = $payload['blockType'] ?? $block_type;
// Parse as block based on type and create proper Gutenberg block structure
$block_data = array();
$block_name = 'core/paragraph'; // Default
if ( $resolved_block_type === 'core/paragraph' ) {
$block_name = 'core/paragraph';
$block_attrs = array( 'content' => $refined_content );
// Create proper HTML for paragraph
$block_html = '<p>' . $refined_content . '</p>';
// Create proper block structure
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
} elseif ( $resolved_block_type === 'core/heading' ) {
$block_name = 'core/heading';
// Detect heading level from markdown-style if present
$level = 2;
if ( preg_match( '/^(#{1,6})\s/', $refined_content ) ) {
$count = strspn( $refined_content, '#' );
$level = min( $count, 6 );
$refined_content = trim( substr( $refined_content, $count ) );
}
$block_attrs = array(
'level' => $level,
'content' => $refined_content,
);
$tag = 'h' . $level;
$block_html = "<{$tag}>{$refined_content}</{$tag}>";
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
} elseif ( $resolved_block_type === 'core/list' ) {
$block_name = 'core/list';
$lines = explode( "\n", $refined_content );
$lines = array_filter( array_map( 'trim', $lines ) );
// Create inner blocks for list items
$inner_blocks = array();
foreach ( $lines as $line ) {
$inner_blocks[] = array(
'blockName' => 'core/list-item',
'attrs' => array( 'content' => $line ),
'innerHTML' => '<li>' . $line . '</li>',
);
}
$block_attrs = array( 'ordered' => false );
$block_html = '<ul>' . implode( '', array_map( function( $item ) {
return $item['innerHTML'];
}, $inner_blocks ) ) . '</ul>';
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerBlocks' => $inner_blocks,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
} else {
// Fallback to paragraph for unknown types
$block_name = 'core/paragraph';
$block_attrs = array( 'content' => $refined_content );
$block_html = '<p>' . $refined_content . '</p>';
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
}
// Send the refined block
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block_data,
)
) . "\n\n";
flush();
// Small delay for visual effect
usleep( 100000 );
// Send completion message with provider metadata.
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'blockId' => $block_id,
'totalCost' => $response['cost'],
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
)
) . "\n\n";
flush();
} catch ( Exception $e ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $e->getMessage(),
)
) . "\n\n";
flush();
}
exit;
}
/**
* Check clarity before article generation.
*
* @since 0.1.0
* @param string $topic User topic.
* @param array $answers Previous answers.
* @param WP_Agentic_Writer_OpenRouter_Provider $provider OpenRouter provider.
* @return array Clarity check result with is_clear and questions.
*/
private function check_clarity_before_generation( $topic, $answers, $provider ) {
// Get settings.
$settings = get_option( 'wp_agentic_writer_settings', array() );
$enabled = $settings['enable_clarification_quiz'] ?? true;
$threshold = $settings['clarity_confidence_threshold'] ?? '0.6';
$required_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
'target_audience',
'tone',
'content_depth',
'expertise_level',
'content_type',
'pov',
);
// If quiz is disabled, always return clear.
if ( ! $enabled ) {
return array( 'is_clear' => true, 'confidence' => 1.0, 'questions' => array() );
}
// Build context from answers if available.
$context = '';
if ( ! empty( $answers ) ) {
$context = "\n\nPrevious answers:\n";
foreach ( $answers as $answer ) {
$context .= "- {$answer['question']}: {$answer['answer']}\n";
}
}
$system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively.
Evaluate the user's request and determine which context categories are clear:
CATEGORIES TO EVALUATE:
1. target_outcome - What should this content achieve? (education/marketing/sales/entertainment/brand_awareness)
2. target_audience - Who is reading this? (demographics, role, knowledge level)
3. tone - How should we sound? (formal/casual/technical/friendly/professional/conversational)
4. content_depth - How comprehensive? (quick_overview/standard_guide/detailed_analysis/comprehensive)
5. expertise_level - Reader's knowledge? (beginner/intermediate/advanced/expert)
6. content_type - What format? (tutorial/how_to/opinion/comparison/listicle/case_study/news_analysis)
7. pov - Whose perspective? (first_person/third_person/expert_voice/neutral)
For each MISSING category, generate a clarifying question using PREDEFINED OPTIONS.
Use 'single_choice' or 'multiple_choice' types - NEVER 'open_text'.
QUESTION STRUCTURE:
{
'id': 'q1',
'category': 'target_outcome',
'question': 'What is the primary goal of this content?',
'type': 'single_choice',
'options': [
{ 'value': 'Education - Teach something new', 'default': true },
{ 'value': 'Marketing - Promote a product/service', 'default': false },
{ 'value': 'Sales - Drive conversions/signups', 'default': false },
{ 'value': 'Entertainment - Engage and entertain', 'default': false },
{ 'value': 'Brand Awareness - Build authority/trust', 'default': false }
]
}
CONFIDENCE CALCULATION:
- Start at 100% (1.0)
- Subtract 15% for each missing required category
- If confidence < {$threshold}, generate questions for ALL missing categories
Return ONLY valid JSON with this structure:
{
'is_clear': true/false,
'confidence': 0.0-1.0,
'missing_categories': ['category1', 'category2'],
'questions': [ ... ]
}
No markdown, no explanation - just JSON.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nRequired Categories: " . implode( ', ', $required_categories ) . "\n\nEvaluate this request and determine which context is missing.{$context}",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
if ( is_wp_error( $response ) ) {
// Log error and use default questions instead of skipping.
error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() );
return $this->get_default_clarification_questions( $topic );
}
// Extract JSON from response.
$content = $response['content'];
$result = $this->extract_json( $content );
if ( null === $result ) {
// Log parse error and use default questions instead of skipping.
error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' );
return $this->get_default_clarification_questions( $topic );
}
return $result;
}
/**
* Send status update via SSE.
*
* @since 0.1.0
* @param string $status Status code.
* @param string $message Status message.
*/
private function send_status( $status, $message = '' ) {
$status_icons = array(
'starting' => '',
'planning' => '',
'plan_complete' => '',
'writing' => '',
'writing_section' => '',
'complete' => '',
);
$icon = isset( $status_icons[ $status ] ) ? $status_icons[ $status ] : '';
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'status' => $status,
'message' => $message,
'icon' => $icon,
)
) . "\n\n";
flush();
}
/**
* Get default clarification questions when AI fails.
*
* @since 0.1.0
* @param string $topic User's topic.
* @return array Clarification result with default questions.
*/
private function get_default_clarification_questions( $topic ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
$required_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
'target_audience',
'tone',
'content_depth',
'expertise_level',
'content_type',
'pov',
);
$questions = array();
$question_id = 1;
$question_templates = array(
'target_outcome' => array(
'category' => 'target_outcome',
'question' => 'What is the primary goal of this content?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Education - Teach something new', 'default' => true ),
array( 'value' => 'Marketing - Promote a product/service', 'default' => false ),
array( 'value' => 'Sales - Drive conversions', 'default' => false ),
array( 'value' => 'Entertainment - Engage readers', 'default' => false ),
array( 'value' => 'Brand Awareness - Build authority', 'default' => false ),
),
),
'target_audience' => array(
'category' => 'target_audience',
'question' => 'Who is the primary audience for this content?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'General public / Beginners', 'default' => true ),
array( 'value' => 'Professionals in the field', 'default' => false ),
array( 'value' => 'Potential customers', 'default' => false ),
array( 'value' => 'Existing customers/users', 'default' => false ),
array( 'value' => 'Industry peers / Experts', 'default' => false ),
),
),
'tone' => array(
'category' => 'tone',
'question' => 'What tone should this content have?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Professional & Authoritative', 'default' => true ),
array( 'value' => 'Friendly & Conversational', 'default' => false ),
array( 'value' => 'Technical & Detailed', 'default' => false ),
array( 'value' => 'Casual & Entertaining', 'default' => false ),
array( 'value' => 'Formal & Academic', 'default' => false ),
),
),
'content_depth' => array(
'category' => 'content_depth',
'question' => 'How comprehensive should this content be?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Quick overview (500-800 words)', 'default' => false ),
array( 'value' => 'Standard guide (800-1500 words)', 'default' => true ),
array( 'value' => 'Detailed analysis (1500-2500 words)', 'default' => false ),
array( 'value' => 'Comprehensive deep-dive (2500+ words)', 'default' => false ),
),
),
'expertise_level' => array(
'category' => 'expertise_level',
'question' => 'What is the target audience\'s expertise level?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Beginner - No prior knowledge', 'default' => true ),
array( 'value' => 'Intermediate - Basic understanding', 'default' => false ),
array( 'value' => 'Advanced - Deep technical knowledge', 'default' => false ),
array( 'value' => 'Expert - Industry professional', 'default' => false ),
),
),
'content_type' => array(
'category' => 'content_type',
'question' => 'What type of content works best for this topic?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Tutorial / How-to guide', 'default' => true ),
array( 'value' => 'Opinion / Commentary', 'default' => false ),
array( 'value' => 'Comparison / Review', 'default' => false ),
array( 'value' => 'Listicle / Tips', 'default' => false ),
array( 'value' => 'Case study', 'default' => false ),
array( 'value' => 'News analysis', 'default' => false ),
),
),
'pov' => array(
'category' => 'pov',
'question' => 'From what perspective should this be written?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Third person (objective, "it", "they")', 'default' => true ),
array( 'value' => 'First person (personal, "I", "my")', 'default' => false ),
array( 'value' => 'Expert voice (authoritative, experienced)', 'default' => false ),
array( 'value' => 'Neutral / Unbiased', 'default' => false ),
),
),
);
foreach ( $required_categories as $category ) {
if ( isset( $question_templates[ $category ] ) ) {
$q = $question_templates[ $category ];
$q['id'] = 'q' . $question_id++;
$questions[] = $q;
}
}
return array(
'is_clear' => false,
'confidence' => 0.0,
'missing_categories' => $required_categories,
'questions' => $questions,
);
}
/**
* Handle chat-based block refinement request.
*
* @since 0.1.0
* @param WP_REST_Request $request Full request data.
* @return void Streams response to client.
*/
public function handle_refine_from_chat( $request ) {
$params = $request->get_json_params();
$message = $params['topic'] ?? '';
$selected_block = $params['selectedBlockClientId'] ?? '';
$post_id = $params['postId'] ?? 0;
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
$blocks_to_refine = $params['blocksToRefine'] ?? array();
$all_blocks = $params['allBlocks'] ?? array();
$diff_plan = ! empty( $params['diffPlan'] );
$selective_refine = ! empty( $params['selectiveRefine'] );
if ( empty( $blocks_to_refine ) || ! is_array( $blocks_to_refine ) ) {
return new WP_Error(
'no_blocks_mentioned',
__( 'No valid blocks found to refine. Try mentioning blocks like @this, @previous, or specific blocks like @paragraph-1', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission BEFORE reading post data.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Only read post config after permission check.
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
// Stream refinement for each mentioned block
$this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config, $session_id, $selective_refine );
// Return early to avoid REST API trying to send headers after streaming
exit;
}
/**
* Save section-to-block mapping.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_save_section_blocks( $request ) {
$params = $request->get_json_params();
$post_id = intval( $params['postId'] ?? 0 );
$section_id = sanitize_text_field( $params['sectionId'] ?? '' );
$block_ids = $params['blockIds'] ?? array();
if ( $post_id <= 0 || empty( $section_id ) || ! is_array( $block_ids ) ) {
return new WP_Error(
'invalid_section_blocks',
__( 'Invalid section block mapping request.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$block_ids = array_values(
array_filter(
array_map( 'sanitize_text_field', $block_ids )
)
);
$mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true );
if ( ! is_array( $mapping ) ) {
$mapping = array();
}
$mapping[ $section_id ] = $block_ids;
update_post_meta( $post_id, '_wpaw_section_blocks', $mapping );
return new WP_REST_Response(
array(
'success' => true,
'sectionId' => $section_id,
'blockCount' => count( $block_ids ),
),
200
);
}
/**
* Get section-to-block mapping for a post.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_section_blocks( $request ) {
$post_id = intval( $request['post_id'] ?? 0 );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true );
if ( ! is_array( $mapping ) ) {
$mapping = array();
}
return new WP_REST_Response(
array(
'sectionBlocks' => $mapping,
),
200
);
}
/**
* Stream block refinement from chat to client.
*
* @since 0.1.0
* @param array $blocks_to_refine Array of block objects to refine (from editor).
* @param string $message User's refinement message.
* @param string $selected_block Currently selected block client ID.
* @param int $post_id Post ID.
* @return void Streams response to client.
*/
private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array(), $session_id = '', $selective_refine = false ) {
// Set headers for streaming.
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering.
// Flush output buffer to ensure immediate streaming.
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
try {
if ( $post_id > 0 ) {
$this->update_post_memory(
$post_id,
array(
'last_prompt' => $message,
'last_intent' => 'refine',
)
);
}
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
$provider = $provider_result->provider;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'refined content' );
$refined_count = 0;
$total_cost = 0.0;
$failed_count = 0;
$consecutive_errors = 0;
$max_consecutive_errors = 3;
$aborted_due_to_provider_errors = false;
$last_model_used = '';
$total_blocks_to_refine = is_array( $blocks_to_refine ) ? count( $blocks_to_refine ) : 0;
$batch_size = 5;
$batch_total = $total_blocks_to_refine > 0 ? (int) ceil( $total_blocks_to_refine / $batch_size ) : 0;
// Get post title for context
$post = get_post( $post_id );
$post_title = $post ? $post->post_title : 'Unknown';
// Normalize blocks for context
$context_blocks = array();
$block_source = is_array( $all_blocks ) && ! empty( $all_blocks ) ? $all_blocks : $this->select_blocks();
$allowed_block_ids = array_values(
array_filter(
array_map(
static function ( $block_obj ) {
return sanitize_text_field( $block_obj['clientId'] ?? '' );
},
is_array( $blocks_to_refine ) ? $blocks_to_refine : array()
)
)
);
foreach ( $block_source as $block ) {
$client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? '';
$block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph';
$block_attrs = $block['attributes'] ?? $block['attrs'] ?? array();
$content = $this->extract_block_content_from_attrs( $block_type, $block_attrs );
if ( empty( $client_id ) ) {
continue;
}
$context_blocks[] = array(
'clientId' => $client_id,
'type' => $block_type,
'content' => $content,
);
}
// Optional evaluator pass: classify blocks first, then refine only necessary ones.
if ( $selective_refine && count( $blocks_to_refine ) > 1 ) {
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => sprintf( 'Evaluating %d block(s) to select only necessary refinements...', count( $blocks_to_refine ) ),
)
) . "\n\n";
flush();
$eval_map = array();
foreach ( $blocks_to_refine as $block_obj ) {
$cid = sanitize_text_field( $block_obj['clientId'] ?? '' );
$bname = sanitize_text_field( $block_obj['name'] ?? 'core/paragraph' );
$battrs = $block_obj['attributes'] ?? array();
$txt = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $bname, $battrs ) ) );
if ( '' === $cid || '' === $txt ) {
continue;
}
if ( strlen( $txt ) > 220 ) {
$txt = substr( $txt, 0, 220 ) . '...';
}
$eval_map[] = array(
'blockId' => $cid,
'type' => $bname,
'text' => $txt,
);
}
if ( ! empty( $eval_map ) ) {
$eval_prompt = "You are a strict editor classifier.\n"
. "Task: decide which blocks NEED refinement for this user request.\n"
. "Return ONLY JSON: {\"keep\":[\"id\"],\"needs_refine\":[\"id\"],\"reasons\":{\"id\":\"short reason\"}}\n"
. "Rules: If block already satisfies request, keep it. Do not rewrite. Only classify.\n"
. "User request: {$message}\nBlocks:\n";
foreach ( $eval_map as $row ) {
$eval_prompt .= "- {$row['blockId']} | {$row['type']} | {$row['text']}\n";
}
$eval_response = $provider->chat(
array(
array( 'role' => 'system', 'content' => $eval_prompt ),
array( 'role' => 'user', 'content' => 'Classify now.' ),
),
array( 'temperature' => 0.1 ),
'planning'
);
if ( ! is_wp_error( $eval_response ) ) {
$eval_raw = trim( (string) ( $eval_response['content'] ?? '' ) );
if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $eval_raw, $m ) ) {
$eval_raw = trim( $m[1] );
}
$eval_json = json_decode( $eval_raw, true );
if ( is_array( $eval_json ) && isset( $eval_json['needs_refine'] ) && is_array( $eval_json['needs_refine'] ) ) {
$needs_lookup = array_fill_keys(
array_map( 'sanitize_text_field', $eval_json['needs_refine'] ),
true
);
$filtered = array_values(
array_filter(
$blocks_to_refine,
static function ( $block_obj ) use ( $needs_lookup ) {
$cid = sanitize_text_field( $block_obj['clientId'] ?? '' );
return isset( $needs_lookup[ $cid ] );
}
)
);
if ( ! empty( $filtered ) ) {
$before_count = count( $blocks_to_refine );
$blocks_to_refine = $filtered;
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => sprintf( 'Selective refinement: %1$d/%2$d block(s) need updates.', count( $blocks_to_refine ), $before_count ),
)
) . "\n\n";
flush();
}
}
}
}
}
if ( $diff_plan && ! empty( $context_blocks ) ) {
$plan_generation_failed = false;
$plan_prompt = "You are an editor planning precise block-level edits.
Return ONLY valid JSON in this format:
{
\"summary\": \"short summary\",
\"actions\": [
{\"action\": \"keep\", \"blockId\": \"...\"},
{\"action\": \"replace\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
{\"action\": \"insert_after\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
{\"action\": \"insert_before\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
{\"action\": \"delete\", \"blockId\": \"...\"},
{\"action\": \"change_type\", \"blockId\": \"...\", \"blockType\": \"core/list\", \"content\": \"...\"}
]
}
Rules:
- Keep actions minimal.
- Use blockId from the provided list only.
- If you need code, use blockType core/code and content with code only.
- For lists, use blockType core/list and content as one item per line.
- For headings, use blockType core/heading.
- For images, use blockType core/image and content as markdown image: ![alt](url).
- No explanations, no extra text, JSON only.
User request: {$message}
Allowed target block IDs (STRICT): " . implode( ', ', $allowed_block_ids ) . "
Blocks:
";
foreach ( $context_blocks as $index => $block ) {
$plan_prompt .= ($index + 1) . ". {$block['clientId']} | {$block['type']} | " . $block['content'] . "\n";
}
$plan_response = $provider->chat(
array(
array( 'role' => 'system', 'content' => $plan_prompt ),
array( 'role' => 'user', 'content' => 'Create the edit plan now.' ),
),
array( 'temperature' => 0.2 ),
'planning'
);
if ( ! is_wp_error( $plan_response ) ) {
// Track cost for edit plan generation
$plan_cost = $plan_response['cost'] ?? 0;
if ( $plan_cost > 0 ) {
$this->track_ai_cost(
$post_id,
$plan_response['model'] ?? '',
'refinement_planning',
$plan_response['input_tokens'] ?? 0,
$plan_response['output_tokens'] ?? 0,
$plan_cost,
$provider_result,
$session_id ?? '',
'success'
);
}
$raw_content = trim( $plan_response['content'] );
error_log( 'WP Agentic Writer: Edit plan raw response: ' . substr( $raw_content, 0, 500 ) );
// Strip markdown code blocks if present (```json ... ```)
$json_content = $raw_content;
if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $raw_content, $matches ) ) {
$json_content = trim( $matches[1] );
error_log( 'WP Agentic Writer: Extracted JSON from markdown code block' );
}
$plan_json = json_decode( $json_content, true );
if ( is_array( $plan_json ) && isset( $plan_json['actions'] ) ) {
$plan_json = $this->sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks );
if ( empty( $plan_json['actions'] ) ) {
$plan_generation_failed = true;
} else {
echo "data: " . wp_json_encode(
array(
'type' => 'edit_plan',
'plan' => $plan_json,
)
) . "\n\n";
flush();
exit;
}
} else {
error_log( 'WP Agentic Writer: Edit plan JSON decode failed or missing actions. JSON error: ' . json_last_error_msg() );
error_log( 'WP Agentic Writer: Attempted to parse: ' . substr( $json_content, 0, 200 ) );
$plan_generation_failed = true;
}
} else {
error_log( 'WP Agentic Writer: Edit plan API error: ' . $plan_response->get_error_message() );
$plan_generation_failed = true;
}
// Fallback path: when edit-plan fails (common on broad @all requests),
// continue with direct per-block refinement instead of hard failing.
if ( $plan_generation_failed ) {
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => 'Edit plan failed, switching to direct block refinement.',
)
) . "\n\n";
flush();
}
}
foreach ( $blocks_to_refine as $block_index_loop => $block_obj ) {
if ( 0 === ( $block_index_loop % $batch_size ) ) {
$current_batch = (int) floor( $block_index_loop / $batch_size ) + 1;
$batch_start = $block_index_loop + 1;
$batch_end = min( $total_blocks_to_refine, $block_index_loop + $batch_size );
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => sprintf(
'Processing batch %1$d/%2$d (blocks %3$d-%4$d of %5$d)',
$current_batch,
$batch_total,
$batch_start,
$batch_end,
$total_blocks_to_refine
),
)
) . "\n\n";
flush();
}
// Extract block data from the block object sent from frontend
$block_client_id = $block_obj['clientId'] ?? '';
$block_type = $block_obj['name'] ?? 'core/paragraph';
$block_attrs = $block_obj['attributes'] ?? array();
$block_content = $this->extract_block_content_from_attrs( $block_type, $block_attrs );
// Find block index in all blocks for context
$block_index = -1;
foreach ( $all_blocks as $i => $block ) {
if ( isset( $block['clientId'] ) && $block['clientId'] === $block_client_id ) {
$block_index = $i;
break;
}
}
// Build article context
$article_context = array(
'title' => $post_title,
'previousBlock' => $block_index > 0 ? $this->extract_heading_from_block( $all_blocks[ $block_index - 1 ] ) : null,
'nextBlock' => $block_index >= 0 && $block_index < count( $all_blocks ) - 1 ? $this->extract_heading_from_block( $all_blocks[ $block_index + 1 ] ) : null,
);
// Build refinement prompt
$memory_context = $this->get_post_memory_context( $post_id );
$context_str = "\n\nArticle Context:\n";
$context_str .= "Title: " . $post_title . "\n";
if ( ! empty( $article_context['previousBlock'] ) ) {
$context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n";
}
$context_str .= "Current block type: " . $block_type . "\n";
$context_str .= "Current content:\n" . $block_content . "\n";
$section_context = $this->build_section_context_for_block( $all_blocks, $block_index, 4 );
if ( ! empty( $section_context ) ) {
$context_str .= "Section context:\n" . $section_context . "\n";
}
if ( ! empty( $article_context['nextBlock'] ) ) {
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
$system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request.
ANTI-ROBOT RULES:
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
- Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested.
- Increase specificity and information density. Do not just increase word count with conversational filler.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
{$context_str}
{$memory_context}
USER REQUEST: {$message}
IMPORTANT RULES:
1. Rewrite the content to fulfill the refinement request
2. Maintain the core meaning and key information
3. Ensure it flows well with surrounding sections
4. Match the article's overall tone and style
5. Return ONLY the refined content payload, no explanations or conversational text
Output format:
- Return STRICT JSON ONLY: {\"content\":\"...\",\"blockType\":\"{$block_type}\"}
- Use content value only for the final refined block text
- If list: content should contain one item per line
- No markdown wrappers, no chain-of-thought, no \"Refined version\", no \"Key refinements\", no explanations";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Refine this content.",
),
);
// Use streaming for real-time feedback
$refined_content = '';
$stream_result = $provider->chat_stream(
$messages,
array( 'temperature' => 0.2 ),
'execution',
function( $chunk ) use ( &$refined_content ) {
// Accumulate the streaming content
$refined_content .= $chunk;
}
);
if ( is_wp_error( $stream_result ) ) {
$failed_count++;
$consecutive_errors++;
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $stream_result->get_error_message(),
)
) . "\n\n";
flush();
if ( $consecutive_errors >= $max_consecutive_errors ) {
$aborted_due_to_provider_errors = true;
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => sprintf(
'Stopped early after %d consecutive provider failures at block %d. Please retry with fewer blocks or check local backend health.',
(int) $consecutive_errors,
(int) $block_index_loop + 1
),
)
) . "\n\n";
flush();
break;
}
continue;
}
$consecutive_errors = 0;
// Track cost from streaming result (always track for debugging).
$stream_cost = $stream_result['cost'] ?? 0;
$last_model_used = $stream_result['model'] ?? $last_model_used;
$total_cost += $stream_cost;
$this->track_ai_cost(
$post_id,
$stream_result['model'] ?? '',
'block_refinement',
$stream_result['input_tokens'] ?? 0,
$stream_result['output_tokens'] ?? 0,
$stream_cost,
$provider_result,
$session_id ?? '',
'success'
);
// Parse and clean the response
$payload = $this->parse_refined_payload( $refined_content );
$refined_content = $this->clean_refined_content( $payload['content'] );
$resolved_block_type = $payload['blockType'] ?? $block_type;
if ( $this->is_contaminated_refinement_output( $refined_content, $resolved_block_type ) ) {
$failed_count++;
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => sprintf(
'Skipped contaminated output for block %1$d/%2$d (%3$s).',
(int) $block_index_loop + 1,
(int) $total_blocks_to_refine,
$resolved_block_type
),
)
) . "\n\n";
flush();
continue;
}
// Create proper block structure
$block_structure = $this->create_block_structure( $block_client_id, $resolved_block_type, $refined_content );
// Send the refined block
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block_structure,
)
) . "\n\n";
flush();
$refined_count++;
if ( 0 === ( $refined_count % 5 ) || $refined_count === $total_blocks_to_refine ) {
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'message' => sprintf(
'Progress: %1$d/%2$d block(s) updated (%3$d failed)',
$refined_count,
$total_blocks_to_refine,
$failed_count
),
)
) . "\n\n";
flush();
}
// Small delay between blocks
usleep( 100000 );
}
// Persist refinement exchange in session history so msg counts remain accurate.
if ( ! empty( $session_id ) && ! empty( $message ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->add_message(
$session_id,
array(
'role' => 'user',
'content' => sanitize_text_field( $message ),
'timestamp' => current_time( 'c' ),
)
);
$context_service->add_message(
$session_id,
array(
'role' => 'assistant',
'content' => sprintf( 'Refined %d block(s) based on your request.', (int) $refined_count ),
'timestamp' => current_time( 'c' ),
)
);
}
// Send completion message with provider metadata.
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'refined' => $refined_count,
'failed' => $failed_count,
'aborted' => $aborted_due_to_provider_errors,
'totalCost' => $total_cost,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$last_model_used
),
)
) . "\n\n";
flush();
} catch ( Exception $e ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $e->getMessage(),
)
) . "\n\n";
flush();
}
}
/**
* Restrict model edit-plan actions to explicitly allowed target block IDs.
*
* @since 0.1.0
*
* @param array $plan_json Parsed model plan response.
* @param array $allowed_block_ids Block IDs allowed to be edited.
* @param array $context_blocks Normalized block context list.
* @return array Sanitized plan response.
*/
private function sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks ) {
$allowed_actions = array( 'keep', 'replace', 'insert_after', 'insert_before', 'delete', 'change_type' );
$allowed_lookup = array_fill_keys( $allowed_block_ids, true );
$context_by_id = array();
foreach ( $context_blocks as $block ) {
$context_id = sanitize_text_field( $block['clientId'] ?? '' );
if ( '' === $context_id ) {
continue;
}
$context_by_id[ $context_id ] = sanitize_text_field( $block['type'] ?? 'core/paragraph' );
}
$raw_actions = $plan_json['actions'] ?? array();
if ( ! is_array( $raw_actions ) ) {
$raw_actions = array();
}
$sanitized_actions = array();
foreach ( $raw_actions as $action ) {
if ( ! is_array( $action ) ) {
continue;
}
$action_name = sanitize_key( $action['action'] ?? '' );
$block_id = sanitize_text_field( $action['blockId'] ?? '' );
if ( '' === $action_name || ! in_array( $action_name, $allowed_actions, true ) ) {
continue;
}
if ( '' === $block_id || ! isset( $allowed_lookup[ $block_id ] ) ) {
continue;
}
$clean_action = array(
'action' => $action_name,
'blockId' => $block_id,
);
if ( in_array( $action_name, array( 'replace', 'insert_after', 'insert_before', 'change_type' ), true ) ) {
$fallback_type = $context_by_id[ $block_id ] ?? 'core/paragraph';
$clean_action['blockType'] = sanitize_text_field( $action['blockType'] ?? $fallback_type );
$clean_action['content'] = isset( $action['content'] ) ? wp_kses_post( (string) $action['content'] ) : '';
}
$sanitized_actions[] = $clean_action;
}
return array(
'summary' => sanitize_text_field( $plan_json['summary'] ?? '' ),
'actions' => $sanitized_actions,
);
}
/**
* Find block by client ID in parsed blocks array.
*
* @since 0.1.0
* @param array $blocks Parsed blocks array.
* @param string $client_id Block client ID to find.
* @return array|null Block data or null if not found.
*/
private function find_block_by_client_id( $blocks, $client_id ) {
foreach ( $blocks as $block ) {
if ( isset( $block['attrs']['clientId'] ) && $block['attrs']['clientId'] === $client_id ) {
return $block;
}
// Check inner blocks
if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
$found = $this->find_block_by_client_id( $block['innerBlocks'], $client_id );
if ( $found ) {
return $found;
}
}
}
return null;
}
/**
* Find block index in parsed blocks array.
*
* @since 0.1.0
* @param array $blocks Parsed blocks array.
* @param string $client_id Block client ID to find.
* @return int Block index or -1 if not found.
*/
private function find_block_index( $blocks, $client_id ) {
foreach ( $blocks as $index => $block ) {
if ( isset( $block['attrs']['clientId'] ) && $block['attrs']['clientId'] === $client_id ) {
return $index;
}
}
return -1;
}
/**
* Extract content from block data.
*
* @since 0.1.0
* @param array $block Block data.
* @return string Block content.
*/
private function extract_block_content( $block ) {
if ( isset( $block['attrs']['content'] ) ) {
return $block['attrs']['content'];
}
if ( isset( $block['innerHTML'] ) ) {
// Strip HTML tags for plain content
return wp_strip_all_tags( $block['innerHTML'] );
}
return '';
}
/**
* Extract heading from block.
*
* @since 0.1.0
* @param array $block Block data.
* @return array|null Heading data or null if not a heading.
*/
private function extract_heading_from_block( $block ) {
if ( 'core/heading' === $block['blockName'] && isset( $block['attrs']['content'] ) ) {
return array(
'heading' => $block['attrs']['content'],
);
}
return null;
}
/**
* Clean refined content by removing conversational text.
*
* @since 0.1.0
* @param string $content Content to clean.
* @return string Cleaned content.
*/
private function clean_refined_content( $content ) {
// Remove common conversational prefixes
$conversational_prefixes = array(
'Certainly! Here\'s',
'Here\'s',
'The refined content',
'Here is the',
'Below is the',
'Okay, here',
'Sure, here',
);
foreach ( $conversational_prefixes as $prefix ) {
if ( stripos( $content, $prefix ) === 0 ) {
$content = substr( $content, strlen( $prefix ) );
$content = ltrim( $content, ":\n\r " );
}
}
// Remove markdown code blocks if present
$content = preg_replace( '/^```(?:text|markdown)?\n*/i', '', $content );
$content = preg_replace( '/```*$/i', '', $content );
// Remove common analysis scaffolding that sometimes leaks into block output.
$content = preg_replace( '/^\s*Refined version\s*:\s*/im', '', $content );
$content = preg_replace( '/^\s*Key refinements\s*:\s*$/im', '', $content );
$content = preg_replace( '/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im', '', $content );
// If model includes a bullet list of substitutions/explanations, strip that section.
$meta_markers = array(
'Key refinements:',
'Changes made:',
'Explanation:',
'Rationale:',
);
foreach ( $meta_markers as $marker ) {
$pos = stripos( $content, $marker );
if ( false !== $pos ) {
$content = substr( $content, 0, $pos );
}
}
// Avoid persisting raw JSON fences or labels.
$content = preg_replace( '/^\s*content\s*:\s*/im', '', $content );
$content = trim( $content );
return $content;
}
/**
* Parse refined payload that may be wrapped in JSON.
*
* @since 0.1.0
* @param string $content Raw model content.
* @return array Parsed payload with content and optional blockType.
*/
private function parse_refined_payload( $content ) {
$payload = array(
'content' => $content,
);
if ( ! is_string( $content ) ) {
return $payload;
}
$trimmed = trim( $content );
if ( '' === $trimmed ) {
return $payload;
}
if ( $trimmed[0] !== '{' || substr( $trimmed, -1 ) !== '}' ) {
return $payload;
}
$decoded = json_decode( $trimmed, true );
if ( ! is_array( $decoded ) ) {
return $payload;
}
if ( isset( $decoded['content'] ) && is_string( $decoded['content'] ) ) {
$payload['content'] = $decoded['content'];
}
$block_type = $decoded['blockType'] ?? $decoded['type'] ?? null;
if ( is_string( $block_type ) && 0 === strpos( $block_type, 'core/' ) ) {
$payload['blockType'] = $block_type;
}
return $payload;
}
/**
* Detect assistant/meta chatter that should never be inserted as block content.
*
* @since 0.1.0
* @param string $content Refined content candidate.
* @param string $block_type Resolved block type.
* @return bool
*/
private function is_contaminated_refinement_output( $content, $block_type = 'core/paragraph' ) {
$text = trim( wp_strip_all_tags( (string) $content ) );
if ( '' === $text ) {
return true;
}
$meta_patterns = array(
'/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i',
'/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i',
'/\b(i have kept the heading|if you\'d like me to refine this)\b/i',
);
foreach ( $meta_patterns as $pattern ) {
if ( preg_match( $pattern, $text ) ) {
return true;
}
}
// Headings should be concise and single-line.
if ( 'core/heading' === $block_type ) {
if ( strlen( $text ) > 180 || substr_count( $text, "\n" ) > 0 ) {
return true;
}
}
return false;
}
/**
* Build a compact section-scoped context window around a block.
*
* @since 0.1.0
* @param array $all_blocks All serialized editor blocks.
* @param int $block_index Current block index.
* @param int $max_snippets Max context snippets.
* @return string
*/
private function build_section_context_for_block( $all_blocks, $block_index, $max_snippets = 4 ) {
if ( ! is_array( $all_blocks ) || $block_index < 0 || ! isset( $all_blocks[ $block_index ] ) ) {
return '';
}
$start = $block_index;
for ( $i = $block_index - 1; $i >= 0; $i-- ) {
$name = $all_blocks[ $i ]['name'] ?? $all_blocks[ $i ]['blockName'] ?? '';
if ( 'core/heading' === $name ) {
$start = $i;
break;
}
$start = $i;
}
$end = $block_index;
for ( $i = $block_index + 1; $i < count( $all_blocks ); $i++ ) {
$name = $all_blocks[ $i ]['name'] ?? $all_blocks[ $i ]['blockName'] ?? '';
if ( 'core/heading' === $name ) {
break;
}
$end = $i;
}
$snippets = array();
for ( $i = $start; $i <= $end; $i++ ) {
$block = $all_blocks[ $i ];
$name = $block['name'] ?? $block['blockName'] ?? '';
if ( ! in_array( $name, array( 'core/heading', 'core/paragraph', 'core/list', 'core/quote' ), true ) ) {
continue;
}
$attrs = $block['attributes'] ?? $block['attrs'] ?? array();
$text = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $name, $attrs ) ) );
if ( '' === $text ) {
continue;
}
if ( strlen( $text ) > 180 ) {
$text = substr( $text, 0, 180 ) . '...';
}
$snippets[] = '- ' . $text;
if ( count( $snippets ) >= $max_snippets ) {
break;
}
}
return implode( "\n", $snippets );
}
/**
* Create block structure for refined content.
*
* @since 0.1.0
* @param string $block_id Block client ID.
* @param string $block_type Block type.
* @param string $content Refined content.
* @return array Block structure.
*/
private function create_block_structure( $block_id, $block_type, $content ) {
if ( preg_match( '/^!\\[(.*?)\\]\\(([^\\s)]+)(?:\\s+\\"[^\\"]*\\")?\\)\\s*$/', trim( $content ), $matches ) ) {
$alt = trim( $matches[1] );
$url = trim( $matches[2] );
$escaped_alt = esc_attr( $alt );
$escaped_url = esc_url( $url );
return array(
'blockName' => 'core/image',
'attrs' => array(
'id' => 0,
'url' => $escaped_url,
'alt' => $alt,
'caption' => '',
'sizeSlug' => 'large',
'linkDestination' => 'none',
),
'innerHTML' => '<figure class="wp-block-image size-large"><img src="' . $escaped_url . '" alt="' . $escaped_alt . '" /></figure>',
'clientId' => $block_id,
);
}
if ( 'core/paragraph' === $block_type ) {
return array(
'blockName' => 'core/paragraph',
'attrs' => array( 'content' => $content ),
'innerHTML' => '<p>' . $content . '</p>',
'clientId' => $block_id,
);
} elseif ( 'core/heading' === $block_type ) {
// Detect heading level
$level = 2;
if ( preg_match( '/^(#{1,6})\s/', $content ) ) {
$count = strspn( $content, '#' );
$level = min( $count, 6 );
$content = trim( substr( $content, $count ) );
}
$tag = 'h' . $level;
return array(
'blockName' => 'core/heading',
'attrs' => array(
'level' => $level,
'content' => $content,
),
'innerHTML' => "<{$tag}>{$content}</{$tag}>",
'clientId' => $block_id,
);
} elseif ( 'core/list' === $block_type ) {
$lines = explode( "\n", $content );
$lines = array_filter( array_map( 'trim', $lines ) );
// Create inner blocks for list items
$inner_blocks = array();
foreach ( $lines as $line ) {
$inner_blocks[] = array(
'blockName' => 'core/list-item',
'attrs' => array( 'content' => $line ),
'innerHTML' => '<li>' . $line . '</li>',
);
}
return array(
'blockName' => 'core/list',
'attrs' => array( 'ordered' => false ),
'innerBlocks' => $inner_blocks,
'clientId' => $block_id,
);
} elseif ( 'core/code' === $block_type ) {
$language = 'text';
$code_content = $content;
if ( preg_match( '/^```(\\w+)?\\s*/', $content, $matches ) ) {
if ( ! empty( $matches[1] ) ) {
$language = $matches[1];
}
$code_content = preg_replace( '/^```\\w*\\s*/', '', $code_content );
$code_content = preg_replace( '/```\\s*$/', '', $code_content );
$code_content = trim( $code_content );
}
$escaped = htmlspecialchars( $code_content, ENT_NOQUOTES, 'UTF-8' );
return array(
'blockName' => 'core/code',
'attrs' => array(
'language' => $language,
'content' => $code_content,
),
'innerHTML' => '<pre class="wp-block-code"><code>' . $escaped . '</code></pre>',
'clientId' => $block_id,
);
}
// Fallback to paragraph
return array(
'blockName' => 'core/paragraph',
'attrs' => array( 'content' => $content ),
'innerHTML' => '<p>' . $content . '</p>',
'clientId' => $block_id,
);
}
/**
* Build a short memory summary from the plan JSON.
*
* @since 0.1.0
* @param array $plan_json Plan data.
* @return string Summary text.
*/
private function build_memory_summary_from_plan( $plan_json ) {
if ( empty( $plan_json ) || ! is_array( $plan_json ) ) {
return '';
}
$title = $plan_json['title'] ?? '';
$headings = array();
if ( ! empty( $plan_json['sections'] ) && is_array( $plan_json['sections'] ) ) {
foreach ( $plan_json['sections'] as $section ) {
if ( ! empty( $section['heading'] ) ) {
$headings[] = $section['heading'];
}
}
}
$summary = '';
if ( $title ) {
$summary .= "Title: {$title}\n";
}
if ( ! empty( $headings ) ) {
$summary .= 'Sections: ' . implode( ' | ', $headings );
}
return trim( $summary );
}
/**
* Update per-post memory meta.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @param array $data Memory fields to update.
* @return void
*/
private function update_post_memory( $post_id, $data ) {
if ( $post_id <= 0 ) {
return;
}
$memory = get_post_meta( $post_id, '_wpaw_memory', true );
if ( ! is_array( $memory ) ) {
$memory = array();
}
$memory = array_merge( $memory, $data );
$memory['updated_at'] = current_time( 'timestamp' );
update_post_meta( $post_id, '_wpaw_memory', $memory );
}
/**
* Build memory context string for prompts.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @return string Context string.
*/
private function get_post_memory_context( $post_id ) {
if ( $post_id <= 0 ) {
return '';
}
$memory = get_post_meta( $post_id, '_wpaw_memory', true );
if ( empty( $memory ) || ! is_array( $memory ) ) {
return '';
}
$lines = array();
if ( ! empty( $memory['summary'] ) ) {
$lines[] = 'Summary: ' . $memory['summary'];
}
if ( ! empty( $memory['last_prompt'] ) ) {
$lines[] = 'Last prompt: ' . $memory['last_prompt'];
}
if ( ! empty( $memory['last_intent'] ) ) {
$lines[] = 'Last intent: ' . $memory['last_intent'];
}
if ( empty( $lines ) ) {
return '';
}
return "\n\n=== POST MEMORY ===\n" . implode( "\n", $lines ) . "\n=== END POST MEMORY ===\n";
}
/**
* Get blocks from the current editor state.
*
* @since 0.1.0
* @return array Array of block objects from editor.
*/
private function select_blocks() {
// Get blocks from the editor via REST API request
// This is a helper to simulate wp.data.select( 'core/block-editor' ).getBlocks()
global $post;
if ( ! $post ) {
return array();
}
// Parse blocks from post content
$blocks = parse_blocks( $post->post_content );
// Filter out empty blocks
return array_filter( $blocks, function( $block ) {
return ! empty( $block['blockName'] );
} );
}
/**
* Serialize block object for consistent handling.
*
* @since 0.1.0
* @param array $block Block data.
* @return array Serialized block with clientId.
*/
private function serialize_block( $block ) {
// Ensure clientId is set in attrs
if ( ! isset( $block['attrs']['clientId'] ) ) {
$block['attrs']['clientId'] = isset( $block['clientId'] ) ? $block['clientId'] : uniqid();
}
return $block;
}
/**
* Extract content from block attributes.
*
* @since 0.1.0
* @param string $block_type Block type (e.g., 'core/paragraph').
* @param array $attrs Block attributes.
* @return string Extracted content.
*/
private function extract_block_content_from_attrs( $block_type, $attrs ) {
switch ( $block_type ) {
case 'core/paragraph':
return isset( $attrs['content'] ) ? $attrs['content'] : '';
case 'core/heading':
return isset( $attrs['content'] ) ? $attrs['content'] : '';
case 'core/list':
// For lists, return a string representation
if ( isset( $attrs['values'] ) && is_array( $attrs['values'] ) ) {
return implode( "\n", $attrs['values'] );
}
return '';
case 'core/code':
return isset( $attrs['content'] ) ? $attrs['content'] : '';
case 'core/image':
if ( isset( $attrs['url'] ) && isset( $attrs['alt'] ) ) {
return '![' . $attrs['alt'] . '](' . $attrs['url'] . ')';
}
return '';
default:
// Try to get content from common attributes
if ( isset( $attrs['content'] ) ) {
return $attrs['content'];
}
if ( isset( $attrs['value'] ) ) {
return $attrs['value'];
}
return '';
}
}
/**
* Handle SEO audit request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_seo_audit( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission before reading post content/config.
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
__( 'Post not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
$post_config = $this->get_post_config( $post_id );
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
$focus_keyword = $post_config['seo_focus_keyword'] ?? '';
$audit = array(
'score' => 0,
'checks' => array(),
'keyword_density' => 0,
'word_count' => 0,
);
// Word count
$word_count = str_word_count( $content );
$audit['word_count'] = $word_count;
// Check 1: Content length
if ( $word_count >= 1500 ) {
$audit['checks'][] = array( 'name' => 'Content length', 'status' => 'good', 'message' => "Excellent! {$word_count} words (recommended: 1500+)" );
$audit['score'] += 15;
} elseif ( $word_count >= 800 ) {
$audit['checks'][] = array( 'name' => 'Content length', 'status' => 'ok', 'message' => "Good: {$word_count} words (recommended: 1500+)" );
$audit['score'] += 10;
} else {
$audit['checks'][] = array( 'name' => 'Content length', 'status' => 'warning', 'message' => "Short: {$word_count} words (recommended: 800+)" );
$audit['score'] += 5;
}
// Check 2: Focus keyword presence
if ( ! empty( $focus_keyword ) ) {
$keyword_count = substr_count( strtolower( $content ), strtolower( $focus_keyword ) );
$keyword_density = $word_count > 0 ? round( ( $keyword_count / $word_count ) * 100, 2 ) : 0;
$audit['keyword_density'] = $keyword_density;
// Keyword in title
if ( stripos( $title, $focus_keyword ) !== false ) {
$audit['checks'][] = array( 'name' => 'Keyword in title', 'status' => 'good', 'message' => 'Focus keyword found in title' );
$audit['score'] += 20;
} else {
$audit['checks'][] = array( 'name' => 'Keyword in title', 'status' => 'warning', 'message' => 'Focus keyword not found in title' );
}
// Keyword density
if ( $keyword_density >= 1 && $keyword_density <= 2.5 ) {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'good', 'message' => "Optimal: {$keyword_density}% (target: 1-2.5%)" );
$audit['score'] += 20;
} elseif ( $keyword_density > 0 && $keyword_density < 1 ) {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'ok', 'message' => "Low: {$keyword_density}% (target: 1-2.5%)" );
$audit['score'] += 10;
} elseif ( $keyword_density > 2.5 ) {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'warning', 'message' => "High: {$keyword_density}% - may be over-optimized" );
$audit['score'] += 5;
} else {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'error', 'message' => 'Focus keyword not found in content' );
}
// Keyword in first paragraph
$first_para = substr( $content, 0, 500 );
if ( stripos( $first_para, $focus_keyword ) !== false ) {
$audit['checks'][] = array( 'name' => 'Keyword in intro', 'status' => 'good', 'message' => 'Focus keyword in first paragraph' );
$audit['score'] += 15;
} else {
$audit['checks'][] = array( 'name' => 'Keyword in intro', 'status' => 'warning', 'message' => 'Add focus keyword to first paragraph' );
}
} else {
$audit['checks'][] = array( 'name' => 'Focus keyword', 'status' => 'warning', 'message' => 'No focus keyword set' );
}
// Check 3: Headings
$heading_count = preg_match_all( '/<!-- wp:heading.*?-->/', $post->post_content, $matches );
if ( $heading_count >= 3 ) {
$audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'good', 'message' => "{$heading_count} subheadings found" );
$audit['score'] += 15;
} elseif ( $heading_count >= 1 ) {
$audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'ok', 'message' => "Only {$heading_count} subheading(s) - add more for readability" );
$audit['score'] += 8;
} else {
$audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'warning', 'message' => 'No subheadings found - add H2/H3 headings' );
}
// Check 4: Images
$image_count = preg_match_all( '/<!-- wp:image.*?-->/', $post->post_content, $matches );
if ( $image_count >= 1 ) {
$audit['checks'][] = array( 'name' => 'Images', 'status' => 'good', 'message' => "{$image_count} image(s) found" );
$audit['score'] += 10;
} else {
$audit['checks'][] = array( 'name' => 'Images', 'status' => 'ok', 'message' => 'No images - consider adding visuals' );
}
// Check 5: Meta description
$meta_desc = $post_config['seo_meta_description'] ?? '';
if ( ! empty( $meta_desc ) ) {
$meta_len = strlen( $meta_desc );
if ( $meta_len >= 120 && $meta_len <= 160 ) {
$audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'good', 'message' => "Perfect length: {$meta_len} chars (120-160)" );
$audit['score'] += 5;
} elseif ( $meta_len > 0 ) {
$audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'ok', 'message' => "Length: {$meta_len} chars (optimal: 120-160)" );
$audit['score'] += 3;
}
} else {
$audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'warning', 'message' => 'No meta description set' );
}
// Check 6: AI-ish writing patterns (heuristic scanner).
$ai_pattern_result = $this->scan_ai_ish_patterns( $post->post_content );
if ( $ai_pattern_result['count'] <= 1 ) {
$audit['checks'][] = array(
'name' => 'AI-ish pattern risk',
'status' => 'good',
'message' => 'Low risk: no significant AI-style pattern detected',
);
$audit['score'] += 15;
} elseif ( $ai_pattern_result['count'] <= 4 ) {
$audit['checks'][] = array(
'name' => 'AI-ish pattern risk',
'status' => 'ok',
'message' => sprintf( 'Moderate risk: %d pattern(s) detected. Consider selective human polish.', $ai_pattern_result['count'] ),
);
$audit['score'] += 8;
} else {
$audit['checks'][] = array(
'name' => 'AI-ish pattern risk',
'status' => 'warning',
'message' => sprintf( 'High risk: %d pattern(s) detected. Refine tone for more natural writing.', $ai_pattern_result['count'] ),
);
$audit['score'] += 3;
}
$audit['ai_ish_pattern_count'] = $ai_pattern_result['count'];
$audit['ai_ish_pattern_examples'] = $ai_pattern_result['examples'];
// Cap score at 100
$audit['score'] = min( 100, $audit['score'] );
// Convert checks to issues for frontend compatibility
$audit['issues'] = array();
foreach ( $audit['checks'] as $check ) {
if ( $check['status'] !== 'good' ) {
$audit['issues'][] = array(
'severity' => $check['status'],
'message' => $check['name'] . ': ' . $check['message'],
);
}
}
return new WP_REST_Response( $audit, 200 );
}
/**
* Scan post content for common AI-ish writing patterns.
*
* @param string $raw_content Raw post content.
* @return array{count:int,examples:array}
*/
private function scan_ai_ish_patterns( $raw_content ) {
$normalized = wp_strip_all_tags( (string) $raw_content );
$normalized = preg_replace( '/\s+/', ' ', $normalized );
$normalized = trim( (string) $normalized );
if ( '' === $normalized ) {
return array(
'count' => 0,
'examples' => array(),
);
}
$rules = array(
array(
'id' => 'double_colon',
'pattern' => '/[^\s]:\s*:[^\s]/u',
'label' => 'double colon punctuation',
),
array(
'id' => 'ai_phrase_not_only_but',
'pattern' => '/\bbukan sekadar\b|\bnot just\b/i',
'label' => 'formulaic contrast phrase',
),
array(
'id' => 'ai_phrase_in_conclusion',
'pattern' => '/\b(pada akhirnya|in conclusion|to summarize)\b/i',
'label' => 'template-like conclusion phrase',
),
array(
'id' => 'meta_instruction_leak',
'pattern' => '/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i',
'label' => 'instructional/meta leakage',
),
array(
'id' => 'dash_overuse',
'pattern' => '/\s[—–-]\s/u',
'label' => 'dash-heavy sentence style',
),
);
$matches = array();
$total = 0;
foreach ( $rules as $rule ) {
if ( preg_match_all( $rule['pattern'], $normalized, $found, PREG_OFFSET_CAPTURE ) ) {
$total += count( $found[0] );
if ( count( $matches ) < 5 ) {
foreach ( $found[0] as $entry ) {
if ( count( $matches ) >= 5 ) {
break;
}
$matched_text = (string) ( $entry[0] ?? '' );
$offset = (int) ( $entry[1] ?? 0 );
$context_start = max( 0, $offset - 48 );
$context = function_exists( 'mb_substr' )
? mb_substr( $normalized, $context_start, 120 )
: substr( $normalized, $context_start, 120 );
$matches[] = array(
'type' => $rule['label'],
'match' => trim( $matched_text ),
'context' => trim( $context ),
);
}
}
}
}
return array(
'count' => (int) $total,
'examples' => $matches,
);
}
/**
* Refine current post title based on user instruction.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_refine_title( $request ) {
$params = $request->get_json_params();
$post_id = isset( $params['postId'] ) ? (int) $params['postId'] : 0;
$instruction = sanitize_text_field( $params['instruction'] ?? '' );
$session_id = sanitize_text_field( $params['sessionId'] ?? '' );
if ( $post_id <= 0 ) {
return new WP_Error( 'invalid_post', __( 'Invalid post ID.', 'wp-agentic-writer' ), array( 'status' => 400 ) );
}
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error( 'forbidden', __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), array( 'status' => 403 ) );
}
if ( '' === $instruction ) {
return new WP_Error( 'missing_instruction', __( 'Title instruction is required.', 'wp-agentic-writer' ), array( 'status' => 400 ) );
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'post_not_found', __( 'Post not found.', 'wp-agentic-writer' ), array( 'status' => 404 ) );
}
$current_title = trim( wp_strip_all_tags( (string) $post->post_title ) );
$post_config = $this->get_post_config( $post_id );
$focus_keyword = trim( (string) ( $post_config['seo_focus_keyword'] ?? '' ) );
$system_prompt = "You are an expert SEO copy editor for article titles.\n"
. "Rewrite the title based on instruction.\n"
. "Return ONLY the final title text.\n"
. "No quotes. No explanation. No markdown.";
$user_prompt = "Current title: " . ( '' !== $current_title ? $current_title : '(empty)' ) . "\n"
. "Focus keyword: " . ( '' !== $focus_keyword ? $focus_keyword : '(not set)' ) . "\n"
. "Instruction: " . $instruction . "\n"
. "Constraints: keep it concise, natural, and publish-ready.";
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
$provider = $provider_result->provider;
$response = $provider->chat(
array(
array( 'role' => 'system', 'content' => $system_prompt ),
array( 'role' => 'user', 'content' => $user_prompt ),
),
array( 'post_id' => $post_id ),
'refinement'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$new_title = trim( wp_strip_all_tags( (string) ( $response['content'] ?? '' ) ) );
$new_title = preg_replace( '/\s+/', ' ', $new_title );
if ( '' === $new_title ) {
return new WP_Error( 'empty_title', __( 'Refined title is empty.', 'wp-agentic-writer' ), array( 'status' => 500 ) );
}
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $new_title,
)
);
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'title_refinement',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id,
'success'
);
return new WP_REST_Response(
array(
'title' => $new_title,
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Suggest relevant internal links based on content similarity.
*
* @since 0.1.0
* @param int $post_id Current post ID.
* @param string $focus_keyword Focus keyword.
* @param int $limit Maximum number of suggestions.
* @return array Array of suggested posts with title and URL.
*/
private function suggest_internal_links( $post_id, $focus_keyword = '', $limit = 3 ) {
$suggestions = array();
// Get all published posts except current
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 50,
'post__not_in' => array( $post_id ),
'orderby' => 'date',
'order' => 'DESC',
);
$posts = get_posts( $args );
if ( empty( $posts ) ) {
return $suggestions;
}
foreach ( $posts as $post ) {
// Skip if this is the current post (safety check)
if ( $post->ID === $post_id ) {
continue;
}
$score = 0;
// 1. Same category (weight: 30 points per category)
$current_cats = wp_get_post_categories( $post_id );
$post_cats = wp_get_post_categories( $post->ID );
$cat_overlap = count( array_intersect( $current_cats, $post_cats ) );
$score += $cat_overlap * 30;
// 2. Same tags (weight: 20 points per tag)
$current_tags = wp_get_post_tags( $post_id, array( 'fields' => 'ids' ) );
$post_tags = wp_get_post_tags( $post->ID, array( 'fields' => 'ids' ) );
$tag_overlap = count( array_intersect( $current_tags, $post_tags ) );
$score += $tag_overlap * 20;
// 3. Focus keyword in title (weight: 25 points)
if ( ! empty( $focus_keyword ) && stripos( $post->post_title, $focus_keyword ) !== false ) {
$score += 25;
}
// 4. Focus keyword in content (weight: 15 points)
if ( ! empty( $focus_keyword ) && stripos( $post->post_content, $focus_keyword ) !== false ) {
$score += 15;
}
// 5. Recency bonus (weight: 10 points for posts < 30 days, 5 points for < 90 days)
$days_old = ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS;
if ( $days_old < 30 ) {
$score += 10;
} elseif ( $days_old < 90 ) {
$score += 5;
}
if ( $score > 0 ) {
$suggestions[] = array(
'id' => $post->ID,
'title' => $post->post_title,
'url' => get_permalink( $post->ID ),
'score' => $score,
);
}
}
// Sort by score descending
usort(
$suggestions,
function ( $a, $b ) {
return $b['score'] - $a['score'];
}
);
return array_slice( $suggestions, 0, $limit );
}
/**
* Auto-generate meta description after article execution.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @param array $post_config Post configuration.
* @param string $effective_language Effective language.
* @return array|WP_Error Result with meta description and cost, or error.
*/
private function auto_generate_meta_description( $post_id, $post_config, $effective_language ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'invalid_post', 'Post not found' );
}
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
$focus_keyword = $post_config['seo_focus_keyword'] ?? '';
if ( empty( $content ) ) {
return new WP_Error( 'no_content', 'No content available' );
}
$language_instruction = $this->build_language_instruction( $effective_language, 'meta description' );
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$provider = $provider_result->provider;
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
$prompt .= "- Include a call-to-action or value proposition\n";
$prompt .= "- Make it enticing for searchers to click\n";
if ( ! empty( $focus_keyword ) ) {
$prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n";
}
$prompt .= "\n{$language_instruction}\n";
$prompt .= "\nTitle: {$title}\n";
$prompt .= "\nContent summary (first 500 chars):\n" . substr( $content, 0, 500 );
$prompt .= "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation.";
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'clarity' );
if ( is_wp_error( $response ) ) {
return $response;
}
$meta_description = trim( $response['content'] ?? '' );
$meta_description = preg_replace( '/^["\']|["\']$/', '', $meta_description );
// Enforce 155 character limit
if ( strlen( $meta_description ) > 155 ) {
$meta_description = substr( $meta_description, 0, 152 ) . '...';
}
// Save to post meta
update_post_meta( $post_id, '_wpaw_meta_description', $meta_description );
// Track cost
$cost = $response['cost'] ?? 0;
if ( $cost > 0 ) {
$this->track_ai_cost(
$post_id,
$response['model'] ?? 'unknown',
'meta_description',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$cost,
$provider_result,
$session_id ?? '',
'success'
);
}
return array(
'meta_description' => $meta_description,
'length' => strlen( $meta_description ),
'cost' => $cost,
);
}
/**
* Handle generate meta description request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_generate_meta( $request ) {
$params = $request->get_json_params();
$post_id = $params['postId'] ?? 0;
$content = $params['content'] ?? '';
$title = $params['title'] ?? '';
$focus_keyword = $params['focusKeyword'] ?? '';
$chat_history = $params['chatHistory'] ?? array();
// Check post permission BEFORE reading post content.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
if ( empty( $content ) && $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post ) {
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
}
}
if ( empty( $content ) ) {
return new WP_Error(
'no_content',
__( 'No content available to generate meta description.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Get detected language from post meta
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$post_config = $this->get_post_config( $post_id );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'meta description' );
// Build chat history context if available
$chat_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_context = "\n\nOriginal discussion context:\n";
$user_messages = array_filter( $chat_history, function( $msg ) {
return isset( $msg['role'] ) && 'user' === strtolower( $msg['role'] );
});
$recent_user = array_slice( $user_messages, -2 );
foreach ( $recent_user as $msg ) {
$content_text = $msg['content'] ?? '';
if ( ! empty( $content_text ) ) {
$chat_context .= "- " . substr( $content_text, 0, 100 ) . "\n";
}
}
}
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$provider = $provider_result->provider;
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
$prompt .= "- Include a call-to-action or value proposition\n";
$prompt .= "- Make it enticing for searchers to click\n";
if ( ! empty( $focus_keyword ) ) {
$prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n";
}
$prompt .= "\n{$language_instruction}\n";
$prompt .= $chat_context;
$prompt .= "\nTitle: {$title}\n";
$prompt .= "\nContent summary (first 500 chars):\n" . substr( $content, 0, 500 );
$prompt .= "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation.";
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array(), 'clarity' );
if ( is_wp_error( $response ) ) {
return $response;
}
$meta_description = trim( $response['content'] ?? '' );
$meta_description = preg_replace( '/^["\']|["\']$/', '', $meta_description );
// Enforce 155 character limit
if ( strlen( $meta_description ) > 155 ) {
$meta_description = substr( $meta_description, 0, 152 ) . '...';
}
// Track cost for meta description generation.
$cost = $response['cost'] ?? 0;
if ( $cost > 0 && $post_id > 0 ) {
$this->track_ai_cost(
$post_id,
$response['model'] ?? 'unknown',
'meta_description',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$cost,
$provider_result,
'',
'success'
);
}
return new WP_REST_Response(
array(
'meta_description' => $meta_description,
'length' => strlen( $meta_description ),
'cost' => $cost,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle suggest keywords request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_suggest_keywords( $request ) {
$params = $request->get_json_params();
$post_id = $params['postId'] ?? 0;
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
$title = $params['title'] ?? '';
$sections = $params['sections'] ?? array();
if ( empty( $title ) || empty( $sections ) ) {
return new WP_Error(
'missing_data',
__( 'Title and sections are required for keyword suggestions.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission before reading post data.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Get detected language from post meta or config
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$post_config = $this->get_post_config( $post_id );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
// Use keyword suggester helper
$result = WP_Agentic_Writer_Keyword_Suggester::suggest_keywords(
$title,
$sections,
$effective_language,
$post_id
);
if ( is_wp_error( $result ) ) {
return $result;
}
// Persist SEO keyword suggestion summary to session history for future recall.
if ( ! empty( $session_id ) ) {
$reasoning = trim( (string) ( $result['reasoning'] ?? '' ) );
$focus_keyword = (string) ( $result['focus_keyword'] ?? '' );
$secondary_keywords = (array) ( $result['secondary_keywords'] ?? array() );
$assistant_summary = "SEO Keywords Suggested:\n\n";
$assistant_summary .= "Focus Keyword: {$focus_keyword}\n\n";
$assistant_summary .= "Secondary Keywords: " . implode( ', ', $secondary_keywords );
if ( '' !== $reasoning ) {
$assistant_summary .= "\n\n{$reasoning}";
}
$assistant_summary .= "\n\nYou can review and edit these in the Config panel before writing.";
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->add_message(
$session_id,
array(
'role' => 'assistant',
'content' => $assistant_summary,
'timestamp' => current_time( 'c' ),
)
);
}
return new WP_REST_Response(
array(
'focus_keyword' => $result['focus_keyword'],
'secondary_keywords' => $result['secondary_keywords'],
'reasoning' => $result['reasoning'],
'cost' => $result['cost'],
'provider_metadata' => $this->build_provider_metadata(
$result['provider_result'] ?? null,
$result['model'] ?? ''
),
),
200
);
}
/**
* Handle context summarization request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_summarize_context( $request ) {
$params = $request->get_json_params();
$chat_history = $params['chatHistory'] ?? array();
$post_id = $params['postId'] ?? 0;
$session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id );
// Check post permission before using postId for cost tracking.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
if ( ! empty( $session_id ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$session_context = $context_service->get_context( $session_id, $post_id );
if ( ! empty( $session_context['messages'] ) && is_array( $session_context['messages'] ) ) {
$chat_history = $session_context['messages'];
}
}
// Short history doesn't need summarization
if ( empty( $chat_history ) || count( $chat_history ) < 4 ) {
return new WP_REST_Response(
array(
'summary' => '',
'use_full_history' => true,
'cost' => 0,
'tokens_saved' => 0,
'session_id' => $session_id,
'message_count' => is_array( $chat_history ) ? count( $chat_history ) : 0,
'source_message_count' => is_array( $chat_history ) ? count( $chat_history ) : 0,
),
200
);
}
// Build history text
$history_text = '';
foreach ( $chat_history as $msg ) {
$role = ucfirst( $msg['role'] ?? 'Unknown' );
$content = $msg['content'] ?? '';
if ( ! empty( $content ) ) {
$history_text .= "{$role}: {$content}\n\n";
}
}
// Build summarization prompt
$prompt = "Summarize this conversation into key points that capture the user's intent and requirements.
Focus on:
- Main topic
- Specific focus areas
- Rejected/excluded topics
- User preferences (tone, audience, etc.)
Keep the summary concise (max 200 words) but preserve critical context.
Write in the same language as the conversation.
Output format:
TOPIC: [main topic]
FOCUS: [what to include]
EXCLUDE: [what to avoid]
PREFERENCES: [any specific requirements]
Conversation:
{$history_text}";
// Call AI with clarity model for language detection
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$provider = $provider_result->provider;
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array(), 'summarize' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Calculate tokens saved
$original_tokens = count( $chat_history ) * 500; // Rough estimate
$summary_tokens = $response['output_tokens'] ?? 100;
$tokens_saved = $original_tokens - $summary_tokens;
$summary = $response['content'] ?? '';
if ( ! empty( $session_id ) && '' !== trim( (string) $summary ) ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->update_session_context(
$session_id,
array(
'working_summary' => array(
'text' => $summary,
'updated_at' => current_time( 'c' ),
'source_message_count' => count( $chat_history ),
),
)
);
}
// Track cost.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'summarize_context',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id,
'success'
);
return new WP_REST_Response(
array(
'summary' => $summary,
'use_full_history' => false,
'cost' => $response['cost'] ?? 0,
'tokens_saved' => $tokens_saved,
'session_id' => $session_id,
'message_count' => count( $chat_history ),
'source_message_count' => count( $chat_history ),
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle intent detection request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_detect_intent( $request ) {
$params = $request->get_json_params();
$last_message = $params['lastMessage'] ?? '';
$has_plan = $params['hasPlan'] ?? false;
$current_mode = $params['currentMode'] ?? 'chat';
$post_id = $params['postId'] ?? 0;
// Check post permission before using postId for cost tracking.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
if ( empty( $last_message ) ) {
return new WP_REST_Response(
array( 'intent' => 'continue_chat' ),
200
);
}
// Build intent detection prompt
$has_plan_str = $has_plan ? 'true' : 'false';
$prompt = "Based on the user's message, determine their intent. Choose ONE:
1. \"create_outline\" - User wants to create an article outline/structure
2. \"start_writing\" - User wants to write the full article
3. \"refine_content\" - User wants to improve existing content
4. \"add_section\" - User wants to add a new section to existing outline or article
5. \"continue_chat\" - User wants to continue discussing/exploring
6. \"clarify\" - User is asking questions or needs clarification
Consider:
- The user's explicit request
- Whether they have an outline already (has_plan: {$has_plan_str})
- Current mode (current_mode: {$current_mode})
User's message: \"{$last_message}\"
Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
// Call AI with clarity model for intent detection
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$provider = $provider_result->provider;
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array(), 'intent_detection' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Track cost
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'detect_intent',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
$session_id ?? '',
'success'
);
// Clean up response
$intent = trim( strtolower( $response['content'] ?? 'continue_chat' ) );
$intent = str_replace( '"', '', $intent );
// Validate intent
$valid_intents = array( 'create_outline', 'start_writing', 'refine_content', 'add_section', 'continue_chat', 'clarify' );
if ( ! in_array( $intent, $valid_intents, true ) ) {
$intent = 'continue_chat';
}
return new WP_REST_Response(
array(
'intent' => $intent,
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle suggest improvements request (proactive AI suggestions).
*
* Analyzes article content and suggests improvements based on
* idle detection trigger.
*
* @since 0.2.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_suggest_improvements( $request ) {
$params = $request->get_json_params();
$post_id = isset( $params['postId'] ) ? (int) $params['postId'] : 0;
$suggestion_types = $params['types'] ?? array( 'clarity', 'depth', 'structure' );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Valid post ID is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Check post permission before reading post content.
if ( ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Get post content for analysis
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
__( 'Post not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
$blocks = parse_blocks( $post->post_content );
$plain_content = '';
$block_count = 0;
foreach ( $blocks as $block ) {
if ( ! empty( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'core/' ) ) {
$block_content = '';
if ( 'core/paragraph' === $block['blockName'] || 'core/heading' === $block['blockName'] ) {
$block_content = $block['attrs']['content'] ?? '';
} elseif ( 'core/list' === $block['blockName'] ) {
$inner_html = $block['innerHTML'] ?? '';
$block_content = wp_strip_all_tags( $inner_html );
} else {
$block_content = $block['innerHTML'] ?? '';
$block_content = wp_strip_all_tags( $block_content );
}
if ( ! empty( $block_content ) ) {
$plain_content .= $block_content . "\n\n";
$block_count++;
}
}
}
if ( empty( $plain_content ) || $block_count < 3 ) {
return new WP_REST_Response(
array(
'suggestions' => array(),
'message' => 'Not enough content to analyze yet.',
),
200
);
}
// Get post config for context
$post_config = $this->get_post_config( $post_id );
$focus_keyword = $post_config['seo_focus_keyword'] ?? '';
// Build suggestion type instruction
$type_instruction = '';
foreach ( $suggestion_types as $type ) {
switch ( $type ) {
case 'clarity':
$type_instruction .= "- Identify sentences or paragraphs that are too complex or confusing\n";
break;
case 'depth':
$type_instruction .= "- Suggest areas where more examples, data, or explanation would improve the content\n";
break;
case 'structure':
$type_instruction .= "- Identify missing sections or structural improvements needed for the article\n";
break;
case 'engagement':
$type_instruction .= "- Suggest ways to increase reader engagement (questions, examples, calls to action)\n";
break;
case 'seo':
if ( ! empty( $focus_keyword ) ) {
$type_instruction .= "- Check keyword '{$focus_keyword}' usage: suggest where to naturally include it\n";
}
break;
}
}
$system_prompt = "You are an expert content editor providing constructive improvement suggestions.
Analyze the provided article content and suggest 1-3 specific improvements.
{$type_instruction}
IMPORTANT GUIDELINES:
- Be specific about WHERE in the content the issue is (e.g., 'paragraph 3', 'the section about X')
- Be actionable - tell the user WHAT they should change and WHY
- Be concise - each suggestion should be 1-2 sentences max
- Prioritize the most impactful improvements
- NEVER suggest adding fluff or padding - only genuine improvements
Return your response as valid JSON in this format:
{
'suggestions': [
{
'type': 'clarity|depth|structure|engagement|seo',
'location': 'Brief description of where in the article',
'issue': 'What the problem is',
'suggestion': 'What to do instead',
'priority': 'high|medium|low'
}
],
'summary': 'One sentence summary of the overall article quality'
}
If the content is already excellent and needs no major improvements, return an empty suggestions array with a positive summary.
Only suggest changes that would genuinely improve the reader's experience or search engine performance.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Please analyze this article and suggest improvements:\n\n{$plain_content}",
),
);
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
$provider = $provider_result->provider;
$response = $provider->chat( $messages, array(), 'analysis' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Track cost with full nine-argument contract including provider attribution.
$cost = $response['cost'] ?? 0;
if ( $cost > 0 ) {
$actual_provider = 'unknown';
if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) {
$actual_provider = $provider_result->actual_provider;
}
// Get session ID for this post if available.
$session_id = '';
if ( $post_id > 0 ) {
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$session = $manager->get_session_by_post_id( $post_id );
if ( $session && isset( $session['session_id'] ) ) {
$session_id = $session['session_id'];
}
}
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'analysis',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$cost,
$actual_provider,
$session_id,
'success'
);
}
// Parse JSON from response
$content = $response['content'] ?? '';
$suggestions_json = $this->extract_json( $content );
if ( null === $suggestions_json ) {
// If JSON parsing fails, return a generic success with no suggestions
return new WP_REST_Response(
array(
'suggestions' => array(),
'message' => 'Analysis complete but suggestions could not be parsed.',
),
200
);
}
return new WP_REST_Response(
array(
'suggestions' => $suggestions_json['suggestions'] ?? array(),
'summary' => $suggestions_json['summary'] ?? 'Analysis complete.',
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle get image recommendations request.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_image_recommendations( $request ) {
$post_id = $request->get_param( 'post_id' );
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
$images = $image_manager->get_image_recommendations( $post_id );
// Block-level sync: ensure each unresolved image block has a stable
// agent id and a corresponding recommendation row.
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post instanceof WP_Post && ! empty( $post->post_content ) ) {
$post_config = $this->get_post_config( $post_id );
if ( ! empty( $post_config['include_images'] ) ) {
$images = $this->sync_image_block_recommendations( $post_id, $post );
}
}
}
return new WP_REST_Response(
array( 'images' => $images ),
200
);
}
/**
* Ensure unresolved image blocks are mapped 1:1 to recommendation rows.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
* @return array
*/
private function sync_image_block_recommendations( $post_id, $post ) {
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
$post_content = (string) $post->post_content;
$blocks = parse_blocks( $post_content );
$changed = false;
$slots = array();
$slot_index = 0;
$post_title = trim( wp_strip_all_tags( (string) $post->post_title ) );
$walk = function( &$items, $heading_context = '' ) use ( &$walk, &$changed, &$slots, &$slot_index, $post_id, $post_title ) {
foreach ( $items as &$block ) {
$name = $block['blockName'] ?? '';
$attrs = $block['attrs'] ?? array();
if ( 'core/heading' === $name ) {
$heading = '';
if ( ! empty( $attrs['content'] ) ) {
$heading = trim( wp_strip_all_tags( (string) $attrs['content'] ) );
} elseif ( ! empty( $block['innerHTML'] ) ) {
$heading = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) );
}
if ( '' !== $heading ) {
$heading_context = $heading;
}
}
if ( 'core/image' === $name ) {
$image_id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0;
if ( $image_id <= 0 ) {
$slot_index++;
$agent_id = isset( $attrs['data-agent-image-id'] ) ? trim( (string) $attrs['data-agent-image-id'] ) : '';
if ( '' === $agent_id ) {
$agent_id = 'img_' . $post_id . '_blk_' . $slot_index . '_' . substr( wp_hash( microtime( true ) . wp_rand() ), 0, 8 );
$attrs['data-agent-image-id'] = $agent_id;
$class_name = isset( $attrs['className'] ) ? (string) $attrs['className'] : '';
if ( false === strpos( $class_name, 'wpaw-agent-img-' ) ) {
$attrs['className'] = trim( $class_name . ' wpaw-agent-img-' . $agent_id );
}
$block['attrs'] = $attrs;
$changed = true;
}
$slots[] = array(
'agent_image_id' => $agent_id,
'section_title' => '' !== $heading_context ? $heading_context : ( '' !== $post_title ? $post_title : 'Article Section' ),
'slot_index' => $slot_index,
);
}
}
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
$walk( $block['innerBlocks'], $heading_context );
}
}
unset( $block );
};
$walk( $blocks, '' );
if ( $changed ) {
$serialized = serialize_blocks( $blocks );
if ( $serialized !== $post_content ) {
wp_update_post(
array(
'ID' => $post_id,
'post_content' => $serialized,
)
);
}
}
$current_images = $image_manager->get_image_recommendations( $post_id );
$by_agent_id = array();
$existing_rows = array();
if ( is_array( $current_images ) ) {
foreach ( $current_images as $row ) {
$key = isset( $row['agent_image_id'] ) ? (string) $row['agent_image_id'] : '';
if ( '' !== $key ) {
$by_agent_id[ $key ] = true;
}
$existing_rows[] = $row;
}
}
$slot_agent_ids = array();
foreach ( $slots as $slot ) {
$slot_agent_ids[ $slot['agent_image_id'] ] = true;
}
$orphan_rows = array();
foreach ( $existing_rows as $row ) {
$key = isset( $row['agent_image_id'] ) ? (string) $row['agent_image_id'] : '';
if ( '' !== $key && ! isset( $slot_agent_ids[ $key ] ) ) {
$orphan_rows[] = $row;
}
}
$focus_variants = array(
'establishing scene',
'close-up detail',
'human activity and impact',
'before-and-after comparison',
'infographic-like composition',
);
foreach ( $slots as $slot ) {
$agent_id = $slot['agent_image_id'];
if ( isset( $by_agent_id[ $agent_id ] ) ) {
continue;
}
$focus = $focus_variants[ ( (int) $slot['slot_index'] - 1 ) % count( $focus_variants ) ];
$section_title = $slot['section_title'];
$prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . '. Realistic editorial style, informative composition, natural lighting, high detail.';
if ( ! empty( $orphan_rows ) ) {
$orphan = array_shift( $orphan_rows );
if ( isset( $orphan['id'] ) ) {
global $wpdb;
$table = $wpdb->prefix . 'wpaw_images';
$wpdb->update(
$table,
array(
'agent_image_id' => $agent_id,
'placement' => 'slot_' . (int) $slot['slot_index'],
'section_title' => $section_title,
'prompt_initial' => $prompt,
'alt_text_initial' => 'Gambar untuk bagian: ' . $section_title,
),
array(
'id' => (int) $orphan['id'],
'post_id' => (int) $post_id,
),
array( '%s', '%s', '%s', '%s', '%s' ),
array( '%d', '%d' )
);
$by_agent_id[ $agent_id ] = true;
continue;
}
}
$image_manager->save_image_recommendation(
$post_id,
$agent_id,
'slot_' . (int) $slot['slot_index'],
$section_title,
$prompt,
'Gambar untuk bagian: ' . $section_title
);
}
$result = $image_manager->get_image_recommendations( $post_id );
return is_array( $result ) ? $result : array();
}
/**
* Seed deterministic image recommendations from post content.
*
* @param int $post_id Post ID.
* @param string $post_title Post title.
* @param string $post_content Post content.
* @return bool True when at least one recommendation is saved.
*/
private function seed_basic_image_recommendations( $post_id, $post_title, $post_content ) {
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
$existing = $image_manager->get_image_recommendations( $post_id );
if ( is_array( $existing ) && ! empty( $existing ) ) {
return true;
}
$max_images = 3;
$title = trim( wp_strip_all_tags( (string) $post_title ) );
$seeded = 0;
if ( '' !== $title ) {
$agent_image_id = 'img_' . $post_id . '_' . time() . '_hero';
$image_manager->save_image_recommendation(
$post_id,
$agent_image_id,
'hero',
$title,
'Editorial hero image illustrating: ' . $title . '. Documentary style, natural lighting, high detail.',
'Ilustrasi utama artikel: ' . $title
);
$seeded++;
}
$headings = array();
if ( preg_match_all( '/<h[2-4][^>]*>(.*?)<\/h[2-4]>/i', $post_content, $matches ) ) {
foreach ( $matches[1] as $heading ) {
$clean = trim( wp_strip_all_tags( $heading ) );
if ( '' !== $clean ) {
$headings[] = $clean;
}
if ( count( $headings ) >= ( $max_images - 1 ) ) {
break;
}
}
}
foreach ( $headings as $index => $heading ) {
$agent_image_id = 'img_' . $post_id . '_' . time() . '_sec_' . ( $index + 1 );
$image_manager->save_image_recommendation(
$post_id,
$agent_image_id,
'section_' . ( $index + 1 ),
$heading,
'Contextual supporting image for section "' . $heading . '". Realistic scene, informative composition, editorial quality.',
'Gambar pendukung untuk bagian: ' . $heading
);
$seeded++;
}
return $seeded > 0;
}
/**
* Ensure recommendations exist for every unresolved image block.
*
* @param int $post_id Post ID.
* @param string $post_content Post content.
* @param string $post_title Post title.
* @return void
*/
private function ensure_recommendations_for_image_blocks( $post_id, $post_content, $post_title ) {
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
$current_images = $image_manager->get_image_recommendations( $post_id );
$current_count = is_array( $current_images ) ? count( $current_images ) : 0;
$image_slots = $this->extract_unresolved_image_slots( $post_content );
$target_count = count( $image_slots );
if ( $target_count <= $current_count ) {
return;
}
$fallback_title = trim( wp_strip_all_tags( (string) $post_title ) );
for ( $i = $current_count; $i < $target_count; $i++ ) {
$slot_title = isset( $image_slots[ $i ]['section_title'] ) ? $image_slots[ $i ]['section_title'] : '';
$section_title = '' !== $slot_title ? $slot_title : ( '' !== $fallback_title ? $fallback_title : 'Article Section' );
$agent_image_id = 'img_' . $post_id . '_' . time() . '_slot_' . ( $i + 1 );
$focus_variants = array(
'establishing scene',
'close-up detail',
'human activity and impact',
'before-and-after comparison',
'infographic-like composition',
);
$focus = $focus_variants[ $i % count( $focus_variants ) ];
$prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . '. Realistic editorial style, informative composition, natural lighting, high detail.';
$image_manager->save_image_recommendation(
$post_id,
$agent_image_id,
'slot_' . ( $i + 1 ),
$section_title,
$prompt,
'Gambar untuk bagian: ' . $section_title
);
}
}
/**
* Extract unresolved image slots with nearest heading context.
*
* @param string $post_content Post content.
* @return array
*/
private function extract_unresolved_image_slots( $post_content ) {
$slots = array();
$blocks = parse_blocks( (string) $post_content );
$walk = function( $items, $heading_context = '' ) use ( &$walk, &$slots ) {
foreach ( $items as $block ) {
$name = $block['blockName'] ?? '';
$attrs = $block['attrs'] ?? array();
if ( 'core/heading' === $name ) {
$heading = '';
if ( ! empty( $attrs['content'] ) ) {
$heading = trim( wp_strip_all_tags( (string) $attrs['content'] ) );
} elseif ( ! empty( $block['innerHTML'] ) ) {
$heading = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) );
}
if ( '' !== $heading ) {
$heading_context = $heading;
}
}
if ( 'core/image' === $name ) {
$image_id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0;
if ( $image_id <= 0 ) {
$slots[] = array(
'section_title' => $heading_context,
);
}
}
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
$walk( $block['innerBlocks'], $heading_context );
}
}
};
$walk( $blocks, '' );
return $slots;
}
/**
* Handle generate image request.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_generate_image( $request ) {
$post_id = $request->get_param( 'post_id' );
$agent_image_id = $request->get_param( 'agent_image_id' );
$prompt = $request->get_param( 'prompt' );
$variant_count = $request->get_param( 'variant_count' ) ?? 2;
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
$variants = $image_manager->generate_image_variants(
$post_id,
$agent_image_id,
$prompt,
$variant_count
);
if ( is_wp_error( $variants ) ) {
return $variants;
}
return new WP_REST_Response(
array( 'variants' => $variants ),
200
);
}
/**
* Handle commit image request.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_commit_image( $request ) {
$post_id = $request->get_param( 'post_id' );
$agent_image_id = $request->get_param( 'agent_image_id' );
$variant_id = $request->get_param( 'variant_id' );
$alt_text = $request->get_param( 'alt' );
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
$result = $image_manager->commit_image_variant(
$post_id,
$agent_image_id,
$variant_id,
$alt_text
);
if ( is_wp_error( $result ) ) {
return $result;
}
return new WP_REST_Response( $result, 200 );
}
/**
* Handle multi-pass refinement request.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_refine_multi_pass( $request ) {
$params = $request->get_json_params();
$pass = $params['pass'] ?? 'clarity';
$blocks = $params['blocks'] ?? array();
$focus_keyword = $params['focusKeyword'] ?? '';
$post_id = $params['postId'] ?? 0;
// Check post permission before using postId for cost tracking.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$pass_prompts = array(
'clarity' => 'Improve the clarity, readability, and flow of this content. Make sentences clearer, remove ambiguity, and ensure smooth transitions between ideas.',
'seo' => 'Optimize this content for SEO. Naturally incorporate the focus keyword "%s" where appropriate. Ensure good keyword density (1-2.5%), include variations of the keyword, and maintain readability.',
'quality' => 'Enhance the overall quality of this content. Check for grammar, spelling, and punctuation errors. Improve sentence structure and word choice. Ensure consistent tone throughout.',
);
$prompt = $pass_prompts[$pass] ?? $pass_prompts['clarity'];
if ($pass === 'seo' && $focus_keyword) {
$prompt = sprintf($prompt, $focus_keyword);
}
// Extract text from blocks
$content = '';
foreach ($blocks as $block) {
$content .= $this->extract_block_content_from_attrs($block['name'] ?? 'core/paragraph', $block['attributes'] ?? array()) . "\n\n";
}
if (empty(trim($content))) {
return new WP_Error('empty_content', 'No content to refine', array('status' => 400));
}
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task('refinement');
$provider = $provider_result->provider;
$messages = array(
array(
'role' => 'user',
'content' => $prompt . "\n\nContent to refine:\n\n" . $content,
),
);
$response = $provider->chat($messages, array(), 'refinement');
if (is_wp_error($response)) {
// Track failed attempt for observability.
$this->track_ai_cost(
$post_id,
WPAW_Model_Registry::get_default_model( 'refinement' ),
'refine_multi_pass',
0,
0,
0,
$provider_result,
'',
'error'
);
return $response;
}
// Track cost.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'refine_multi_pass',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
'',
'success'
);
return new WP_REST_Response(
array(
'pass' => $pass,
'refined_content' => $response['content'] ?? '',
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Handle article-wide refinement request.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_refine_article( $request ) {
$params = $request->get_json_params();
$instructions = $params['instructions'] ?? 'Improve overall quality';
$blocks = $params['blocks'] ?? array();
$post_id = $params['postId'] ?? 0;
// Extract text from blocks
$content = '';
$block_count = 0;
foreach ($blocks as $block) {
$block_content = $this->extract_block_content_from_attrs($block['name'] ?? 'core/paragraph', $block['attributes'] ?? array());
if (!empty(trim($block_content))) {
$content .= "[Block " . ($block_count + 1) . "]\n" . $block_content . "\n\n";
$block_count++;
}
}
if (empty(trim($content))) {
return new WP_Error('empty_content', 'No content to refine', array('status' => 400));
}
// Check post permission if post_id is provided.
if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$prompt = "Review and improve the following article content based on these instructions: " . $instructions . "\n\n";
$prompt .= "IMPORTANT: Return the improved content preserving all block structure using this exact format:\n";
$prompt .= "- Start each block with [Block N] on its own line\n";
$prompt .= "- Keep the same number of blocks as the original\n";
$prompt .= "- Preserve any code blocks, lists, or formatting within each block\n\n";
$prompt .= "Original content:\n\n" . $content;
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task('refinement');
$provider = $provider_result->provider;
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat($messages, array(), 'refinement');
if (is_wp_error($response)) {
// Track failed attempt for observability.
$this->track_ai_cost(
$post_id,
WPAW_Model_Registry::get_default_model( 'refinement' ),
'refine_article',
0,
0,
0,
$provider_result,
'',
'error'
);
return $response;
}
// Parse response back to blocks format
$refined_blocks = $this->parse_refined_blocks($response['content'] ?? '', $block_count);
// Track cost.
$this->track_ai_cost(
$post_id,
$response['model'] ?? '',
'refine_article',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0,
$provider_result,
'',
'success'
);
return new WP_REST_Response(
array(
'blocks' => $refined_blocks,
'count' => count($refined_blocks),
'cost' => $response['cost'] ?? 0,
'provider_metadata' => $this->build_provider_metadata(
$provider_result,
$response['model'] ?? ''
),
),
200
);
}
/**
* Parse refined blocks from AI response.
*
* @param string $content AI response content.
* @param int $expected_count Expected number of blocks.
* @return array Array of block contents.
*/
private function parse_refined_blocks( $content, $expected_count = 0 ) {
$blocks = array();
// Split by [Block N] markers
$parts = preg_split('/\[Block\s*\d+\]/i', $content);
// First part is usually empty or intro text, skip it
array_shift($parts);
foreach ($parts as $part) {
$block_content = trim($part);
if (!empty($block_content)) {
$blocks[] = $block_content;
}
}
// If parsing didn't work well, return the whole content as single block
if (empty($blocks) && !empty(trim($content))) {
$blocks[] = trim($content);
}
return $blocks;
}
/**
* Handle GEO (Generative Engine Optimization) scoring request.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_geo_score( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error( 'invalid_post', 'Invalid post ID', array( 'status' => 400 ) );
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) );
}
$post_config = $this->get_post_config( $post_id );
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
$geo = array(
'score' => 0,
'max_score' => 100,
'rating' => 'poor',
'checks' => array(),
'suggestions' => array(),
);
$total_checks = 0;
$total_score = 0;
// Check 1: Directness - Does the content answer questions directly?
$total_checks++;
$directness_indicators = array(
'this article', 'in this guide', 'in this post', 'here\'s how', 'here\'s what',
'the best way', 'how to', 'step by step', 'in this tutorial', 'learn how'
);
$directness_count = 0;
foreach ( $directness_indicators as $indicator ) {
$directness_count += substr_count( strtolower( $content ), $indicator );
}
if ( $directness_count >= 2 ) {
$geo['checks'][] = array(
'name' => 'Directness',
'status' => 'good',
'message' => 'Content provides direct answers',
'score' => 20,
);
$total_score += 20;
} elseif ( $directness_count >= 1 ) {
$geo['checks'][] = array(
'name' => 'Directness',
'status' => 'ok',
'message' => 'Some direct answers found, consider being more explicit',
'score' => 12,
);
$total_score += 12;
} else {
$geo['checks'][] = array(
'name' => 'Directness',
'status' => 'warning',
'message' => 'Content may be too indirect. Add clear intro sentences that directly address the topic.',
'score' => 5,
);
$total_score += 5;
$geo['suggestions'][] = 'Start with a clear statement: "This guide explains how to [topic]" or "In this article, you\'ll learn [benefit]"';
}
// Check 2: Structure - Is the content well-organized with clear headings?
$total_checks++;
$heading_count = preg_match_all( '/<h[1-6][^>]*>/i', $post->post_content, $matches );
$paragraph_count = preg_match_all( '/<p[^>]*>/i', $post->post_content, $matches );
if ( $heading_count >= 3 && $paragraph_count >= 5 ) {
$geo['checks'][] = array(
'name' => 'Structure',
'status' => 'good',
'message' => "Excellent structure with {$heading_count} headings and {$paragraph_count} paragraphs",
'score' => 20,
);
$total_score += 20;
} elseif ( $heading_count >= 1 ) {
$geo['checks'][] = array(
'name' => 'Structure',
'status' => 'ok',
'message' => 'Basic structure present, consider adding more subheadings',
'score' => 12,
);
$total_score += 12;
} else {
$geo['checks'][] = array(
'name' => 'Structure',
'status' => 'warning',
'message' => 'Content lacks structure. Add clear H2/H3 headings to break up content.',
'score' => 5,
);
$total_score += 5;
$geo['suggestions'][] = 'Add H2 headings every 200-300 words to organize content into scannable sections';
}
// Check 3: Authority - Does the content demonstrate expertise?
$total_checks++;
$authority_indicators = array(
'experience', 'years', 'research', 'study', 'according to', 'expert',
'professional', 'certified', 'proven', 'tested', 'verified'
);
$authority_count = 0;
foreach ( $authority_indicators as $indicator ) {
$authority_count += substr_count( strtolower( $content ), $indicator );
}
if ( $authority_count >= 3 ) {
$geo['checks'][] = array(
'name' => 'Authority',
'status' => 'good',
'message' => 'Content demonstrates strong expertise',
'score' => 20,
);
$total_score += 20;
} elseif ( $authority_count >= 1 ) {
$geo['checks'][] = array(
'name' => 'Authority',
'status' => 'ok',
'message' => 'Some authority signals present',
'score' => 12,
);
$total_score += 12;
} else {
$geo['checks'][] = array(
'name' => 'Authority',
'status' => 'warning',
'message' => 'Content lacks authority signals. Add experience, research, or expert references.',
'score' => 5,
);
$total_score += 5;
$geo['suggestions'][] = 'Add phrases like "Based on years of experience", "Research shows", or "Experts recommend"';
}
// Check 4: Clarity - Is the content easy to understand?
$total_checks++;
$word_count = str_word_count( $content );
$sentence_count = preg_match_all( '/[.!?]+/', $content );
$avg_sentence_length = $sentence_count > 0 ? $word_count / $sentence_count : 0;
// Count complex words (7+ characters)
$words = preg_split( '/\s+/', $content );
$complex_words = 0;
foreach ( $words as $word ) {
$clean_word = preg_replace( '/[^a-zA-Z]/', '', $word );
if ( strlen( $clean_word ) >= 7 ) {
$complex_words++;
}
}
$flesch_score = $word_count > 0 ? 206.835 - (1.015 * ($word_count / max( 1, $sentence_count ))) - (84.6 * ($complex_words / $word_count)) : 0;
$readability = $flesch_score >= 60 ? 'good' : ( $flesch_score >= 40 ? 'ok' : 'complex' );
if ( $readability === 'good' ) {
$geo['checks'][] = array(
'name' => 'Clarity',
'status' => 'good',
'message' => sprintf( 'Excellent readability (Flesch: %.0f)', $flesch_score ),
'score' => 20,
);
$total_score += 20;
} elseif ( $readability === 'ok' ) {
$geo['checks'][] = array(
'name' => 'Clarity',
'status' => 'ok',
'message' => sprintf( 'Average readability (Flesch: %.0f)', $flesch_score ),
'score' => 12,
);
$total_score += 12;
} else {
$geo['checks'][] = array(
'name' => 'Clarity',
'status' => 'warning',
'message' => sprintf( 'Complex text (Flesch: %.0f). Consider shorter sentences.', $flesch_score ),
'score' => 5,
);
$total_score += 5;
$geo['suggestions'][] = 'Break long sentences into shorter ones. Aim for 15-20 words per sentence average.';
}
// Check 5: Completeness - Does the content cover the topic thoroughly?
$total_checks++;
$focus_keyword = $post_config['seo_focus_keyword'] ?? '';
if ( ! empty( $focus_keyword ) ) {
$keyword_in_intro = stripos( substr( $content, 0, 200 ), $focus_keyword ) !== false;
$keyword_in_conclusion = stripos( substr( $content, -200 ), $focus_keyword ) !== false;
$keyword_count = substr_count( strtolower( $content ), strtolower( $focus_keyword ) );
$keyword_density = $word_count > 0 ? ($keyword_count / $word_count) * 100 : 0;
if ( $keyword_in_intro && $keyword_in_conclusion && $keyword_density >= 0.5 ) {
$geo['checks'][] = array(
'name' => 'Completeness',
'status' => 'good',
'message' => 'Topic covered comprehensively with keyword in intro and conclusion',
'score' => 20,
);
$total_score += 20;
} elseif ( $keyword_density >= 0.5 ) {
$geo['checks'][] = array(
'name' => 'Completeness',
'status' => 'ok',
'message' => 'Topic covered but improve keyword placement',
'score' => 12,
);
$total_score += 12;
} else {
$geo['checks'][] = array(
'name' => 'Completeness',
'status' => 'warning',
'message' => 'Topic may not be fully covered. Ensure keyword appears in intro, body, and conclusion.',
'score' => 5,
);
$total_score += 5;
$geo['suggestions'][] = 'Include focus keyword in your introduction and conclusion paragraph';
}
} else {
$geo['checks'][] = array(
'name' => 'Completeness',
'status' => 'ok',
'message' => 'Focus keyword not set - cannot fully assess completeness',
'score' => 10,
);
$total_score += 10;
$geo['suggestions'][] = 'Set a focus keyword to enable full GEO analysis';
}
// Calculate final score
$geo['score'] = $total_score;
// Determine rating
if ( $geo['score'] >= 80 ) {
$geo['rating'] = 'excellent';
} elseif ( $geo['score'] >= 60 ) {
$geo['rating'] = 'good';
} elseif ( $geo['score'] >= 40 ) {
$geo['rating'] = 'fair';
} else {
$geo['rating'] = 'poor';
}
// Add AI Overview eligibility note
$geo['ai_overview_eligible'] = $geo['score'] >= 80;
return new WP_REST_Response( $geo, 200 );
}
/**
* Handle generate title request.
*
* Uses WordPress 7.0 AI Client when available, falls back to legacy.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_generate_title( $request ) {
$params = $request->get_json_params();
$content = sanitize_textarea_field( $params['content'] ?? '' );
if ( empty( $content ) ) {
return new WP_Error(
'missing_content',
__( 'Content is required for title generation.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$options = array(
'post_id' => isset( $params['post_id'] ) ? (int) $params['post_id'] : 0,
);
$client = WPAW_WP_AI_Client::get_instance();
$result = $client->generate_title( $content, $options );
if ( is_wp_error( $result ) ) {
return $result;
}
return new WP_REST_Response(
array(
'title' => $result,
'source' => $client->get_ai_mode(),
),
200
);
}
/**
* Handle generate excerpt request.
*
* Uses WordPress 7.0 AI Client when available, falls back to legacy.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_generate_excerpt( $request ) {
$params = $request->get_json_params();
$content = sanitize_textarea_field( $params['content'] ?? '' );
if ( empty( $content ) ) {
return new WP_Error(
'missing_content',
__( 'Content is required for excerpt generation.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$options = array(
'post_id' => isset( $params['post_id'] ) ? (int) $params['post_id'] : 0,
);
$client = WPAW_WP_AI_Client::get_instance();
$result = $client->generate_excerpt( $content, $options );
if ( is_wp_error( $result ) ) {
return $result;
}
return new WP_REST_Response(
array(
'excerpt' => $result,
'source' => $client->get_ai_mode(),
),
200
);
}
/**
* Handle get AI capabilities request.
*
* Returns the current AI capabilities based on available providers.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response
*/
public function handle_get_ai_capabilities( $request ) {
$client = WPAW_WP_AI_Client::get_instance();
$capabilities = $client->get_capabilities();
return new WP_REST_Response( $capabilities, 200 );
}
/**
* Handle search request for research.
*
* Uses Brave Search API for web search results.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_search( $request ) {
$params = $request->get_json_params();
$query = sanitize_text_field( $params['query'] ?? '' );
$count = isset( $params['count'] ) ? absint( $params['count'] ) : 5;
if ( empty( $query ) ) {
return new WP_Error(
'missing_query',
__( 'Search query is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$brave = WP_Agentic_Writer_Brave_Search_API::get_instance();
$results = $brave->search( $query, $count );
if ( is_wp_error( $results ) ) {
return $results;
}
return new WP_REST_Response(
array(
'query' => $query,
'results' => $results,
'count' => count( $results ),
),
200
);
}
/**
* Handle fetch content request for research.
*
* Fetches and extracts content from a URL for AI context.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_fetch_content( $request ) {
$params = $request->get_json_params();
$url = esc_url_raw( $params['url'] ?? '' );
if ( empty( $url ) ) {
return new WP_Error(
'missing_url',
__( 'URL is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Validate URL format
if ( ! wp_http_validate_url( $url ) ) {
return new WP_Error(
'invalid_url',
__( 'Invalid URL provided.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Fetch the content
$response = wp_remote_get(
$url,
array(
'timeout' => 20,
'user-agent' => 'Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)',
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$http_code = wp_remote_retrieve_response_code( $response );
if ( $http_code !== 200 ) {
return new WP_Error(
'fetch_failed',
sprintf( __( 'Failed to fetch URL (HTTP %d).', 'wp-agentic-writer' ), $http_code ),
array( 'status' => $http_code )
);
}
$body = wp_remote_retrieve_body( $response );
// Strip HTML tags and get clean text
$content = wp_strip_all_tags( $body );
// Truncate to prevent token overflow (max ~4000 chars for context)
if ( strlen( $content ) > 4000 ) {
$content = substr( $content, 0, 4000 ) . '...';
}
return new WP_REST_Response(
array(
'url' => $url,
'content' => $content,
'length' => strlen( $content ),
),
200
);
}
/**
* Handle research summary request.
*
* Performs multiple searches and generates a research summary.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_research_summary( $request ) {
$params = $request->get_json_params();
$topic = sanitize_text_field( $params['topic'] ?? '' );
$depth = sanitize_text_field( $params['depth'] ?? 'basic' );
$include_urls = isset( $params['include_urls'] ) ? (bool) $params['include_urls'] : false;
if ( empty( $topic ) ) {
return new WP_Error(
'missing_topic',
__( 'Research topic is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Determine search count based on depth
$search_counts = array(
'basic' => 3,
'medium' => 5,
'deep' => 8,
);
$count = $search_counts[ $depth ] ?? 3;
$brave = WP_Agentic_Writer_Brave_Search_API::get_instance();
// Perform main search
$main_results = $brave->search( $topic, $count );
if ( is_wp_error( $main_results ) ) {
return $main_results;
}
$research_data = array(
'topic' => $topic,
'depth' => $depth,
'search_results' => $main_results,
'formatted_context' => $brave->format_results_for_llm( $main_results, $topic ),
);
// Optionally fetch content from top URLs
if ( $include_urls && ! empty( $main_results ) ) {
$fetched_content = array();
$max_urls = min( 2, count( $main_results ) ); // Limit to 2 URLs
for ( $i = 0; $i < $max_urls; $i++ ) {
$url = $main_results[ $i ]['url'] ?? '';
if ( empty( $url ) ) {
continue;
}
$fetch_response = wp_remote_get(
$url,
array(
'timeout' => 15,
'user-agent' => 'Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)',
)
);
if ( ! is_wp_error( $fetch_response ) && 200 === wp_remote_retrieve_response_code( $fetch_response ) ) {
$body = wp_remote_retrieve_body( $fetch_response );
$content = wp_strip_all_tags( $body );
if ( strlen( $content ) > 2000 ) {
$content = substr( $content, 0, 2000 ) . '...';
}
$fetched_content[] = array(
'title' => $main_results[ $i ]['title'],
'url' => $url,
'excerpt' => $content,
);
}
}
$research_data['fetched_content'] = $fetched_content;
}
return new WP_REST_Response( $research_data, 200 );
}
/**
* Handle get conversations list request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_get_conversations( $request ) {
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$status = sanitize_text_field( $request->get_param( 'status' ) ?: 'active' );
$limit = (int) $request->get_param( 'limit' ) ?: 20;
$post_id = (int) $request->get_param( 'post_id' ) ?: 0;
// If post_id is specified, check authorization before returning session.
if ( $post_id > 0 ) {
// Authorization: User must be able to edit this post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$sessions = $manager->get_sessions_for_post( $post_id );
return new WP_REST_Response(
array(
'sessions' => $sessions,
'count' => count( $sessions ),
),
200
);
}
if ( $request->get_param( 'uncompleted' ) ) {
$sessions = $manager->get_uncompleted_sessions( $limit );
} else {
$sessions = $manager->get_user_sessions( $status, $limit );
}
return new WP_REST_Response(
array(
'sessions' => $sessions,
'count' => count( $sessions ),
),
200
);
}
/**
* Handle create conversation request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_create_conversation( $request ) {
$params = $request->get_json_params();
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0;
$focus_keyword = isset( $params['focus_keyword'] ) ? sanitize_text_field( $params['focus_keyword'] ) : '';
$title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : '';
// Authorization: If linking to a post, check edit permission.
if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to create a session for this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
if ( '' === $title && $post_id > 0 ) {
$post = get_post( $post_id );
$base_title = $post ? sanitize_text_field( $post->post_title ) : '';
if ( '' === $base_title ) {
$base_title = 'Conversation';
}
$title = sprintf( '%s - %s', $base_title, current_time( 'Y-m-d H:i' ) );
}
$session_id = $manager->create_session( array(
'post_id' => $post_id,
'focus_keyword' => $focus_keyword,
'title' => $title,
) );
if ( is_wp_error( $session_id ) ) {
return $session_id;
}
$session = $manager->get_session( $session_id );
return new WP_REST_Response( $session, 201 );
}
/**
* Handle get single conversation request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_get_conversation( $request ) {
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Check authorization
if ( ! $manager->current_user_can_access( $session_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to access this conversation.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$session = $manager->get_session( $session_id );
if ( ! $session ) {
return new WP_Error(
'not_found',
__( 'Conversation not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
$session = $this->hydrate_session_plan_messages( $session );
return new WP_REST_Response( $session, 200 );
}
/**
* Restore rich plan UI payloads for sessions that only stored a text summary.
*
* @since 0.2.2
* @param array $session Conversation session.
* @return array
*/
private function hydrate_session_plan_messages( $session ) {
if ( ! is_array( $session ) ) {
return $session;
}
$post_id = isset( $session['post_id'] ) ? (int) $session['post_id'] : 0;
if ( $post_id <= 0 || empty( $session['messages'] ) || ! is_array( $session['messages'] ) ) {
return $session;
}
foreach ( $session['messages'] as $message ) {
if ( isset( $message['type'] ) && 'plan' === $message['type'] && ! empty( $message['plan'] ) ) {
return $session;
}
}
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( ! is_array( $plan ) ) {
return $session;
}
foreach ( $session['messages'] as $index => $message ) {
$content = isset( $message['content'] ) ? (string) $message['content'] : '';
$role = isset( $message['role'] ) ? (string) $message['role'] : '';
if ( 'assistant' !== $role || false === strpos( $content, 'Outline ready.' ) ) {
continue;
}
$session['messages'][ $index ]['type'] = 'plan';
$session['messages'][ $index ]['plan'] = $plan;
break;
}
return $session;
}
/**
* Handle update conversation request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_update_conversation( $request ) {
$params = $request->get_json_params();
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Check authorization
if ( ! $manager->current_user_can_access( $session_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to modify this conversation.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$session = $manager->get_session( $session_id );
if ( ! $session ) {
return new WP_Error(
'not_found',
__( 'Conversation not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
// Update fields
if ( isset( $params['title'] ) ) {
$manager->update_title( $session_id, $params['title'] );
}
if ( isset( $params['focus_keyword'] ) ) {
$manager->update_focus_keyword( $session_id, $params['focus_keyword'] );
}
if ( isset( $params['status'] ) ) {
if ( $params['status'] === 'completed' ) {
$manager->mark_completed( $session_id );
}
}
$updated_session = $manager->get_session( $session_id );
return new WP_REST_Response( $updated_session, 200 );
}
/**
* Handle delete conversation request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_delete_conversation( $request ) {
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Check authorization
if ( ! $manager->current_user_can_access( $session_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to delete this conversation.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$result = $manager->delete_session( $session_id );
if ( ! $result ) {
return new WP_Error(
'delete_failed',
__( 'Failed to delete conversation.', 'wp-agentic-writer' ),
array( 'status' => 500 )
);
}
return new WP_REST_Response(
array( 'deleted' => true ),
200
);
}
/**
* Handle update conversation messages request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_update_conversation_messages( $request ) {
$params = $request->get_json_params();
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Check authorization
if ( ! $manager->current_user_can_access( $session_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have permission to modify this conversation.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$session = $manager->get_session( $session_id );
if ( ! $session ) {
return new WP_Error(
'not_found',
__( 'Conversation not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
$messages = isset( $params['messages'] ) ? $params['messages'] : array();
if ( ! is_array( $messages ) ) {
return new WP_Error(
'invalid_messages',
__( 'Messages must be an array.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$updated = $manager->update_messages( $session_id, $messages );
if ( ! $updated ) {
return new WP_Error(
'message_update_failed',
__( 'Failed to update conversation messages.', 'wp-agentic-writer' ),
array( 'status' => 500 )
);
}
return new WP_REST_Response(
array( 'updated' => true, 'message_count' => count( $messages ) ),
200
);
}
/**
* Handle link conversation to post request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_link_conversation_to_post( $request ) {
$params = $request->get_json_params();
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// First verify user has access to this session (before linking to post).
if ( ! $manager->current_user_can_access( $session_id ) ) {
return new WP_Error(
'forbidden',
__( 'You do not have access to this conversation.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$session = $manager->get_session( $session_id );
if ( ! $session ) {
return new WP_Error(
'not_found',
__( 'Conversation not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
$post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Valid post ID is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Verify post exists and user can edit
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'permission_denied',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
$manager->link_to_post( $session_id, $post_id );
$updated_session = $manager->get_session( $session_id );
return new WP_REST_Response(
array(
'linked' => true,
'post_id' => $post_id,
'session' => $updated_session,
),
200
);
}
/**
* Handle migrate chat history request.
*
* Migrates legacy _wpaw_chat_history from post meta to session table.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_migrate_chat_history( $request ) {
$post_id = (int) $request->get_param( 'post_id' );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Valid post ID is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Verify post exists and user can edit
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'permission_denied',
__( 'You do not have permission to edit this post.', 'wp-agentic-writer' ),
array( 'status' => 403 )
);
}
// Use Context Service for migration
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$result = $context_service->migrate_legacy_chat_history( $post_id );
if ( ! $result ) {
return new WP_Error(
'migration_failed',
__( 'Failed to migrate chat history.', 'wp-agentic-writer' ),
array( 'status' => 500 )
);
}
// Return migration status
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$sessions = $manager->get_sessions_for_post( $post_id );
return new WP_REST_Response(
array(
'migrated' => true,
'post_id' => $post_id,
'sessions_count' => count( $sessions ),
'message' => 'Legacy chat history has been migrated to session table.',
),
200
);
}
/**
* Auto-save post and link conversation when writing execution begins.
*
* @since 0.1.4
* @param string $session_id Session ID.
* @param int $post_id Current post ID (can be 0).
* @return int New post ID if saved, or original if not needed.
*/
public function ensure_conversation_linked_to_post( $session_id, $post_id = 0 ) {
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Already linked
if ( $post_id > 0 ) {
return $post_id;
}
// Check if editor has content
if ( ! $manager->post_has_content( get_the_ID() ) ) {
// No content yet, keep as post_id = 0
return 0;
}
// Get current post (auto-save with placeholder title)
$current_post_id = get_the_ID();
if ( $current_post_id && $current_post_id > 0 ) {
// Update post with placeholder title if needed
$post = get_post( $current_post_id );
if ( $post && empty( $post->post_title ) ) {
wp_update_post( array(
'ID' => $current_post_id,
'post_title' => 'Draft - ' . date( 'Y-m-d H:i' ),
) );
}
// Link conversation to post
$manager->link_to_post( $session_id, $current_post_id );
return $current_post_id;
}
return 0;
}
/**
* Get user preferences (per-user settings).
*
* @since 0.2.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_get_user_preferences( $request ) {
$user_id = get_current_user_id();
// Return defaults if not logged in
if ( $user_id === 0 ) {
return new WP_REST_Response(
array(
'proactive_suggestions' => true,
'command_palette_enabled' => true,
'outline_panel_enabled' => true,
'auto_save_interval' => 30,
'preferred_model' => '',
'preferred_language' => 'auto',
'theme' => 'dark',
),
200
);
}
$preferences = get_user_meta( $user_id, 'wpaw_user_preferences', true );
// Merge with defaults
$defaults = array(
'proactive_suggestions' => true,
'command_palette_enabled' => true,
'outline_panel_enabled' => true,
'auto_save_interval' => 30,
'preferred_model' => '',
'preferred_language' => 'auto',
'theme' => 'dark',
);
$preferences = is_array( $preferences ) ? array_merge( $defaults, $preferences ) : $defaults;
return new WP_REST_Response( $preferences, 200 );
}
/**
* Save user preferences (per-user settings).
*
* @since 0.2.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_save_user_preferences( $request ) {
$user_id = get_current_user_id();
if ( $user_id === 0 ) {
return new WP_Error(
'unauthorized',
__( 'You must be logged in to save preferences.', 'wp-agentic-writer' ),
array( 'status' => 401 )
);
}
$preferences = $request->get_json_params();
// Validate and sanitize
$sanitized = array(
'proactive_suggestions' => ! empty( $preferences['proactive_suggestions'] ),
'command_palette_enabled' => ! empty( $preferences['command_palette_enabled'] ),
'outline_panel_enabled' => ! empty( $preferences['outline_panel_enabled'] ),
'auto_save_interval' => max( 5, min( 300, (int) ( $preferences['auto_save_interval'] ?? 30 ) ) ),
'preferred_model' => sanitize_text_field( $preferences['preferred_model'] ?? '' ),
'preferred_language' => sanitize_text_field( $preferences['preferred_language'] ?? 'auto' ),
'theme' => in_array( $preferences['theme'] ?? 'dark', array( 'dark', 'light' ), true ) ? $preferences['theme'] : 'dark',
);
update_user_meta( $user_id, 'wpaw_user_preferences', $sanitized );
return new WP_REST_Response( $sanitized, 200 );
}
}