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;
}
}