diff --git a/wp-content/plugins/postsecret-admin/migrations/001_init.php b/wp-content/plugins/postsecret-admin/migrations/001_init.php index 2809ba7..a27c120 100644 --- a/wp-content/plugins/postsecret-admin/migrations/001_init.php +++ b/wp-content/plugins/postsecret-admin/migrations/001_init.php @@ -12,7 +12,7 @@ * * @global \wpdb $wpdb */ -function up() { +function up_001_init() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); diff --git a/wp-content/plugins/postsecret-admin/migrations/002_facets.php b/wp-content/plugins/postsecret-admin/migrations/002_facets.php index ecbaf4a..2105751 100644 --- a/wp-content/plugins/postsecret-admin/migrations/002_facets.php +++ b/wp-content/plugins/postsecret-admin/migrations/002_facets.php @@ -15,7 +15,7 @@ * * @global \wpdb $wpdb */ -function up() { +function up_002_facets() { global $wpdb; // Drop ps_tag_alias table - no longer needed with facets diff --git a/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php b/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php index bb6a634..f603060 100644 --- a/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php +++ b/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php @@ -17,7 +17,7 @@ * * @global \wpdb $wpdb */ -function up() { +function up_003_embeddings() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); diff --git a/wp-content/plugins/postsecret-admin/migrations/004_embeddings_input_hash.php b/wp-content/plugins/postsecret-admin/migrations/004_embeddings_input_hash.php new file mode 100644 index 0000000..d74faf9 --- /dev/null +++ b/wp-content/plugins/postsecret-admin/migrations/004_embeddings_input_hash.php @@ -0,0 +1,42 @@ +prefix . 'ps_text_embeddings'; + + // Check if column already exists + $column_exists = $wpdb->get_results( + $wpdb->prepare( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = %s + AND COLUMN_NAME = 'input_hash'", + DB_NAME, + $table_name + ) + ); + + if (empty($column_exists)) { + $wpdb->query( + "ALTER TABLE $table_name + ADD COLUMN input_hash varchar(64) NULL AFTER dimension" + ); + } +} diff --git a/wp-content/plugins/postsecret-admin/run-migrations.php b/wp-content/plugins/postsecret-admin/run-migrations.php index b21613c..b70497e 100644 --- a/wp-content/plugins/postsecret-admin/run-migrations.php +++ b/wp-content/plugins/postsecret-admin/run-migrations.php @@ -2,59 +2,128 @@ /** * Manual migration runner for PostSecret Admin. * - * Run this file directly to execute all pending migrations. - * Or visit: wp-admin/admin.php?page=postsecret-run-migrations + * Run this file directly to execute all pending migrations, + * or pass ?only= to run specific ones. * * @package PostSecret\Admin */ // Load WordPress -// Path: /wp-content/plugins/postsecret-admin/ -> /wp-load.php require_once __DIR__ . '/../../../wp-load.php'; -if ( ! current_user_can( 'manage_options' ) && ! defined( 'WP_CLI' ) ) { - wp_die( 'Unauthorized' ); +if (!current_user_can('manage_options') && !defined('WP_CLI')) { + wp_die('Unauthorized'); } -global $wpdb; - echo "

PostSecret Database Migrations

\n"; $migrations_dir = __DIR__ . '/migrations/'; -$migrations = glob( $migrations_dir . '*.php' ); -sort( $migrations ); +$migrations = glob($migrations_dir . '*.php'); +sort($migrations); + +/** + * Turn a filename (e.g., "002_facets.php") into a safe slug "002_facets". + */ +$slugify = static function (string $filename): string { + $base = pathinfo($filename, PATHINFO_FILENAME); + return preg_replace('/[^a-zA-Z0-9_]+/', '_', strtolower($base)); +}; + +/** + * Optional filter: ?only=003_embeddings or ?only=001_init,003_embeddings.php + * Accepts slugs (without .php) or exact filenames; case-insensitive. + */ +$only_raw = isset($_GET['only']) ? trim((string)$_GET['only']) : ''; +if ($only_raw !== '') { + $only_set = []; + foreach (preg_split('/\s*,\s*/', $only_raw, -1, PREG_SPLIT_NO_EMPTY) as $piece) { + $p = strtolower($piece); + // accept either "003_embeddings" or "003_embeddings.php" + $only_set[$p] = true; + if (substr($p, -4) !== '.php') { + $only_set[$p . '.php'] = true; + } else { + $only_set[substr($p, 0, -4)] = true; // slug form + } + // also accept slugified variant of whatever was passed + $only_set[$slugify($p)] = true; + } -foreach ( $migrations as $migration_file ) { - $migration_name = basename( $migration_file ); - echo "

Running migration: {$migration_name}..."; + $migrations = array_values(array_filter($migrations, function ($file) use ($only_set, $slugify) { + $base = strtolower(basename($file)); // e.g. 003_embeddings.php + $slug = strtolower($slugify($base)); // e.g. 003_embeddings + return isset($only_set[$base]) || isset($only_set[$slug]); + })); - // Create isolated scope and execute - $result = ( function() use ( $migration_file, $wpdb ) { + if (empty($migrations)) { + echo "

No migrations matched " . esc_html($only_raw) . ".

"; + echo "

← Back to Dashboard

"; + exit; + } + + echo "

Filtered run: " + . esc_html(implode(', ', array_map('basename', $migrations))) + . "

"; +} + +foreach ($migrations as $migration_file) { + $migration_name = basename($migration_file); + $slug = $slugify($migration_name); + + // Preferred function name inside the migration file + $preferred_fn = "\\PostSecret\\Admin\\Migrations\\up_{$slug}"; + $legacy_fn = "\\PostSecret\\Admin\\Migrations\\up"; + + echo "

Running migration: " . esc_html($migration_name) . "..."; + + $result = (static function (string $file, string $preferred_fn, string $legacy_fn) { ob_start(); try { - require $migration_file; - \PostSecret\Admin\Migrations\up(); + // Include once and capture any return (for closure-based migrations) + /** @var mixed $maybe_callable */ + $maybe_callable = (static function ($f) { + return include $f; + })($file); + + // Preferred: function up_() + if (function_exists($preferred_fn)) { + $preferred_fn(); + } // Legacy: function up() + elseif (function_exists($legacy_fn)) { + echo "[notice] Using legacy migration function `{$legacy_fn}`. Consider renaming to `{$preferred_fn}`.\n"; + $legacy_fn(); + } // Closure-based: migration file returned a callable + elseif (is_callable($maybe_callable)) { + $maybe_callable(); + } else { + throw new \RuntimeException( + "No callable migration found. Expected {$preferred_fn}(), {$legacy_fn}(), or a returned closure." + ); + } + $output = ob_get_clean(); - return [ 'success' => true, 'output' => $output ]; - } catch ( \Throwable $e ) { + return ['success' => true, 'output' => $output]; + } catch (\Throwable $e) { $output = ob_get_clean(); - return [ 'success' => false, 'error' => $e->getMessage(), 'output' => $output ]; + return ['success' => false, 'error' => $e->getMessage(), 'output' => $output]; } - } )(); + })($migration_file, $preferred_fn, $legacy_fn); - if ( $result['success'] ) { + if ($result['success']) { echo " ✓ Success

\n"; - if ( ! empty( $result['output'] ) ) { - echo "
" . esc_html( $result['output'] ) . "
\n"; + if (!empty($result['output'])) { + echo "
" . esc_html($result['output']) . "
\n"; } } else { echo " ✗ Failed

\n"; - echo "

Error: " . esc_html( $result['error'] ) . "

\n"; - if ( ! empty( $result['output'] ) ) { - echo "
" . esc_html( $result['output'] ) . "
\n"; + echo "

Error: " . esc_html($result['error']) . "

\n"; + if (!empty($result['output'])) { + echo "
" . esc_html($result['output']) . "
\n"; } + // Bail on first failure so you can fix and re-run. + break; } } echo "

Migrations complete!

\n"; -echo "

← Back to Dashboard

\n"; +echo "

← Back to Dashboard

\n"; \ No newline at end of file diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php index 0449e64..dd91c61 100644 --- a/wp-content/plugins/postsecret-ai/postsecret-ai.php +++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php @@ -2,7 +2,7 @@ /** * Plugin Name: PostSecret AI (Ultra-MVP) * Description: Admin-only tools for classification: test console + single postcard uploader. - * Version: 0.0.4 + * Version: 0.0.5 */ if (!defined('ABSPATH')) exit; @@ -26,6 +26,44 @@ require __DIR__ . '/src/AdminSingleUpload.php'; require __DIR__ . '/src/AdminMetaBox.php'; +/* --------------------------------------------------------------------------- + * Small helpers: set/clear last error consistently on attachments + * ------------------------------------------------------------------------- */ +if (!function_exists('psai_set_last_error')) { + /** + * Store a trimmed error string to _ps_last_error on one or more attachment IDs. + * @param int|array $ids + * @param string $message + */ + function psai_set_last_error($ids, string $message): void + { + $msg = substr(trim($message), 0, 500); + $ids = is_array($ids) ? $ids : [$ids]; + foreach ($ids as $id) { + $id = (int)$id; + if ($id > 0) { + update_post_meta($id, '_ps_last_error', $msg); + } + } + } +} +if (!function_exists('psai_clear_last_error')) { + /** + * Remove _ps_last_error from one or more attachment IDs. + * @param int|array $ids + */ + function psai_clear_last_error($ids): void + { + $ids = is_array($ids) ? $ids : [$ids]; + foreach ($ids as $id) { + $id = (int)$id; + if ($id > 0) { + delete_post_meta($id, '_ps_last_error'); + } + } + } +} + /* --------------------------------------------------------------------------- * Menus * - Tools → PostSecret AI (tester/settings) @@ -69,6 +107,8 @@ function () { /* --------------------------------------------------------------------------- * Tester handler (Tools page) — URL-based single image test + * NOTE: This tester does not create attachments; we keep using transients + * but also store a global “last error” transient for quick feedback. * ------------------------------------------------------------------------- */ add_action('admin_post_psai_classify', function () { if (!current_user_can('manage_options')) wp_die('Unauthorized', 403); @@ -80,6 +120,7 @@ function () { $image = isset($_POST['psai_image_url']) ? esc_url_raw(trim($_POST['psai_image_url'])) : ''; if (!$api || !$image) { + set_transient('_ps_last_error_global', !$api ? 'Missing API key.' : 'Image URL is required.', 600); $q = ['page' => PSAI_SLUG, 'psai_err' => !$api ? 'no_key' : 'no_image']; wp_redirect(add_query_arg($q, admin_url('tools.php'))); exit; @@ -87,6 +128,7 @@ function () { try { $payload = \PSAI\Classifier::classify($api, $model, $image, null); + set_transient('psai_last_result', [ 'image_url' => $image, 'model' => $model, @@ -94,10 +136,14 @@ function () { 'ts' => time(), ], 600); + delete_transient('_ps_last_error_global'); + wp_redirect(add_query_arg(['page' => PSAI_SLUG, 'psai_done' => '1'], admin_url('tools.php'))); exit; } catch (\Throwable $e) { - set_transient('psai_last_error', $e->getMessage(), 300); + $msg = substr($e->getMessage(), 0, 500); + set_transient('_ps_last_error_global', $msg, 600); + set_transient('psai_last_error', $msg, 300); wp_redirect(add_query_arg(['page' => PSAI_SLUG, 'psai_err' => 'call_failed'], admin_url('tools.php'))); exit; } @@ -119,14 +165,18 @@ function () { // 1) FRONT (required) if (empty($_FILES['psai_front']['name'])) { + set_transient('_ps_last_error_global', 'Front image is required.', 600); wp_redirect(add_query_arg(['page' => 'psai_upload_single', 'psai_msg' => 'err'], admin_url('admin.php'))); exit; } + $front_id = \PSAI\Ingress::sideload($_FILES['psai_front'], 'front'); if (!$front_id) { + set_transient('_ps_last_error_global', 'Failed to import front image.', 600); wp_redirect(add_query_arg(['page' => 'psai_upload_single', 'psai_msg' => 'err'], admin_url('admin.php'))); exit; } + if (\PSAI\Ingress::mark_exact_duplicate($front_id)) $had_dupe = true; // 2) BACK (optional) @@ -135,6 +185,9 @@ function () { if ($back_id) { \PSAI\Ingress::pair($front_id, $back_id); if (\PSAI\Ingress::mark_exact_duplicate($back_id)) $had_dupe = true; + } else { + // record on the front if back import failed + psai_set_last_error($front_id, 'Failed to import back image.'); } } @@ -151,10 +204,6 @@ function () { /* --------------------------------------------------------------------------- * Background (and on-demand) processor for a front/back pair - * - builds data URLs (works on localhost/private) - * - classifies - * - stores payload + flags, syncs attachment fields - * - computes orientation/color * ------------------------------------------------------------------------- */ add_action('psai_process_pair_event', function ($front_id, $back_id = 0) { $front_id = (int)$front_id; @@ -164,8 +213,19 @@ function () { $api = $env['API_KEY'] ?? ''; $model = $env['MODEL_NAME'] ?? 'gpt-4o-mini'; - if (!$api || !$front_id) return; - if (get_post_meta($front_id, '_ps_duplicate_of', true)) return; + // Precondition checks → set error and bail cleanly + if (!$front_id) { + set_transient('_ps_last_error_global', 'Missing front_id in processor.', 600); + return; + } + if (get_post_meta($front_id, '_ps_duplicate_of', true)) { + psai_set_last_error($front_id, 'Skipped: duplicate image.'); + return; + } + if (!$api) { + psai_set_last_error([$front_id, $back_id], 'Missing OpenAI API key.'); + return; + } try { // Data URLs so we don’t rely on public URLs @@ -179,7 +239,7 @@ function () { // Store result (sets tags, model, prompt version, vetted flags) \PSAI\psai_store_result($front_id, $payload, $model); - // Sync media fields (Alt/Caption/Description) — safe no-op if class missing + // Sync media fields (Alt/Caption/Description) \PSAI\AttachmentSync::sync_from_payload($front_id, $payload, $back_id); // Enrich quick orientation/color on both sides @@ -198,27 +258,22 @@ function () { update_post_meta($back_id, '_ps_model', get_post_meta($front_id, '_ps_model', true)); update_post_meta($back_id, '_ps_prompt_version', get_post_meta($front_id, '_ps_prompt_version', true)); update_post_meta($back_id, '_ps_updated_at', wp_date('c')); - - // keep vetted flags mirrored on back for UI/API convenience $rs = get_post_meta($front_id, '_ps_review_status', true); update_post_meta($back_id, '_ps_review_status', $rs); update_post_meta($back_id, '_ps_is_vetted', $rs === 'auto_vetted' ? '1' : '0'); } - delete_post_meta($front_id, '_ps_last_error'); - if ($back_id) delete_post_meta($back_id, '_ps_last_error'); + // Clear any previous errors on success + psai_clear_last_error([$front_id, $back_id]); } catch (\Throwable $e) { $msg = substr($e->getMessage(), 0, 500); - update_post_meta($front_id, '_ps_last_error', $msg); - if ($back_id) update_post_meta($back_id, '_ps_last_error', $msg); + psai_set_last_error([$front_id, $back_id], $msg); } }, 10, 2); /* --------------------------------------------------------------------------- * “Process now” button on the attachment edit screen - * - If payload missing → classify this single attachment (front-only) - * - In all cases → normalize flags + compute orientation/color * ------------------------------------------------------------------------- */ add_action('admin_post_psai_process_now', function () { if (!current_user_can('upload_files')) wp_die('Not allowed', 403); @@ -262,14 +317,15 @@ function () { // Also compute orientation/color for the side the user is viewing \PSAI\Metadata::compute_and_store($att); - delete_post_meta($front_id, '_ps_last_error'); + psai_clear_last_error($front_id); $url = add_query_arg(['psai_msg' => 'ok'], get_edit_post_link($att, '')); wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=ok')); exit; } catch (\Throwable $e) { - update_post_meta($att, '_ps_last_error', substr($e->getMessage(), 0, 500)); + $msg = substr($e->getMessage(), 0, 500); + psai_set_last_error($att, $msg); $url = add_query_arg(['psai_msg' => 'err'], get_edit_post_link($att, '')); wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=err')); exit; @@ -289,6 +345,8 @@ function () { exit; } + $error_details = []; + try { // If this is the back, flip to the front as canonical $maybePair = (int)get_post_meta($att, '_ps_pair_id', true); @@ -309,22 +367,49 @@ function () { \PSAI\psai_store_result($front_id, $payload, $model); \PSAI\AttachmentSync::sync_from_payload($front_id, $payload, null); - // Generate and store embedding + // Generate and store embedding with detailed error capture $embed_model = $env['EMBEDDING_MODEL'] ?? 'text-embedding-3-small'; - \PSAI\EmbeddingService::generate_and_store($front_id, $payload, $api, $embed_model); + $embedding_ok = \PSAI\EmbeddingService::generate_and_store($front_id, $payload, $api, $embed_model); + + if (!$embedding_ok) { + // The error is already stored in _ps_last_error by EmbeddingService + // Just get it to show in the notice + $stored_error = get_post_meta($front_id, '_ps_last_error', true); + + // Also try transient for additional context + $transient_error = get_transient('_ps_last_embedding_error'); + + if ($stored_error) { + $error_details[] = $stored_error; + } elseif ($transient_error) { + $error_details[] = $transient_error; + // Store it persistently since transient might expire + psai_set_last_error($front_id, 'Classification succeeded but embedding failed. ' . $transient_error); + } else { + $error_details[] = 'Embedding generation failed - check API key and model configuration'; + psai_set_last_error($front_id, 'Classification succeeded but embedding generation failed. Check API key and model configuration.'); + } + } // Normalize flags + recompute metadata \PSAI\Ingress::normalize_from_existing_payload($front_id); \PSAI\Metadata::compute_and_store($att); - delete_post_meta($front_id, '_ps_last_error'); + // If embedding failed but classification succeeded, show partial error + if (!$embedding_ok) { + $url = add_query_arg(['psai_msg' => 'partial_err'], get_edit_post_link($att, '')); + wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=partial_err')); + exit; + } + + psai_clear_last_error($front_id); $url = add_query_arg(['psai_msg' => 'reclassified'], get_edit_post_link($att, '')); wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=reclassified')); exit; } catch (\Throwable $e) { - update_post_meta($att, '_ps_last_error', substr($e->getMessage(), 0, 500)); + psai_set_last_error($att, substr($e->getMessage(), 0, 500)); $url = add_query_arg(['psai_msg' => 'err'], get_edit_post_link($att, '')); wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=err')); exit; @@ -339,6 +424,8 @@ function () { echo '

PostSecret AI: Attachment normalized.

'; } elseif ($msg === 'reclassified') { echo '

PostSecret AI: Attachment re-classified with latest AI model and prompt.

'; + } elseif ($msg === 'partial_err') { + echo '

PostSecret AI: Classification completed but embedding generation failed. Check the meta box for details.

'; } elseif ($msg === 'err') { echo '

PostSecret AI: There was an error. See the meta box for details.

'; } elseif ($msg === 'bad_id') { @@ -346,4 +433,56 @@ function () { } elseif ($msg === 'dupe') { echo '

PostSecret AI: Uploaded image matches an existing file (duplicate marked).

'; } +}); + + +/** + * Quick log peek: /wp-json/psai/v1/debug-log?lines=200 + * (Permissive: any logged-in user. Switch to stricter if needed.) + */ +add_action('rest_api_init', function () { + register_rest_route('psai/v1', '/debug-log', [ + 'methods' => 'GET', + 'permission_callback' => function () { + return is_user_logged_in(); + }, + 'args' => [ + 'lines' => ['type' => 'integer', 'default' => 200, 'minimum' => 10, 'maximum' => 2000], + ], + 'callback' => function (\WP_REST_Request $req) { + $content_dir = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : ABSPATH . 'wp-content'; + $file = trailingslashit($content_dir) . 'debug.log'; + + if (!file_exists($file)) { + return new \WP_REST_Response(['exists' => false, 'message' => 'No debug.log yet'], 200); + } + $n = (int)$req->get_param('lines'); + $n = max(10, min(2000, $n)); + + $lines = []; + $fp = fopen($file, 'r'); + if (!$fp) return new \WP_Error('fs_error', 'Cannot open debug.log'); + + // tail n lines + $pos = -1; + $line = ''; + fseek($fp, 0, SEEK_END); + $len = ftell($fp); + while ($len > 0 && count($lines) <= $n) { + fseek($fp, $len--, SEEK_SET); + $char = fgetc($fp); + if ($char === "\n" && $line !== '') { + $lines[] = strrev($line); + $line = ''; + continue; + } + $line .= $char; + } + if ($line !== '') $lines[] = strrev($line); + fclose($fp); + + $lines = array_slice(array_reverse($lines), -$n); + return new \WP_REST_Response(['exists' => true, 'lines' => $lines], 200); + }, + ]); }); \ No newline at end of file diff --git a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php index d8286cc..3b1e885 100644 --- a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php +++ b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php @@ -147,9 +147,79 @@ public static function render(\WP_Post $post): void // Error (if any) if ($err) { - echo '

Error:
' . esc_html($err) . '

'; + echo '

Error:
' . esc_html($err) . '

'; + + // Show link to debug logs if available + $debug_url = rest_url('psai/v1/debug-log?lines=100'); + echo '

View Debug Logs

'; + + // Check if there's a transient error with more details + $transient_err = get_transient('_ps_last_embedding_error'); + if ($transient_err && $transient_err !== $err) { + echo '
Additional error details'; + echo '' . esc_html($transient_err) . ''; + echo '
'; + } } + // Show Qdrant sync warning if embedding exists but Qdrant failed + $qdrant_err = get_transient('_ps_last_qdrant_error'); + if ($embedding && $qdrant_err) { + echo '

Qdrant Sync Warning:
'; + echo ''; + echo 'Embedding stored in MySQL but not synced to Qdrant: ' . esc_html($qdrant_err); + echo '

'; + } + + // Show diagnostic button for troubleshooting + $diag_url = rest_url('psai/v1/qdrant-status'); + echo '

'; + echo 'Check Qdrant Status '; + + // Add "Initialize Qdrant" button + echo ''; + echo '

'; + + // Add inline JavaScript for the button + echo ''; + // Raw JSON viewer if ($payload) { $json = wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/wp-content/plugins/postsecret-ai/src/Classifier.php b/wp-content/plugins/postsecret-ai/src/Classifier.php index 8127bb7..6d3f04d 100644 --- a/wp-content/plugins/postsecret-ai/src/Classifier.php +++ b/wp-content/plugins/postsecret-ai/src/Classifier.php @@ -1,58 +1,426 @@ 'text', 'text' => 'SIDE: front'], - ['type' => 'image_url', 'image_url' => ['url' => $frontUrl, 'detail' => 'high']], + // ── Credentials / endpoint + $apiKey = $apiKey !== '' ? $apiKey : (string)($opts['API_KEY'] ?? ''); + if ($apiKey === '') { + throw new \RuntimeException('API key is not configured.'); + } + $base = self::apiBase($opts); // e.g. https://api.openai.com/v1 (or compatible) + $endpoint = rtrim($base, '/') . '/chat/completions'; + + // ── Model & sampling + $model = $model !== '' ? $model : (string)($opts['MODEL_NAME'] ?? self::DEFAULT_MODEL); + $temperature = self::optFloat($opts, 'TEMPERATURE', 0.2); + $topP = self::optFloat($opts, 'TOP_P', 1.0); + $maxTokens = self::optInt($opts, 'MAX_TOKENS', 1200, 1); + $frequency = self::optFloat($opts, 'FREQUENCY_PENALTY', 0.0); + $presence = self::optFloat($opts, 'PRESENCE_PENALTY', 0.0); + $seed = isset($opts['SEED']) && $opts['SEED'] !== '' ? (int)$opts['SEED'] : null; + + // ── Vision options + $detail = in_array(($opts['VISION_DETAIL'] ?? 'high'), ['low', 'auto', 'high'], true) + ? (string)$opts['VISION_DETAIL'] + : 'high'; + + // ── HTTP knobs + $timeout = self::optInt($opts, 'REQUEST_TIMEOUT_SECONDS', 60, 5); + $retries = self::optInt($opts, 'REQUEST_MAX_RETRIES', 3, 0); + $backoff = max(0.0, (float)($opts['REQUEST_BACKOFF_FACTOR'] ?? 0.5)); + $ssl = self::optBool($opts, 'SSL_VERIFY', true); + + // ── Headers (support org/project if configured) + $headers = [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + 'User-Agent' => self::UA, ]; - if ($backUrl) { - $userContent[] = ['type' => 'text', 'text' => 'SIDE: back']; - $userContent[] = ['type' => 'image_url', 'image_url' => ['url' => $backUrl, 'detail' => 'high']]; + if (!empty($opts['OPENAI_ORG'])) { + $headers['OpenAI-Organization'] = (string)$opts['OPENAI_ORG']; + } + if (!empty($opts['OPENAI_PROJECT'])) { + $headers['OpenAI-Project'] = (string)$opts['OPENAI_PROJECT']; } + // ── Messages $messages = [ ['role' => 'system', 'content' => Prompt::TEXT], - ['role' => 'user', 'content' => $userContent], + ['role' => 'user', 'content' => self::buildVisionContent($frontUrl, $backUrl, $detail)], ]; - $body = [ + // ── Response format (default json_object for broad compatibility) + $responseFormat = ['type' => 'json_object']; + if (!empty($opts['RESPONSE_FORMAT']) && $opts['RESPONSE_FORMAT'] === 'json_schema' && !empty(Prompt::SCHEMA)) { + // If your provider supports json_schema, you may toggle it from Settings. + $responseFormat = [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'postsecret_schema', + 'schema' => Prompt::SCHEMA, + 'strict' => true, + ], + ]; + } + + // ── Body + $body = array_filter([ 'model' => $model, - 'temperature' => 0.2, - 'response_format' => ['type' => 'json_object'], + 'temperature' => $temperature, + 'top_p' => $topP, + 'max_tokens' => $maxTokens, + 'frequency_penalty' => $frequency, + 'presence_penalty' => $presence, + 'seed' => $seed, + 'response_format' => $responseFormat, 'messages' => $messages, + ], static fn($v) => $v !== null); + + // ── Request (with retries) + $raw = self::httpPostJson( + url: $endpoint, + headers: $headers, + body: $body, + timeout: $timeout, + retries: $retries, + backoffFactor: $backoff, + sslVerify: $ssl + ); + + // ── Parse chat response + $json = self::decodeJson($raw); + $content = (string)($json['choices'][0]['message']['content'] ?? ''); + if ($content === '') { + throw new \RuntimeException('Unexpected model response (empty content).'); + } + + $payload = self::decodeJson($content); + if (!is_array($payload)) { + throw new \RuntimeException('Unexpected model response (no JSON payload).'); + } + + // ── Optional moderation (non-blocking) + $moderated = self::maybeModerate( + base: $base, + apiKey: $apiKey, + opts: $opts, + text: (string)$content, + timeout: $timeout, + sslVerify: $ssl + ); + if ($moderated !== null) { + $payload['_moderation'] = $moderated; + } + + // ── Normalize to project schema + return \PSAI\SchemaGuard::normalize($payload); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Internals + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Resolve the API base URL; appends /v1 if a custom base without version is provided. + * + * @param array $opts + * @return string + */ + private static function apiBase(array $opts): string + { + $base = trim((string)($opts['API_BASE'] ?? '')); + if ($base === '') { + return 'https://api.openai.com/v1'; + } + $base = rtrim($base, '/'); + // If caller already provides .../v1 keep it, else add /v1 for OpenAI-compatible layout. + return preg_match('~/v\d+$~', $base) ? $base : ($base . '/v1'); + } + + /** + * Build multi-part user content for vision inputs. + * + * @param string $frontUrl + * @param string|null $backUrl + * @param string $detail low|auto|high + * @return array> + */ + private static function buildVisionContent(string $frontUrl, ?string $backUrl, string $detail): array + { + $allowed_details = ['low', 'high', 'auto']; + if (!in_array($detail, $allowed_details, true)) { + $detail = 'high'; + } + + $content = [ + ['type' => 'text', 'text' => 'SIDE: front'], + ['type' => 'image_url', 'image_url' => ['url' => $frontUrl, 'detail' => $detail]], ]; - $res = wp_remote_post($endpoint, [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $apiKey, - 'Content-Type' => 'application/json', - ], - 'timeout' => (int)(get_option('psai_env')['REQUEST_TIMEOUT_SECONDS'] ?? 60), - 'body' => wp_json_encode($body), - ]); + if ($backUrl) { + $content[] = ['type' => 'text', 'text' => 'SIDE: back']; + $content[] = ['type' => 'image_url', 'image_url' => ['url' => $backUrl, 'detail' => $detail]]; + } + + return $content; + } + + /** + * HTTP POST with JSON body, retries on 429/5xx, honors Retry-After, with jitter. + * + * @param string $url + * @param array $headers + * @param array $body + * @param int $timeout + * @param int $retries + * @param float $backoffFactor + * @param bool $sslVerify + * @return string Raw body + */ + private static function httpPostJson( + string $url, + array $headers, + array $body, + int $timeout, + int $retries, + float $backoffFactor, + bool $sslVerify + ): string + { + $attempt = 0; + + do { + $attempt++; - if (is_wp_error($res)) throw new \RuntimeException($res->get_error_message()); - $code = wp_remote_retrieve_response_code($res); - $raw = wp_remote_retrieve_body($res); - if ($code >= 300) throw new \RuntimeException('OpenAI HTTP ' . $code . ': ' . substr($raw, 0, 500)); + $res = wp_remote_post($url, [ + 'headers' => $headers, + 'timeout' => $timeout, + 'sslverify' => $sslVerify, + 'body' => wp_json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ]); - $json = json_decode($raw, true); - $content = $json['choices'][0]['message']['content'] ?? ''; - $payload = json_decode($content, true); - if (!is_array($payload)) throw new \RuntimeException('Unexpected model response.'); + if (is_wp_error($res)) { + if ($attempt > $retries) { + throw new \RuntimeException('HTTP error: ' . $res->get_error_message()); + } + self::sleepBackoff($attempt, $backoffFactor); + continue; + } - $payload = \PSAI\SchemaGuard::normalize($payload); + $code = (int)wp_remote_retrieve_response_code($res); + $raw = (string)wp_remote_retrieve_body($res); - return $payload; + if ($code >= 200 && $code < 300) { + return $raw; + } + + // Retry policy for 429/5xx + if (($code === 429 || $code >= 500) && $attempt <= $retries) { + // Respect Retry-After if present (seconds) + $retryAfter = wp_remote_retrieve_header($res, 'retry-after'); + if (is_string($retryAfter) && $retryAfter !== '') { + $sec = (float)$retryAfter; + $sec = min(max($sec, 0.0), self::MAX_BACKOFF_SECONDS); + if ($sec > 0) { + usleep((int)round($sec * 1_000_000)); + continue; + } + } + self::sleepBackoff($attempt, $backoffFactor); + continue; + } + + // Hard fail for non-retriable codes + throw new \RuntimeException('HTTP ' . $code . ': ' . substr($raw, 0, 800)); + } while ($attempt <= $retries); + + throw new \RuntimeException('Exhausted retries.'); + } + + /** + * Basic exponential backoff with jitter. + * + * @param int $attempt + * @param float $factor + * @return void + */ + private static function sleepBackoff(int $attempt, float $factor): void + { + if ($factor <= 0) { + return; + } + $base = $factor * (2 ** max(0, $attempt - 1)); + $base = min($base, self::MAX_BACKOFF_SECONDS); + // Full jitter (0..base) + $secs = mt_rand() / mt_getrandmax() * $base; + if ($secs > 0) { + usleep((int)round($secs * 1_000_000)); + } + } + + /** + * Optional moderation pass (non-blocking). Returns array if available, else null. + * + * @param string $base + * @param string $apiKey + * @param array $opts + * @param string $text + * @param int $timeout + * @param bool $sslVerify + * @return array|null + */ + private static function maybeModerate(string $base, string $apiKey, array $opts, string $text, int $timeout, bool $sslVerify): ?array + { + $enabled = self::optBool($opts, 'MODERATION_ENABLE', false); + if (!$enabled || $text === '') { + return null; + } + + $endpoint = rtrim($base, '/') . '/moderations'; + $model = (string)($opts['MODERATION_MODEL'] ?? 'omni-moderation-latest'); + + $headers = [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + 'User-Agent' => self::UA, + ]; + + try { + $raw = self::httpPostJson( + url: $endpoint, + headers: $headers, + body: ['model' => $model, 'input' => $text], + timeout: $timeout, + retries: 0, // keep it fast & non-blocking + backoffFactor: 0.0, + sslVerify: $sslVerify + ); + $json = self::decodeJson($raw); + return $json; + } catch (\Throwable $e) { + // Non-fatal; annotate error for observability + return ['error' => 'moderation_failed', 'message' => $e->getMessage()]; + } + } + + /** + * Decode JSON into array with robust flags and clear errors. + * + * @param string $raw + * @return array + */ + private static function decodeJson(string $raw): array + { + // Avoid JSON_THROW_ON_ERROR to keep PHP 7.x compatibility in some WP installs. + $data = json_decode($raw, true, 512, JSON_INVALID_UTF8_SUBSTITUTE); + if (!is_array($data)) { + $msg = function_exists('json_last_error_msg') ? json_last_error_msg() : 'invalid_json'; + throw new \RuntimeException('Invalid JSON: ' . $msg); + } + return $data; + } + + /** + * Typed option helpers (with sane bounds). + */ + private static function optInt(array $opts, string $key, int $default, int $min = PHP_INT_MIN, ?int $max = null): int + { + if (!isset($opts[$key]) || $opts[$key] === '') { + return $default; + } + $val = (int)$opts[$key]; + $val = max($val, $min); + if ($max !== null) { + $val = min($val, $max); + } + return $val; + } + + private static function optFloat(array $opts, string $key, float $default, float $min = -INF, ?float $max = null): float + { + if (!isset($opts[$key]) || $opts[$key] === '') { + return $default; + } + $val = (float)$opts[$key]; + $val = max($val, $min); + if ($max !== null) { + $val = min($val, $max); + } + return $val; + } + + private static function optBool(array $opts, string $key, bool $default): bool + { + if (!array_key_exists($key, $opts)) { + return $default; + } + $v = $opts[$key]; + if (is_bool($v)) { + return $v; + } + // Accept "1"/"0", "true"/"false" + if (is_string($v)) { + $lv = strtolower(trim($v)); + if ($lv === '1' || $lv === 'true' || $lv === 'yes' || $lv === 'on') { + return true; + } + if ($lv === '0' || $lv === 'false' || $lv === 'no' || $lv === 'off') { + return false; + } + } + return (bool)$v; } } \ No newline at end of file diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php index b049c89..e9f3fad 100644 --- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php +++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php @@ -39,16 +39,27 @@ public static function generate_and_store(int $secret_id, array $payload, string // If API key wasn't supplied, use settings. if ($api_key === '') { $api_key = (string)self::opt('API_KEY', ''); + if ($api_key === '') { + $msg = 'Embedding failed: OpenAI API key not configured in settings'; + error_log("[EmbeddingService] {$msg} for secret {$secret_id}"); + psai_set_last_error($secret_id, $msg); + return false; + } } $input = self::build_embedding_input($payload); if ($input === '') { - error_log("[EmbeddingService] Empty embedding input for secret {$secret_id}; skipping."); + $msg = 'Embedding failed: Empty input (no topics/feelings/text extracted)'; + error_log("[EmbeddingService] {$msg} for secret {$secret_id}"); + psai_set_last_error($secret_id, $msg); return false; } $embedding = self::generate_embedding($api_key, $model, $input); if ($embedding === null) { + $msg = 'Embedding failed: OpenAI API call returned no data (check API key, quota, and network)'; + error_log("[EmbeddingService] {$msg} for secret {$secret_id}"); + psai_set_last_error($secret_id, $msg); return false; } @@ -57,6 +68,9 @@ public static function generate_and_store(int $secret_id, array $payload, string // Canonical: store in MySQL $ok = self::store_embedding($secret_id, $model, $embedding, $payload); if (!$ok) { + $msg = 'Embedding failed: Database storage error'; + error_log("[EmbeddingService] {$msg} for secret {$secret_id}"); + psai_set_last_error($secret_id, $msg); return false; } @@ -72,14 +86,27 @@ public static function generate_and_store(int $secret_id, array $payload, string /** @var array $qdrantPayload */ $qdrantPayload = apply_filters('psai/embedding/qdrant-payload', $qdrantPayload, $secret_id, $payload); - self::qdrant_upsert($secret_id, $model, $embedding, $qdrantPayload); + $qdrant_ok = self::qdrant_upsert($secret_id, $model, $embedding, $qdrantPayload); + if (!$qdrant_ok) { + // Non-fatal - Qdrant is best-effort, but log it and store warning + $msg = "Embedding stored in MySQL but Qdrant sync failed (check QDRANT_URL and QDRANT_API_KEY)"; + error_log("[EmbeddingService] Qdrant upsert failed for secret {$secret_id} (non-fatal)"); + + // Store warning so user can see it + $existing_error = get_post_meta($secret_id, '_ps_last_error', true); + if (empty($existing_error)) { + psai_set_last_error($secret_id, $msg); + } + } } do_action('psai/embedding/saved', $secret_id, $model, $embedding, $payload); return true; } catch (\Throwable $e) { - error_log('[EmbeddingService] Error for secret ' . $secret_id . ': ' . $e->getMessage()); + $msg = 'Embedding exception: ' . $e->getMessage(); + error_log('[EmbeddingService] Error for secret ' . $secret_id . ': ' . $msg); + psai_set_last_error($secret_id, substr($msg, 0, 500)); do_action('psai/embedding/error', $secret_id, $e); return false; } @@ -182,7 +209,10 @@ private static function generate_embedding(string $api_key, string $model, strin $res = wp_remote_post(self::openai_embeddings_url(), $args); if (is_wp_error($res)) { - error_log('[EmbeddingService] Embedding API error: ' . $res->get_error_message()); + $err_msg = $res->get_error_message(); + error_log('[EmbeddingService] Embedding API WP_Error: ' . $err_msg); + // Store the network/timeout error + set_transient('_ps_last_embedding_error', 'Network error: ' . $err_msg, 300); return null; } @@ -191,6 +221,12 @@ private static function generate_embedding(string $api_key, string $model, strin if ($code >= 300) { error_log('[EmbeddingService] Embedding API HTTP ' . $code . ': ' . substr($raw, 0, 600)); + + // Try to parse OpenAI error message + $json = json_decode($raw, true); + $openai_error = $json['error']['message'] ?? 'Unknown API error'; + $err_msg = "OpenAI API HTTP {$code}: {$openai_error}"; + set_transient('_ps_last_embedding_error', $err_msg, 300); return null; } @@ -199,6 +235,7 @@ private static function generate_embedding(string $api_key, string $model, strin if (!is_array($embedding) || $embedding === []) { error_log('[EmbeddingService] Invalid embedding response: ' . substr($raw, 0, 300)); + set_transient('_ps_last_embedding_error', 'Invalid API response: missing embedding data', 300); return null; } @@ -210,6 +247,9 @@ private static function generate_embedding(string $api_key, string $model, strin )); } + // Clear any previous error on success + delete_transient('_ps_last_embedding_error'); + /** @var array $embedding */ return array_map(static fn($v) => (float)$v, $embedding); } @@ -278,11 +318,12 @@ private static function qdrant_vector_size(int $fallback): int /** * Upsert a single point into Qdrant (best-effort). + * @return bool True if upsert succeeded, false otherwise */ - private static function qdrant_upsert(int $secret_id, string $model, array $vector, array $payload = []): void + private static function qdrant_upsert(int $secret_id, string $model, array $vector, array $payload = []): bool { $base = self::qdrant_url(); - if ($base === null) return; + if ($base === null) return false; // Ensure collection exists (cached) $collection = self::qdrant_collection($model); @@ -299,7 +340,8 @@ private static function qdrant_upsert(int $secret_id, string $model, array $vect ]], ]; - self::qdrant_request('PUT', "/collections/{$collection}/points?wait=true", $body, self::qdrant_timeout_seconds()); + $result = self::qdrant_request('PUT', "/collections/{$collection}/points?wait=true", $body, self::qdrant_timeout_seconds()); + return $result !== null && ($result['status'] ?? '') === 'ok'; } /** @@ -310,10 +352,12 @@ private static function qdrant_ensure_collection(string $collection, int $dim): $cache_key = "psai_qdrant_has_{$collection}"; if (get_transient($cache_key)) return; + error_log("[EmbeddingService] Checking if Qdrant collection '{$collection}' exists..."); $exists = self::qdrant_request('GET', "/collections/{$collection}", null, 5); $ok = is_array($exists) && (($exists['status'] ?? '') === 'ok'); if (!$ok) { + error_log("[EmbeddingService] Collection '{$collection}' not found, creating with dimension {$dim}..."); $create = [ 'vectors' => [ 'size' => $dim, @@ -328,7 +372,21 @@ private static function qdrant_ensure_collection(string $collection, int $dim): 'indexing_threshold' => 0, // index immediately (dev-friendly) ], ]; - self::qdrant_request('PUT', "/collections/{$collection}", $create, 20); + $result = self::qdrant_request('PUT', "/collections/{$collection}", $create, 20); + + if ($result === null) { + error_log("[EmbeddingService] Failed to create collection '{$collection}'"); + set_transient('_ps_last_qdrant_error', "Failed to create collection '{$collection}'", 300); + return; // Don't cache failure + } + + if (($result['result'] ?? false) === true || ($result['status'] ?? '') === 'ok') { + error_log("[EmbeddingService] Successfully created collection '{$collection}'"); + } else { + error_log("[EmbeddingService] Unexpected response when creating collection: " . wp_json_encode($result)); + } + } else { + error_log("[EmbeddingService] Collection '{$collection}' already exists"); } set_transient($cache_key, 1, HOUR_IN_SECONDS); @@ -340,7 +398,10 @@ private static function qdrant_ensure_collection(string $collection, int $dim): private static function qdrant_request(string $method, string $path, ?array $body = null, int $timeout = null): ?array { $base = self::qdrant_url(); - if ($base === null) return null; + if ($base === null) { + error_log('[EmbeddingService] Qdrant request skipped: URL not configured'); + return null; + } $headers = [ 'Content-Type' => 'application/json', @@ -363,10 +424,13 @@ private static function qdrant_request(string $method, string $path, ?array $bod } $url = $base . $path; + error_log("[EmbeddingService] Qdrant request: {$method} {$url}"); $res = wp_remote_request($url, $args); if (is_wp_error($res)) { - error_log('[EmbeddingService] Qdrant HTTP error: ' . $res->get_error_message()); + $err = $res->get_error_message(); + error_log("[EmbeddingService] Qdrant HTTP error: {$err}"); + set_transient('_ps_last_qdrant_error', $err, 300); return null; } @@ -375,10 +439,14 @@ private static function qdrant_request(string $method, string $path, ?array $bod if ($code >= 300) { error_log("[EmbeddingService] Qdrant HTTP {$code}: " . substr($raw, 0, 300)); + set_transient('_ps_last_qdrant_error', "HTTP {$code}: " . substr($raw, 0, 200), 300); return null; } $json = json_decode($raw, true); + if (is_array($json)) { + delete_transient('_ps_last_qdrant_error'); // Clear error on success + } return is_array($json) ? $json : null; } @@ -494,7 +562,10 @@ private static function store_embedding(int $secret_id, string $model, array $em ); if ($result === false) { - error_log("[EmbeddingService] DB write failed for secret {$secret_id}."); + $db_error = $wpdb->last_error ?: 'Unknown database error'; + $msg = "DB write failed for secret {$secret_id}: {$db_error}"; + error_log("[EmbeddingService] {$msg}"); + psai_set_last_error($secret_id, "Embedding DB storage error: {$db_error}"); return false; } return true; @@ -538,6 +609,42 @@ private static function sanitize_space(string $s): string return trim($s); } + /** + * Build embedding input text from classification payload. + * Combines topics, feelings, meanings, and extracted text into a single string. + */ + private static function build_embedding_input(array $payload): string + { + $parts = []; + + // Add description/secret text + if (!empty($payload['secret'])) { + $parts[] = 'Secret: ' . self::sanitize_space((string)$payload['secret']); + } + + // Add topics + if (!empty($payload['topics']) && is_array($payload['topics'])) { + $parts[] = 'Topics: ' . implode(', ', array_map('self::sanitize_space', $payload['topics'])); + } + + // Add feelings + if (!empty($payload['feelings']) && is_array($payload['feelings'])) { + $parts[] = 'Feelings: ' . implode(', ', array_map('self::sanitize_space', $payload['feelings'])); + } + + // Add meanings + if (!empty($payload['meanings']) && is_array($payload['meanings'])) { + $parts[] = 'Meanings: ' . implode(', ', array_map('self::sanitize_space', $payload['meanings'])); + } + + // Add extracted text if available + if (!empty($payload['text'])) { + $parts[] = 'Text: ' . self::sanitize_space((string)$payload['text']); + } + + return implode('. ', $parts); + } + // ───────────────────────────────────────────────────────────────────────────── // Settings helper // ───────────────────────────────────────────────────────────────────────────── diff --git a/wp-content/plugins/postsecret-ai/src/Ingress.php b/wp-content/plugins/postsecret-ai/src/Ingress.php index e2810f8..ee90431 100644 --- a/wp-content/plugins/postsecret-ai/src/Ingress.php +++ b/wp-content/plugins/postsecret-ai/src/Ingress.php @@ -249,8 +249,11 @@ function psai_make_data_url(int $att_id, int $maxDim = 1600, int $quality = 85): $editor->set_quality($quality); // Save temp JPEG → base64 - $tmp = wp_tempnam('psai'); - $tmpJpg = $tmp . '.jpg'; + // Use WordPress temp dir, fallback to system temp + $tmpDir = function_exists('wp_tempnam') + ? dirname(wp_tempnam('psai')) + : (function_exists('get_temp_dir') ? get_temp_dir() : sys_get_temp_dir()); + $tmpJpg = tempnam($tmpDir, 'psai_') . '.jpg'; $saved = $editor->save($tmpJpg, 'image/jpeg'); if (is_wp_error($saved) || empty($saved['path'])) { $raw = @file_get_contents($path); diff --git a/wp-content/plugins/postsecret-ai/src/Schema.php b/wp-content/plugins/postsecret-ai/src/Schema.php index fb77cba..56b3d35 100644 --- a/wp-content/plugins/postsecret-ai/src/Schema.php +++ b/wp-content/plugins/postsecret-ai/src/Schema.php @@ -16,8 +16,8 @@ public static function get(): array // 2) Qdrant (Vector DB) — moved directly below OpenAI ['section' => 'qdrant', 'order' => 10, 'key' => 'QDRANT_ENABLE', 'label' => 'Enable Qdrant', 'kind' => 'bool', 'default' => true, 'help' => 'Use Qdrant for ANN search (falls back to MySQL if disabled).'], - ['section' => 'qdrant', 'order' => 20, 'key' => 'QDRANT_URL', 'label' => 'Qdrant URL', 'kind' => 'str', 'default' => '', 'help' => 'e.g., http://:6333 (env: PS_QDRANT_URL)'], - ['section' => 'qdrant', 'order' => 30, 'key' => 'QDRANT_API_KEY', 'label' => 'Qdrant API Key', 'kind' => 'str', 'default' => '', 'secret' => true, 'help' => 'Sent as header: api-key: (env: PS_QDRANT_API_KEY)'], + ['section' => 'qdrant', 'order' => 20, 'key' => 'QDRANT_URL', 'label' => 'Qdrant URL', 'kind' => 'str', 'default' => '', 'help' => 'e.g., http://178.156.164.233:6333 or http://localhost:6333 (env: PS_QDRANT_URL)'], + ['section' => 'qdrant', 'order' => 30, 'key' => 'QDRANT_API_KEY', 'label' => 'Qdrant API Key', 'kind' => 'str', 'default' => '', 'secret' => true, 'help' => 'Required for remote instances. Sent as header: api-key: (env: PS_QDRANT_API_KEY)'], ['section' => 'qdrant', 'order' => 40, 'key' => 'QDRANT_COLLECTION', 'label' => 'Collection Name', 'kind' => 'str', 'default' => 'secrets_text_embedding_3_small', 'help' => 'Target collection name.'], ['section' => 'qdrant', 'order' => 50, 'key' => 'QDRANT_VECTOR_SIZE', 'label' => 'Vector Size', 'kind' => 'int', 'default' => 1536, 'min' => 1, 'help' => 'Embedding dimension (e.g., 1536 for text-embedding-3-small).'], ['section' => 'qdrant', 'order' => 60, 'key' => 'QDRANT_DISTANCE', 'label' => 'Distance Metric', 'kind' => 'choice', 'default' => 'Cosine', 'choices' => ['Cosine', 'Dot', 'Euclid'], 'help' => 'Must match collection config.'], @@ -25,6 +25,7 @@ public static function get(): array // 3) Model & Generation ['section' => 'model', 'order' => 10, 'key' => 'MODEL_PROVIDER', 'label' => 'Model Provider', 'kind' => 'choice', 'default' => 'openai', 'choices' => ['openai'], 'help' => 'Provider (fixed to OpenAI for MVP)'], ['section' => 'model', 'order' => 20, 'key' => 'MODEL_NAME', 'label' => 'Model Name', 'kind' => 'str', 'default' => 'gpt-4o-mini', 'help' => 'Vision-capable model'], + ['section' => 'model', 'order' => 30, 'key' => 'EMBEDDING_MODEL', 'label' => 'Embedding Model', 'kind' => 'choice', 'default' => 'text-embedding-3-small', 'choices' => ['text-embedding-3-small', 'text-embedding-3-large'], 'help' => 'Model for generating embeddings (small=1536d, large=3072d)'], ['section' => 'model', 'order' => 40, 'key' => 'TEMPERATURE', 'label' => 'Temperature', 'kind' => 'float', 'default' => 0.2, 'min' => 0.0, 'max' => 2.0, 'help' => 'Creativity (0.0–2.0)'], ['section' => 'model', 'order' => 50, 'key' => 'TOP_P', 'label' => 'Top-p', 'kind' => 'float', 'default' => 1.0, 'min' => 0.0, 'max' => 1.0, 'help' => 'Nucleus sampling (0.0–1.0)'], ['section' => 'model', 'order' => 60, 'key' => 'MAX_TOKENS', 'label' => 'Max Tokens', 'kind' => 'int', 'default' => 1200, 'min' => 1, 'help' => 'Token limit per call'], diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php index 599e62e..903b1f2 100644 --- a/wp-content/plugins/postsecret-search/postsecret-search.php +++ b/wp-content/plugins/postsecret-search/postsecret-search.php @@ -38,6 +38,197 @@ function register_rest_routes() }, ]); + // Connection test endpoint (bypasses Qdrant completely) + register_rest_route('psai/v1', '/test-connection', [ + 'methods' => 'GET', + 'permission_callback' => function () { + return is_user_logged_in() && current_user_can('upload_files'); + }, + 'callback' => function () { + $qdrant_url = (string)opt('QDRANT_URL', ''); + if ($qdrant_url === '') { + $qdrant_url = getenv('PS_QDRANT_URL') ?: (defined('PS_QDRANT_URL') ? constant('PS_QDRANT_URL') : ''); + } + + $results = [ + 'qdrant_url' => $qdrant_url, + 'tests' => [], + ]; + + // Test 1: Basic connectivity + $test_url = rtrim($qdrant_url, '/') . '/'; + $response = wp_remote_get($test_url, ['timeout' => 10, 'sslverify' => false]); + $results['tests']['root'] = [ + 'url' => $test_url, + 'is_error' => is_wp_error($response), + 'error' => is_wp_error($response) ? $response->get_error_message() : null, + 'code' => is_wp_error($response) ? null : wp_remote_retrieve_response_code($response), + 'body' => is_wp_error($response) ? null : substr(wp_remote_retrieve_body($response), 0, 500), + ]; + + // Test 2: Collections endpoint (with API key) + $test_url = rtrim($qdrant_url, '/') . '/collections'; + $headers = ['Content-Type' => 'application/json']; + $api_key = (string)opt('QDRANT_API_KEY', ''); + if ($api_key === '') { + $api_key = getenv('PS_QDRANT_API_KEY') ?: (defined('PS_QDRANT_API_KEY') ? constant('PS_QDRANT_API_KEY') : ''); + } + if ($api_key !== '') { + $headers['api-key'] = $api_key; + } + + $response = wp_remote_get($test_url, ['timeout' => 10, 'headers' => $headers, 'sslverify' => false]); + $results['tests']['collections'] = [ + 'url' => $test_url, + 'has_api_key' => $api_key !== '', + 'is_error' => is_wp_error($response), + 'error' => is_wp_error($response) ? $response->get_error_message() : null, + 'code' => is_wp_error($response) ? null : wp_remote_retrieve_response_code($response), + 'body' => is_wp_error($response) ? null : substr(wp_remote_retrieve_body($response), 0, 500), + ]; + + return new WP_REST_Response($results, 200); + }, + ]); + + // IP detection endpoint + register_rest_route('psai/v1', '/my-ip', [ + 'methods' => 'GET', + 'permission_callback' => function () { + return is_user_logged_in() && current_user_can('upload_files'); + }, + 'callback' => function () { + // Try to detect outgoing IP by making a request to a service + $response = wp_remote_get('https://api.ipify.org?format=json', ['timeout' => 10]); + $external_ip = 'unknown'; + + if (!is_wp_error($response)) { + $body = json_decode(wp_remote_retrieve_body($response), true); + $external_ip = $body['ip'] ?? 'unknown'; + } + + return new WP_REST_Response([ + 'server_ip' => $_SERVER['SERVER_ADDR'] ?? 'unknown', + 'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'external_ip' => $external_ip, + 'http_x_forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? 'not set', + ], 200); + }, + ]); + + // Initialize Qdrant collection + register_rest_route('psai/v1', '/qdrant-init', [ + 'methods' => 'POST', + 'permission_callback' => function () { + return is_user_logged_in() && current_user_can('manage_options'); + }, + 'callback' => function () { + if (!class_exists('PSAI\\EmbeddingService')) { + return new WP_Error('missing_service', 'EmbeddingService not available', ['status' => 500]); + } + + // Use reflection to call private method + try { + $model = 'text-embedding-3-small'; + $collection = 'secrets_text_embedding_3_small'; // From settings + $dim = 1536; // text-embedding-3-small dimension + + // Clear any cached "collection exists" transient to force re-check + delete_transient("psai_qdrant_has_{$collection}"); + + // Try to trigger collection creation by calling the public method + // We'll create a dummy embedding to trigger the collection creation + $reflection = new \ReflectionClass('PSAI\\EmbeddingService'); + $method = $reflection->getMethod('qdrant_ensure_collection'); + $method->setAccessible(true); + $method->invokeArgs(null, [$collection, $dim]); + + return new WP_REST_Response([ + 'success' => true, + 'message' => "Attempted to initialize collection '{$collection}' with dimension {$dim}", + 'collection' => $collection, + 'dimension' => $dim, + ], 200); + + } catch (\Exception $e) { + return new WP_Error('init_failed', $e->getMessage(), ['status' => 500]); + } + }, + ]); + + // Diagnostic endpoint to check Qdrant configuration + register_rest_route('psai/v1', '/qdrant-status', [ + 'methods' => 'GET', + 'permission_callback' => function () { + return is_user_logged_in() && current_user_can('upload_files'); + }, + 'callback' => function () { + global $wpdb; + + $qdrant_enabled = (bool)opt('QDRANT_ENABLE', true); + $qdrant_url = (string)opt('QDRANT_URL', ''); + $env_url = getenv('PS_QDRANT_URL') ?: 'not set'; + $const_url = defined('PS_QDRANT_URL') ? constant('PS_QDRANT_URL') : 'not set'; + + // Try to connect to Qdrant + $final_url = $qdrant_url !== '' ? $qdrant_url : ($env_url !== 'not set' ? $env_url : $const_url); + $qdrant_accessible = false; + $qdrant_error = null; + + if ($final_url !== 'not set') { + $test_url = rtrim($final_url, '/') . '/collections'; + + // Get API key if configured + $headers = ['Content-Type' => 'application/json']; + $api_key = (string)opt('QDRANT_API_KEY', ''); + if ($api_key === '') { + $api_key = getenv('PS_QDRANT_API_KEY') ?: (defined('PS_QDRANT_API_KEY') ? constant('PS_QDRANT_API_KEY') : ''); + } + if ($api_key !== '') { + $headers['api-key'] = $api_key; + } + + $response = wp_remote_get($test_url, ['timeout' => 5, 'headers' => $headers]); + if (!is_wp_error($response)) { + $code = wp_remote_retrieve_response_code($response); + $qdrant_accessible = ($code === 200); + if ($code !== 200) { + $qdrant_error = "HTTP {$code}: " . wp_remote_retrieve_body($response); + } + } else { + $qdrant_error = $response->get_error_message(); + } + } + + // Check database table structure + $table = $wpdb->prefix . 'ps_text_embeddings'; + $columns = $wpdb->get_results("DESCRIBE {$table}", ARRAY_A); + $has_input_hash = false; + foreach ($columns as $col) { + if ($col['Field'] === 'input_hash') { + $has_input_hash = true; + break; + } + } + + // Count embeddings in database + $embedding_count = $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); + + return new WP_REST_Response([ + 'qdrant_enabled' => $qdrant_enabled, + 'qdrant_url_settings' => $qdrant_url, + 'qdrant_url_env' => $env_url, + 'qdrant_url_const' => $const_url, + 'qdrant_url_final' => $final_url, + 'qdrant_accessible' => $qdrant_accessible, + 'qdrant_error' => $qdrant_error, + 'database_table_exists' => !empty($columns), + 'database_has_input_hash_column' => $has_input_hash, + 'database_embedding_count' => (int)$embedding_count, + ], 200); + }, + ]); + // POST /wp-json/psai/v1/semantic-search register_rest_route('psai/v1', '/semantic-search', [ 'methods' => 'POST', @@ -117,8 +308,17 @@ function handle_semantic_search(WP_REST_Request $request) // Perform ANN search in Qdrant (returns null if Qdrant disabled/unavailable) $results = QdrantSearchService::search_by_vector($embedding, $model, $limit, $min_score, $filters); if ($results === null) { + // Log diagnostic info for debugging + $qdrant_url = getenv('PS_QDRANT_URL') ?: (defined('PS_QDRANT_URL') ? constant('PS_QDRANT_URL') : 'not set'); + $settings_url = (string)opt('QDRANT_URL', ''); + $qdrant_enabled = (bool)opt('QDRANT_ENABLE', true); + + error_log("[PSSearch] Vector search unavailable. Qdrant enabled: " . ($qdrant_enabled ? 'yes' : 'no') . + ", Env URL: {$qdrant_url}, Settings URL: {$settings_url}"); + // For query-based search we don't have a cheap MySQL brute-force fallback, so surface 503. - return new WP_Error('search_failed', 'Vector search unavailable', ['status' => 503]); + $error_details = "Qdrant not available. Check: 1) QDRANT_ENABLE in settings, 2) QDRANT_URL configured, 3) Qdrant service running"; + return new WP_Error('search_failed', $error_details, ['status' => 503]); } if (empty($results)) { diff --git a/wp-content/themes/postsecret/assets/css/semantic-search.css b/wp-content/themes/postsecret/assets/css/semantic-search.css index 9658bdb..1bb5b94 100644 --- a/wp-content/themes/postsecret/assets/css/semantic-search.css +++ b/wp-content/themes/postsecret/assets/css/semantic-search.css @@ -15,19 +15,46 @@ .ps-search-input { padding: 0.5rem 2.5rem 0.5rem 1rem; - border: 1px solid var(--wp--preset--color--contrast-2, #ddd); + border: 1px solid var(--wp--preset--color--border, #ddd); border-radius: 24px; font-size: 0.95rem; width: 240px; transition: all 0.2s ease; + background: var(--wp--preset--color--bg, #fff); + color: var(--wp--preset--color--text, #000); + -webkit-appearance: none; + appearance: none; +} + +.ps-search-input::placeholder { + color: var(--wp--preset--color--muted, #666); + opacity: 0.7; } .ps-search-input:focus { outline: none; - border-color: var(--wp--preset--color--primary, #333); + border-color: var(--wp--preset--color--accent, #a51818); width: 280px; } +/* Clear button (X) styling - WebKit browsers */ +.ps-search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + height: 16px; + width: 16px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + cursor: pointer; + margin-right: 0.5rem; +} + +/* Dark mode clear button */ +html[data-theme="dark"] .ps-search-input::-webkit-search-cancel-button { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2' stroke-linecap='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); +} + .ps-search-button { position: absolute; right: 0.25rem; @@ -35,12 +62,13 @@ border: none; padding: 0.5rem; cursor: pointer; - color: var(--wp--preset--color--contrast, #333); + color: var(--wp--preset--color--text, #333); transition: color 0.2s ease; + pointer-events: none; /* Prevent overlap with clear button */ } .ps-search-button:hover { - color: var(--wp--preset--color--primary, #000); + color: var(--wp--preset--color--accent, #a51818); } .ps-search-button:disabled { @@ -48,6 +76,21 @@ cursor: not-allowed; } +/* Dark mode search input */ +html[data-theme="dark"] .ps-search-input { + background: var(--wp--preset--color--bg, #000); + color: var(--wp--preset--color--text, #fff); + border-color: var(--wp--preset--color--border, #333); +} + +html[data-theme="dark"] .ps-search-input::placeholder { + color: var(--wp--preset--color--muted, #999); +} + +html[data-theme="dark"] .ps-search-button { + color: var(--wp--preset--color--text, #fff); +} + .ps-search-error { position: absolute; top: 100%; @@ -65,35 +108,59 @@ /* Search Results Page */ .ps-search-header { - margin-bottom: 2rem; + margin-bottom: 3rem; text-align: center; - padding: 2rem 1rem; + padding: clamp(2rem, 4vw, 4rem) 1rem clamp(1rem, 2vw, 2rem); + background: var(--wp--preset--color--tint, transparent); } .ps-search-header h1 { - font-size: clamp(1.75rem, 3vw, 2.25rem); - margin-bottom: 0.5rem; + font-size: clamp(2rem, 4vw, 3rem); + margin-bottom: 0.75rem; + font-weight: 700; + color: var(--wp--preset--color--text, #000); } .ps-search-count { - color: var(--wp--preset--color--contrast-2, #666); - font-size: 1rem; + color: var(--wp--preset--color--muted, #666); + font-size: 1.125rem; + font-weight: 400; } .ps-search-loading { - color: var(--wp--preset--color--contrast-2, #666); - font-size: 1.1rem; + color: var(--wp--preset--color--muted, #666); + font-size: 1.125rem; margin-top: 1rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; } .ps-search-loading i { - margin-right: 0.5rem; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .ps-search-error-msg { - color: #c33; - font-size: 1.1rem; + color: var(--wp--preset--color--accent, #c33); + font-size: 1.125rem; margin: 1rem 0; + padding: 1rem; + background: rgba(165, 24, 24, 0.1); + border-radius: 8px; +} + +/* Dark mode adjustments */ +html[data-theme="dark"] .ps-search-header { + background: var(--wp--preset--color--tint, transparent); +} + +html[data-theme="dark"] .ps-search-error-msg { + background: rgba(224, 42, 42, 0.15); } .ps-button { @@ -128,26 +195,51 @@ color: var(--wp--preset--color--contrast-3, #999) !important; } -/* Search Results - Single Column Layout for Reading */ +/* Search Results - Single Column Reading Experience */ #ps-search-grid { display: flex; flex-direction: column; - gap: 2rem; - margin-top: 2rem; - max-width: 900px; - margin-left: auto; - margin-right: auto; + gap: clamp(2rem, 4vw, 4rem); + margin: 0 auto; + max-width: 800px; + padding: 0 var(--breathing-room, 2rem) 4rem; } #ps-search-grid .ps-card { width: 100%; max-width: none; + position: relative; + display: flex; + flex-direction: column; + align-items: center; } #ps-search-grid .ps-card__img { width: 100%; + max-width: 100%; height: auto; object-fit: contain; + display: block; +} + +/* Match percentage label - prominent and modern */ +#ps-search-grid .ps-card__similarity { + position: relative; + margin-top: 1rem; + padding: 0.5rem 1.25rem; + background: var(--wp--preset--color--accent, #a51818); + color: #ffffff; + font-size: 0.875rem; + font-weight: 600; + border-radius: 24px; + text-transform: uppercase; + letter-spacing: 0.05em; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +html[data-theme="dark"] #ps-search-grid .ps-card__similarity { + background: var(--wp--preset--color--accent, #E02A2A); + box-shadow: 0 2px 8px rgba(224, 42, 42, 0.3); } /* Loading indicator for infinite scroll */ @@ -161,7 +253,7 @@ margin-right: 0.5rem; } -/* Similarity score badge */ +/* Similarity score badge - base styles (overridden for search grid) */ .ps-card__similarity { display: inline-block; margin-top: 0.5rem; @@ -173,11 +265,70 @@ font-weight: 500; } +html[data-theme="dark"] .ps-card__similarity { + background: var(--wp--preset--color--border, #333); + color: var(--wp--preset--color--text, #fff); +} + /* Sentinel element for intersection observer */ .ps-search-sentinel { visibility: hidden; } +/* Subtle card styling for reading experience */ +#ps-search-grid .ps-card { + transition: opacity 0.2s ease; + border-radius: 0; + opacity: 1; +} + +#ps-search-grid .ps-card:hover { + opacity: 0.95; +} + +#ps-search-grid .ps-card__link { + display: block; + width: 100%; +} + +#ps-search-grid .ps-card__media { + width: 100%; + margin: 0; +} + +/* Center card metadata */ +#ps-search-grid .ps-card__meta { + text-align: center; + width: 100%; + padding: 1rem 0; +} + +#ps-search-grid .ps-card__tags { + justify-content: center; + margin-top: 0.75rem; +} + +#ps-search-grid .ps-card__excerpt { + max-width: 600px; + margin: 0.5rem auto; + line-height: 1.6; + color: var(--wp--preset--color--text, #000); +} + +html[data-theme="dark"] #ps-search-grid .ps-card__excerpt { + color: var(--wp--preset--color--text, #fff); +} + +/* Respect reduced motion */ +@media (prefers-reduced-motion: reduce) { + #ps-search-grid .ps-card { + transition: none; + } + #ps-search-grid .ps-card:hover { + opacity: 1; + } +} + /* Responsive adjustments */ @media (max-width: 768px) { .ps-search-input { @@ -189,7 +340,13 @@ } #ps-search-grid { - gap: 1.5rem; + gap: 3rem; + padding: 0 1rem 3rem; + } + + #ps-search-grid .ps-card__excerpt { + font-size: 0.9375rem; + line-height: 1.5; } } @@ -208,6 +365,15 @@ } #ps-search-grid { - gap: 1rem; + gap: 2.5rem; + max-width: 100%; + } + + .ps-search-header { + padding: 1.5rem 1rem 1rem; + } + + .ps-search-header h1 { + font-size: 1.75rem; } }