From 03170e33d96635ab386621b5f984f61a10b8ef49 Mon Sep 17 00:00:00 2001 From: Flatts Date: Thu, 2 Oct 2025 15:07:10 -0400 Subject: [PATCH 1/6] feat: extend docker-compose with Qdrant service and updated configurations Integrated Qdrant service for semantic search functionality. Enhanced WordPress and MySQL configurations with additional environment variables and memory limits. Updated health checks and replaced outdated defaults for improved reliability. --- docker-compose.yml | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ee715fb..861afad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,18 +13,25 @@ services: WP_HOME: http://localhost:8080 WP_SITEURL: http://localhost:8080 WORDPRESS_DEBUG: 1 + PS_QDRANT_URL: http://qdrant:6333 WORDPRESS_CONFIG_EXTRA: | define('WP_DEBUG_LOG', true); define('WP_DEBUG_DISPLAY', false); define('SCRIPT_DEBUG', true); + define('WP_ENVIRONMENT_TYPE', 'development'); + @ini_set('memory_limit', '512M'); + @ini_set('upload_max_filesize', '128M'); + @ini_set('post_max_size', '128M'); volumes: - ./wp-content:/var/www/html/wp-content - wordpress_data:/var/www/html depends_on: db: condition: service_healthy + qdrant: + condition: service_started healthcheck: - test: ["CMD", "curl", "-f", "http://localhost"] + test: [ "CMD-SHELL", "wget -qO- http://localhost >/dev/null 2>&1 || exit 1" ] interval: 30s timeout: 10s retries: 3 @@ -34,7 +41,7 @@ services: image: mysql:8.0 container_name: postsecret_db restart: unless-stopped - command: --default-authentication-plugin=mysql_native_password + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci environment: MYSQL_DATABASE: wordpress MYSQL_USER: wordpress @@ -43,14 +50,14 @@ services: volumes: - db_data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "wordpress", "-pwordpress"] + test: [ "CMD-SHELL", "mysqladmin ping -h localhost -uwordpress -pwordpress --silent" ] interval: 10s timeout: 5s retries: 5 start_period: 30s phpmyadmin: - image: phpmyadmin:5.2 + image: phpmyadmin:latest container_name: postsecret_phpmyadmin restart: unless-stopped ports: @@ -61,8 +68,19 @@ services: PMA_PASSWORD: wordpress UPLOAD_LIMIT: 100M depends_on: - - db + db: + condition: service_healthy + + qdrant: + image: qdrant/qdrant:latest + container_name: postsecret_qdrant + restart: unless-stopped + ports: + - "6333:6333" + volumes: + - qdrant_storage:/qdrant/storage volumes: db_data: wordpress_data: + qdrant_storage: \ No newline at end of file From 91c58587f10856ed3173ae73151a17db0d5e6f9a Mon Sep 17 00:00:00 2001 From: Flatts Date: Thu, 2 Oct 2025 15:21:33 -0400 Subject: [PATCH 2/6] feat: add PostSecret Search plugin with Qdrant integration for semantic similarity search Introduced a new plugin, `PostSecret Search`, for semantic similarity searches leveraging the Qdrant vector database. Added primary functionality for processing embeddings and similarity matching, along with a MySQL fallback for robust operation. Enhanced the `EmbeddingService` with Qdrant support, payload extensibility, and improved logging. Integrated REST endpoints and admin capabilities for future enhancements. --- .../postsecret-ai/src/EmbeddingService.php | 648 +++++++++++++----- .../postsecret-search/postsecret-search.php | 21 + .../src/QdrantSearchService.php | 179 +++++ 3 files changed, 679 insertions(+), 169 deletions(-) create mode 100644 wp-content/plugins/postsecret-search/postsecret-search.php create mode 100644 wp-content/plugins/postsecret-search/src/QdrantSearchService.php diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php index d576da7..85d8069 100644 --- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php +++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php @@ -1,228 +1,357 @@ Map models → vector dimensions (update as needed). */ + private const MODEL_DIMS = [ + 'text-embedding-3-small' => 1536, + 'text-embedding-3-large' => 3072, + ]; + + /** HTTP defaults for remote calls. */ + private const HTTP_TIMEOUT_EMBEDDING = 30; + private const HTTP_TIMEOUT_QDRANT = 10; + private const HTTP_USER_AGENT = 'PostSecret-EmbeddingService/1.0 (+WordPress)'; + + /** OpenAI endpoints. */ + private const OPENAI_EMBEDDINGS_URL = 'https://api.openai.com/v1/embeddings'; + + /** Options/env keys for Qdrant. */ + private const OPT_QDRANT_URL = 'PS_QDRANT_URL'; + private const OPT_QDRANT_API_KEY = 'PS_QDRANT_API_KEY'; // optional + /** - * Generate and store embedding for a Secret. + * Generate, store, and index embedding (canonical in MySQL, best-effort mirror to Qdrant). * - * @param int $secret_id Attachment ID - * @param array $payload Normalized classification payload - * @param string $api_key OpenAI API key - * @param string $model Embedding model (default: text-embedding-3-small) - * @return bool Success + * @param int $secret_id WordPress attachment/post ID of the Secret. + * @param array $payload Normalized classification payload (facets, text, etc.). + * @param string $api_key OpenAI API key. + * @param string $model Embedding model ID (default: text-embedding-3-small). + * @return bool True on success, false on any failure path. */ public static function generate_and_store(int $secret_id, array $payload, string $api_key, string $model = 'text-embedding-3-small'): bool { try { - // Construct embedding input from facets and text - $input = self::build_embedding_input($payload); + // Allow last-mile overrides (e.g., staged rollouts). + /** @var string $model */ + $model = apply_filters('psai/embedding/model', $model, $secret_id, $payload); - if (empty($input)) { + $input = self::build_embedding_input($payload); + if ($input === '') { + error_log("[EmbeddingService] Empty embedding input for secret {$secret_id}; skipping."); return false; } - // Generate embedding via OpenAI API $embedding = self::generate_embedding($api_key, $model, $input); - - if (!$embedding) { + if ($embedding === null) { return false; } - // Normalize to unit vector $embedding = self::normalize_vector($embedding); - // Store in database - return self::store_embedding($secret_id, $model, $embedding); + // Canonical: store in MySQL + $ok = self::store_embedding($secret_id, $model, $embedding, $payload); + if (!$ok) { + return false; + } + // Mirror to Qdrant (fast ANN), best-effort and non-blocking + $qdrantPayload = [ + 'status' => 'public', // adjust upstream as needed at query time + 'teachesWisdom' => !empty($payload['teachesWisdom']), + 'topics' => $payload['topics'] ?? [], + 'feelings' => $payload['feelings'] ?? [], + 'meanings' => $payload['meanings'] ?? [], + ]; + /** @var array $qdrantPayload */ + $qdrantPayload = apply_filters('psai/embedding/qdrant-payload', $qdrantPayload, $secret_id, $payload); + + self::qdrant_upsert($secret_id, $model, $embedding, $qdrantPayload); + + /** + * Fires after a successful embedding generation + storage (regardless of Qdrant status). + * + * @param int $secret_id + * @param string $model + * @param array $embedding + * @param array $payload + */ + 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()); + error_log('[EmbeddingService] Error for secret ' . $secret_id . ': ' . $e->getMessage()); + /** + * Fires on embedding failure (any stage). + * + * @param int $secret_id + * @param \Throwable $e + */ + do_action('psai/embedding/error', $secret_id, $e); return false; } } /** - * Build embedding input string from classification payload. + * Build deterministic input string for the embedding request. * - * Format: "Secret: [description]. Topics: [t1, t2]. Feelings: [f1]. Meanings: [m1]. Text: [fullText]" + * Notes: + * - We keep labels ("Secret:", "Topics:"...) to anchor sections semantically. + * - Text is truncated at ~2000 chars to bound request size while keeping signal. + * - Filters allow downstream feature flags to adjust format without editing core. * - * @param array $payload Classification payload - * @return string Input text for embedding + * @param array $payload + * @return string */ private static function build_embedding_input(array $payload): string { $parts = []; - // Secret description + // Facets if (!empty($payload['secretDescription'])) { - $parts[] = 'Secret: ' . $payload['secretDescription']; + $parts[] = 'Secret: ' . self::sanitize_space((string)$payload['secretDescription']); } - - // Topics - if (!empty($payload['topics'])) { - $parts[] = 'Topics: ' . implode(', ', $payload['topics']); + if (!empty($payload['topics']) && is_array($payload['topics'])) { + $parts[] = 'Topics: ' . implode(', ', array_map('strval', $payload['topics'])); } - - // Feelings - if (!empty($payload['feelings'])) { - $parts[] = 'Feelings: ' . implode(', ', $payload['feelings']); + if (!empty($payload['feelings']) && is_array($payload['feelings'])) { + $parts[] = 'Feelings: ' . implode(', ', array_map('strval', $payload['feelings'])); } - - // Meanings - if (!empty($payload['meanings'])) { - $parts[] = 'Meanings: ' . implode(', ', $payload['meanings']); + if (!empty($payload['meanings']) && is_array($payload['meanings'])) { + $parts[] = 'Meanings: ' . implode(', ', array_map('strval', $payload['meanings'])); } - // Extracted text (front + back, truncated to ~2000 chars total) + // OCR/Text (front/back) $texts = []; - if (!empty($payload['front']['text']['fullText'])) { - $texts[] = $payload['front']['text']['fullText']; + $front = $payload['front']['text']['fullText'] ?? null; + $back = $payload['back']['text']['fullText'] ?? null; + + if (is_string($front) && $front !== '') { + $texts[] = self::sanitize_space($front); } - if (!empty($payload['back']['text']['fullText'])) { - $texts[] = $payload['back']['text']['fullText']; + if (is_string($back) && $back !== '') { + $texts[] = self::sanitize_space($back); } + if (!empty($texts)) { $combined = implode(' ', $texts); - // Truncate if too long (embeddings work best with ~8k tokens max, ~2k chars is safe) + // 2000 char conservative cap (UTF-8 aware) if (mb_strlen($combined, 'UTF-8') > 2000) { $combined = mb_substr($combined, 0, 2000, 'UTF-8') . '…'; } $parts[] = 'Text: ' . $combined; } - return implode('. ', $parts); + /** @var array $parts */ + $parts = apply_filters('psai/embedding/input_parts', $parts, $payload); + + return implode('. ', array_filter($parts, static fn($p) => $p !== null && $p !== '')); } /** - * Generate embedding via OpenAI API. + * Call OpenAI Embeddings API. * - * @param string $api_key OpenAI API key - * @param string $model Embedding model - * @param string $input Input text - * @return array|null Embedding vector or null on failure + * @param string $api_key + * @param string $model + * @param string $input + * @return array|null */ private static function generate_embedding(string $api_key, string $model, string $input): ?array { - $endpoint = 'https://api.openai.com/v1/embeddings'; - $body = [ 'model' => $model, 'input' => $input, ]; - $res = wp_remote_post($endpoint, [ + $args = [ 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', + 'User-Agent' => self::HTTP_USER_AGENT, ], - 'timeout' => 30, - 'body' => wp_json_encode($body), - ]); + 'timeout' => self::HTTP_TIMEOUT_EMBEDDING, + 'body' => wp_json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ]; + + $res = wp_remote_post(self::OPENAI_EMBEDDINGS_URL, $args); if (is_wp_error($res)) { - error_log('Embedding API error: ' . $res->get_error_message()); + error_log('[EmbeddingService] Embedding API error: ' . $res->get_error_message()); return null; } - $code = wp_remote_retrieve_response_code($res); - $raw = wp_remote_retrieve_body($res); + $code = (int)wp_remote_retrieve_response_code($res); + $raw = (string)wp_remote_retrieve_body($res); if ($code >= 300) { - error_log('Embedding API HTTP ' . $code . ': ' . substr($raw, 0, 500)); + error_log('[EmbeddingService] Embedding API HTTP ' . $code . ': ' . substr($raw, 0, 600)); return null; } $json = json_decode($raw, true); $embedding = $json['data'][0]['embedding'] ?? null; - if (!is_array($embedding) || empty($embedding)) { - error_log('Invalid embedding response: ' . substr($raw, 0, 200)); + if (!is_array($embedding) || $embedding === []) { + error_log('[EmbeddingService] Invalid embedding response: ' . substr($raw, 0, 300)); return null; } + // Optional dimension check (warn only to avoid breaking if provider shifts) + $expected = self::MODEL_DIMS[$model] ?? null; + if (is_int($expected) && count($embedding) !== $expected) { + error_log(sprintf( + '[EmbeddingService] Model "%s" expected dim %d but received %d.', + $model, + $expected, + count($embedding) + )); + } + + // Normalize numeric type + /** @var array $embedding */ + $embedding = array_map(static fn($v) => (float)$v, $embedding); + return $embedding; } /** - * Normalize vector to unit length (L2 normalization). + * L2-normalize a dense vector. No-op for zero magnitude. * - * @param array $vector Input vector - * @return array Normalized vector + * @param array $vector + * @return array */ private static function normalize_vector(array $vector): array { - $magnitude = sqrt(array_sum(array_map(fn($x) => $x * $x, $vector))); + $sumSquares = 0.0; + foreach ($vector as $v) { + $fv = (float)$v; + $sumSquares += $fv * $fv; + } - if ($magnitude == 0) { + if ($sumSquares <= 0.0) { return $vector; } - return array_map(fn($x) => $x / $magnitude, $vector); + $mag = sqrt($sumSquares); + foreach ($vector as $i => $v) { + $vector[$i] = (float)$v / $mag; + } + return $vector; } /** - * Store embedding in database. + * Store embedding row in MySQL (REPLACE for idempotency). + * + * Columns expected (see migrations): + * - secret_id (PK), model_version, embedding (JSON), dimension (int), input_hash (char(64)), updated_at (datetime) * - * @param int $secret_id Attachment ID - * @param string $model Model version - * @param array $embedding Embedding vector - * @return bool Success + * @param int $secret_id + * @param string $model + * @param array $embedding + * @param array $payload + * @return bool */ - private static function store_embedding(int $secret_id, string $model, array $embedding): bool + private static function store_embedding(int $secret_id, string $model, array $embedding, array $payload): bool { global $wpdb; + $table = $wpdb->prefix . 'ps_text_embeddings'; - $table_name = $wpdb->prefix . 'ps_text_embeddings'; - $dimension = count($embedding); + // Optional idempotency: if input hash unchanged, short-circuit write. + $inputHash = hash('sha256', wp_json_encode([ + 'model' => $model, + 'payload' => [ + 'secretDescription' => $payload['secretDescription'] ?? null, + 'topics' => $payload['topics'] ?? [], + 'feelings' => $payload['feelings'] ?? [], + 'meanings' => $payload['meanings'] ?? [], + 'frontText' => $payload['front']['text']['fullText'] ?? null, + 'backText' => $payload['back']['text']['fullText'] ?? null, + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $existing = $wpdb->get_row( + $wpdb->prepare( + "SELECT input_hash FROM {$table} WHERE secret_id = %d AND model_version = %s", + $secret_id, + $model + ), + ARRAY_A + ); + + if (is_array($existing) && isset($existing['input_hash']) && $existing['input_hash'] === $inputHash) { + // No change; avoid unnecessary write. + return true; + } - // Convert to JSON for storage - $embedding_json = wp_json_encode($embedding); + $embeddingJson = wp_json_encode($embedding, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $dimension = count($embedding); $result = $wpdb->replace( - $table_name, + $table, [ 'secret_id' => $secret_id, 'model_version' => $model, - 'embedding' => $embedding_json, + 'embedding' => $embeddingJson, 'dimension' => $dimension, + 'input_hash' => $inputHash, 'updated_at' => current_time('mysql'), ], - ['%d', '%s', '%s', '%d', '%s'] + ['%d', '%s', '%s', '%d', '%s', '%s'] ); - return $result !== false; + if ($result === false) { + error_log("[EmbeddingService] DB write failed for secret {$secret_id}."); + return false; + } + + return true; } /** - * Get embedding for a Secret. + * Fetch embedding record by secret ID. * - * @param int $secret_id Attachment ID - * @return array|null Embedding data or null if not found + * @param int $secret_id + * @return array|null ['secret_id','model_version','embedding'=>array,'dimension','updated_at'...] */ public static function get_embedding(int $secret_id): ?array { global $wpdb; + $table = $wpdb->prefix . 'ps_text_embeddings'; - $table_name = $wpdb->prefix . 'ps_text_embeddings'; - + /** @var array|null $row */ $row = $wpdb->get_row( - $wpdb->prepare( - "SELECT * FROM $table_name WHERE secret_id = %d", - $secret_id - ), + $wpdb->prepare("SELECT * FROM {$table} WHERE secret_id = %d", $secret_id), ARRAY_A ); @@ -230,21 +359,206 @@ public static function get_embedding(int $secret_id): ?array return null; } - // Decode embedding JSON - $row['embedding'] = json_decode($row['embedding'], true); + $decoded = json_decode((string)($row['embedding'] ?? '[]'), true); + $row['embedding'] = is_array($decoded) ? $decoded : []; return $row; } /** - * Find similar Secrets using cosine similarity. + * Similarity search via QdrantSearchService when present; fallback to MySQL brute force. * - * @param int $secret_id Source Secret ID - * @param int $limit Number of results - * @param float $min_similarity Minimum similarity threshold (0.0-1.0) - * @return array Array of [secret_id, similarity] sorted by similarity desc + * @param int $secret_id + * @param int $limit + * @param float $min_score Cosine similarity threshold (0..1). + * @param array $filters Optional metadata filters (e.g., ['status'=>'public']). + * @return array */ - public static function find_similar(int $secret_id, int $limit = 10, float $min_similarity = 0.5): array + public static function find_similar(int $secret_id, int $limit = 10, float $min_score = 0.5, array $filters = []): array + { + if (class_exists('PSSearch\\QdrantSearchService')) { + $results = \PSSearch\QdrantSearchService::find_similar($secret_id, $limit, $min_score, $filters); + if ($results !== null) { + return $results; + } + // Fallback path implemented in the companion service for consistency + return \PSSearch\QdrantSearchService::find_similar_mysql($secret_id, $limit, $min_score); + } + + // Legacy internal fallback + return self::find_similar_mysql($secret_id, $limit, $min_score); + } + + // === QDRANT INTEGRATION (Upsert only) =================================== + + /** + * Resolve Qdrant base URL from env/constant. + * + * @return string|null + */ + private static function qdrant_url(): ?string + { + $url = getenv(self::OPT_QDRANT_URL) ?: (defined(self::OPT_QDRANT_URL) ? constant(self::OPT_QDRANT_URL) : null); + return is_string($url) && $url !== '' ? rtrim($url, '/') : null; + } + + /** + * Optional Qdrant API key (if your instance enforces auth). + * + * @return string|null + */ + private static function qdrant_api_key(): ?string + { + $key = getenv(self::OPT_QDRANT_API_KEY) ?: (defined(self::OPT_QDRANT_API_KEY) ? constant(self::OPT_QDRANT_API_KEY) : null); + return is_string($key) && $key !== '' ? $key : null; + } + + /** + * Per-model collection name (sanitize to avoid invalid chars). + * + * @param string $model + * @return string + */ + private static function qdrant_collection(string $model): string + { + $name = 'secrets_' . preg_replace('/[^a-z0-9]+/i', '_', $model); + /** @var string $name */ + $name = apply_filters('psai/embedding/qdrant-collection', $name, $model); + return $name; + } + + /** + * Upsert a single point into Qdrant (best-effort). + * + * @param int $secret_id + * @param string $model + * @param array $vector + * @param array $payload + * @return void + */ + private static function qdrant_upsert(int $secret_id, string $model, array $vector, array $payload = []): void + { + $base = self::qdrant_url(); + if ($base === null) { + return; + } + + // Ensure collection exists (cached to avoid repeated calls) + $collection = self::qdrant_collection($model); + self::qdrant_ensure_collection($collection, count($vector)); + + $body = [ + 'points' => [[ + 'id' => $secret_id, // stable integer id + 'vector' => array_values($vector), + 'payload' => array_merge($payload, [ + 'secret_id' => $secret_id, + 'model_version' => $model, + ]), + ]], + ]; + + self::qdrant_request('PUT', "/collections/{$collection}/points?wait=true", $body, self::HTTP_TIMEOUT_QDRANT); + } + + /** + * Ensure Qdrant collection exists; create if missing. + * + * @param string $collection + * @param int $dim + * @return void + */ + private static function qdrant_ensure_collection(string $collection, int $dim): void + { + $cache_key = "psai_qdrant_has_{$collection}"; + if (get_transient($cache_key)) { + return; + } + + $exists = self::qdrant_request('GET', "/collections/{$collection}", null, 5); + $ok = is_array($exists) && (($exists['status'] ?? '') === 'ok'); + + if (!$ok) { + $create = [ + 'vectors' => ['size' => $dim, 'distance' => 'Cosine'], + 'hnsw_config' => ['m' => 16, 'ef_construct' => 200], + 'optimizers_config' => ['default_segment_number' => 2], + ]; + self::qdrant_request('PUT', "/collections/{$collection}", $create, 20); + } + + set_transient($cache_key, 1, HOUR_IN_SECONDS); + } + + /** + * Minimal Qdrant HTTP client wrapper with optional API key header. + * + * @param string $method + * @param string $path + * @param array|null $body + * @param int $timeout + * @return array|null + */ + private static function qdrant_request(string $method, string $path, ?array $body = null, int $timeout = self::HTTP_TIMEOUT_QDRANT): ?array + { + $base = self::qdrant_url(); + if ($base === null) { + return null; + } + + $headers = [ + 'Content-Type' => 'application/json', + 'User-Agent' => self::HTTP_USER_AGENT, + ]; + + $apiKey = self::qdrant_api_key(); + if ($apiKey !== null) { + // Qdrant supports multiple auth mechanisms; "api-key" is common for cloud/self-hosted. + $headers['api-key'] = $apiKey; + } + + $args = [ + 'method' => $method, + 'headers' => $headers, + 'timeout' => $timeout, + ]; + + if ($body !== null) { + $args['body'] = wp_json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + $url = $base . $path; + $res = wp_remote_request($url, $args); + + if (is_wp_error($res)) { + error_log('[EmbeddingService] Qdrant HTTP error: ' . $res->get_error_message()); + return null; + } + + $code = (int)wp_remote_retrieve_response_code($res); + $raw = (string)wp_remote_retrieve_body($res); + + if ($code >= 300) { + error_log("[EmbeddingService] Qdrant HTTP {$code}: " . substr($raw, 0, 300)); + return null; + } + + $json = json_decode($raw, true); + return is_array($json) ? $json : null; + } + + // === MySQL brute-force fallback (legacy) ================================= + + /** + * Brute-force cosine similarity over stored embeddings for the same model. + * Note: This is intended only as a compatibility fallback. + * + * @param int $secret_id + * @param int $limit + * @param float $min_similarity + * @return array + */ + private static function find_similar_mysql(int $secret_id, int $limit, float $min_similarity): array { global $wpdb; @@ -253,115 +567,111 @@ public static function find_similar(int $secret_id, int $limit = 10, float $min_ return []; } - $table_name = $wpdb->prefix . 'ps_text_embeddings'; + $table = $wpdb->prefix . 'ps_text_embeddings'; + $model = (string)$source['model_version']; - // Get all embeddings (except source) - // For large datasets, you'd want to use a vector DB or approximate NN + // Pull embeddings for same model; exclude self. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT secret_id, embedding FROM $table_name WHERE secret_id != %d AND model_version = %s", + "SELECT secret_id, embedding FROM {$table} WHERE secret_id != %d AND model_version = %s", $secret_id, - $source['model_version'] + $model ), ARRAY_A ); - $source_vector = $source['embedding']; - $results = []; + if (!is_array($rows) || $rows === []) { + return []; + } + + /** @var array $src */ + $src = array_map(static fn($v) => (float)$v, (array)$source['embedding']); + $res = []; foreach ($rows as $row) { - $target_vector = json_decode($row['embedding'], true); - $similarity = self::cosine_similarity($source_vector, $target_vector); + $vec = json_decode((string)$row['embedding'], true); + if (!is_array($vec)) { + continue; + } + /** @var array $vec */ + $vec = array_map(static fn($v) => (float)$v, $vec); - if ($similarity >= $min_similarity) { - $results[] = [ + $sim = self::cosine_similarity($src, $vec); + if ($sim >= $min_similarity) { + $res[] = [ 'secret_id' => (int)$row['secret_id'], - 'similarity' => round($similarity, 4), + 'similarity' => (float)round($sim, 4), ]; } } - // Sort by similarity descending - usort($results, fn($a, $b) => $b['similarity'] <=> $a['similarity']); - - return array_slice($results, 0, $limit); + usort($res, static fn($a, $b) => $b['similarity'] <=> $a['similarity']); + return array_slice($res, 0, $limit); } /** - * Calculate cosine similarity between two vectors. + * Cosine similarity between two equal-length vectors. * - * @param array $a First vector - * @param array $b Second vector - * @return float Similarity score (0.0-1.0) + * @param array $a + * @param array $b + * @return float */ private static function cosine_similarity(array $a, array $b): float { - if (count($a) !== count($b)) { + $na = count($a); + if ($na === 0 || $na !== count($b)) { return 0.0; } $dot = 0.0; - $mag_a = 0.0; - $mag_b = 0.0; - - for ($i = 0; $i < count($a); $i++) { - $dot += $a[$i] * $b[$i]; - $mag_a += $a[$i] * $a[$i]; - $mag_b += $b[$i] * $b[$i]; + $ma = 0.0; + $mb = 0.0; + + for ($i = 0; $i < $na; $i++) { + $ai = (float)$a[$i]; + $bi = (float)$b[$i]; + $dot += $ai * $bi; + $ma += $ai * $ai; + $mb += $bi * $bi; } - $mag_a = sqrt($mag_a); - $mag_b = sqrt($mag_b); - - if ($mag_a == 0 || $mag_b == 0) { + if ($ma <= 0.0 || $mb <= 0.0) { return 0.0; } - return $dot / ($mag_a * $mag_b); + return $dot / (sqrt($ma) * sqrt($mb)); } + // === Utilities ========================================================== + /** - * Delete embedding for a Secret. + * Service stats (row counts per model). * - * @param int $secret_id Attachment ID - * @return bool Success + * @return array{total:int,by_model:array} */ - public static function delete_embedding(int $secret_id): bool + public static function get_stats(): array { global $wpdb; + $table = $wpdb->prefix . 'ps_text_embeddings'; - $table_name = $wpdb->prefix . 'ps_text_embeddings'; - - $result = $wpdb->delete( - $table_name, - ['secret_id' => $secret_id], - ['%d'] - ); + $total = (int)$wpdb->get_var("SELECT COUNT(*) FROM {$table}"); + $models = $wpdb->get_results("SELECT model_version, COUNT(*) as count FROM {$table} GROUP BY model_version", ARRAY_A); - return $result !== false; + return [ + 'total' => $total, + 'by_model' => is_array($models) ? $models : [], + ]; } /** - * Get embedding statistics. + * Normalize whitespace and trim (helps reduce noisy input). * - * @return array Stats: total count, model versions, etc. + * @param string $s + * @return string */ - public static function get_stats(): array + private static function sanitize_space(string $s): string { - global $wpdb; - - $table_name = $wpdb->prefix . 'ps_text_embeddings'; - - $total = $wpdb->get_var("SELECT COUNT(*) FROM $table_name"); - - $models = $wpdb->get_results( - "SELECT model_version, COUNT(*) as count FROM $table_name GROUP BY model_version", - ARRAY_A - ); - - return [ - 'total' => (int)$total, - 'by_model' => $models, - ]; + $s = preg_replace('/\s+/u', ' ', $s ?? '') ?? ''; + return trim($s); } -} +} \ No newline at end of file diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php new file mode 100644 index 0000000..d66087e --- /dev/null +++ b/wp-content/plugins/postsecret-search/postsecret-search.php @@ -0,0 +1,21 @@ + 'public']) + * @return array|null Array of results or null if Qdrant unavailable + */ + public static function find_similar(int $secret_id, int $limit = 10, float $min_score = 0.5, array $filters = []): ?array + { + $base = self::qdrant_url(); + if (!$base) return null; + + // Get embedding from MySQL canonical store + $src = self::get_embedding($secret_id); + if (!$src) return []; + + $collection = self::qdrant_collection($src['model_version']); + + // Build Qdrant filter + $must = []; + foreach ($filters as $k => $v) { + if (is_array($v)) { // array match + $must[] = ['key' => $k, 'values_count' => ['gte' => 1], 'should' => array_map(fn($vv) => ['key' => $k, 'match' => ['value' => $vv]], $v)]; + } else { + $must[] = ['key' => $k, 'match' => ['value' => $v]]; + } + } + // Exclude self + $must[] = ['key' => 'secret_id', 'match' => ['except' => [$secret_id]]]; + + $body = [ + 'vector' => array_values($src['embedding']), + 'top' => max(1, $limit + 3), // over-fetch a bit then trim + 'filter' => $must ? ['must' => $must] : null, + 'params' => ['hnsw_ef' => 96], // trade recall/latency during dev + 'score_threshold' => max(0.0, min(1.0, $min_score)), + ]; + + $res = self::qdrant_request('POST', "/collections/{$collection}/points/search", $body, 10); + if (!$res || ($res['status'] ?? '') !== 'ok') return null; + + $hits = $res['result'] ?? []; + + $out = []; + foreach ($hits as $h) { + $id = (int)($h['id'] ?? 0); + if (!$id || $id === $secret_id) continue; + $score = (float)($h['score'] ?? 0.0); + if ($score < $min_score) continue; + $out[] = ['secret_id' => $id, 'similarity' => round($score, 4)]; + if (count($out) >= $limit) break; + } + return $out; + } + + /** + * MySQL fallback for similarity search (brute-force cosine). + * + * @param int $secret_id + * @param int $limit + * @param float $min_similarity + * @return array + */ + public static function find_similar_mysql(int $secret_id, int $limit, float $min_similarity): array + { + global $wpdb; + + $source = self::get_embedding($secret_id); + if (!$source) return []; + + $table = $wpdb->prefix . 'ps_text_embeddings'; + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT secret_id, embedding FROM $table WHERE secret_id != %d AND model_version = %s", + $secret_id, + $source['model_version'] + ), + ARRAY_A + ); + + $src = $source['embedding']; + $res = []; + foreach ($rows as $row) { + $vec = json_decode($row['embedding'], true); + $sim = self::cosine_similarity($src, $vec); + if ($sim >= $min_similarity) { + $res[] = ['secret_id' => (int)$row['secret_id'], 'similarity' => round($sim, 4)]; + } + } + usort($res, fn($a, $b) => $b['similarity'] <=> $a['similarity']); + return array_slice($res, 0, $limit); + } + + // === Internal Helpers === + + private static function get_embedding(int $secret_id): ?array + { + global $wpdb; + $table = $wpdb->prefix . 'ps_text_embeddings'; + + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM $table WHERE secret_id = %d", $secret_id), + ARRAY_A + ); + if (!$row) return null; + $row['embedding'] = json_decode($row['embedding'], true); + return $row; + } + + private static function qdrant_url(): ?string + { + $url = getenv('PS_QDRANT_URL') ?: (defined('PS_QDRANT_URL') ? PS_QDRANT_URL : null); + return $url ?: null; + } + + private static function qdrant_collection(string $model): string + { + // Keep separate collections per model for easier swaps + return 'secrets_' . preg_replace('/[^a-z0-9]+/i', '_', $model); + } + + private static function qdrant_request(string $method, string $path, ?array $body = null, int $timeout = 10): ?array + { + $base = self::qdrant_url(); + if (!$base) return null; + + $args = [ + 'method' => $method, + 'headers' => ['Content-Type' => 'application/json'], + 'timeout' => $timeout, + ]; + if ($body !== null) $args['body'] = wp_json_encode($body); + + $res = wp_remote_request(rtrim($base, '/') . $path, $args); + if (is_wp_error($res)) { + error_log('Qdrant http error: ' . $res->get_error_message()); + return null; + } + + $code = wp_remote_retrieve_response_code($res); + $raw = wp_remote_retrieve_body($res); + if ($code >= 300) { + error_log("Qdrant HTTP {$code}: " . substr($raw, 0, 300)); + return null; + } + + $json = json_decode($raw, true); + return is_array($json) ? $json : null; + } + + private static function cosine_similarity(array $a, array $b): float + { + if (count($a) !== count($b)) return 0.0; + $dot = 0.0; + $ma = 0.0; + $mb = 0.0; + $n = count($a); + for ($i = 0; $i < $n; $i++) { + $ai = (float)$a[$i]; + $bi = (float)$b[$i]; + $dot += $ai * $bi; + $ma += $ai * $ai; + $mb += $bi * $bi; + } + if ($ma == 0.0 || $mb == 0.0) return 0.0; + return $dot / (sqrt($ma) * sqrt($mb)); + } +} From 2c3cc467063f39125b61750341f012a270f84502 Mon Sep 17 00:00:00 2001 From: Flatts Date: Thu, 2 Oct 2025 15:24:16 -0400 Subject: [PATCH 3/6] refactor: enhance QdrantSearchService with better filters, error handling, and type safety Improved QdrantSearchService by introducing stricter typing, a refined filter schema for Qdrant queries, and defensive error handling. Added MySQL fallback enhancements, over-fetching logic, and extensibility hooks for query customization. Updated methods for better modularity, readability, and adherence to coding standards. --- .../src/QdrantSearchService.php | 338 ++++++++++++++---- 1 file changed, 274 insertions(+), 64 deletions(-) diff --git a/wp-content/plugins/postsecret-search/src/QdrantSearchService.php b/wp-content/plugins/postsecret-search/src/QdrantSearchService.php index 6a0319f..ad36506 100644 --- a/wp-content/plugins/postsecret-search/src/QdrantSearchService.php +++ b/wp-content/plugins/postsecret-search/src/QdrantSearchService.php @@ -1,65 +1,122 @@ 'public']) - * @return array|null Array of results or null if Qdrant unavailable + * @param int $secret_id Source secret. + * @param int $limit Max results to return (trimmed after fetch). + * @param float $min_score Minimum similarity score (0..1). + * @param array $filters Optional payload filters, e.g. ['status' => 'public', 'topics' => ['grief','family']]. + * @return array|null */ public static function find_similar(int $secret_id, int $limit = 10, float $min_score = 0.5, array $filters = []): ?array { $base = self::qdrant_url(); - if (!$base) return null; + if ($base === null) { + return null; // Qdrant not configured + } - // Get embedding from MySQL canonical store + // Fetch canonical embedding from MySQL. $src = self::get_embedding($secret_id); - if (!$src) return []; + if (!$src || empty($src['embedding']) || !is_array($src['embedding'])) { + return []; + } - $collection = self::qdrant_collection($src['model_version']); + $collection = self::qdrant_collection((string)$src['model_version']); - // Build Qdrant filter - $must = []; - foreach ($filters as $k => $v) { - if (is_array($v)) { // array match - $must[] = ['key' => $k, 'values_count' => ['gte' => 1], 'should' => array_map(fn($vv) => ['key' => $k, 'match' => ['value' => $vv]], $v)]; - } else { - $must[] = ['key' => $k, 'match' => ['value' => $v]]; - } - } - // Exclude self - $must[] = ['key' => 'secret_id', 'match' => ['except' => [$secret_id]]]; + // Build Qdrant filter: translate simple associative array -> Qdrant "must/should" predicates. + $filter = self::build_qdrant_filter($filters, $secret_id); + + // Slightly over-fetch to allow trimming after thresholding. + $top = max(1, (int)$limit + 3); + $scoreThreshold = max(0.0, min(1.0, (float)$min_score)); $body = [ - 'vector' => array_values($src['embedding']), - 'top' => max(1, $limit + 3), // over-fetch a bit then trim - 'filter' => $must ? ['must' => $must] : null, - 'params' => ['hnsw_ef' => 96], // trade recall/latency during dev - 'score_threshold' => max(0.0, min(1.0, $min_score)), + 'vector' => array_values(array_map(static fn($v) => (float)$v, (array)$src['embedding'])), + 'top' => $top, + 'filter' => $filter, + 'params' => ['hnsw_ef' => 96], // Balanced recall/latency during development. + 'score_threshold' => $scoreThreshold, ]; - $res = self::qdrant_request('POST', "/collections/{$collection}/points/search", $body, 10); - if (!$res || ($res['status'] ?? '') !== 'ok') return null; + /** + * Allow callers to adjust raw Qdrant search body before the request. + * + * @param array $body + * @param string $collection + * @param int $secret_id + */ + $body = apply_filters('psai/qdrant/search_body', $body, $collection, $secret_id); + + $res = self::qdrant_request('POST', "/collections/{$collection}/points/search", $body, self::HTTP_TIMEOUT_QDRANT); + if (!$res || ($res['status'] ?? '') !== 'ok') { + // If Qdrant is configured but failing, treat as unavailable to allow caller fallback. + return null; + } $hits = $res['result'] ?? []; + if (!is_array($hits) || $hits === []) { + return []; + } $out = []; foreach ($hits as $h) { - $id = (int)($h['id'] ?? 0); - if (!$id || $id === $secret_id) continue; + $id = isset($h['id']) ? (int)$h['id'] : 0; + if ($id <= 0 || $id === $secret_id) { + continue; + } $score = (float)($h['score'] ?? 0.0); - if ($score < $min_score) continue; - $out[] = ['secret_id' => $id, 'similarity' => round($score, 4)]; - if (count($out) >= $limit) break; + if ($score < $scoreThreshold) { + continue; + } + $out[] = [ + 'secret_id' => $id, + 'similarity' => (float)round($score, 4), + ]; + if (count($out) >= $limit) { + break; + } } + return $out; } @@ -69,89 +126,227 @@ public static function find_similar(int $secret_id, int $limit = 10, float $min_ * @param int $secret_id * @param int $limit * @param float $min_similarity - * @return array + * @return array */ public static function find_similar_mysql(int $secret_id, int $limit, float $min_similarity): array { global $wpdb; $source = self::get_embedding($secret_id); - if (!$source) return []; + if (!$source || empty($source['embedding']) || !is_array($source['embedding'])) { + return []; + } $table = $wpdb->prefix . 'ps_text_embeddings'; + $model = (string)$source['model_version']; + // Same-model comparisons only; exclude self. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT secret_id, embedding FROM $table WHERE secret_id != %d AND model_version = %s", + "SELECT secret_id, embedding FROM {$table} WHERE secret_id != %d AND model_version = %s", $secret_id, - $source['model_version'] + $model ), ARRAY_A ); - $src = $source['embedding']; + if (!is_array($rows) || $rows === []) { + return []; + } + + /** @var array $src */ + $src = array_map(static fn($v) => (float)$v, (array)$source['embedding']); + $res = []; foreach ($rows as $row) { - $vec = json_decode($row['embedding'], true); + $vecRaw = json_decode((string)($row['embedding'] ?? '[]'), true); + if (!is_array($vecRaw)) { + continue; + } + /** @var array $vec */ + $vec = array_map(static fn($v) => (float)$v, $vecRaw); + $sim = self::cosine_similarity($src, $vec); if ($sim >= $min_similarity) { - $res[] = ['secret_id' => (int)$row['secret_id'], 'similarity' => round($sim, 4)]; + $res[] = [ + 'secret_id' => (int)$row['secret_id'], + 'similarity' => (float)round($sim, 4), + ]; } } - usort($res, fn($a, $b) => $b['similarity'] <=> $a['similarity']); - return array_slice($res, 0, $limit); + + usort($res, static fn($a, $b) => $b['similarity'] <=> $a['similarity']); + return array_slice($res, 0, max(0, (int)$limit)); } - // === Internal Helpers === + // === Internal Helpers =================================================== + /** + * Fetch canonical embedding record for a Secret. + * + * @param int $secret_id + * @return array|null + */ private static function get_embedding(int $secret_id): ?array { global $wpdb; $table = $wpdb->prefix . 'ps_text_embeddings'; + /** @var array|null $row */ $row = $wpdb->get_row( - $wpdb->prepare("SELECT * FROM $table WHERE secret_id = %d", $secret_id), + $wpdb->prepare("SELECT * FROM {$table} WHERE secret_id = %d", $secret_id), ARRAY_A ); - if (!$row) return null; - $row['embedding'] = json_decode($row['embedding'], true); + + if (!$row) { + return null; + } + + $decoded = json_decode((string)($row['embedding'] ?? '[]'), true); + $row['embedding'] = is_array($decoded) ? $decoded : []; + return $row; } + /** + * Build a Qdrant filter object from simple associative filters. + * + * Supports: + * - Scalar equality: ['status' => 'public'] => must match + * - Array "OR": ['topics' => ['grief','family']] => should of matches on the same key + * Also excludes the source vector by id. + * + * @param array $filters + * @param int $excludeId + * @return array|null + */ + private static function build_qdrant_filter(array $filters, int $excludeId): ?array + { + $must = []; + $should = []; + + foreach ($filters as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (is_array($value)) { + // OR semantics across provided values for the same key. + $value = array_values(array_filter($value, static fn($v) => $v !== null && $v !== '')); + if ($value === []) { + continue; + } + $shouldMatches = array_map( + static fn($vv) => ['key' => $key, 'match' => ['value' => $vv]], + $value + ); + // Group into a single should clause; Qdrant treats multiple should as OR. + $should = array_merge($should, $shouldMatches); + } else { + $must[] = ['key' => $key, 'match' => ['value' => $value]]; + } + } + + $filter = []; + if ($must !== []) { + $filter['must'] = $must; + } + if ($should !== []) { + $filter['should'] = $should; + } + + // Exclude the source point. + $filter['must_not'] = [ + ['has_id' => ['values' => [$excludeId]]], + ]; + + return $filter === [] ? null : $filter; + } + + /** + * Resolve Qdrant base URL from env/constant. + * + * @return string|null + */ private static function qdrant_url(): ?string { - $url = getenv('PS_QDRANT_URL') ?: (defined('PS_QDRANT_URL') ? PS_QDRANT_URL : null); - return $url ?: null; + $url = getenv(self::OPT_QDRANT_URL) ?: (defined(self::OPT_QDRANT_URL) ? constant(self::OPT_QDRANT_URL) : null); + return is_string($url) && $url !== '' ? rtrim($url, '/') : null; } + /** + * Optional Qdrant API key (if instance enforces auth). + * + * @return string|null + */ + private static function qdrant_api_key(): ?string + { + $key = getenv(self::OPT_QDRANT_API_KEY) ?: (defined(self::OPT_QDRANT_API_KEY) ? constant(self::OPT_QDRANT_API_KEY) : null); + return is_string($key) && $key !== '' ? $key : null; + } + + /** + * Derive per-model collection name (sanitized). + * + * @param string $model + * @return string + */ private static function qdrant_collection(string $model): string { - // Keep separate collections per model for easier swaps - return 'secrets_' . preg_replace('/[^a-z0-9]+/i', '_', $model); + $name = 'secrets_' . preg_replace('/[^a-z0-9]+/i', '_', $model); + /** @var string $name */ + $name = apply_filters('psai/qdrant/collection', $name, $model); + return $name; } - private static function qdrant_request(string $method, string $path, ?array $body = null, int $timeout = 10): ?array + /** + * Minimal Qdrant HTTP client wrapper with optional API key header. + * + * @param string $method + * @param string $path + * @param array|null $body + * @param int $timeout + * @return array|null + */ + private static function qdrant_request(string $method, string $path, ?array $body = null, int $timeout = self::HTTP_TIMEOUT_QDRANT): ?array { $base = self::qdrant_url(); - if (!$base) return null; + if ($base === null) { + return null; + } + + $headers = [ + 'Content-Type' => 'application/json', + 'User-Agent' => self::HTTP_USER_AGENT, + ]; + + $apiKey = self::qdrant_api_key(); + if ($apiKey !== null) { + $headers['api-key'] = $apiKey; + } $args = [ 'method' => $method, - 'headers' => ['Content-Type' => 'application/json'], + 'headers' => $headers, 'timeout' => $timeout, ]; - if ($body !== null) $args['body'] = wp_json_encode($body); - $res = wp_remote_request(rtrim($base, '/') . $path, $args); + if ($body !== null) { + $args['body'] = wp_json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + $url = $base . $path; + $res = wp_remote_request($url, $args); + if (is_wp_error($res)) { - error_log('Qdrant http error: ' . $res->get_error_message()); + error_log('[QdrantSearchService] HTTP error: ' . $res->get_error_message()); return null; } - $code = wp_remote_retrieve_response_code($res); - $raw = wp_remote_retrieve_body($res); + $code = (int)wp_remote_retrieve_response_code($res); + $raw = (string)wp_remote_retrieve_body($res); + if ($code >= 300) { - error_log("Qdrant HTTP {$code}: " . substr($raw, 0, 300)); + error_log("[QdrantSearchService] HTTP {$code}: " . substr($raw, 0, 300)); return null; } @@ -159,21 +354,36 @@ private static function qdrant_request(string $method, string $path, ?array $bod return is_array($json) ? $json : null; } + /** + * Cosine similarity between two equal-length vectors. + * + * @param array $a + * @param array $b + * @return float + */ private static function cosine_similarity(array $a, array $b): float { - if (count($a) !== count($b)) return 0.0; + $na = count($a); + if ($na === 0 || $na !== count($b)) { + return 0.0; + } + $dot = 0.0; $ma = 0.0; $mb = 0.0; - $n = count($a); - for ($i = 0; $i < $n; $i++) { + + for ($i = 0; $i < $na; $i++) { $ai = (float)$a[$i]; $bi = (float)$b[$i]; $dot += $ai * $bi; $ma += $ai * $ai; $mb += $bi * $bi; } - if ($ma == 0.0 || $mb == 0.0) return 0.0; + + if ($ma <= 0.0 || $mb <= 0.0) { + return 0.0; + } + return $dot / (sqrt($ma) * sqrt($mb)); } -} +} \ No newline at end of file From f6812383add53290e64679887edd151813a691f8 Mon Sep 17 00:00:00 2001 From: Flatts Date: Thu, 2 Oct 2025 16:28:14 -0400 Subject: [PATCH 4/6] feat: introduce semantic search with enhanced frontend and API integration Integrated a new semantic search interface in the theme using dynamic query embedding, responsive design, and infinite scrolling for results. Added REST API endpoints via the `PostSecret Search` plugin to handle embedding-based searches. Enhanced EmbeddingService with query embedding and immediate indexing support. Updated Docker configurations and templates for streamlined deployment and setup. --- docker-compose.yml | 1 + .../postsecret-ai/src/EmbeddingService.php | 33 +- .../postsecret-feed/postsecret-feed.php | 4 +- .../postsecret-search/postsecret-search.php | 148 ++++++- .../src/QdrantSearchService.php | 94 ++++- .../postsecret/assets/css/semantic-search.css | 213 ++++++++++ .../postsecret/assets/js/semantic-search.js | 374 ++++++++++++++++++ wp-content/themes/postsecret/functions.php | 28 +- .../themes/postsecret/parts/header.html | 22 +- wp-content/themes/postsecret/search.php | 84 ++-- 10 files changed, 953 insertions(+), 48 deletions(-) create mode 100644 wp-content/themes/postsecret/assets/css/semantic-search.css create mode 100644 wp-content/themes/postsecret/assets/js/semantic-search.js diff --git a/docker-compose.yml b/docker-compose.yml index 861afad..226a2dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: WP_SITEURL: http://localhost:8080 WORDPRESS_DEBUG: 1 PS_QDRANT_URL: http://qdrant:6333 + OPENAI_API_KEY: ${OPENAI_API_KEY} WORDPRESS_CONFIG_EXTRA: | define('WP_DEBUG_LOG', true); define('WP_DEBUG_DISPLAY', false); diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php index 85d8069..48d4d5e 100644 --- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php +++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php @@ -482,7 +482,10 @@ private static function qdrant_ensure_collection(string $collection, int $dim): $create = [ 'vectors' => ['size' => $dim, 'distance' => 'Cosine'], 'hnsw_config' => ['m' => 16, 'ef_construct' => 200], - 'optimizers_config' => ['default_segment_number' => 2], + 'optimizers_config' => [ + 'default_segment_number' => 2, + 'indexing_threshold' => 0, // Index immediately on every change (good for dev) + ], ]; self::qdrant_request('PUT', "/collections/{$collection}", $create, 20); } @@ -644,6 +647,34 @@ private static function cosine_similarity(array $a, array $b): float // === Utilities ========================================================== + /** + * Generate embedding for a search query (no storage). + * + * @param string $query User search query text. + * @param string $api_key OpenAI API key. + * @param string $model Embedding model ID. + * @return array|null Normalized embedding vector or null on failure. + */ + public static function generate_query_embedding(string $query, string $api_key, string $model = 'text-embedding-3-small'): ?array + { + try { + $query = trim($query); + if ($query === '') { + return null; + } + + $embedding = self::generate_embedding($api_key, $model, $query); + if ($embedding === null) { + return null; + } + + return self::normalize_vector($embedding); + } catch (\Throwable $e) { + error_log('[EmbeddingService] Query embedding error: ' . $e->getMessage()); + return null; + } + } + /** * Service stats (row counts per model). * diff --git a/wp-content/plugins/postsecret-feed/postsecret-feed.php b/wp-content/plugins/postsecret-feed/postsecret-feed.php index 9fea6bf..612fc1e 100644 --- a/wp-content/plugins/postsecret-feed/postsecret-feed.php +++ b/wp-content/plugins/postsecret-feed/postsecret-feed.php @@ -92,8 +92,8 @@ // Front-page stream (only on home) add_action('wp_enqueue_scripts', function () { - // load wherever you want — front page only once this works - if (is_admin()) return; + // Don't load on admin or search pages + if (is_admin() || is_search()) return; $handle = 'psai-stream'; wp_register_script($handle, plugins_url('assets/psai-stream.js', __FILE__), ['mustache'], null, true); diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php index d66087e..c219128 100644 --- a/wp-content/plugins/postsecret-search/postsecret-search.php +++ b/wp-content/plugins/postsecret-search/postsecret-search.php @@ -12,10 +12,148 @@ require_once __DIR__ . '/src/QdrantSearchService.php'; -// Plugin initialization hook -add_action('plugins_loaded', __NAMESPACE__ . '\\init'); +// Register REST endpoints directly +add_action('rest_api_init', function() { + \PSSearch\register_rest_routes(); +}); -function init() { - // Service is stateless, no initialization needed - // Future: register REST endpoints, admin UI, etc. +/** + * Register REST API routes for semantic search. + */ +function register_rest_routes() { + // Test endpoint to verify plugin is loaded + register_rest_route('psai/v1', '/search-test', [ + 'methods' => 'GET', + 'permission_callback' => '__return_true', + 'callback' => function() { + return new \WP_REST_Response(['status' => 'ok', 'message' => 'Search plugin loaded'], 200); + }, + ]); + + // POST /wp-json/psai/v1/semantic-search + register_rest_route('psai/v1', '/semantic-search', [ + 'methods' => 'POST', + 'permission_callback' => '__return_true', + 'args' => [ + 'query' => [ + 'type' => 'string', + 'required' => true, + 'validate_callback' => function($param) { + return is_string($param) && strlen(trim($param)) >= 3; + }, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'limit' => [ + 'type' => 'integer', + 'default' => 24, + 'minimum' => 1, + 'maximum' => 60, + ], + 'min_score' => [ + 'type' => 'number', + 'default' => 0.5, + 'minimum' => 0.0, + 'maximum' => 1.0, + ], + ], + 'callback' => __NAMESPACE__ . '\\handle_semantic_search', + ]); +} + +/** + * Handle semantic search request. + * + * @param \WP_REST_Request $request + * @return \WP_REST_Response|\WP_Error + */ +function handle_semantic_search(\WP_REST_Request $request) { + $query = trim($request->get_param('query')); + $limit = (int)$request->get_param('limit'); + $min_score = (float)$request->get_param('min_score'); + + // Get OpenAI API key + $api_key = getenv('OPENAI_API_KEY') ?: (defined('OPENAI_API_KEY') ? constant('OPENAI_API_KEY') : null); + if (!$api_key) { + return new \WP_Error('no_api_key', 'OpenAI API key not configured', ['status' => 500]); + } + + // Generate embedding for query using EmbeddingService + if (!class_exists('PSAI\\EmbeddingService')) { + return new \WP_Error('missing_dependency', 'EmbeddingService not available', ['status' => 500]); + } + + $model = 'text-embedding-3-small'; + $embedding = \PSAI\EmbeddingService::generate_query_embedding($query, $api_key, $model); + + if ($embedding === null) { + return new \WP_Error('embedding_failed', 'Failed to generate embedding for query', ['status' => 500]); + } + + // Search using Qdrant + $filters = []; // No filters for now - add ['status' => 'public'] when ready + $results = QdrantSearchService::search_by_vector($embedding, $model, $limit, $min_score, $filters); + + if ($results === null) { + return new \WP_Error('search_failed', 'Vector search unavailable', ['status' => 503]); + } + + if (empty($results)) { + return new \WP_REST_Response([ + 'query' => $query, + 'total' => 0, + 'items' => [], + ], 200); + } + + // Fetch full secret data for results + $items = []; + foreach ($results as $result) { + $secret_id = $result['secret_id']; + $similarity = $result['similarity']; + + // Get attachment data + $src = wp_get_attachment_image_src($secret_id, 'secret-card'); + if (!$src) { + $src = [wp_get_attachment_url($secret_id), 0, 0, true]; + } + + // Get back side data if exists + $back_id = (int)(get_post_meta($secret_id, '_ps_pair_id', true) ?: 0) ?: null; + $back_src = null; + $back_alt = null; + if ($back_id) { + $back_image = wp_get_attachment_image_src($back_id, 'secret-card'); + if ($back_image) { + $back_src = $back_image[0]; + } else { + $back_src = wp_get_attachment_url($back_id); + } + $back_alt = get_post_meta($back_id, '_wp_attachment_image_alt', true) ?: ''; + } + + $items[] = [ + 'id' => $secret_id, + 'similarity' => $similarity, + 'src' => $src[0], + 'width' => (int)$src[1], + 'height' => (int)$src[2], + 'alt' => get_post_meta($secret_id, '_wp_attachment_image_alt', true) ?: '', + 'caption' => get_post_field('post_excerpt', $secret_id) ?: '', + 'excerpt' => get_post_field('post_content', $secret_id) ?: '', + 'date' => get_post_datetime($secret_id)?->format('c'), + 'tags' => array_values((array)get_post_meta($secret_id, '_ps_tags', true) ?: []), + 'primary' => get_post_meta($secret_id, '_ps_primary_hex', true) ?: '', + 'orientation' => get_post_meta($secret_id, '_ps_orientation', true) ?: '', + 'back_id' => $back_id, + 'back_src' => $back_src, + 'back_alt' => $back_alt, + 'link' => get_attachment_link($secret_id), + ]; + } + + return new \WP_REST_Response([ + 'query' => $query, + 'total' => count($items), + 'items' => $items, + ], 200); } diff --git a/wp-content/plugins/postsecret-search/src/QdrantSearchService.php b/wp-content/plugins/postsecret-search/src/QdrantSearchService.php index ad36506..b99d650 100644 --- a/wp-content/plugins/postsecret-search/src/QdrantSearchService.php +++ b/wp-content/plugins/postsecret-search/src/QdrantSearchService.php @@ -120,6 +120,74 @@ public static function find_similar(int $secret_id, int $limit = 10, float $min_ return $out; } + /** + * Search by embedding vector directly (for query embeddings). + * + * @param array $vector Query embedding vector. + * @param string $model Model version (for collection selection). + * @param int $limit Max results to return. + * @param float $min_score Minimum similarity score (0..1). + * @param array $filters Optional payload filters. + * @return array|null + */ + public static function search_by_vector(array $vector, string $model, int $limit = 10, float $min_score = 0.5, array $filters = []): ?array + { + $base = self::qdrant_url(); + if ($base === null) { + return null; + } + + $collection = self::qdrant_collection($model); + + // Build filter (no need to exclude source since this is a fresh query) + $filter = self::build_qdrant_filter_query($filters); + + $top = max(1, (int)$limit + 3); + $scoreThreshold = max(0.0, min(1.0, (float)$min_score)); + + $body = [ + 'vector' => array_values(array_map(static fn($v) => (float)$v, $vector)), + 'top' => $top, + 'filter' => $filter, + 'params' => ['hnsw_ef' => 96], + 'score_threshold' => $scoreThreshold, + ]; + + /** @var array $body */ + $body = apply_filters('psai/qdrant/query_search_body', $body, $collection); + + $res = self::qdrant_request('POST', "/collections/{$collection}/points/search", $body, self::HTTP_TIMEOUT_QDRANT); + if (!$res || ($res['status'] ?? '') !== 'ok') { + return null; + } + + $hits = $res['result'] ?? []; + if (!is_array($hits) || $hits === []) { + return []; + } + + $out = []; + foreach ($hits as $h) { + $id = isset($h['id']) ? (int)$h['id'] : 0; + if ($id <= 0) { + continue; + } + $score = (float)($h['score'] ?? 0.0); + if ($score < $scoreThreshold) { + continue; + } + $out[] = [ + 'secret_id' => $id, + 'similarity' => (float)round($score, 4), + ]; + if (count($out) >= $limit) { + break; + } + } + + return $out; + } + /** * MySQL fallback for similarity search (brute-force cosine). * @@ -221,6 +289,27 @@ private static function get_embedding(int $secret_id): ?array * @return array|null */ private static function build_qdrant_filter(array $filters, int $excludeId): ?array + { + $filter = self::build_qdrant_filter_query($filters); + + // Exclude the source point. + if ($filter === null) { + $filter = []; + } + $filter['must_not'] = [ + ['has_id' => ['values' => [$excludeId]]], + ]; + + return $filter === [] ? null : $filter; + } + + /** + * Build a Qdrant filter object for queries (no exclusion). + * + * @param array $filters + * @return array|null + */ + private static function build_qdrant_filter_query(array $filters): ?array { $must = []; $should = []; @@ -254,11 +343,6 @@ private static function build_qdrant_filter(array $filters, int $excludeId): ?ar $filter['should'] = $should; } - // Exclude the source point. - $filter['must_not'] = [ - ['has_id' => ['values' => [$excludeId]]], - ]; - return $filter === [] ? null : $filter; } diff --git a/wp-content/themes/postsecret/assets/css/semantic-search.css b/wp-content/themes/postsecret/assets/css/semantic-search.css new file mode 100644 index 0000000..9658bdb --- /dev/null +++ b/wp-content/themes/postsecret/assets/css/semantic-search.css @@ -0,0 +1,213 @@ +/** + * Semantic Search Styles + */ + +/* Search Form in Header */ +.ps-semantic-search { + position: relative; +} + +.ps-search-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.ps-search-input { + padding: 0.5rem 2.5rem 0.5rem 1rem; + border: 1px solid var(--wp--preset--color--contrast-2, #ddd); + border-radius: 24px; + font-size: 0.95rem; + width: 240px; + transition: all 0.2s ease; +} + +.ps-search-input:focus { + outline: none; + border-color: var(--wp--preset--color--primary, #333); + width: 280px; +} + +.ps-search-button { + position: absolute; + right: 0.25rem; + background: transparent; + border: none; + padding: 0.5rem; + cursor: pointer; + color: var(--wp--preset--color--contrast, #333); + transition: color 0.2s ease; +} + +.ps-search-button:hover { + color: var(--wp--preset--color--primary, #000); +} + +.ps-search-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ps-search-error { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25rem; + padding: 0.5rem; + background: #fee; + color: #c33; + border-radius: 4px; + font-size: 0.875rem; + display: none; + white-space: nowrap; + z-index: 10; +} + +/* Search Results Page */ +.ps-search-header { + margin-bottom: 2rem; + text-align: center; + padding: 2rem 1rem; +} + +.ps-search-header h1 { + font-size: clamp(1.75rem, 3vw, 2.25rem); + margin-bottom: 0.5rem; +} + +.ps-search-count { + color: var(--wp--preset--color--contrast-2, #666); + font-size: 1rem; +} + +.ps-search-loading { + color: var(--wp--preset--color--contrast-2, #666); + font-size: 1.1rem; + margin-top: 1rem; +} + +.ps-search-loading i { + margin-right: 0.5rem; +} + +.ps-search-error-msg { + color: #c33; + font-size: 1.1rem; + margin: 1rem 0; +} + +.ps-button { + display: inline-block; + padding: 0.75rem 1.5rem; + background: var(--wp--preset--color--primary, #333); + color: white; + text-decoration: none; + border-radius: 4px; + margin-top: 1rem; + transition: background 0.2s ease; +} + +.ps-button:hover { + background: var(--wp--preset--color--primary-dark, #000); +} + +.ps-no-results { + grid-column: 1 / -1; + text-align: center; + padding: 3rem 1rem; +} + +.ps-no-results p { + font-size: 1.25rem; + color: var(--wp--preset--color--contrast-2, #666); + margin-bottom: 0.5rem; +} + +.ps-search-hint { + font-size: 1rem !important; + color: var(--wp--preset--color--contrast-3, #999) !important; +} + +/* Search Results - Single Column Layout for Reading */ +#ps-search-grid { + display: flex; + flex-direction: column; + gap: 2rem; + margin-top: 2rem; + max-width: 900px; + margin-left: auto; + margin-right: auto; +} + +#ps-search-grid .ps-card { + width: 100%; + max-width: none; +} + +#ps-search-grid .ps-card__img { + width: 100%; + height: auto; + object-fit: contain; +} + +/* Loading indicator for infinite scroll */ +.ps-search-loading-more { + text-align: center; + padding: 2rem; + color: var(--wp--preset--color--contrast-2, #666); +} + +.ps-search-loading-more i { + margin-right: 0.5rem; +} + +/* Similarity score badge */ +.ps-card__similarity { + display: inline-block; + margin-top: 0.5rem; + padding: 0.25rem 0.5rem; + background: var(--wp--preset--color--contrast-2, #eee); + color: var(--wp--preset--color--contrast, #333); + font-size: 0.75rem; + border-radius: 3px; + font-weight: 500; +} + +/* Sentinel element for intersection observer */ +.ps-search-sentinel { + visibility: hidden; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .ps-search-input { + width: 180px; + } + + .ps-search-input:focus { + width: 200px; + } + + #ps-search-grid { + gap: 1.5rem; + } +} + +@media (max-width: 480px) { + .ps-search-wrapper { + flex-direction: column; + align-items: stretch; + } + + .ps-search-input { + width: 100%; + } + + .ps-search-input:focus { + width: 100%; + } + + #ps-search-grid { + gap: 1rem; + } +} diff --git a/wp-content/themes/postsecret/assets/js/semantic-search.js b/wp-content/themes/postsecret/assets/js/semantic-search.js new file mode 100644 index 0000000..4ac9c6f --- /dev/null +++ b/wp-content/themes/postsecret/assets/js/semantic-search.js @@ -0,0 +1,374 @@ +/** + * Semantic Search Handler + * Handles search form submission and result display with infinite scroll and caching + */ +(function () { + 'use strict'; + + const MIN_QUERY_LENGTH = 3; + const SEARCH_ENDPOINT = window.location.origin + '/index.php?rest_route=/psai/v1/semantic-search'; + const ITEMS_PER_PAGE = 12; + const CACHE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes + + // Search state + let currentQuery = ''; + let allResults = []; + let displayedCount = 0; + let isLoading = false; + let observerSentinel = null; + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function init() { + const form = document.getElementById('ps-semantic-search-form'); + const input = document.getElementById('ps-search-input'); + const errorEl = document.querySelector('.ps-search-error'); + + if (form && input) { + // Validate on submit + form.addEventListener('submit', function (e) { + const query = input.value.trim(); + + // Validate minimum length + if (query.length < MIN_QUERY_LENGTH) { + e.preventDefault(); + showError(errorEl, `Please enter at least ${MIN_QUERY_LENGTH} characters`); + return; + } + + // Clear cache for this query to get fresh results + clearCacheForQuery(query); + + // Clear any previous errors and let form submit naturally + clearError(errorEl); + }); + + // Clear error on input + input.addEventListener('input', function () { + clearError(errorEl); + }); + } + + // Handle search results display if on results page + displayResults(); + } + + function showError(errorEl, message) { + if (!errorEl) return; + errorEl.textContent = message; + errorEl.style.display = 'block'; + } + + function clearError(errorEl) { + if (!errorEl) return; + errorEl.textContent = ''; + errorEl.style.display = 'none'; + } + + // Cache management + function getCacheKey(query) { + return `ps_search_${query.toLowerCase().trim()}`; + } + + function getCachedResults(query) { + try { + const key = getCacheKey(query); + const cached = sessionStorage.getItem(key); + if (!cached) return null; + + const data = JSON.parse(cached); + const age = Date.now() - data.timestamp; + + if (age > CACHE_EXPIRY_MS) { + sessionStorage.removeItem(key); + return null; + } + + return data.results; + } catch (e) { + console.error('Cache read error:', e); + return null; + } + } + + function setCachedResults(query, results) { + try { + const key = getCacheKey(query); + const data = { + results: results, + timestamp: Date.now() + }; + sessionStorage.setItem(key, JSON.stringify(data)); + } catch (e) { + console.error('Cache write error:', e); + } + } + + function clearCacheForQuery(query) { + try { + const key = getCacheKey(query); + sessionStorage.removeItem(key); + } catch (e) { + console.error('Cache clear error:', e); + } + } + + async function displayResults() { + // Check if we're on a search page with semantic search flag + const urlParams = new URLSearchParams(window.location.search); + const query = urlParams.get('s'); + const isSemantic = urlParams.get('semantic'); + + // Only run for semantic searches (has ?s= and &semantic=1 parameters) + if (!query || !isSemantic) { + return; + } + + currentQuery = query; + + // Find the main content area + const main = document.querySelector('main') || document.getElementById('primary'); + if (!main) return; + + // Check cache first + const cached = getCachedResults(query); + if (cached) { + console.log('Using cached results for:', query); + allResults = cached.items || []; + renderSearchResults(main, query, cached); + return; + } + + // Show loading state + main.innerHTML = ` +
+

