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.
1542 lines
52 KiB
PHP
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);
|
|
}
|
|
}
|