Files
wp-agentic-writer/includes/class-context-service.php

425 lines
12 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 = array();
if ( $post_id > 0 ) {
$post_data = $this->get_post_context( $post_id );
}
return array(
'session_id' => $effective_session_id,
'post_id' => $post_id,
'messages' => $session['messages'] ?? array(),
'context' => $session['context'] ?? array(),
'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 array(
'session_id' => $session_id,
'post_id' => $post_id,
'messages' => array(),
'context' => array(),
'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 array(
'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 ) {
return false;
}
$messages = $session['messages'] ?? array();
$messages[] = $message;
return $manager->update_messages( $session_id, $messages );
}
/**
* 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', array() );
return array(
'article_length' => 'medium',
'language' => 'auto',
'tone' => '',
'audience' => '',
'experience_level' => 'general',
'include_images' => true,
'web_search' => false,
'default_mode' => 'writing',
'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 = array();
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'] ?? array();
// Merge legacy messages (avoid duplicates based on content hash)
$existing_hashes = array();
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( array(
'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 );
}
/**
* 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, array() );
} 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'], array() );
}
}
}
// 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 = array();
// Message count
$msg_count = count( $context['messages'] ?? array() );
$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'] ?? array() );
$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'] ?? array();
// Limit to most recent messages
if ( count( $messages ) > $max_messages ) {
$messages = array_slice( $messages, -$max_messages );
}
// Add context summary if available
$context_summary = array(
'role' => 'system',
'content' => 'Current context: ' . $this->get_context_summary( $context ),
);
return array_merge( array( $context_summary ), $messages );
}
}