From cf3b2b89632ee6835e1218244defb8e383613692 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 18:42:12 -0400
Subject: [PATCH 01/17] refactor: enhance migration executor with slug-based
function naming and improved error handling
Updated migration logic to support slug-based function names (e.g., `up_`) for better organization and future-proofing. Introduced dynamic callable detection, fallback logic for legacy methods, and robust error reporting with early failure handling. Standardized naming for migration scripts to ensure consistency.
---
.../postsecret-admin/migrations/001_init.php | 2 +-
.../migrations/002_facets.php | 2 +-
.../migrations/003_embeddings.php | 2 +-
.../postsecret-admin/run-migrations.php | 81 +++++++++++++------
4 files changed, 60 insertions(+), 27 deletions(-)
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/run-migrations.php b/wp-content/plugins/postsecret-admin/run-migrations.php
index b21613c..f1631b0 100644
--- a/wp-content/plugins/postsecret-admin/run-migrations.php
+++ b/wp-content/plugins/postsecret-admin/run-migrations.php
@@ -9,52 +9,85 @@
*/
// 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));
+};
+
+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";
-foreach ( $migrations as $migration_file ) {
- $migration_name = basename( $migration_file );
echo "Running migration: {$migration_name}...";
- // Create isolated scope and execute
- $result = ( function() use ( $migration_file, $wpdb ) {
+ $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)) {
+ // Soft warning but still execute
+ 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
From 0b33f120bd9dbd8235050d27656255395497ab4a Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 18:44:50 -0400
Subject: [PATCH 02/17] feat: add migration filtering by slug or filename with
improved input handling
Introduced optional filtering to execute specific migrations via the `?only=` parameter. Enhanced input sanitization and validation, standardized output, and improved legacy compatibility messaging. Updated migration list UI with filter results.
---
.../postsecret-admin/run-migrations.php | 46 +++++++++++++++++--
1 file changed, 41 insertions(+), 5 deletions(-)
diff --git a/wp-content/plugins/postsecret-admin/run-migrations.php b/wp-content/plugins/postsecret-admin/run-migrations.php
index f1631b0..b70497e 100644
--- a/wp-content/plugins/postsecret-admin/run-migrations.php
+++ b/wp-content/plugins/postsecret-admin/run-migrations.php
@@ -2,8 +2,8 @@
/**
* 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
*/
@@ -29,6 +29,43 @@
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;
+ }
+
+ $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]);
+ }));
+
+ 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);
@@ -37,7 +74,7 @@
$preferred_fn = "\\PostSecret\\Admin\\Migrations\\up_{$slug}";
$legacy_fn = "\\PostSecret\\Admin\\Migrations\\up";
- echo "Running migration: {$migration_name}...";
+ echo "
Running migration: " . esc_html($migration_name) . "...";
$result = (static function (string $file, string $preferred_fn, string $legacy_fn) {
ob_start();
@@ -53,7 +90,6 @@
$preferred_fn();
} // Legacy: function up()
elseif (function_exists($legacy_fn)) {
- // Soft warning but still execute
echo "[notice] Using legacy migration function `{$legacy_fn}`. Consider renaming to `{$preferred_fn}`.\n";
$legacy_fn();
} // Closure-based: migration file returned a callable
@@ -90,4 +126,4 @@
}
echo "
Migrations complete!
\n";
-echo "← Back to Dashboard
\n";
\ No newline at end of file
+echo "← Back to Dashboard
\n";
\ No newline at end of file
From 6e2b10eeefae55c6b1c8c4aab0519f9711e5c0a0 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 19:00:05 -0400
Subject: [PATCH 03/17] feat: overhaul Classifier with robust OpenAI
integration and resilient features
Refactored the Classifier to include OpenAI-compatible functionality with enhanced HTTP request handling, exponential backoff, and detailed JSON processing for vision-classification workflows. Added support for runtime options, fallback logic, moderation passes, and dynamic schema normalization. Improved error handling, configuration flexibility, and retry mechanisms for API interactions. Enforced strict typing and project alignment with extended extensibility.
---
.../plugins/postsecret-ai/src/Classifier.php | 427 ++++++++++++++++--
1 file changed, 395 insertions(+), 32 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/src/Classifier.php b/wp-content/plugins/postsecret-ai/src/Classifier.php
index 8127bb7..fc3d8c3 100644
--- a/wp-content/plugins/postsecret-ai/src/Classifier.php
+++ b/wp-content/plugins/postsecret-ai/src/Classifier.php
@@ -1,58 +1,421 @@
'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
+ {
+ $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++;
+
+ $res = wp_remote_post($url, [
+ 'headers' => $headers,
+ 'timeout' => $timeout,
+ 'sslverify' => $sslVerify,
+ 'body' => wp_json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ ]);
- 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));
+ if (is_wp_error($res)) {
+ if ($attempt > $retries) {
+ throw new \RuntimeException('HTTP error: ' . $res->get_error_message());
+ }
+ self::sleepBackoff($attempt, $backoffFactor);
+ continue;
+ }
- $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.');
+ $code = (int)wp_remote_retrieve_response_code($res);
+ $raw = (string)wp_remote_retrieve_body($res);
- $payload = \PSAI\SchemaGuard::normalize($payload);
+ if ($code >= 200 && $code < 300) {
+ return $raw;
+ }
- return $payload;
+ // 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
From 5ea69571e2d5f37c11684e86af0bf0d12fa1caa4 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 19:03:49 -0400
Subject: [PATCH 04/17] feat: add REST API endpoint for debug log inspection
with line limit control
Introduced a `/wp-json/psai/v1/debug-log` API endpoint for admins to inspect the debug log. Includes customizable line limit control (default 200, min 10, max 2000) and ensures proper permissions (`manage_options`). Implemented safe file handling, robust parameter validation, and efficient log reading with reverse tailing logic.
---
.../plugins/postsecret-ai/postsecret-ai.php | 47 +++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index 0449e64..c742ad8 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -346,4 +346,51 @@ function () {
} elseif ($msg === 'dupe') {
echo 'PostSecret AI: Uploaded image matches an existing file (duplicate marked).
';
}
+});
+
+
+/**
+ * Quick log peek for admins: /wp-json/psai/v1/debug-log?lines=200
+ */
+add_action('rest_api_init', function () {
+ register_rest_route('psai/v1', '/debug-log', [
+ 'methods' => 'GET',
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ 'args' => [
+ 'lines' => ['type' => 'integer', 'default' => 200, 'minimum' => 10, 'maximum' => 2000],
+ ],
+ 'callback' => function (\WP_REST_Request $req) {
+ $file = WP_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) {
+ $char = '';
+ 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
From 78cc5d4575e925b6553a7afe3ad9fd1a921751fd Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 19:08:14 -0400
Subject: [PATCH 05/17] fix: update debug-log endpoint permissions to restrict
access
Adjusted the `/psai/v1/debug-log` API endpoint permissions to require logged-in users with `edit_posts` capability instead of `manage_options` for broader yet controlled accessibility.
---
wp-content/plugins/postsecret-ai/postsecret-ai.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index c742ad8..a899fb7 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -356,7 +356,7 @@ function () {
register_rest_route('psai/v1', '/debug-log', [
'methods' => 'GET',
'permission_callback' => function () {
- return current_user_can('manage_options');
+ return is_user_logged_in() && current_user_can('edit_posts');
},
'args' => [
'lines' => ['type' => 'integer', 'default' => 200, 'minimum' => 10, 'maximum' => 2000],
From 2e0985ebe4380213c517f5b7f63fb4bbc54258d3 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 19:23:41 -0400
Subject: [PATCH 06/17] fix: ensure default detail level in Classifier method
to prevent invalid values
Enforced allowed `detail` values and set a fallback to `high` when an invalid value is provided in `buildVisionContent`.
---
wp-content/plugins/postsecret-ai/src/Classifier.php | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/wp-content/plugins/postsecret-ai/src/Classifier.php b/wp-content/plugins/postsecret-ai/src/Classifier.php
index fc3d8c3..6d3f04d 100644
--- a/wp-content/plugins/postsecret-ai/src/Classifier.php
+++ b/wp-content/plugins/postsecret-ai/src/Classifier.php
@@ -200,6 +200,11 @@ private static function apiBase(array $opts): string
*/
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]],
From 46a9e3edbd02c2e11a35ebfffad4ae0531d90293 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 19:24:08 -0400
Subject: [PATCH 07/17] fix: relax debug-log endpoint permissions to only
require logged-in users
---
wp-content/plugins/postsecret-ai/postsecret-ai.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index a899fb7..63eba61 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -356,7 +356,7 @@ function () {
register_rest_route('psai/v1', '/debug-log', [
'methods' => 'GET',
'permission_callback' => function () {
- return is_user_logged_in() && current_user_can('edit_posts');
+ return is_user_logged_in();
},
'args' => [
'lines' => ['type' => 'integer', 'default' => 200, 'minimum' => 10, 'maximum' => 2000],
From 8e0d82cdcd10cee84e482ba58ec904ebbff82e8f Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 19:35:30 -0400
Subject: [PATCH 08/17] feat: add helper functions for consistent error
handling and improve image processing flow
Implemented `psai_set_last_error` and `psai_clear_last_error` functions for streamlined error management on attachment metadata. Enhanced image processing logic with global transient error storage, clearer error feedback, and robust precondition checks.
---
.../plugins/postsecret-ai/postsecret-ai.php | 109 ++++++++++++++----
1 file changed, 85 insertions(+), 24 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index 63eba61..7153a44 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;
@@ -317,14 +373,14 @@ function () {
\PSAI\Ingress::normalize_from_existing_payload($front_id);
\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' => '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;
@@ -350,7 +406,8 @@ function () {
/**
- * Quick log peek for admins: /wp-json/psai/v1/debug-log?lines=200
+ * 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', [
@@ -362,22 +419,25 @@ function () {
'lines' => ['type' => 'integer', 'default' => 200, 'minimum' => 10, 'maximum' => 2000],
],
'callback' => function (\WP_REST_Request $req) {
- $file = WP_CONTENT_DIR . '/debug.log';
+ $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) {
- $char = '';
fseek($fp, $len--, SEEK_SET);
$char = fgetc($fp);
if ($char === "\n" && $line !== '') {
@@ -389,6 +449,7 @@ function () {
}
if ($line !== '') $lines[] = strrev($line);
fclose($fp);
+
$lines = array_slice(array_reverse($lines), -$n);
return new \WP_REST_Response(['exists' => true, 'lines' => $lines], 200);
},
From dcde72b02c8e9ececc2195b6016cab2e4f1194be Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:03:46 -0400
Subject: [PATCH 09/17] feat: enhance EmbeddingService with detailed error
handling and improved feedback
Added specific error messages and transient storage for embedding failures, including API call, input validation, and database errors. Introduced automatic transient error clearing on success. Improved admin messaging with partial error states and debug log links for troubleshooting. Refined Qdrant logging for non-fatal upserts.
---
.../plugins/postsecret-ai/postsecret-ai.php | 27 +++++++++++-
.../postsecret-ai/src/AdminMetaBox.php | 6 ++-
.../postsecret-ai/src/EmbeddingService.php | 41 +++++++++++++++++--
3 files changed, 67 insertions(+), 7 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index 7153a44..3534bb3 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -345,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);
@@ -365,14 +367,33 @@ 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) {
+ // Try to get detailed error from transient
+ $detailed_error = get_transient('_ps_last_embedding_error');
+ if ($detailed_error) {
+ $error_details[] = $detailed_error;
+ } else {
+ $error_details[] = '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);
+ // If embedding failed but classification succeeded, store partial error
+ if (!$embedding_ok) {
+ $err_msg = 'Classification succeeded but embedding generation failed. ' . implode('; ', $error_details);
+ psai_set_last_error($front_id, $err_msg);
+ $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, ''));
@@ -395,6 +416,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') {
diff --git a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
index d8286cc..ec1cee6 100644
--- a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
+++ b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
@@ -147,7 +147,11 @@ 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
';
}
// Raw JSON viewer
diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
index b049c89..fde3bd3 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,20 @@ 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
+ error_log("[EmbeddingService] Qdrant upsert failed for secret {$secret_id} (non-fatal)");
+ }
}
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 +202,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 +214,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 +228,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 +240,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);
}
From 587ab7abce2c2491556634e5a88cd9949102cab9 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:10:23 -0400
Subject: [PATCH 10/17] feat: improve error handling for embedding and update
schema with new model options
Enhanced error reporting by combining transient and metadata storage for embedding failures. Added `EMBEDDING_MODEL` to schema config for flexible embedding model selection. Improved admin UI with additional error detail display.
---
.../plugins/postsecret-ai/postsecret-ai.php | 22 +++++++++++++------
.../postsecret-ai/src/AdminMetaBox.php | 8 +++++++
.../plugins/postsecret-ai/src/Schema.php | 1 +
3 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index 3534bb3..dd91c61 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -372,12 +372,22 @@ function () {
$embedding_ok = \PSAI\EmbeddingService::generate_and_store($front_id, $payload, $api, $embed_model);
if (!$embedding_ok) {
- // Try to get detailed error from transient
- $detailed_error = get_transient('_ps_last_embedding_error');
- if ($detailed_error) {
- $error_details[] = $detailed_error;
+ // 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.');
}
}
@@ -385,10 +395,8 @@ function () {
\PSAI\Ingress::normalize_from_existing_payload($front_id);
\PSAI\Metadata::compute_and_store($att);
- // If embedding failed but classification succeeded, store partial error
+ // If embedding failed but classification succeeded, show partial error
if (!$embedding_ok) {
- $err_msg = 'Classification succeeded but embedding generation failed. ' . implode('; ', $error_details);
- psai_set_last_error($front_id, $err_msg);
$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;
diff --git a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
index ec1cee6..24d0437 100644
--- a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
+++ b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
@@ -152,6 +152,14 @@ public static function render(\WP_Post $post): void
// 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 ' ';
+ }
}
// Raw JSON viewer
diff --git a/wp-content/plugins/postsecret-ai/src/Schema.php b/wp-content/plugins/postsecret-ai/src/Schema.php
index fb77cba..9db3955 100644
--- a/wp-content/plugins/postsecret-ai/src/Schema.php
+++ b/wp-content/plugins/postsecret-ai/src/Schema.php
@@ -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'],
From 2a0da29cd5e9b7f17675b1505f704661304e19a1 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:14:24 -0400
Subject: [PATCH 11/17] feat: add embedding input builder and enhance Qdrant
upsert response handling
Introduced `build_embedding_input()` for constructing detailed embedding input from payloads. Updated `qdrant_upsert()` to return success status, improving feedback mechanisms for upsert operations.
---
.../postsecret-ai/src/EmbeddingService.php | 44 +++++++++++++++++--
1 file changed, 41 insertions(+), 3 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
index fde3bd3..38e1d47 100644
--- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
+++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
@@ -311,11 +311,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);
@@ -332,7 +333,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';
}
/**
@@ -571,6 +573,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
// ─────────────────────────────────────────────────────────────────────────────
From ba6c1f07cfd35e831f78c48b72cbee2da4050d67 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:18:17 -0400
Subject: [PATCH 12/17] feat: add input_hash column to embeddings table and
enhance error handling
Added `input_hash` column to `ps_text_embeddings` via migration to optimize storage by avoiding redundant embedding generations. Improved `EmbeddingService` error handling with detailed database error messages and persistent error storage using `psai_set_last_error`.
---
.../migrations/004_embeddings_input_hash.php | 42 +++++++++++++++++++
.../postsecret-ai/src/EmbeddingService.php | 5 ++-
2 files changed, 46 insertions(+), 1 deletion(-)
create mode 100644 wp-content/plugins/postsecret-admin/migrations/004_embeddings_input_hash.php
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-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
index 38e1d47..7f0320e 100644
--- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
+++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
@@ -529,7 +529,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;
From d9b09a686524cd1cabbe931598b7488b420cb4f6 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:28:54 -0400
Subject: [PATCH 13/17] feat: add Qdrant diagnostic endpoint and enhance error
logging/debugging
Introduced a new REST API endpoint (`/psai/v1/qdrant-status`) for Qdrant configuration diagnostics. Improved error handling for vector search with detailed debug logs, including Qdrant availability status and configuration details. Updated temp file handling for more robust image processing and clarified Qdrant schema configuration documentation.
---
.../plugins/postsecret-ai/src/Ingress.php | 7 ++-
.../plugins/postsecret-ai/src/Schema.php | 4 +-
.../postsecret-search/postsecret-search.php | 51 ++++++++++++++++++-
3 files changed, 57 insertions(+), 5 deletions(-)
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 9db3955..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.'],
diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php
index 599e62e..750b790 100644
--- a/wp-content/plugins/postsecret-search/postsecret-search.php
+++ b/wp-content/plugins/postsecret-search/postsecret-search.php
@@ -38,6 +38,46 @@ function register_rest_routes()
},
]);
+ // 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('manage_options');
+ },
+ 'callback' => function () {
+ $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';
+ $response = wp_remote_get($test_url, ['timeout' => 5]);
+ if (!is_wp_error($response)) {
+ $code = wp_remote_retrieve_response_code($response);
+ $qdrant_accessible = ($code === 200);
+ } else {
+ $qdrant_error = $response->get_error_message();
+ }
+ }
+
+ 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,
+ ], 200);
+ },
+ ]);
+
// POST /wp-json/psai/v1/semantic-search
register_rest_route('psai/v1', '/semantic-search', [
'methods' => 'POST',
@@ -117,8 +157,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)) {
From e85fa8915c29617d7ce6c6f5b595da32fb6ccb85 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:37:28 -0400
Subject: [PATCH 14/17] feat: enhance Qdrant diagnostics and error handling
Improved Qdrant diagnostic endpoint (`/psai/v1/qdrant-status`) with additional database insights, including table structure validation and embedding count. Enhanced `EmbeddingService` with persistent error storage (`psai_set_last_error`) for Qdrant sync failures and cleared transient errors on success. Updated admin UI with detailed Qdrant sync warnings and troubleshooting tools.
---
.../postsecret-ai/src/AdminMetaBox.php | 13 +++++++++++
.../postsecret-ai/src/EmbeddingService.php | 23 ++++++++++++++++---
.../postsecret-search/postsecret-search.php | 21 ++++++++++++++++-
3 files changed, 53 insertions(+), 4 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
index 24d0437..105a873 100644
--- a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
+++ b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
@@ -162,6 +162,19 @@ public static function render(\WP_Post $post): void
}
}
+ // 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 'Check Qdrant Status
';
+
// 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/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
index 7f0320e..5413ddf 100644
--- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
+++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
@@ -88,8 +88,15 @@ public static function generate_and_store(int $secret_id, array $payload, string
$qdrant_ok = self::qdrant_upsert($secret_id, $model, $embedding, $qdrantPayload);
if (!$qdrant_ok) {
- // Non-fatal - Qdrant is best-effort, but log it
+ // 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);
+ }
}
}
@@ -375,7 +382,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',
@@ -398,10 +408,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;
}
@@ -410,10 +423,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;
}
diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php
index 750b790..2a4fcff 100644
--- a/wp-content/plugins/postsecret-search/postsecret-search.php
+++ b/wp-content/plugins/postsecret-search/postsecret-search.php
@@ -42,9 +42,11 @@ function register_rest_routes()
register_rest_route('psai/v1', '/qdrant-status', [
'methods' => 'GET',
'permission_callback' => function () {
- return is_user_logged_in() && current_user_can('manage_options');
+ 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';
@@ -66,6 +68,20 @@ function register_rest_routes()
}
}
+ // 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,
@@ -74,6 +90,9 @@ function register_rest_routes()
'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);
},
]);
From 0980920cdd8b6c2dda2181ada07ba32224d989f2 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 20:47:17 -0400
Subject: [PATCH 15/17] feat: add IP detection endpoint and enhance Qdrant
diagnostics
Introduced `/psai/v1/my-ip` API endpoint for logged-in users to retrieve server, external, and forwarded IP details. Improved `/psai/v1/qdrant-status` diagnostics with API key header support and detailed error handling for unsuccessful responses.
---
.../postsecret-search/postsecret-search.php | 41 ++++++++++++++++++-
1 file changed, 40 insertions(+), 1 deletion(-)
diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php
index 2a4fcff..7f2385c 100644
--- a/wp-content/plugins/postsecret-search/postsecret-search.php
+++ b/wp-content/plugins/postsecret-search/postsecret-search.php
@@ -38,6 +38,31 @@ function register_rest_routes()
},
]);
+ // 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);
+ },
+ ]);
+
// Diagnostic endpoint to check Qdrant configuration
register_rest_route('psai/v1', '/qdrant-status', [
'methods' => 'GET',
@@ -59,10 +84,24 @@ function register_rest_routes()
if ($final_url !== 'not set') {
$test_url = rtrim($final_url, '/') . '/collections';
- $response = wp_remote_get($test_url, ['timeout' => 5]);
+
+ // 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();
}
From 4f9380854ce3a7452584ab8dbffa07478b8f1fb7 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 21:13:28 -0400
Subject: [PATCH 16/17] feat: add Qdrant initialization endpoint and enhance
admin UI
Added `/psai/v1/qdrant-init` endpoint for initializing Qdrant collections with public method invocation and transient clearing. Enhanced admin UI with "Initialize Qdrant Collection" button, including inline JavaScript for interactive initialization status and error feedback. Improved `EmbeddingService` logs for collection existence and creation.
---
.../postsecret-ai/src/AdminMetaBox.php | 47 +++++++++-
.../postsecret-ai/src/EmbeddingService.php | 18 +++-
.../postsecret-search/postsecret-search.php | 93 +++++++++++++++++++
3 files changed, 156 insertions(+), 2 deletions(-)
diff --git a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
index 105a873..3b1e885 100644
--- a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
+++ b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php
@@ -173,7 +173,52 @@ public static function render(\WP_Post $post): void
// Show diagnostic button for troubleshooting
$diag_url = rest_url('psai/v1/qdrant-status');
- echo 'Check 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) {
diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
index 5413ddf..e9f3fad 100644
--- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
+++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php
@@ -352,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,
@@ -370,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);
diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php
index 7f2385c..903b1f2 100644
--- a/wp-content/plugins/postsecret-search/postsecret-search.php
+++ b/wp-content/plugins/postsecret-search/postsecret-search.php
@@ -38,6 +38,59 @@ 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',
@@ -63,6 +116,46 @@ function register_rest_routes()
},
]);
+ // 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',
From 114b74e8c705ef2362995fca929fd07b4704c4a4 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Thu, 2 Oct 2025 21:55:15 -0400
Subject: [PATCH 17/17] feat: improve semantic search UI/UX with dark mode,
animations, and accessibility updates
Enhanced semantic search styling by introducing dark mode compatibility, modernized hover/transition effects, improved responsiveness, and polished placeholder design. Added support for reduced motion preferences, streamlined card layouts, and refined accessibility elements for search interactions.
---
.../postsecret/assets/css/semantic-search.css | 214 ++++++++++++++++--
1 file changed, 190 insertions(+), 24 deletions(-)
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;
}
}