checkpoint: pre-audit baseline state

This commit is contained in:
Dwindi Ramadhana
2026-06-06 00:29:10 +07:00
parent 579aab1b2b
commit ae70e4aea9
38 changed files with 4061 additions and 3149 deletions

View File

@@ -0,0 +1,530 @@
<?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 );
}
}

View File

@@ -177,6 +177,78 @@ class WP_Agentic_Writer_Context_Service {
return $manager->update_messages( $session_id, $messages );
}
/**
* 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 array();
}
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 ] = array();
}
$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.
*
@@ -254,6 +326,7 @@ class WP_Agentic_Writer_Context_Service {
'include_images' => true,
'web_search' => false,
'default_mode' => 'writing',
'focus_keyword' => '',
'seo_focus_keyword' => '',
'seo_secondary_keywords' => '',
'seo_meta_description' => '',
@@ -336,6 +409,26 @@ class WP_Agentic_Writer_Context_Service {
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.
*
@@ -422,4 +515,4 @@ class WP_Agentic_Writer_Context_Service {
return array_merge( array( $context_summary ), $messages );
}
}
}

View File

@@ -539,7 +539,7 @@ class WP_Agentic_Writer_Conversation_Manager {
$sessions = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC",
"SELECT *, JSON_LENGTH(messages) as message_count FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC",
$post_id
),
ARRAY_A

File diff suppressed because it is too large Load Diff

View File

@@ -342,32 +342,44 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
);
}
// Test /ping endpoint
$ping_response = wp_remote_get(
$this->base_url . '/ping',
array(
'timeout' => 5,
'sslverify' => false,
)
);
if ( is_wp_error( $ping_response ) ) {
return new WP_Error(
'ping_failed',
sprintf(
/* translators: %s: error message */
__( 'Cannot reach proxy: %s. Is it running?', 'wp-agentic-writer' ),
$ping_response->get_error_message()
// Best-effort reachability checks. Do not hard-fail here; inference test below is authoritative.
$reachable = false;
$health_endpoints = array( '/ping', '/health', '/' );
foreach ( $health_endpoints as $endpoint ) {
$health_response = wp_remote_get(
$this->base_url . $endpoint,
array(
'timeout' => 5,
'sslverify' => false,
)
);
}
$ping_body = wp_remote_retrieve_body( $ping_response );
if ( 'pong' !== $ping_body ) {
return new WP_Error(
'invalid_ping',
__( 'Proxy responded but with unexpected format', 'wp-agentic-writer' )
);
if ( is_wp_error( $health_response ) ) {
continue;
}
$health_body = trim( (string) wp_remote_retrieve_body( $health_response ) );
$health_code = (int) wp_remote_retrieve_response_code( $health_response );
$health_json = json_decode( $health_body, true );
// Any 2xx indicates proxy process is reachable.
if ( $health_code >= 200 && $health_code < 300 ) {
$reachable = true;
}
// Stronger signal for known proxy responses.
if ( strcasecmp( $health_body, 'pong' ) === 0 ) {
$reachable = true;
break;
}
if ( is_array( $health_json ) ) {
$ok_flag = $health_json['ok'] ?? $health_json['success'] ?? null;
$status = strtolower( (string) ( $health_json['status'] ?? '' ) );
if ( true === $ok_flag || in_array( $status, array( 'ok', 'healthy', 'pong' ), true ) ) {
$reachable = true;
break;
}
}
}
// Test actual inference with simple prompt
@@ -393,6 +405,17 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
);
if ( is_wp_error( $test_response ) ) {
// If both health and inference are unreachable, report connection issue.
if ( ! $reachable ) {
return new WP_Error(
'ping_failed',
sprintf(
/* translators: %s: error message */
__( 'Cannot reach proxy: %s. Is it running and reachable from this server?', 'wp-agentic-writer' ),
$test_response->get_error_message()
)
);
}
return new WP_Error(
'inference_failed',
sprintf(

View File

@@ -514,6 +514,49 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
return __( 'Please go to Settings → Models and select a different model that is available on OpenRouter.', 'wp-agentic-writer' );
}
/**
* Build optional request-level OpenRouter provider routing preferences.
*
* This is intentionally settings-driven. BYOK users may pin a provider and
* disable fallbacks, but the plugin should not assume every OpenRouter model
* should use OpenAI, Anthropic, Azure, or any other provider.
*
* @since 0.2.3
* @param array $options Request options.
* @return array Provider routing preferences.
*/
private function get_provider_routing_preferences( $options = array() ) {
if ( isset( $options['provider'] ) && is_array( $options['provider'] ) ) {
return $options['provider'];
}
if ( array_key_exists( 'openrouter_provider_routing', $options ) && false === (bool) $options['openrouter_provider_routing'] ) {
return array();
}
$settings = get_option( 'wp_agentic_writer_settings', array() );
$enabled = ! empty( $settings['openrouter_provider_routing_enabled'] );
$provider_slug = isset( $settings['openrouter_provider_slug'] ) ? sanitize_key( $settings['openrouter_provider_slug'] ) : '';
if ( ! $enabled || '' === $provider_slug || 'auto' === $provider_slug ) {
return array();
}
$routing = array(
'order' => array( $provider_slug ),
);
if ( ! empty( $settings['openrouter_provider_only'] ) ) {
$routing['only'] = array( $provider_slug );
}
if ( isset( $settings['openrouter_allow_provider_fallbacks'] ) ) {
$routing['allow_fallbacks'] = (bool) $settings['openrouter_allow_provider_fallbacks'];
}
return $routing;
}
/**
* Get singleton instance.
*
@@ -605,6 +648,10 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
'include' => true,
),
);
$provider_routing = $this->get_provider_routing_preferences( $options );
if ( ! empty( $provider_routing ) ) {
$body['provider'] = $provider_routing;
}
// Add optional parameters.
if ( isset( $options['max_tokens'] ) ) {
@@ -752,6 +799,10 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
'include' => true,
),
);
$provider_routing = $this->get_provider_routing_preferences( $options );
if ( ! empty( $provider_routing ) ) {
$body['provider'] = $provider_routing;
}
// Add optional parameters.
if ( isset( $options['max_tokens'] ) ) {

View File

@@ -43,6 +43,7 @@ class WP_Agentic_Writer_Provider_Manager {
public static function get_provider_for_task( $type ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
$task_providers = $settings['task_providers'] ?? array();
$allow_openrouter_fallback = ! empty( $settings['allow_openrouter_fallback'] );
// Determine which provider to use for this task
$requested_provider = $task_providers[ $type ] ?? 'openrouter';
@@ -58,11 +59,26 @@ class WP_Agentic_Writer_Provider_Manager {
// Get provider instance with fallback logic
$provider = self::get_provider_instance( $requested_provider, $type );
// If provider not configured or unavailable, fallback to OpenRouter
$can_fallback_to_openrouter = ( 'openrouter' === $requested_provider ) || $allow_openrouter_fallback;
// If provider not configured or unavailable.
if ( ! $provider || ! $provider->is_configured() ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( "Provider '{$requested_provider}' not available for task '{$type}', using OpenRouter fallback" );
error_log( "Provider '{$requested_provider}' not available for task '{$type}'" );
}
// Never silently spend OpenRouter credits when user selected another provider.
if ( ! $can_fallback_to_openrouter ) {
$warnings[] = "Provider '{$requested_provider}' unavailable. No automatic fallback was applied.";
return new WPAW_Provider_Selection_Result(
$provider,
$requested_provider,
$requested_provider,
false,
$warnings
);
}
$warnings[] = "Provider '{$requested_provider}' unavailable, fell back to OpenRouter";
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$actual_provider = 'openrouter';
@@ -74,12 +90,16 @@ class WP_Agentic_Writer_Provider_Manager {
$test_result = $provider->test_connection();
if ( is_wp_error( $test_result ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( "Local Backend not reachable for task '{$type}', using OpenRouter fallback. Error: " . $test_result->get_error_message() );
error_log( "Local Backend not reachable for task '{$type}'. Error: " . $test_result->get_error_message() );
}
if ( $can_fallback_to_openrouter ) {
$warnings[] = "Local Backend not reachable, fell back to OpenRouter.";
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$actual_provider = 'openrouter';
$fallback_used = true;
} else {
$warnings[] = "Local Backend not reachable. No automatic fallback was applied.";
}
$warnings[] = "Local Backend not reachable, fell back to OpenRouter";
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$actual_provider = 'openrouter';
$fallback_used = true;
}
}

View File

@@ -640,6 +640,7 @@ class WP_Agentic_Writer_Settings_V2 {
'monthly' => '0.0000',
'today' => '0.0000',
'avg_per_post' => '0.0000',
'action_summary' => array(),
),
'filters' => array(
'models' => array(),
@@ -662,8 +663,8 @@ class WP_Agentic_Writer_Settings_V2 {
$filter_date_from = isset( $_POST['filter_date_from'] ) ? sanitize_text_field( $_POST['filter_date_from'] ) : '';
$filter_date_to = isset( $_POST['filter_date_to'] ) ? sanitize_text_field( $_POST['filter_date_to'] ) : '';
// Build WHERE clause
$where = array( '1=1' );
// Build WHERE clause (OpenRouter-only for this OpenRouter cost log screen).
$where = array( "provider = 'openrouter'" );
if ( $filter_post > 0 ) {
$where[] = $wpdb->prepare( 'post_id = %d', $filter_post );
}
@@ -697,7 +698,7 @@ class WP_Agentic_Writer_Settings_V2 {
FROM {$table_name}
WHERE {$where_clause}
GROUP BY post_id
ORDER BY total_cost DESC
ORDER BY post_id DESC
LIMIT %d OFFSET %d",
$per_page,
$offset
@@ -737,25 +738,80 @@ class WP_Agentic_Writer_Settings_V2 {
);
}
// Get details for visible posts only (on-demand loading).
// For better performance, we skip details here and let frontend request them.
// The details can be loaded via a separate endpoint when user expands a row.
// Load detail rows for visible posts.
// This keeps expand/collapse usable without requiring a second endpoint.
if ( ! empty( $post_ids ) ) {
$placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
$details_sql = $wpdb->prepare(
"SELECT post_id, created_at, model, action, input_tokens, output_tokens, cost
FROM {$table_name}
WHERE provider = 'openrouter' AND post_id IN ({$placeholders})
ORDER BY created_at DESC",
...$post_ids
);
$detail_rows = $wpdb->get_results( $details_sql, ARRAY_A );
$detail_map = array();
foreach ( $detail_rows as $detail_row ) {
$pid = (int) ( $detail_row['post_id'] ?? 0 );
if ( ! isset( $detail_map[ $pid ] ) ) {
$detail_map[ $pid ] = array();
}
$detail_map[ $pid ][] = array(
'created_at' => date_i18n( 'Y-m-d H:i:s', strtotime( $detail_row['created_at'] ) ),
'model' => (string) ( $detail_row['model'] ?? '' ),
'action' => (string) ( $detail_row['action'] ?? '' ),
'input_tokens' => (int) ( $detail_row['input_tokens'] ?? 0 ),
'output_tokens' => (int) ( $detail_row['output_tokens'] ?? 0 ),
'cost' => number_format( (float) ( $detail_row['cost'] ?? 0 ), 4 ),
);
}
foreach ( $formatted_records as $idx => $formatted_record ) {
$pid = (int) ( $formatted_record['post_id'] ?? 0 );
$formatted_records[ $idx ]['details'] = $detail_map[ $pid ] ?? array();
$formatted_records[ $idx ]['details_total'] = count( $formatted_records[ $idx ]['details'] );
}
}
// Get summary stats (all-time aggregation in SQL)
$total_all_time = $wpdb->get_var( "SELECT COALESCE(SUM(cost), 0) FROM {$table_name}" );
$monthly_total = $cost_tracker->get_monthly_total();
$total_all_time = $wpdb->get_var( "SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter'" );
$month_start = date( 'Y-m-01 00:00:00' );
$monthly_total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND created_at >= %s",
$month_start
)
);
$today_total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE DATE(created_at) = %s",
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND DATE(created_at) = %s",
current_time( 'Y-m-d' )
)
);
$total_posts = $wpdb->get_var( "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE post_id > 0" );
$total_posts = $wpdb->get_var( "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE provider = 'openrouter' AND post_id > 0" );
$avg_per_post = $total_posts > 0 ? $total_all_time / $total_posts : 0;
$action_summary_rows = $wpdb->get_results(
"SELECT action, COUNT(*) AS calls, COALESCE(SUM(cost), 0) AS total_cost, COALESCE(AVG(cost), 0) AS avg_cost
FROM {$table_name}
WHERE provider = 'openrouter'
GROUP BY action
ORDER BY total_cost DESC",
ARRAY_A
);
$action_summary = array();
foreach ( $action_summary_rows as $row ) {
$action_summary[] = array(
'action' => (string) ( $row['action'] ?? '' ),
'calls' => (int) ( $row['calls'] ?? 0 ),
'total' => number_format( (float) ( $row['total_cost'] ?? 0 ), 4 ),
'average' => number_format( (float) ( $row['avg_cost'] ?? 0 ), 4 ),
);
}
// Get filter options (distinct values from DB)
$models = $wpdb->get_col( "SELECT DISTINCT model FROM {$table_name} ORDER BY model LIMIT 100" );
$types = $wpdb->get_col( "SELECT DISTINCT action FROM {$table_name} ORDER BY action" );
$models = $wpdb->get_col( "SELECT DISTINCT model FROM {$table_name} WHERE provider = 'openrouter' ORDER BY model LIMIT 100" );
$types = $wpdb->get_col( "SELECT DISTINCT action FROM {$table_name} WHERE provider = 'openrouter' ORDER BY action" );
wp_send_json_success( array(
'records' => $formatted_records,
@@ -768,6 +824,7 @@ class WP_Agentic_Writer_Settings_V2 {
'monthly' => number_format( (float) $monthly_total, 4 ),
'today' => number_format( (float) $today_total, 4 ),
'avg_per_post' => number_format( (float) $avg_per_post, 4 ),
'action_summary' => $action_summary,
),
'filters' => array(
'models' => $models,
@@ -1042,6 +1099,13 @@ class WP_Agentic_Writer_Settings_V2 {
$sanitized['cost_tracking_enabled'] = isset( $input['cost_tracking_enabled'] ) && '1' === $input['cost_tracking_enabled'];
$sanitized['enable_clarification_quiz'] = isset( $input['enable_clarification_quiz'] ) && '1' === $input['enable_clarification_quiz'];
$sanitized['enable_faq_schema'] = isset( $input['enable_faq_schema'] ) ? '1' === $input['enable_faq_schema'] : false;
$sanitized['allow_openrouter_fallback'] = isset( $input['allow_openrouter_fallback'] ) && '1' === $input['allow_openrouter_fallback'];
$sanitized['openrouter_provider_routing_enabled'] = isset( $input['openrouter_provider_routing_enabled'] ) && '1' === $input['openrouter_provider_routing_enabled'];
$sanitized['openrouter_provider_only'] = isset( $input['openrouter_provider_only'] ) && '1' === $input['openrouter_provider_only'];
$sanitized['openrouter_allow_provider_fallbacks'] = isset( $input['openrouter_allow_provider_fallbacks'] ) && '1' === $input['openrouter_allow_provider_fallbacks'];
$provider_slug = isset( $input['openrouter_provider_slug'] ) ? sanitize_key( $input['openrouter_provider_slug'] ) : 'auto';
$sanitized['openrouter_provider_slug'] = '' !== $provider_slug ? $provider_slug : 'auto';
// Sanitize search options
$sanitized['search_engine'] = in_array( $input['search_engine'] ?? '', array( 'auto', 'native', 'exa' ), true )
@@ -1178,6 +1242,11 @@ class WP_Agentic_Writer_Settings_V2 {
$local_backend_key = $settings['local_backend_key'] ?? 'dummy';
$local_backend_model = $settings['local_backend_model'] ?? 'claude-local';
$task_providers = $settings['task_providers'] ?? array();
$allow_openrouter_fallback = ! empty( $settings['allow_openrouter_fallback'] );
$openrouter_provider_routing_enabled = ! empty( $settings['openrouter_provider_routing_enabled'] );
$openrouter_provider_slug = $settings['openrouter_provider_slug'] ?? 'auto';
$openrouter_provider_only = ! empty( $settings['openrouter_provider_only'] );
$openrouter_allow_provider_fallbacks = ! empty( $settings['openrouter_allow_provider_fallbacks'] );
// Get cost tracking data
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
@@ -1214,6 +1283,11 @@ class WP_Agentic_Writer_Settings_V2 {
'local_backend_key',
'local_backend_model',
'task_providers',
'allow_openrouter_fallback',
'openrouter_provider_routing_enabled',
'openrouter_provider_slug',
'openrouter_provider_only',
'openrouter_allow_provider_fallbacks',
'settings'
);
}