Files
wp-agentic-writer/includes/class-context-service.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

608 lines
18 KiB
PHP

<?php
/**
* Context Service
*
* Centralized service for managing conversation context across
* all generation paths. Provides a single source of truth for
* chat history, plan, and per-post configuration.
*
* @package WP_Agentic_Writer
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Class WP_Agentic_Writer_Context_Service
*
* Single source of truth for conversation context.
*
* Storage Layer Rules:
* - Conversation messages → wpaw_conversations table (authoritative)
* - Article outline/plan → post_meta._wpaw_plan (authoritative)
* - Per-post config → post_meta._wpaw_post_config (authoritative)
* - Legacy _wpaw_chat_history → migrated to session table on read
*/
class WP_Agentic_Writer_Context_Service
{
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Context_Service
*/
private static $instance = null;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Context_Service
*/
public static function get_instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct()
{
// Private constructor for singleton.
}
/**
* Get conversation context for a session.
*
* @param string $session_id Session ID.
* @param int $post_id Post ID (optional).
* @return array Context data.
*/
public function get_context($session_id, $post_id = 0)
{
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$session = $manager->get_session($session_id);
$effective_session_id = $session_id;
// Migrate legacy history on read if no session exists but post has legacy data.
if (!$session && $post_id > 0) {
$legacy_history = get_post_meta(
$post_id,
"_wpaw_chat_history",
true,
);
$is_migrated = get_post_meta(
$post_id,
"_wpaw_chat_history_migrated",
true,
);
if (!empty($legacy_history) && empty($is_migrated)) {
$migrated_session_id = $this->migrate_legacy_chat_history(
$post_id,
$session_id,
);
// Use the session_id returned from migration (may be newly created)
$effective_session_id = is_string($migrated_session_id)
? $migrated_session_id
: $session_id;
$session = $manager->get_session($effective_session_id);
}
}
if (!$session) {
return $this->get_empty_context($effective_session_id, $post_id);
}
// If post_id is provided, get post-specific data
$post_data = [];
if ($post_id > 0) {
$post_data = $this->get_post_context($post_id);
}
return [
"session_id" => $effective_session_id,
"post_id" => $post_id,
"messages" => $session["messages"] ?? [],
"context" => $session["context"] ?? [],
"plan" => $post_data["plan"] ?? null,
"post_config" =>
$post_data["post_config"] ?? $this->get_default_post_config(),
"title" => $session["title"] ?? "",
"focus_keyword" => $session["focus_keyword"] ?? "",
"status" => $session["status"] ?? "active",
];
}
/**
* Get empty context structure.
*
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @return array Empty context.
*/
private function get_empty_context($session_id, $post_id)
{
return [
"session_id" => $session_id,
"post_id" => $post_id,
"messages" => [],
"context" => [],
"plan" => null,
"post_config" => $this->get_default_post_config(),
"title" => "",
"focus_keyword" => "",
"status" => "active",
];
}
/**
* Get post-specific context (plan, config).
*
* @param int $post_id Post ID.
* @return array Post context.
*/
public function get_post_context($post_id)
{
$plan = get_post_meta($post_id, "_wpaw_plan", true);
if (!is_array($plan)) {
$plan = null;
}
$post_config = get_post_meta($post_id, "_wpaw_post_config", true);
if (!is_array($post_config)) {
$post_config = $this->get_default_post_config();
}
return [
"plan" => $plan,
"post_config" => $post_config,
];
}
/**
* Save messages to session.
*
* @param string $session_id Session ID.
* @param array $messages Messages array.
* @return bool Success.
*/
public function save_messages($session_id, $messages)
{
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
return $manager->update_messages($session_id, $messages);
}
/**
* Add a message to the session.
*
* @param string $session_id Session ID.
* @param array $message Message data.
* @return bool Success.
*/
public function add_message($session_id, $message)
{
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$session = $manager->get_session($session_id);
if (!$session) {
error_log(
"WPAW_Context_Service: Failed to get session $session_id for add_message",
);
return false;
}
$messages = $session["messages"] ?? [];
$messages[] = $message;
$result = $manager->update_messages($session_id, $messages);
if (!$result) {
error_log(
"WPAW_Context_Service: Failed to update_messages for session $session_id",
);
}
return $result;
}
/**
* Get structured session context JSON.
*
* @since 0.2.3
* @param string $session_id Session ID.
* @return array Session context.
*/
public function get_session_context($session_id)
{
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$session = $manager->get_session($session_id);
if (
!$session ||
empty($session["context"]) ||
!is_array($session["context"])
) {
return [];
}
return $session["context"];
}
/**
* Merge a context patch into the stored session context.
*
* @since 0.2.3
* @param string $session_id Session ID.
* @param array $patch Context fields to merge.
* @return bool Success.
*/
public function update_session_context($session_id, $patch)
{
if (empty($session_id) || !is_array($patch)) {
return false;
}
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$context = $this->get_session_context($session_id);
$context = $this->merge_context_recursive($context, $patch);
$context["updated_at"] = current_time("c");
return $manager->update_context($session_id, $context);
}
/**
* Append an item to an array inside session context.
*
* @since 0.2.3
* @param string $session_id Session ID.
* @param string $key Context key.
* @param array $item Item to append.
* @param int $limit Maximum retained items.
* @return bool Success.
*/
public function append_session_context_item(
$session_id,
$key,
$item,
$limit = 25,
) {
if (empty($session_id) || empty($key) || !is_array($item)) {
return false;
}
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
$context = $this->get_session_context($session_id);
if (empty($context[$key]) || !is_array($context[$key])) {
$context[$key] = [];
}
$item["created_at"] = $item["created_at"] ?? current_time("c");
$context[$key][] = $item;
if ($limit > 0 && count($context[$key]) > $limit) {
$context[$key] = array_slice($context[$key], -1 * $limit);
}
$context["updated_at"] = current_time("c");
return $manager->update_context($session_id, $context);
}
/**
* Save plan to post meta.
*
* @param int $post_id Post ID.
* @param array $plan Plan data.
* @return bool Success.
*/
public function save_plan($post_id, $plan)
{
if ($post_id <= 0) {
return false;
}
return update_post_meta($post_id, "_wpaw_plan", $plan) !== false;
}
/**
* Get plan from post meta.
*
* @param int $post_id Post ID.
* @return array|null Plan or null.
*/
public function get_plan($post_id)
{
if ($post_id <= 0) {
return null;
}
$plan = get_post_meta($post_id, "_wpaw_plan", true);
return is_array($plan) ? $plan : null;
}
/**
* Save post config to post meta.
*
* @param int $post_id Post ID.
* @param array $post_config Post config.
* @return bool Success.
*/
public function save_post_config($post_id, $post_config)
{
if ($post_id <= 0) {
return false;
}
return update_post_meta($post_id, "_wpaw_post_config", $post_config) !==
false;
}
/**
* Get post config from post meta.
*
* @param int $post_id Post ID.
* @return array Post config.
*/
public function get_post_config($post_id)
{
if ($post_id <= 0) {
return $this->get_default_post_config();
}
$config = get_post_meta($post_id, "_wpaw_post_config", true);
return is_array($config)
? wp_parse_args($config, $this->get_default_post_config())
: $this->get_default_post_config();
}
/**
* Get default post config.
*
* @return array Default config.
*/
public function get_default_post_config()
{
$settings = get_option("wp_agentic_writer_settings", []);
return [
"article_length" => "medium",
"language" => "auto",
"tone" => "",
"audience" => "",
"experience_level" => "general",
"include_images" => true,
"web_search" => false,
"default_mode" => "writing",
"focus_keyword" => "",
"seo_focus_keyword" => "",
"seo_secondary_keywords" => "",
"seo_meta_description" => "",
"seo_enabled" => false,
];
}
/**
* Migrate legacy chat history from post meta to session.
*
* @param int $post_id Post ID.
* @param string $session_id Optional session ID to use. If not provided and no
* session exists, creates a new one.
* @return string|false Session ID used for migration, or false on failure.
*/
public function migrate_legacy_chat_history($post_id, $session_id = "")
{
if ($post_id <= 0) {
return false;
}
$legacy_history = get_post_meta($post_id, "_wpaw_chat_history", true);
if (empty($legacy_history) || !is_array($legacy_history)) {
return $session_id ?: true; // Nothing to migrate, return existing or true
}
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Try to find existing session for this post if no session_id provided
$sessions = [];
if (empty($session_id)) {
$sessions = $manager->get_sessions_for_post($post_id);
}
if (!empty($sessions)) {
// Append to existing session
$session = $sessions[0];
$existing_messages = $session["messages"] ?? [];
// Merge legacy messages (avoid duplicates based on content hash)
$existing_hashes = [];
foreach ($existing_messages as $msg) {
$existing_hashes[] = $this->get_message_hash($msg);
}
foreach ($legacy_history as $msg) {
$hash = $this->get_message_hash($msg);
if (!in_array($hash, $existing_hashes, true)) {
$existing_messages[] = $msg;
}
}
$manager->update_messages(
$session["session_id"],
$existing_messages,
);
$migrated_session_id = $session["session_id"];
} else {
// Create new session with legacy messages
$new_session_id = $manager->create_session([
"post_id" => $post_id,
"messages" => $legacy_history,
"title" => "Migrated from legacy",
]);
$migrated_session_id = is_string($new_session_id)
? $new_session_id
: (!empty($session_id)
? $session_id
: "");
}
// Delete legacy meta after successful migration and mark as migrated.
delete_post_meta($post_id, "_wpaw_chat_history");
update_post_meta(
$post_id,
"_wpaw_chat_history_migrated",
current_time("mysql"),
);
return $migrated_session_id;
}
/**
* Get hash for message deduplication.
*
* @param array $message Message.
* @return string Hash.
*/
private function get_message_hash($message)
{
$content = $message["content"] ?? "";
$role = $message["role"] ?? "";
return md5($role . ":" . $content);
}
/**
* Merge context arrays while preserving nested JSON objects.
*
* @since 0.2.3
* @param array $base Existing context.
* @param array $patch Context patch.
* @return array Merged context.
*/
private function merge_context_recursive($base, $patch)
{
foreach ($patch as $key => $value) {
if (
is_array($value) &&
isset($base[$key]) &&
is_array($base[$key]) &&
!wp_is_numeric_array($value)
) {
$base[$key] = $this->merge_context_recursive(
$base[$key],
$value,
);
} else {
$base[$key] = $value;
}
}
return $base;
}
/**
* Clear context for a session and post.
*
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @return bool Success.
*/
public function clear_context($session_id, $post_id = 0)
{
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
// Clear specific session if provided
if ($session_id) {
$manager->update_messages($session_id, []);
} elseif ($post_id > 0) {
// No session_id provided - clear all active sessions for this post
$sessions = $manager->get_sessions_for_post($post_id);
foreach ($sessions as $session) {
if (
!empty($session["session_id"]) &&
"active" === ($session["status"] ?? "")
) {
$manager->update_messages($session["session_id"], []);
}
}
}
// Clear post meta if post_id provided
if ($post_id > 0) {
delete_post_meta($post_id, "_wpaw_plan");
delete_post_meta($post_id, "_wpaw_memory");
delete_post_meta($post_id, "_wpaw_chat_history");
// Keep _wpaw_post_config as it's user settings
}
return true;
}
/**
* Get context summary for display.
*
* @param array $context Context data.
* @return string Human-readable summary.
*/
public function get_context_summary($context)
{
$parts = [];
// Message count
$msg_count = count($context["messages"] ?? []);
$parts[] = sprintf(
_n("%d message", "%d messages", $msg_count, "wp-agentic-writer"),
$msg_count,
);
// Plan status
if (!empty($context["plan"])) {
$sections = count($context["plan"]["sections"] ?? []);
$parts[] = sprintf(
_n(
"%d section in plan",
"%d sections in plan",
$sections,
"wp-agentic-writer",
),
$sections,
);
} else {
$parts[] = "No plan";
}
// Focus keyword
if (!empty($context["focus_keyword"])) {
$parts[] = "Focus: " . $context["focus_keyword"];
}
return implode(" | ", $parts);
}
/**
* Build context for AI prompt.
*
* @param array $context Context data.
* @param int $max_messages Maximum messages to include.
* @return array Messages for AI.
*/
public function build_ai_context($context, $max_messages = 20)
{
$messages = $context["messages"] ?? [];
// Limit to most recent messages
if (count($messages) > $max_messages) {
$messages = array_slice($messages, -$max_messages);
}
// Add context summary if available
$context_summary = [
"role" => "system",
"content" =>
"Current context: " . $this->get_context_summary($context),
];
return array_merge([$context_summary], $messages);
}
}