sidebar = $sidebar; } /** * 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) { // Check rate limit. $rate_limit = WPAW_Rate_Limiter::check("seo_audit"); if (is_wp_error($rate_limit)) { return $rate_limit; } $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"), ["status" => 400], ); } // Check post permission before reading post content/config. if (!$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $post = get_post($post_id); if (!$post) { return new WP_Error( "post_not_found", __("Post not found.", "wp-agentic-writer"), ["status" => 404], ); } $post_config = $this->sidebar->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 = [ "score" => 0, "checks" => [], "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"][] = [ "name" => "Content length", "status" => "good", "message" => "Excellent! {$word_count} words (recommended: 1500+)", ]; $audit["score"] += 15; } elseif ($word_count >= 800) { $audit["checks"][] = [ "name" => "Content length", "status" => "ok", "message" => "Good: {$word_count} words (recommended: 1500+)", ]; $audit["score"] += 10; } else { $audit["checks"][] = [ "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"][] = [ "name" => "Keyword in title", "status" => "good", "message" => "Focus keyword found in title", ]; $audit["score"] += 20; } else { $audit["checks"][] = [ "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"][] = [ "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"][] = [ "name" => "Keyword density", "status" => "ok", "message" => "Low: {$keyword_density}% (target: 1-2.5%)", ]; $audit["score"] += 10; } elseif ($keyword_density > 2.5) { $audit["checks"][] = [ "name" => "Keyword density", "status" => "warning", "message" => "High: {$keyword_density}% - may be over-optimized", ]; $audit["score"] += 5; } else { $audit["checks"][] = [ "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"][] = [ "name" => "Keyword in intro", "status" => "good", "message" => "Focus keyword in first paragraph", ]; $audit["score"] += 15; } else { $audit["checks"][] = [ "name" => "Keyword in intro", "status" => "warning", "message" => "Add focus keyword to first paragraph", ]; } } else { $audit["checks"][] = [ "name" => "Focus keyword", "status" => "warning", "message" => "No focus keyword set", ]; } // Check 3: Headings $heading_count = preg_match_all( "//", $post->post_content, $matches, ); if ($heading_count >= 3) { $audit["checks"][] = [ "name" => "Subheadings", "status" => "good", "message" => "{$heading_count} subheadings found", ]; $audit["score"] += 15; } elseif ($heading_count >= 1) { $audit["checks"][] = [ "name" => "Subheadings", "status" => "ok", "message" => "Only {$heading_count} subheading(s) - add more for readability", ]; $audit["score"] += 8; } else { $audit["checks"][] = [ "name" => "Subheadings", "status" => "warning", "message" => "No subheadings found - add H2/H3 headings", ]; } // Check 4: Images $image_count = preg_match_all( "//", $post->post_content, $matches, ); if ($image_count >= 1) { $audit["checks"][] = [ "name" => "Images", "status" => "good", "message" => "{$image_count} image(s) found", ]; $audit["score"] += 10; } else { $audit["checks"][] = [ "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"][] = [ "name" => "Meta description", "status" => "good", "message" => "Perfect length: {$meta_len} chars (120-160)", ]; $audit["score"] += 5; } elseif ($meta_len > 0) { $audit["checks"][] = [ "name" => "Meta description", "status" => "ok", "message" => "Length: {$meta_len} chars (optimal: 120-160)", ]; $audit["score"] += 3; } } else { $audit["checks"][] = [ "name" => "Meta description", "status" => "warning", "message" => "No meta description set", ]; } // Check 6: AI-ish writing patterns (heuristic scanner). $ai_pattern_result = $this->sidebar->scan_ai_ish_patterns($post->post_content); if ($ai_pattern_result["count"] <= 1) { $audit["checks"][] = [ "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"][] = [ "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"][] = [ "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"] = []; foreach ($audit["checks"] as $check) { if ($check["status"] !== "good") { $audit["issues"][] = [ "severity" => $check["status"], "message" => $check["name"] . ": " . $check["message"], ]; } } return new WP_REST_Response($audit, 200); } /** * 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"] ?? []; // Check post permission BEFORE reading post content. if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["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", ), ["status" => 400], ); } // Get detected language from post meta $stored_language = get_post_meta( $post_id, "_wpaw_detected_language", true, ); $post_config = $this->sidebar->get_post_config($post_id); $effective_language = $this->sidebar->resolve_language_preference( $post_config, $stored_language, ); $language_instruction = $this->sidebar->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 = [ [ "role" => "user", "content" => $prompt, ], ]; $response = $provider->chat($messages, [], "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->sidebar->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( [ "meta_description" => $meta_description, "length" => strlen($meta_description), "cost" => $cost, "provider_metadata" => $this->sidebar->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->sidebar->resolve_or_create_session_id( $params["sessionId"] ?? "", $post_id, ); $title = $params["title"] ?? ""; $sections = $params["sections"] ?? []; if (empty($title) || empty($sections)) { return new WP_Error( "missing_data", __( "Title and sections are required for keyword suggestions.", "wp-agentic-writer", ), ["status" => 400], ); } // Check post permission before reading post data. if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } // Get detected language from post meta or config $stored_language = get_post_meta( $post_id, "_wpaw_detected_language", true, ); $post_config = $this->sidebar->get_post_config($post_id); $effective_language = $this->sidebar->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"] ?? []); $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, [ "role" => "assistant", "content" => $assistant_summary, "timestamp" => current_time("c"), ]); } return new WP_REST_Response( [ "focus_keyword" => $result["focus_keyword"], "secondary_keywords" => $result["secondary_keywords"], "reasoning" => $result["reasoning"], "cost" => $result["cost"], "provider_metadata" => $this->sidebar->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"] ?? []; $post_id = $params["postId"] ?? 0; $session_id = $this->sidebar->resolve_or_create_session_id( $params["sessionId"] ?? "", $post_id, ); // Check post permission before using postId for cost tracking. if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["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( [ "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 = [ [ "role" => "user", "content" => $prompt, ], ]; $response = $provider->chat($messages, [], "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"] ?? ""; // Track cost $cost = $response["cost"] ?? 0; if ($cost > 0) { $this->sidebar->track_ai_cost( $post_id, $response["model"] ?? "", "summarize", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $cost, $provider_result, $session_id, "success", ); } return new WP_REST_Response( [ "summary" => $summary, "use_full_history" => false, "cost" => $cost, "tokens_saved" => max(0, $tokens_saved), "session_id" => $session_id, "message_count" => count($chat_history), "source_message_count" => count($chat_history), ], 200, ); } /** * Handle suggest improvements request (proactive AI suggestions). * * @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"] ?? [ "clarity", "depth", "structure", ]; if ($post_id <= 0) { return new WP_Error( "invalid_post", __("Valid post ID is required.", "wp-agentic-writer"), ["status" => 400], ); } // Check post permission before reading post content. if (!$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["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"), ["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( [ "suggestions" => [], "message" => "Not enough content to analyze yet.", ], 200, ); } // Get post config for context $post_config = $this->sidebar->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 = [ [ "role" => "system", "content" => $system_prompt, ], [ "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, [], "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->sidebar->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->sidebar->extract_json($content); if (null === $suggestions_json) { // If JSON parsing fails, return a generic success with no suggestions return new WP_REST_Response( [ "suggestions" => [], "message" => "Analysis complete but suggestions could not be parsed.", ], 200, ); } return new WP_REST_Response( [ "suggestions" => $suggestions_json["suggestions"] ?? [], "summary" => $suggestions_json["summary"] ?? "Analysis complete.", "cost" => $response["cost"] ?? 0, "provider_metadata" => $this->sidebar->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ], 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"] ?? []; $focus_keyword = $params["focusKeyword"] ?? ""; $post_id = $params["postId"] ?? 0; // Check post permission before using postId for cost tracking. if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } $pass_prompts = [ "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->sidebar->extract_block_content_from_attrs( $block["name"] ?? "core/paragraph", $block["attributes"] ?? [], ) . "\n\n"; } if (empty(trim($content))) { return new WP_Error("empty_content", "No content to refine", [ "status" => 400, ]); } $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "refinement", ); $provider = $provider_result->provider; $messages = [ [ "role" => "user", "content" => $prompt . "\n\nContent to refine:\n\n" . $content, ], ]; $response = $provider->chat($messages, [], "refinement"); if (is_wp_error($response)) { // Track failed attempt for observability. $this->sidebar->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->sidebar->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( [ "pass" => $pass, "refined_content" => $response["content"] ?? "", "cost" => $response["cost"] ?? 0, "provider_metadata" => $this->sidebar->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) { // Check rate limit. $rate_limit = WPAW_Rate_Limiter::check("refine"); if (is_wp_error($rate_limit)) { return $rate_limit; } $params = $request->get_json_params(); $instructions = $params["instructions"] ?? "Improve overall quality"; $blocks = $params["blocks"] ?? []; $post_id = $params["postId"] ?? 0; // Extract text from blocks $content = ""; $block_count = 0; foreach ($blocks as $block) { $block_content = $this->sidebar->extract_block_content_from_attrs( $block["name"] ?? "core/paragraph", $block["attributes"] ?? [], ); 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", [ "status" => 400, ]); } // Check post permission if post_id is provided. if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["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 = [ [ "role" => "user", "content" => $prompt, ], ]; $response = $provider->chat($messages, [], "refinement"); if (is_wp_error($response)) { // Track failed attempt for observability. $this->sidebar->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->sidebar->parse_refined_blocks( $response["content"] ?? "", $block_count, ); // Track cost. $this->sidebar->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( [ "blocks" => $refined_blocks, "count" => count($refined_blocks), "cost" => $response["cost"] ?? 0, "provider_metadata" => $this->sidebar->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ], 200, ); } /** * 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", [ "status" => 400, ]); } $post = get_post($post_id); if (!$post) { return new WP_Error("post_not_found", "Post not found", [ "status" => 404, ]); } $post_config = $this->sidebar->get_post_config($post_id); $content = wp_strip_all_tags($post->post_content); $title = $post->post_title; $geo = [ "score" => 0, "max_score" => 100, "rating" => "poor", "checks" => [], "suggestions" => [], ]; $total_checks = 0; $total_score = 0; // Check 1: Directness - Does the content answer questions directly? $total_checks++; $directness_indicators = [ "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"][] = [ "name" => "Directness", "status" => "good", "message" => "Content provides direct answers", "score" => 20, ]; $total_score += 20; } elseif ($directness_count >= 1) { $geo["checks"][] = [ "name" => "Directness", "status" => "ok", "message" => "Some direct answers found, consider being more explicit", "score" => 12, ]; $total_score += 12; } else { $geo["checks"][] = [ "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( "/]*>/i", $post->post_content, $matches, ); $paragraph_count = preg_match_all( "/]*>/i", $post->post_content, $matches, ); if ($heading_count >= 3 && $paragraph_count >= 5) { $geo["checks"][] = [ "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"][] = [ "name" => "Structure", "status" => "ok", "message" => "Basic structure present, consider adding more subheadings", "score" => 12, ]; $total_score += 12; } else { $geo["checks"][] = [ "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 = [ "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"][] = [ "name" => "Authority", "status" => "good", "message" => "Content demonstrates strong expertise", "score" => 20, ]; $total_score += 20; } elseif ($authority_count >= 1) { $geo["checks"][] = [ "name" => "Authority", "status" => "ok", "message" => "Some authority signals present", "score" => 12, ]; $total_score += 12; } else { $geo["checks"][] = [ "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"][] = [ "name" => "Clarity", "status" => "good", "message" => sprintf( "Excellent readability (Flesch: %.0f)", $flesch_score, ), "score" => 20, ]; $total_score += 20; } elseif ($readability === "ok") { $geo["checks"][] = [ "name" => "Clarity", "status" => "ok", "message" => sprintf( "Average readability (Flesch: %.0f)", $flesch_score, ), "score" => 12, ]; $total_score += 12; } else { $geo["checks"][] = [ "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"][] = [ "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"][] = [ "name" => "Completeness", "status" => "ok", "message" => "Topic covered but improve keyword placement", "score" => 12, ]; $total_score += 12; } else { $geo["checks"][] = [ "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"][] = [ "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); } }