Searching for: "${escapeHtml(query)}"

+

+ + Finding similar secrets... +

+
+ `; + + try { + // Fetch search results from API + const response = await fetch(SEARCH_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: query, + limit: 60, // Fetch more for infinite scroll + min_score: 0.15, + }), + }); + + // Check content type + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + console.error('Received non-JSON response:', contentType); + throw new Error('Server returned invalid response format'); + } + + if (!response.ok) { + const error = await response.json(); + console.error('API error:', error); + throw new Error(error.message || 'Search failed'); + } + + const data = await response.json(); + console.log('Search results:', data); + + // Cache the results + setCachedResults(query, data); + + // Store results for infinite scroll + allResults = data.items || []; + + // Render results + renderSearchResults(main, query, data); + + } catch (error) { + console.error('Search error:', error); + main.innerHTML = ` +
+

Search Error

+

Failed to load search results. Please try again.

+ Back to Home +
+ `; + } + } + + function renderSearchResults(container, query, data) { + // Reset state + displayedCount = 0; + + // Create results section + const section = document.createElement('div'); + section.id = 'ps-search-results'; + section.innerHTML = ` +
+

Search Results for: "${escapeHtml(query)}"

+

${data.total} secret${data.total !== 1 ? 's' : ''} found

+
+
+ `; + + container.innerHTML = ''; + container.appendChild(section); + + const grid = document.getElementById('ps-search-grid'); + if (!grid) return; + + // Handle empty results + if (data.total === 0) { + grid.innerHTML = ` +
+

