Files
wp-agentic-writer/includes/class-context-builder.php
2026-06-06 00:29:10 +07:00

531 lines
15 KiB
PHP

<?php
/**
* Context Builder
*
* Builds compact, backend-owned prompt context from saved sessions and posts.
*
* @package WP_Agentic_Writer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class WP_Agentic_Writer_Context_Builder
*
* OpenRouter and other providers are stateless gateways. This class keeps
* continuity in WordPress by selecting the relevant session/post context for
* each request without resending the full browser history.
*/
class WP_Agentic_Writer_Context_Builder {
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Context_Builder
*/
private static $instance = null;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Context_Builder
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Build a context package for a task.
*
* @param string $task Task name.
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @param array $request_params Request params.
* @return array Context package.
*/
public function build_for_task( $task, $session_id, $post_id, $request_params = array() ) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$saved_context = ! empty( $session_id )
? $context_service->get_context( $session_id, $post_id )
: array();
$session_context = $saved_context['context'] ?? array();
$messages = $saved_context['messages'] ?? array();
if ( empty( $messages ) ) {
$messages = $this->get_request_messages( $request_params );
}
$token_policy = $this->get_token_policy( $session_context );
$recent_messages = $this->prepare_recent_messages( $messages, $token_policy['max_recent_messages'] );
$recent_messages = $this->remove_active_user_message( $recent_messages, $request_params['latestUserMessage'] ?? '' );
$post_config = $this->resolve_post_config( $saved_context, $request_params );
$plan = $this->resolve_plan( $saved_context, $request_params, $post_id );
$working_context = $this->build_working_context(
$task,
$session_context,
$recent_messages,
$plan,
$post_config,
$request_params
);
return array(
'system_context' => '',
'working_context' => $working_context,
'active_content' => $this->get_active_content( $request_params ),
'research_context' => $this->build_research_context( $session_context, $request_params, $token_policy['max_research_snippets'] ),
'audit' => array(
'included_recent_messages' => count( $recent_messages ),
'included_research_items' => $this->count_research_items( $session_context, $request_params, $token_policy['max_research_snippets'] ),
'estimated_input_tokens' => $this->estimate_tokens( $working_context ),
'used_full_history' => false,
),
);
}
/**
* Build a system message that can be inserted after the primary system prompt.
*
* @param string $task Task name.
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @param array $request_params Request params.
* @return array Context system message and audit metadata.
*/
public function build_system_message( $task, $session_id, $post_id, $request_params = array() ) {
$package = $this->build_for_task( $task, $session_id, $post_id, $request_params );
$content = trim(
$package['working_context']
. "\n"
. $package['active_content']
. "\n"
. $package['research_context']
);
if ( '' === $content ) {
return array(
'message' => null,
'audit' => $package['audit'],
);
}
return array(
'message' => array(
'role' => 'system',
'content' => $content,
),
'audit' => $package['audit'],
);
}
/**
* Get task token policy.
*
* @param array $session_context Session context.
* @return array Token policy.
*/
private function get_token_policy( $session_context ) {
$policy = isset( $session_context['token_policy'] ) && is_array( $session_context['token_policy'] )
? $session_context['token_policy']
: array();
return array(
'max_recent_messages' => max( 2, (int) ( $policy['max_recent_messages'] ?? 6 ) ),
'max_summary_tokens' => max( 200, (int) ( $policy['max_summary_tokens'] ?? 600 ) ),
'max_research_snippets' => max( 0, (int) ( $policy['max_research_snippets'] ?? 5 ) ),
);
}
/**
* Get messages from request fallback.
*
* @param array $request_params Request params.
* @return array Messages.
*/
private function get_request_messages( $request_params ) {
if ( ! empty( $request_params['messages'] ) && is_array( $request_params['messages'] ) ) {
return $request_params['messages'];
}
if ( ! empty( $request_params['chatHistory'] ) && is_array( $request_params['chatHistory'] ) ) {
return $request_params['chatHistory'];
}
return array();
}
/**
* Prepare compact recent messages.
*
* @param array $messages Messages.
* @param int $max_messages Max messages.
* @return array Recent messages.
*/
private function prepare_recent_messages( $messages, $max_messages ) {
$prepared = array();
foreach ( (array) $messages as $message ) {
$role = isset( $message['role'] ) ? (string) $message['role'] : '';
if ( ! in_array( $role, array( 'user', 'assistant' ), true ) ) {
continue;
}
$content = isset( $message['content'] ) ? trim( wp_strip_all_tags( (string) $message['content'] ) ) : '';
if ( '' === $content ) {
continue;
}
$prepared[] = array(
'role' => $role,
'content' => $this->truncate_text( $content, 900 ),
);
}
if ( count( $prepared ) > $max_messages ) {
$prepared = array_slice( $prepared, -1 * $max_messages );
}
return $prepared;
}
/**
* Avoid echoing the active user turn inside the saved-context excerpt.
*
* @param array $messages Recent messages.
* @param string $active_user_message Active user message.
* @return array Messages without duplicate active turn.
*/
private function remove_active_user_message( $messages, $active_user_message ) {
$active_user_message = trim( wp_strip_all_tags( (string) $active_user_message ) );
if ( '' === $active_user_message || empty( $messages ) ) {
return $messages;
}
for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) {
if ( 'user' !== ( $messages[ $i ]['role'] ?? '' ) ) {
continue;
}
$content = trim( (string) ( $messages[ $i ]['content'] ?? '' ) );
if ( $content === $active_user_message ) {
array_splice( $messages, $i, 1 );
}
break;
}
return $messages;
}
/**
* Resolve post config.
*
* @param array $saved_context Saved context package.
* @param array $request_params Request params.
* @return array Post config.
*/
private function resolve_post_config( $saved_context, $request_params ) {
$config = $saved_context['post_config'] ?? array();
if ( ! empty( $request_params['postConfig'] ) && is_array( $request_params['postConfig'] ) ) {
$config = wp_parse_args( $request_params['postConfig'], $config );
}
return is_array( $config ) ? $config : array();
}
/**
* Resolve current plan.
*
* @param array $saved_context Saved context package.
* @param array $request_params Request params.
* @param int $post_id Post ID.
* @return array|null Plan.
*/
private function resolve_plan( $saved_context, $request_params, $post_id ) {
if ( ! empty( $saved_context['plan'] ) && is_array( $saved_context['plan'] ) ) {
return $saved_context['plan'];
}
if ( ! empty( $request_params['plan'] ) && is_array( $request_params['plan'] ) ) {
return $request_params['plan'];
}
if ( $post_id > 0 ) {
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( is_array( $plan ) ) {
return $plan;
}
}
return null;
}
/**
* Build compact working context.
*
* @param string $task Task name.
* @param array $session_context Session context.
* @param array $recent_messages Recent messages.
* @param array $plan Current plan.
* @param array $post_config Post config.
* @param array $request_params Request params.
* @return string Context text.
*/
private function build_working_context( $task, $session_context, $recent_messages, $plan, $post_config, $request_params ) {
$sections = array();
$sections[] = "BACKEND CONTINUITY CONTEXT\nUse this compact WordPress-saved context as continuity. Do not assume OpenRouter remembers prior turns.";
$sections[] = 'Current task: ' . sanitize_key( $task );
$summary = $session_context['working_summary']['text'] ?? '';
if ( '' !== trim( (string) $summary ) ) {
$sections[] = "Working summary:\n" . $this->truncate_text( (string) $summary, 1600 );
}
$config_summary = $this->summarize_post_config( $post_config );
if ( '' !== $config_summary ) {
$sections[] = "Article configuration:\n" . $config_summary;
}
$plan_summary = $this->summarize_plan( $plan );
if ( '' !== $plan_summary ) {
$sections[] = "Current plan:\n" . $plan_summary;
}
$decision_summary = $this->summarize_context_items( $session_context, 'decisions', 'Decisions' );
if ( '' !== $decision_summary ) {
$sections[] = $decision_summary;
}
$rejection_summary = $this->summarize_context_items( $session_context, 'rejections', 'Rejected directions' );
if ( '' !== $rejection_summary ) {
$sections[] = $rejection_summary;
}
if ( ! empty( $request_params['context'] ) ) {
$sections[] = "User supplied context:\n" . $this->truncate_text( (string) $request_params['context'], 1600 );
}
if ( ! empty( $recent_messages ) ) {
$lines = array();
foreach ( $recent_messages as $message ) {
$lines[] = ucfirst( $message['role'] ) . ': ' . $message['content'];
}
$sections[] = "Recent saved conversation excerpts:\n" . implode( "\n", $lines );
}
return implode( "\n\n", array_filter( $sections ) );
}
/**
* Summarize post config.
*
* @param array $post_config Post config.
* @return string Summary.
*/
private function summarize_post_config( $post_config ) {
$lines = array();
$keys = array(
'article_length' => 'Article length',
'language' => 'Language',
'tone' => 'Tone',
'audience' => 'Audience',
'experience_level' => 'Experience level',
'seo_focus_keyword' => 'SEO focus keyword',
'seo_secondary_keywords' => 'SEO secondary keywords',
);
foreach ( $keys as $key => $label ) {
if ( isset( $post_config[ $key ] ) && '' !== trim( (string) $post_config[ $key ] ) ) {
$lines[] = '- ' . $label . ': ' . trim( (string) $post_config[ $key ] );
}
}
if ( isset( $post_config['include_images'] ) ) {
$lines[] = '- Include images: ' . ( $post_config['include_images'] ? 'yes' : 'no' );
}
if ( isset( $post_config['web_search'] ) ) {
$lines[] = '- Web search: ' . ( $post_config['web_search'] ? 'yes' : 'no' );
}
return implode( "\n", $lines );
}
/**
* Summarize current plan.
*
* @param array|null $plan Plan.
* @return string Summary.
*/
private function summarize_plan( $plan ) {
if ( empty( $plan ) || ! is_array( $plan ) ) {
return '';
}
$lines = array();
if ( ! empty( $plan['title'] ) ) {
$lines[] = 'Title: ' . $plan['title'];
}
if ( ! empty( $plan['sections'] ) && is_array( $plan['sections'] ) ) {
foreach ( $plan['sections'] as $index => $section ) {
$heading = $section['heading'] ?? $section['title'] ?? '';
if ( '' === trim( (string) $heading ) ) {
continue;
}
$status = $section['status'] ?? 'pending';
$lines[] = sprintf( '%d. [%s] %s', $index + 1, $status, $heading );
}
}
return implode( "\n", $lines );
}
/**
* Summarize context array items.
*
* @param array $session_context Session context.
* @param string $key Context key.
* @param string $label Label.
* @return string Summary.
*/
private function summarize_context_items( $session_context, $key, $label ) {
if ( empty( $session_context[ $key ] ) || ! is_array( $session_context[ $key ] ) ) {
return '';
}
$lines = array();
foreach ( array_slice( $session_context[ $key ], -8 ) as $item ) {
$summary = $item['summary'] ?? '';
if ( '' === trim( (string) $summary ) ) {
continue;
}
$target = ! empty( $item['target'] ) ? '[' . $item['target'] . '] ' : '';
$lines[] = '- ' . $target . $summary;
}
return empty( $lines ) ? '' : $label . ":\n" . implode( "\n", $lines );
}
/**
* Get active content from request params.
*
* @param array $request_params Request params.
* @return string Active content context.
*/
private function get_active_content( $request_params ) {
$candidates = array(
'activeContent',
'blockContent',
'selectedText',
'sectionContent',
'articleContent',
);
$lines = array();
foreach ( $candidates as $key ) {
if ( ! empty( $request_params[ $key ] ) && is_string( $request_params[ $key ] ) ) {
$lines[] = $key . ":\n" . $this->truncate_text( $request_params[ $key ], 2200 );
}
}
return empty( $lines ) ? '' : "ACTIVE CONTENT SLICE\n" . implode( "\n\n", $lines );
}
/**
* Build research context.
*
* @param array $session_context Session context.
* @param array $request_params Request params.
* @param int $limit Max snippets.
* @return string Research context.
*/
private function build_research_context( $session_context, $request_params, $limit ) {
if ( $limit <= 0 ) {
return '';
}
$items = array();
if ( ! empty( $session_context['research_notes'] ) && is_array( $session_context['research_notes'] ) ) {
$items = array_merge( $items, $session_context['research_notes'] );
}
if ( ! empty( $request_params['researchNotes'] ) && is_array( $request_params['researchNotes'] ) ) {
$items = array_merge( $items, $request_params['researchNotes'] );
}
$items = array_slice( $items, -1 * $limit );
$lines = array();
foreach ( $items as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$title = $item['title'] ?? $item['source'] ?? 'Research note';
$excerpt = $item['excerpt'] ?? $item['notes'] ?? '';
if ( '' === trim( (string) $excerpt ) ) {
continue;
}
$lines[] = '- ' . $title . ': ' . $this->truncate_text( (string) $excerpt, 700 );
}
return empty( $lines ) ? '' : "RELEVANT RESEARCH\n" . implode( "\n", $lines );
}
/**
* Count included research items.
*
* @param array $session_context Session context.
* @param array $request_params Request params.
* @param int $limit Max snippets.
* @return int Count.
*/
private function count_research_items( $session_context, $request_params, $limit ) {
if ( $limit <= 0 ) {
return 0;
}
$count = 0;
if ( ! empty( $session_context['research_notes'] ) && is_array( $session_context['research_notes'] ) ) {
$count += count( $session_context['research_notes'] );
}
if ( ! empty( $request_params['researchNotes'] ) && is_array( $request_params['researchNotes'] ) ) {
$count += count( $request_params['researchNotes'] );
}
return min( $limit, $count );
}
/**
* Truncate text safely.
*
* @param string $text Text.
* @param int $limit Character limit.
* @return string Truncated text.
*/
private function truncate_text( $text, $limit ) {
$text = trim( (string) $text );
if ( strlen( $text ) <= $limit ) {
return $text;
}
return substr( $text, 0, $limit ) . '...';
}
/**
* Estimate tokens from character length.
*
* @param string $text Text.
* @return int Estimated tokens.
*/
private function estimate_tokens( $text ) {
return (int) ceil( strlen( (string) $text ) / 4 );
}
}