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.
590 lines
19 KiB
PHP
590 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* Planning REST Controller
|
|
*
|
|
* Handles article planning operations including outline generation and revision.
|
|
*
|
|
* @package WP_Agentic_Writer
|
|
*/
|
|
|
|
/**
|
|
* Class WP_Agentic_Writer_Controller_Planning
|
|
*
|
|
* REST controller for planning operations.
|
|
*
|
|
* @since 0.3.0
|
|
*/
|
|
class WP_Agentic_Writer_Controller_Planning
|
|
{
|
|
/**
|
|
* 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 generate plan request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_generate_plan($request)
|
|
{
|
|
// Check rate limit.
|
|
$rate_limit = WPAW_Rate_Limiter::check("generate_plan");
|
|
if (is_wp_error($rate_limit)) {
|
|
return $rate_limit;
|
|
}
|
|
|
|
$params = $request->get_json_params();
|
|
$topic = $params["topic"] ?? "";
|
|
$context = $params["context"] ?? "";
|
|
$post_id = $params["postId"] ?? 0;
|
|
$session_id = $this->sidebar->resolve_or_create_session_id(
|
|
$params["sessionId"] ?? "",
|
|
$post_id,
|
|
);
|
|
$auto_execute = $params["autoExecute"] ?? false;
|
|
$stream = $params["stream"] ?? false;
|
|
$chat_history = $params["chatHistory"] ?? [];
|
|
$post_config = $this->sidebar->resolve_post_config_from_request(
|
|
$params,
|
|
$post_id,
|
|
);
|
|
$article_length =
|
|
$post_config["article_length"] ??
|
|
($params["articleLength"] ?? "medium");
|
|
$clarification_answers = $params["clarificationAnswers"] ?? [];
|
|
$detected_language = $params["detectedLanguage"] ?? "auto";
|
|
$clarified_language = $this->sidebar->resolve_language_from_clarification_answers(
|
|
$clarification_answers,
|
|
);
|
|
$effective_language = $this->sidebar->resolve_language_preference(
|
|
$post_config,
|
|
$clarified_language ?: $detected_language,
|
|
);
|
|
$post_config_context = $this->sidebar->build_post_config_context(
|
|
$post_config,
|
|
);
|
|
$web_search_options = $this->sidebar->get_web_search_options(
|
|
$post_config,
|
|
);
|
|
|
|
if (empty($topic)) {
|
|
return new WP_Error(
|
|
"no_topic",
|
|
__("Topic is required.", "wp-agentic-writer"),
|
|
["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],
|
|
);
|
|
}
|
|
|
|
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
|
|
$context_package = $context_builder->build_for_task(
|
|
"planning",
|
|
$session_id,
|
|
$post_id,
|
|
array_merge($params, [
|
|
"chatHistory" => $chat_history,
|
|
"postConfig" => $post_config,
|
|
]),
|
|
);
|
|
|
|
// If streaming is requested, delegate to sidebar streaming method.
|
|
if ($stream) {
|
|
return $this->sidebar->stream_generate_plan(
|
|
$topic,
|
|
$context,
|
|
$post_id,
|
|
$auto_execute,
|
|
$article_length,
|
|
$clarification_answers,
|
|
$effective_language,
|
|
$post_config,
|
|
$chat_history,
|
|
$session_id,
|
|
);
|
|
}
|
|
|
|
// Get provider for planning task.
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"planning",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
|
|
// Build prompt for plan generation.
|
|
$plan_language_instruction = $this->sidebar->build_language_instruction(
|
|
$effective_language,
|
|
"article plan (title, section headings, descriptions)",
|
|
);
|
|
$system_prompt = "You are an Information Architect and SEO/GEO Strategist. Your task is to outline a high-information-density article based on the user's topic and context.
|
|
|
|
ANTI-ROBOT RULES:
|
|
- Never use generic intros or 'throat-clearing' fluff.
|
|
- Avoid academic, pompous, or 'expert' posturing.
|
|
- Headings must provide direct value or ask specific questions the article will answer.
|
|
|
|
GEO/SEO STRATEGY:
|
|
- Design the outline for Generative Engine Optimization (GEO): sections must flow logically to answer the user's core intent comprehensively.
|
|
- Suggest strategic use of tables, bullet points, and Q&A formats where they maximize information density.
|
|
- Incorporate secondary entities and related concepts naturally to show topical depth.
|
|
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$plan_language_instruction}
|
|
Keep JSON keys in English for parsing, but write every user-visible JSON value (title, headings, descriptions, labels) in the required article language.
|
|
{$post_config_context}
|
|
|
|
Generate a JSON outline with the following structure:
|
|
{
|
|
\"title\": \"Article title\",
|
|
\"meta\": {
|
|
\"reading_time\": \"5 min\",
|
|
\"difficulty\": \"intermediate\",
|
|
\"cost_estimate\": 0.70
|
|
},
|
|
\"sections\": [
|
|
{
|
|
\"id\": \"unique-section-id\",
|
|
\"status\": \"pending\",
|
|
\"type\": \"section\",
|
|
\"heading\": \"Section heading\",
|
|
\"content\": [
|
|
{
|
|
\"type\": \"paragraph\",
|
|
\"content\": \"Brief description of what this section should cover\"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text.
|
|
Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks.";
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Topic: {$topic}\n\nContext:\n{$context_package["working_context"]}\n\n{$context_package["research_context"]}",
|
|
],
|
|
];
|
|
|
|
// Generate plan.
|
|
$this->sidebar->maybe_inject_brave_search(
|
|
$messages,
|
|
$provider,
|
|
$web_search_options,
|
|
);
|
|
$response = $provider->chat(
|
|
$messages,
|
|
array_merge(
|
|
[
|
|
"temperature" => 0.7,
|
|
"max_tokens" => 2200,
|
|
],
|
|
$web_search_options,
|
|
),
|
|
"planning",
|
|
);
|
|
|
|
// Debug: log the provider type and response
|
|
$provider_class = get_class($provider);
|
|
wpaw_debug_log("Plan generation using provider: " . $provider_class);
|
|
|
|
if (is_wp_error($response)) {
|
|
wpaw_debug_log(
|
|
"Plan generation error: " . $response->get_error_message(),
|
|
);
|
|
return new WP_Error(
|
|
"plan_generation_error",
|
|
$response->get_error_message(),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
// Extract JSON from response.
|
|
$content = $response["content"] ?? "";
|
|
$plan_json = WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response(
|
|
$content,
|
|
$topic,
|
|
);
|
|
|
|
// Debug: log the raw response
|
|
wpaw_debug_log(
|
|
"Plan generation raw response length: " . strlen($content),
|
|
);
|
|
|
|
if (empty(trim((string) $content))) {
|
|
$model_used = $response["model"] ?? "unknown";
|
|
return new WP_Error(
|
|
"empty_response",
|
|
sprintf(
|
|
__(
|
|
"The AI model (%s) returned an empty response. Try a different planning model or simplify your topic.",
|
|
"wp-agentic-writer",
|
|
),
|
|
$model_used,
|
|
),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
if (null === $plan_json) {
|
|
wpaw_debug_log(
|
|
"extract_plan_from_response returned null. Content preview: " .
|
|
substr($content, 0, 500),
|
|
);
|
|
return new WP_Error(
|
|
"invalid_json",
|
|
sprintf(
|
|
/* translators: %s: model output preview */
|
|
__(
|
|
'The AI responded but the outline couldn\'t be parsed as JSON. Try again — this is usually a one-time formatting issue. Preview: %s',
|
|
"wp-agentic-writer",
|
|
),
|
|
$this->sidebar->build_model_output_preview($content),
|
|
),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
$plan_json = $this->sidebar->ensure_plan_sections_with_tasks($plan_json);
|
|
|
|
// MEMANTO: Remember plan was generated.
|
|
do_action("wpaw_memanto_plan_generated", $post_id, $plan_json);
|
|
|
|
// Persist planning exchange into session history.
|
|
if (!empty($session_id)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->update_session_context($session_id, [
|
|
"working_summary" => [
|
|
"text" => WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
|
$plan_json,
|
|
),
|
|
"updated_at" => current_time("c"),
|
|
"source_message_count" => 0,
|
|
],
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "user",
|
|
"content" => trim((string) $topic),
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "assistant",
|
|
"type" => "plan",
|
|
"plan" => $plan_json,
|
|
"content" => $this->sidebar->build_plan_summary_for_session(
|
|
$plan_json,
|
|
$post_config,
|
|
),
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
}
|
|
|
|
// Store plan in post meta.
|
|
if ($post_id > 0) {
|
|
update_post_meta($post_id, "_wpaw_plan", $plan_json);
|
|
update_post_meta(
|
|
$post_id,
|
|
"_wpaw_detected_language",
|
|
$effective_language,
|
|
);
|
|
$summary = WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
|
$plan_json,
|
|
);
|
|
$this->sidebar->update_post_memory($post_id, [
|
|
"summary" => $summary,
|
|
"last_prompt" => $topic,
|
|
"last_intent" => "generate",
|
|
]);
|
|
}
|
|
|
|
// Track cost with provider metadata.
|
|
$this->sidebar->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"planning",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"plan" => $plan_json,
|
|
"cost" => $response["cost"] ?? 0,
|
|
"web_search_results" => $response["web_search_results"] ?? [],
|
|
"context_audit" => $context_package["audit"] ?? [],
|
|
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle revise plan request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_revise_plan($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$instruction = $params["instruction"] ?? "";
|
|
$plan = $params["plan"] ?? [];
|
|
$post_id = $params["postId"] ?? 0;
|
|
$session_id = $this->sidebar->resolve_or_create_session_id(
|
|
$params["sessionId"] ?? "",
|
|
$post_id,
|
|
);
|
|
|
|
if (empty($instruction)) {
|
|
return new WP_Error(
|
|
"no_instruction",
|
|
__("Instruction is required.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
if (empty($plan) || !is_array($plan)) {
|
|
return new WP_Error(
|
|
"no_plan",
|
|
__("Plan is required to revise.", "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 edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
// Only read post config/meta after permission check.
|
|
$post_config = $this->sidebar->resolve_post_config_from_request(
|
|
$params,
|
|
$post_id,
|
|
);
|
|
$post_config_context = $this->sidebar->build_post_config_context(
|
|
$post_config,
|
|
);
|
|
$effective_language = $this->sidebar->resolve_language_preference(
|
|
$post_config,
|
|
get_post_meta($post_id, "_wpaw_detected_language", true),
|
|
);
|
|
$plan_language_instruction = $this->sidebar->build_language_instruction(
|
|
$effective_language,
|
|
"article plan (title, section headings, descriptions)",
|
|
);
|
|
$web_search_options = $this->sidebar->get_web_search_options(
|
|
$post_config,
|
|
);
|
|
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"planning",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$memory_context = WP_Agentic_Writer_Sidebar_Helpers::get_post_memory_context(
|
|
$post_id,
|
|
);
|
|
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
|
|
$context_package = $context_builder->build_for_task(
|
|
"plan_revision",
|
|
$session_id,
|
|
$post_id,
|
|
array_merge($params, [
|
|
"plan" => $plan,
|
|
"postConfig" => $post_config,
|
|
]),
|
|
);
|
|
|
|
$system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction.
|
|
|
|
{$plan_language_instruction}
|
|
{$post_config_context}
|
|
{$memory_context}
|
|
|
|
IMPORTANT:
|
|
- Return ONLY valid raw JSON matching the plan schema below.
|
|
- Do not wrap in markdown code fences or add explanatory text.
|
|
- Keep the same structure: title, meta, sections with id/status/type/heading/content.
|
|
|
|
Schema:
|
|
{
|
|
\"title\": \"Article title\",
|
|
\"meta\": { \"reading_time\": \"5 min\", \"difficulty\": \"intermediate\", \"cost_estimate\": 0.70 },
|
|
\"sections\": [
|
|
{
|
|
\"id\": \"unique-section-id\",
|
|
\"status\": \"pending\",
|
|
\"type\": \"section\",
|
|
\"heading\": \"Section heading\",
|
|
\"content\": [{\"type\": \"paragraph\", \"content\": \"Description\"]}
|
|
}
|
|
]
|
|
}";
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Revision instruction: {$instruction}\n\nExisting outline:\n" .
|
|
wp_json_encode($plan) .
|
|
"\n\nContext:\n{$context_package["working_context"]}",
|
|
],
|
|
];
|
|
|
|
// Generate revised plan.
|
|
$this->sidebar->maybe_inject_brave_search(
|
|
$messages,
|
|
$provider,
|
|
$web_search_options,
|
|
);
|
|
$response = $provider->chat(
|
|
$messages,
|
|
array_merge(["temperature" => 0.6], $web_search_options),
|
|
"planning",
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
return new WP_Error(
|
|
"plan_revision_error",
|
|
$response->get_error_message(),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
$plan_json = WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response(
|
|
$response["content"],
|
|
$instruction,
|
|
$plan,
|
|
);
|
|
if (null === $plan_json) {
|
|
return new WP_Error(
|
|
"plan_revision_invalid",
|
|
__("Failed to generate valid plan JSON.", "wp-agentic-writer"),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
$plan_json = $this->sidebar->ensure_plan_sections_with_tasks(
|
|
$plan_json,
|
|
$plan,
|
|
);
|
|
|
|
// MEMANTO: Remember plan revision (implies previous plan was rejected).
|
|
do_action("wpaw_memanto_plan_rejected", $post_id, $instruction);
|
|
|
|
if ($post_id > 0) {
|
|
update_post_meta($post_id, "_wpaw_plan", $plan_json);
|
|
if (!empty($effective_language)) {
|
|
update_post_meta(
|
|
$post_id,
|
|
"_wpaw_detected_language",
|
|
$effective_language,
|
|
);
|
|
}
|
|
$summary = WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
|
$plan_json,
|
|
);
|
|
$this->sidebar->update_post_memory($post_id, [
|
|
"summary" => $summary,
|
|
"last_prompt" => $instruction,
|
|
"last_intent" => "plan",
|
|
]);
|
|
}
|
|
|
|
if (!empty($session_id)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->append_session_context_item(
|
|
$session_id,
|
|
"plan_versions",
|
|
[
|
|
"instruction" => sanitize_text_field($instruction),
|
|
"plan" => $plan,
|
|
],
|
|
10,
|
|
);
|
|
$context_service->update_session_context($session_id, [
|
|
"working_summary" => [
|
|
"text" => WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
|
$plan_json,
|
|
),
|
|
"updated_at" => current_time("c"),
|
|
"source_message_count" => 0,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// Track cost with provider metadata.
|
|
$this->sidebar->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"planning",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"plan" => $plan_json,
|
|
"cost" => $response["cost"] ?? 0,
|
|
"context_audit" => $context_package["audit"] ?? [],
|
|
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
}
|