No secrets found matching "${escapeHtml(query)}"

+

Try different words or feelings

+
+ `; + return; + } + + // Render initial batch + renderNextBatch(grid); + + // Set up infinite scroll if there are more results + if (allResults.length > ITEMS_PER_PAGE) { + setupInfiniteScroll(grid); + } + } + + function renderNextBatch(grid) { + const cardTemplate = document.getElementById('psai-card-tpl'); + const useMustache = cardTemplate && window.Mustache; + const template = useMustache ? cardTemplate.innerHTML : null; + + const start = displayedCount; + const end = Math.min(start + ITEMS_PER_PAGE, allResults.length); + + for (let i = start; i < end; i++) { + const item = allResults[i]; + + if (useMustache) { + const cardData = prepareCardData(item); + const html = window.Mustache.render(template, cardData); + grid.insertAdjacentHTML('beforeend', html); + } else { + renderSimpleCard(grid, item); + } + } + + displayedCount = end; + + // Update sentinel if exists + if (observerSentinel && displayedCount >= allResults.length) { + observerSentinel.remove(); + observerSentinel = null; + } + } + + function setupInfiniteScroll(grid) { + // Create sentinel element for intersection observer + const sentinel = document.createElement('div'); + sentinel.className = 'ps-search-sentinel'; + sentinel.style.height = '1px'; + grid.parentElement.appendChild(sentinel); + observerSentinel = sentinel; + + // Create intersection observer + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && !isLoading && displayedCount < allResults.length) { + isLoading = true; + + // Add loading indicator + let loadingEl = document.querySelector('.ps-search-loading-more'); + if (!loadingEl) { + loadingEl = document.createElement('div'); + loadingEl.className = 'ps-search-loading-more'; + loadingEl.innerHTML = ' Loading more...'; + grid.parentElement.appendChild(loadingEl); + } + + // Small delay for UX + setTimeout(() => { + renderNextBatch(grid); + isLoading = false; + + if (loadingEl) { + loadingEl.remove(); + } + }, 300); + } + }); + }, { + rootMargin: '400px' // Start loading before user reaches bottom + }); + + observer.observe(sentinel); + } + + function prepareCardData(item) { + const hasBack = !!item.back_src; + const displayTags = (item.tags || []).slice(0, 3); + const overflowCount = Math.max(0, (item.tags || []).length - 3); + const date = item.date ? new Date(item.date) : null; + const similarityPercent = item.similarity ? Math.round(item.similarity * 100) : 0; + + return { + id: item.id, + src: item.src, + width: item.width, + height: item.height, + alt: item.alt || 'Secret postcard', + altFallback: item.alt || item.excerpt || 'Secret postcard', + caption: item.caption, + excerpt: item.excerpt ? item.excerpt.substring(0, 140) + (item.excerpt.length > 140 ? '…' : '') : '', + dateFmt: date ? date.toLocaleDateString() : '', + displayTags: displayTags, + overflowCount: overflowCount > 0 ? overflowCount : null, + advisory: false, // Set based on content flags if available + primary: item.primary, + orientation: item.orientation, + hasBack: hasBack, + back_src: item.back_src, + back_alt: item.back_alt || 'Secret postcard (back)', + link: item.link, + similarity: item.similarity, + similarityPercent: similarityPercent > 0 ? similarityPercent : null, + }; + } + + function renderSimpleCard(grid, item) { + const card = document.createElement('article'); + card.className = 'ps-card'; + + // Format similarity score as percentage + const similarityPercent = item.similarity ? Math.round(item.similarity * 100) : 0; + + card.innerHTML = ` + + ${escapeHtml(item.alt || 'Secret')} + ${item.excerpt ? `

${escapeHtml(item.excerpt.substring(0, 140))}

` : ''} + ${item.similarity ? `${similarityPercent}% match` : ''} +
+ `; + grid.appendChild(card); + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + +})(); diff --git a/wp-content/themes/postsecret/functions.php b/wp-content/themes/postsecret/functions.php index dd17460..506e189 100644 --- a/wp-content/themes/postsecret/functions.php +++ b/wp-content/themes/postsecret/functions.php @@ -10,6 +10,14 @@ filemtime(get_stylesheet_directory() . '/assets/css/ps-stream.css') ); + // Semantic search styles + wp_enqueue_style( + 'ps-semantic-search', + get_stylesheet_directory_uri() . '/assets/css/semantic-search.css', + [], + filemtime(get_stylesheet_directory() . '/assets/css/semantic-search.css') + ); + // Mustache (templating) — keep it global so any template can use it wp_enqueue_script( 'mustache', @@ -28,6 +36,15 @@ true ); + // Semantic search script (global - needed for header search bar) + wp_enqueue_script( + 'ps-semantic-search', + get_stylesheet_directory_uri() . '/assets/js/semantic-search.js', + ['mustache'], + filemtime(get_stylesheet_directory() . '/assets/js/semantic-search.js'), + true + ); + // Secret metadata script (only on single secret pages) if (is_singular('secret')) { wp_enqueue_script( @@ -73,17 +90,6 @@ } } - // Search enhancements script (on search pages) - if (is_search()) { - wp_enqueue_script( - 'ps-search-enhancements', - get_stylesheet_directory_uri() . '/search-enhancements.js', - [], - null, - true - ); - } - // Font Awesome 6 wp_enqueue_style( 'fa6', diff --git a/wp-content/themes/postsecret/parts/header.html b/wp-content/themes/postsecret/parts/header.html index a7a936c..b52276a 100644 --- a/wp-content/themes/postsecret/parts/header.html +++ b/wp-content/themes/postsecret/parts/header.html @@ -40,7 +40,27 @@ - + + +