Files
wp-agentic-writer/includes/class-controller-seo.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

1542 lines
52 KiB
PHP

<?php
/**
* SEO REST Controller
*
* Handles SEO audit, keywords, meta description, and GEO scoring operations.
*
* @package WP_Agentic_Writer
*/
/**
* Class WP_Agentic_Writer_Controller_Seo
*
* REST controller for SEO operations.
*
* @since 0.3.0
*/
class WP_Agentic_Writer_Controller_Seo
{
/**
* Sidebar instance for dependency access.
*
* @var WP_Agentic_Writer_Gutenberg_Sidebar
*/
private $sidebar;
/**
* Constructor.
*
* @since 0.3.0
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
*/
public function __construct($sidebar)
{
$this->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(
"/<!-- wp:heading.*?-->/",
$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(
"/<!-- wp:image.*?-->/",
$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(
"/<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"][] = [
"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);
}
}