diff --git a/docker-compose.yml b/docker-compose.yml index ee715fb..226a2dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,18 +13,26 @@ services: WP_HOME: http://localhost:8080 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); 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 +42,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 +51,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 +69,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 diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php index d576da7..b049c89 100644 --- a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php +++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php @@ -1,367 +1,559 @@ 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 (overridden by settings where present). */ + private const HTTP_TIMEOUT_EMBEDDING_DEFAULT = 30; // s + private const HTTP_TIMEOUT_QDRANT_DEFAULT = 10; // s + private const HTTP_USER_AGENT = 'PostSecret-EmbeddingService/1.0 (+WordPress)'; + + /** Env/const fallbacks for Qdrant (legacy). */ + private const OPT_QDRANT_URL = 'PS_QDRANT_URL'; + private const OPT_QDRANT_API_KEY = 'PS_QDRANT_API_KEY'; + + // ───────────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────────── + /** - * Generate and store embedding for a Secret. - * - * @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 + * Generate, store, and index embedding (canonical in MySQL, best-effort mirror to Qdrant). */ 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)) { + // If API key wasn't supplied, use settings. + if ($api_key === '') { + $api_key = (string)self::opt('API_KEY', ''); + } + + $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, only if enabled + if (self::qdrant_enabled()) { + $qdrantPayload = [ + 'status' => 'public', // adjust at query time if needed + '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); + } + + 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()); + do_action('psai/embedding/error', $secret_id, $e); return false; } } /** - * Build embedding input string from classification payload. - * - * Format: "Secret: [description]. Topics: [t1, t2]. Feelings: [f1]. Meanings: [m1]. Text: [fullText]" - * - * @param array $payload Classification payload - * @return string Input text for embedding + * Similarity search via QdrantSearchService when present; fallback to MySQL brute force. + * If the caller used defaults, pull tuned values from settings. */ - private static function build_embedding_input(array $payload): string + public static function find_similar(int $secret_id, int $limit = 10, float $min_score = 0.5, array $filters = []): array { - $parts = []; - - // Secret description - if (!empty($payload['secretDescription'])) { - $parts[] = 'Secret: ' . $payload['secretDescription']; + // If the method was called with library defaults, adopt the configured values. + if ($limit === 10) { + $limit = (int)self::opt('ANN_TOP_K', 24); } - - // Topics - if (!empty($payload['topics'])) { - $parts[] = 'Topics: ' . implode(', ', $payload['topics']); + if (abs($min_score - 0.5) < 1e-9) { + $min_score = (float)self::opt('ANN_MIN_SCORE', 0.55); } - // Feelings - if (!empty($payload['feelings'])) { - $parts[] = 'Feelings: ' . implode(', ', $payload['feelings']); + if (class_exists('PSSearch\\QdrantSearchService')) { + $results = \PSSearch\QdrantSearchService::find_similar($secret_id, $limit, $min_score, $filters); + if ($results !== null) { + return $results; + } + return \PSSearch\QdrantSearchService::find_similar_mysql($secret_id, $limit, $min_score); } - // Meanings - if (!empty($payload['meanings'])) { - $parts[] = 'Meanings: ' . implode(', ', $payload['meanings']); + return self::find_similar_mysql($secret_id, $limit, $min_score); + } + + /** + * Generate an embedding for an arbitrary query string (no DB write). + * + * @param string $query Free-text query to embed. + * @param string $api_key OpenAI API key. If empty, falls back to Settings::API_KEY. + * @param string $model Embedding model (default: text-embedding-3-small). + * @return array|null L2-normalized vector or null on failure. + */ + public static function generate_query_embedding(string $query, string $api_key = '', string $model = 'text-embedding-3-small'): ?array + { + $query = trim($query); + if ($query === '') { + return null; } - // Extracted text (front + back, truncated to ~2000 chars total) - $texts = []; - if (!empty($payload['front']['text']['fullText'])) { - $texts[] = $payload['front']['text']['fullText']; + // Allow caller to omit the key; pull from Settings if needed. + if ($api_key === '') { + $api_key = (string)self::opt('API_KEY', ''); + if ($api_key === '') { + error_log('[EmbeddingService] No API key available for generate_query_embedding().'); + return null; + } } - if (!empty($payload['back']['text']['fullText'])) { - $texts[] = $payload['back']['text']['fullText']; + + $vec = self::generate_embedding($api_key, $model, $query); + if ($vec === null) { + return null; } - if (!empty($texts)) { - $combined = implode(' ', $texts); - // Truncate if too long (embeddings work best with ~8k tokens max, ~2k chars is safe) - if (mb_strlen($combined, 'UTF-8') > 2000) { - $combined = mb_substr($combined, 0, 2000, 'UTF-8') . '…'; - } - $parts[] = 'Text: ' . $combined; + + return self::normalize_vector($vec); + } + + // ───────────────────────────────────────────────────────────────────────────── + // OpenAI + // ───────────────────────────────────────────────────────────────────────────── + + private static function openai_embeddings_url(): string + { + $base = (string)self::opt('API_BASE', ''); + $base = trim($base); + if ($base === '') { + $base = 'https://api.openai.com/v1'; } + return rtrim($base, '/') . '/embeddings'; + } - return implode('. ', $parts); + private static function embedding_timeout_seconds(): int + { + // Reuse HTTP timeout if provided, else internal default. + return (int)self::opt('REQUEST_TIMEOUT_SECONDS', self::HTTP_TIMEOUT_EMBEDDING_DEFAULT); } /** - * Generate embedding via OpenAI 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 + * Call OpenAI Embeddings API. */ 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]; - $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::embedding_timeout_seconds(), + '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; } - return $embedding; + $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) + )); + } + + /** @var array $embedding */ + return array_map(static fn($v) => (float)$v, $embedding); } - /** - * Normalize vector to unit length (L2 normalization). - * - * @param array $vector Input vector - * @return array Normalized vector - */ - private static function normalize_vector(array $vector): array + // ───────────────────────────────────────────────────────────────────────────── + // Qdrant Integration (Upsert) + // ───────────────────────────────────────────────────────────────────────────── + + private static function qdrant_enabled(): bool { - $magnitude = sqrt(array_sum(array_map(fn($x) => $x * $x, $vector))); + return (bool)self::opt('QDRANT_ENABLE', true); + } - if ($magnitude == 0) { - return $vector; + /** Resolve Qdrant base URL: settings first, then env/const fallback. */ + private static function qdrant_url(): ?string + { + $url = (string)self::opt('QDRANT_URL', ''); + if ($url === '') { + $url = getenv(self::OPT_QDRANT_URL) ?: (defined(self::OPT_QDRANT_URL) ? constant(self::OPT_QDRANT_URL) : ''); } + return is_string($url) && $url !== '' ? rtrim($url, '/') : null; + } - return array_map(fn($x) => $x / $magnitude, $vector); + /** Optional Qdrant API key. */ + private static function qdrant_api_key(): ?string + { + $key = (string)self::opt('QDRANT_API_KEY', ''); + if ($key === '') { + $key = getenv(self::OPT_QDRANT_API_KEY) ?: (defined(self::OPT_QDRANT_API_KEY) ? constant(self::OPT_QDRANT_API_KEY) : ''); + } + return is_string($key) && $key !== '' ? $key : null; + } + + /** Collection name: prefer setting; else per-model safe name. */ + private static function qdrant_collection(string $model): string + { + $configured = (string)self::opt('QDRANT_COLLECTION', ''); + if ($configured !== '') { + return $configured; + } + $name = 'secrets_' . preg_replace('/[^a-z0-9]+/i', '_', $model); + /** @var string $name */ + $name = apply_filters('psai/embedding/qdrant-collection', $name, $model); + return $name; + } + + /** Distance metric from settings (Cosine/Dot/Euclid). */ + private static function qdrant_distance(): string + { + $d = (string)self::opt('QDRANT_DISTANCE', 'Cosine'); + return in_array($d, ['Cosine', 'Dot', 'Euclid'], true) ? $d : 'Cosine'; + } + + /** Per-request Qdrant timeout. */ + private static function qdrant_timeout_seconds(): int + { + return (int)self::opt('ANN_TIMEOUT_SECONDS', self::HTTP_TIMEOUT_QDRANT_DEFAULT); + } + + /** Ensure vector dim: prefer setting, else from actual vector length. */ + private static function qdrant_vector_size(int $fallback): int + { + $sz = (int)self::opt('QDRANT_VECTOR_SIZE', 0); + return $sz > 0 ? $sz : $fallback; } /** - * Store embedding in database. - * - * @param int $secret_id Attachment ID - * @param string $model Model version - * @param array $embedding Embedding vector - * @return bool Success + * Upsert a single point into Qdrant (best-effort). */ - private static function store_embedding(int $secret_id, string $model, array $embedding): bool + private static function qdrant_upsert(int $secret_id, string $model, array $vector, array $payload = []): void { - global $wpdb; + $base = self::qdrant_url(); + if ($base === null) return; - $table_name = $wpdb->prefix . 'ps_text_embeddings'; - $dimension = count($embedding); + // Ensure collection exists (cached) + $collection = self::qdrant_collection($model); + self::qdrant_ensure_collection($collection, self::qdrant_vector_size(count($vector))); - // Convert to JSON for storage - $embedding_json = wp_json_encode($embedding); + $body = [ + 'points' => [[ + 'id' => $secret_id, + 'vector' => array_values($vector), + 'payload' => array_merge($payload, [ + 'secret_id' => $secret_id, + 'model_version' => $model, + ]), + ]], + ]; - $result = $wpdb->replace( - $table_name, - [ - 'secret_id' => $secret_id, - 'model_version' => $model, - 'embedding' => $embedding_json, - 'dimension' => $dimension, - 'updated_at' => current_time('mysql'), - ], - ['%d', '%s', '%s', '%d', '%s'] - ); + self::qdrant_request('PUT', "/collections/{$collection}/points?wait=true", $body, self::qdrant_timeout_seconds()); + } + + /** + * Ensure Qdrant collection exists; create if missing (uses settings for size/distance). + */ + 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' => self::qdrant_distance(), + ], + 'hnsw_config' => [ + 'm' => 16, + 'ef_construct' => 200, + ], + 'optimizers_config' => [ + 'default_segment_number' => 2, + 'indexing_threshold' => 0, // index immediately (dev-friendly) + ], + ]; + self::qdrant_request('PUT', "/collections/{$collection}", $create, 20); + } - return $result !== false; + set_transient($cache_key, 1, HOUR_IN_SECONDS); } /** - * Get embedding for a Secret. - * - * @param int $secret_id Attachment ID - * @return array|null Embedding data or null if not found + * Minimal Qdrant HTTP client wrapper with optional API key header. */ - public static function get_embedding(int $secret_id): ?array + private static function qdrant_request(string $method, string $path, ?array $body = null, int $timeout = null): ?array { - global $wpdb; + $base = self::qdrant_url(); + if ($base === null) return null; - $table_name = $wpdb->prefix . 'ps_text_embeddings'; + $headers = [ + 'Content-Type' => 'application/json', + 'User-Agent' => self::HTTP_USER_AGENT, + ]; - $row = $wpdb->get_row( - $wpdb->prepare( - "SELECT * FROM $table_name WHERE secret_id = %d", - $secret_id - ), - ARRAY_A - ); + $apiKey = self::qdrant_api_key(); + if ($apiKey !== null) { + $headers['api-key'] = $apiKey; // Qdrant standard header + } + + $args = [ + 'method' => $method, + 'headers' => $headers, + 'timeout' => $timeout ?? self::qdrant_timeout_seconds(), + ]; - if (!$row) { + 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; } - // Decode embedding JSON - $row['embedding'] = json_decode($row['embedding'], true); + $code = (int)wp_remote_retrieve_response_code($res); + $raw = (string)wp_remote_retrieve_body($res); - return $row; + 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; } - /** - * Find similar Secrets using cosine similarity. - * - * @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 - */ - public static function find_similar(int $secret_id, int $limit = 10, float $min_similarity = 0.5): array + // ───────────────────────────────────────────────────────────────────────────── + // MySQL brute-force fallback + // ───────────────────────────────────────────────────────────────────────────── + + private 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) 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 $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 ); + if (!is_array($rows) || $rows === []) return []; - $source_vector = $source['embedding']; - $results = []; + /** @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; - if ($similarity >= $min_similarity) { - $results[] = [ - 'secret_id' => (int)$row['secret_id'], - 'similarity' => round($similarity, 4), - ]; + /** @var array $vec */ + $vec = array_map(static fn($v) => (float)$v, $vec); + + $sim = self::cosine_similarity($src, $vec); + if ($sim >= $min_similarity) { + $res[] = ['secret_id' => (int)$row['secret_id'], 'similarity' => (float)round($sim, 4)]; } } - // Sort by similarity descending - usort($results, fn($a, $b) => $b['similarity'] <=> $a['similarity']); + usort($res, static fn($a, $b) => $b['similarity'] <=> $a['similarity']); + return array_slice($res, 0, $limit); + } + + // ───────────────────────────────────────────────────────────────────────────── + // DB helpers, math, utils + // ───────────────────────────────────────────────────────────────────────────── - return array_slice($results, 0, $limit); + public 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), + ARRAY_A + ); + if (!$row) return null; + + $decoded = json_decode((string)($row['embedding'] ?? '[]'), true); + $row['embedding'] = is_array($decoded) ? $decoded : []; + return $row; } - /** - * Calculate cosine similarity between two vectors. - * - * @param array $a First vector - * @param array $b Second vector - * @return float Similarity score (0.0-1.0) - */ - private static function cosine_similarity(array $a, array $b): float + private static function store_embedding(int $secret_id, string $model, array $embedding, array $payload): bool { - if (count($a) !== count($b)) { - return 0.0; - } + global $wpdb; + $table = $wpdb->prefix . 'ps_text_embeddings'; - $dot = 0.0; - $mag_a = 0.0; - $mag_b = 0.0; + $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 + ); - for ($i = 0; $i < count($a); $i++) { - $dot += $a[$i] * $b[$i]; - $mag_a += $a[$i] * $a[$i]; - $mag_b += $b[$i] * $b[$i]; + if (is_array($existing) && ($existing['input_hash'] ?? '') === $inputHash) { + return true; // unchanged } - $mag_a = sqrt($mag_a); - $mag_b = sqrt($mag_b); + $embeddingJson = wp_json_encode($embedding, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $dimension = count($embedding); - if ($mag_a == 0 || $mag_b == 0) { - return 0.0; - } + $result = $wpdb->replace( + $table, + [ + 'secret_id' => $secret_id, + 'model_version' => $model, + 'embedding' => $embeddingJson, + 'dimension' => $dimension, + 'input_hash' => $inputHash, + 'updated_at' => current_time('mysql'), + ], + ['%d', '%s', '%s', '%d', '%s', '%s'] + ); - return $dot / ($mag_a * $mag_b); + if ($result === false) { + error_log("[EmbeddingService] DB write failed for secret {$secret_id}."); + return false; + } + return true; } - /** - * Delete embedding for a Secret. - * - * @param int $secret_id Attachment ID - * @return bool Success - */ - public static function delete_embedding(int $secret_id): bool + private static function cosine_similarity(array $a, array $b): float { - global $wpdb; + $na = count($a); + if ($na === 0 || $na !== count($b)) return 0.0; - $table_name = $wpdb->prefix . 'ps_text_embeddings'; + $dot = 0.0; + $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; + } + if ($ma <= 0.0 || $mb <= 0.0) return 0.0; + return $dot / (sqrt($ma) * sqrt($mb)); + } - $result = $wpdb->delete( - $table_name, - ['secret_id' => $secret_id], - ['%d'] - ); + private static function normalize_vector(array $vector): array + { + $sumSquares = 0.0; + foreach ($vector as $v) { + $fv = (float)$v; + $sumSquares += $fv * $fv; + } + if ($sumSquares <= 0.0) return $vector; + $mag = sqrt($sumSquares); + foreach ($vector as $i => $v) $vector[$i] = (float)$v / $mag; + return $vector; + } - return $result !== false; + private static function sanitize_space(string $s): string + { + $s = preg_replace('/\s+/u', ' ', $s ?? '') ?? ''; + return trim($s); } + // ───────────────────────────────────────────────────────────────────────────── + // Settings helper + // ───────────────────────────────────────────────────────────────────────────── + /** - * Get embedding statistics. - * - * @return array Stats: total count, model versions, etc. + * Read a setting from the single-array option, with a default. + * @param string $key + * @param mixed $default + * @return mixed */ - public static function get_stats(): array + private static function opt(string $key, $default = null) { - 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, - ]; + $all = get_option(Settings::OPTION, []); + if (is_array($all) && array_key_exists($key, $all) && $all[$key] !== '' && $all[$key] !== null) { + return $all[$key]; + } + return $default; } -} +} \ No newline at end of file diff --git a/wp-content/plugins/postsecret-ai/src/Schema.php b/wp-content/plugins/postsecret-ai/src/Schema.php index 0b210f2..fb77cba 100644 --- a/wp-content/plugins/postsecret-ai/src/Schema.php +++ b/wp-content/plugins/postsecret-ai/src/Schema.php @@ -4,17 +4,6 @@ if (!defined('ABSPATH')) exit; -/** - * Logically ordered settings with sections: - * 1) OpenAI API - * 2) Model & Generation - * 3) Moderation - * 4) HTTP (timeouts & retries) - * 5) Logging - * 6) Encoding (WebP) - * 7) Ingest (Future) - * 8) Paths (Future) - */ class Schema { /** @return array> */ @@ -25,39 +14,54 @@ public static function get(): array ['section' => 'api', 'order' => 10, 'key' => 'API_BASE', 'label' => 'API Base URL', 'kind' => 'str', 'default' => '', 'help' => 'Optional OpenAI-compatible base URL (leave empty for api.openai.com)'], ['section' => 'api', 'order' => 20, 'key' => 'API_KEY', 'label' => 'API Key', 'kind' => 'str', 'default' => '', 'secret' => true, 'help' => 'Your OpenAI API key'], - // 2) Model & Generation + // 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' => 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.'], + + // 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' => 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'], - // 3) Moderation + // 4) Moderation ['section' => 'moderation', 'order' => 10, 'key' => 'MODERATION_ENABLE', 'label' => 'Enable Moderation', 'kind' => 'bool', 'default' => false, 'help' => 'Run an additional moderation check'], ['section' => 'moderation', 'order' => 20, 'key' => 'MODERATION_MODEL', 'label' => 'Moderation Model', 'kind' => 'str', 'default' => 'omni-moderation-latest', 'help' => 'Model for moderation (when enabled)'], - // 4) HTTP (timeouts & retries) + // 5) HTTP (timeouts & retries) ['section' => 'http', 'order' => 10, 'key' => 'REQUEST_TIMEOUT_SECONDS', 'label' => 'HTTP Timeout (s)', 'kind' => 'int', 'default' => 60, 'min' => 1, 'help' => 'Per-request timeout'], ['section' => 'http', 'order' => 20, 'key' => 'REQUEST_MAX_RETRIES', 'label' => 'HTTP Retries', 'kind' => 'int', 'default' => 3, 'min' => 0, 'help' => 'Retries on transient errors'], ['section' => 'http', 'order' => 30, 'key' => 'REQUEST_BACKOFF_FACTOR', 'label' => 'HTTP Backoff Factor', 'kind' => 'float', 'default' => 0.5, 'min' => 0.0, 'max' => 10.0, 'help' => 'Delay multiplier between retries'], - // 5) Logging + // 6) Logging ['section' => 'logging', 'order' => 10, 'key' => 'LOG_LEVEL', 'label' => 'Log Level', 'kind' => 'choice', 'default' => 'INFO', 'choices' => ['DEBUG', 'INFO', 'WARN', 'ERROR'], 'help' => 'Controls plugin logging verbosity'], - // 6) Encoding (WebP) + // 7) Encoding (WebP) ['section' => 'encoding', 'order' => 10, 'key' => 'WEBP_ENABLE', 'label' => 'Save WebP', 'kind' => 'bool', 'default' => false, 'help' => 'Save WebP copies (requires WebP support on server)'], ['section' => 'encoding', 'order' => 20, 'key' => 'WEBP_QUALITY', 'label' => 'WebP Quality', 'kind' => 'int', 'default' => 80, 'min' => 0, 'max' => 100, 'help' => 'Lossy quality (0–100)'], ['section' => 'encoding', 'order' => 30, 'key' => 'WEBP_LOSSLESS', 'label' => 'WebP Lossless', 'kind' => 'bool', 'default' => false, 'help' => 'Use lossless compression'], ['section' => 'encoding', 'order' => 40, 'key' => 'WEBP_METHOD', 'label' => 'WebP Method', 'kind' => 'int', 'default' => 4, 'min' => 0, 'max' => 6, 'help' => 'Encoder effort (0–6)'], - // 7) Ingest (Future) + // 8) Ingest (Future) ['section' => 'ingest', 'order' => 10, 'key' => 'ALLOWED_EXT', 'label' => 'Allowed Extensions', 'kind' => 'str', 'default' => 'jpg,jpeg,png,webp,tif,tiff', 'help' => 'For future folder scanning'], ['section' => 'ingest', 'order' => 20, 'key' => 'RECURSIVE', 'label' => 'Recursive', 'kind' => 'bool', 'default' => true, 'help' => 'Scan subdirectories (future)'], ['section' => 'ingest', 'order' => 30, 'key' => 'FORCE', 'label' => 'Force Reprocess', 'kind' => 'bool', 'default' => false, 'help' => 'Reprocess even if outputs exist (future)'], - // 8) Paths (Future) + // 9) Paths (Future) ['section' => 'paths', 'order' => 10, 'key' => 'IMAGES_DIR', 'label' => 'Images Directory', 'kind' => 'path', 'default' => 'images', 'help' => 'Folder containing input images (future)', 'path_kind' => 'dir'], ['section' => 'paths', 'order' => 20, 'key' => 'OUTPUT_DIR', 'label' => 'Output Directory', 'kind' => 'path', 'default' => 'output', 'help' => 'Folder for classification results (future)', 'path_kind' => 'dir'], + + // 10) Vector Search (tuning) + ['section' => 'search', 'order' => 10, 'key' => 'ANN_TOP_K', 'label' => 'Top-K', 'kind' => 'int', 'default' => 24, 'min' => 1, 'help' => 'Max results to return'], + ['section' => 'search', 'order' => 20, 'key' => 'ANN_MIN_SCORE', 'label' => 'Min Similarity', 'kind' => 'float', 'default' => 0.55, 'min' => 0.0, 'max' => 1.0, 'help' => 'Score threshold (0–1)'], + ['section' => 'search', 'order' => 30, 'key' => 'ANN_HNSW_EF', 'label' => 'HNSW ef_search', 'kind' => 'int', 'default' => 96, 'min' => 8, 'help' => 'Higher = better recall, slower'], + ['section' => 'search', 'order' => 40, 'key' => 'ANN_TIMEOUT_SECONDS', 'label' => 'Qdrant Timeout (s)', 'kind' => 'int', 'default' => 10, 'min' => 1, 'help' => 'Per-request timeout to Qdrant'], + ['section' => 'search', 'order' => 50, 'key' => 'UPSERT_BATCH_SIZE', 'label' => 'Upsert Batch Size', 'kind' => 'int', 'default' => 1000, 'min' => 1, 'help' => 'Batch size when mirroring to Qdrant'], ]; } @@ -66,6 +70,7 @@ public static function sections(): array { return [ 'api' => ['title' => 'OpenAI API', 'desc' => 'Connection settings for the OpenAI API.'], + 'qdrant' => ['title' => 'Qdrant (Vector DB)', 'desc' => 'Connection and collection settings for Qdrant.'], // ← moved up 'model' => ['title' => 'Model & Generation', 'desc' => 'Choose the model and its generation parameters.'], 'moderation' => ['title' => 'Moderation', 'desc' => 'Optional post-classification moderation.'], 'http' => ['title' => 'HTTP (Timeouts & Retries)', 'desc' => 'Network behavior for API requests.'], @@ -73,6 +78,7 @@ public static function sections(): array 'encoding' => ['title' => 'Encoding (WebP)', 'desc' => 'Optional WebP export (server support required).'], 'ingest' => ['title' => 'Ingest (Future)', 'desc' => 'Reserved for future folder scanning.'], 'paths' => ['title' => 'Paths (Future)', 'desc' => 'Reserved for future file-system workflows.'], + 'search' => ['title' => 'Vector Search', 'desc' => 'Approximate nearest neighbor (ANN) tuning.'], ]; } } \ No newline at end of file diff --git a/wp-content/plugins/postsecret-ai/src/Settings.php b/wp-content/plugins/postsecret-ai/src/Settings.php index 5605718..cf29adc 100644 --- a/wp-content/plugins/postsecret-ai/src/Settings.php +++ b/wp-content/plugins/postsecret-ai/src/Settings.php @@ -16,9 +16,22 @@ public static function register(): void ['type' => 'array', 'sanitize_callback' => [self::class, 'sanitizeAll']] ); - // Create sections in logical order + // Render sections in a fixed, logical order (Qdrant directly below OpenAI) $sections = Schema::sections(); - foreach (['api', 'model', 'moderation', 'http', 'logging', 'encoding', 'ingest', 'paths'] as $sid) { + $order = [ + 'api', + 'qdrant', // ← new: show right after OpenAI + 'model', + 'moderation', + 'http', + 'logging', + 'encoding', + 'ingest', + 'paths', + 'search', // ← include Vector Search tuning section + ]; + + foreach ($order as $sid) { if (!isset($sections[$sid])) continue; add_settings_section( 'psai_' . $sid, @@ -57,7 +70,14 @@ public static function sanitizeAll($input) foreach (Schema::get() as $spec) { $k = $spec['key']; $v = $input[$k] ?? $defs[$k]; - $out[$k] = self::sanitizeOne($spec, $v); + $val = self::sanitizeOne($spec, $v); + + // Small normalization for known keys + if ($k === 'QDRANT_URL' && is_string($val)) { + $val = rtrim($val, "/ \t\n\r\0\x0B"); // trim trailing slash/space + } + + $out[$k] = $val; } // --- Minimal validation & user feedback --- @@ -71,7 +91,20 @@ public static function sanitizeAll($input) add_settings_error('postsecret-ai', 'psai_model_bad', 'Model Name must be a string.', 'error'); } - // You could block saving by returning the old value when critical errors occur. + // Qdrant-specific sanity checks (only when enabled) + if (!empty($out['QDRANT_ENABLE'])) { + if (empty($out['QDRANT_URL'])) { + add_settings_error('postsecret-ai', 'psai_qdrant_url_missing', 'Qdrant is enabled but URL is empty. Set QDRANT URL or disable Qdrant.', 'error'); + } elseif (!preg_match('#^https?://#i', (string)$out['QDRANT_URL'])) { + add_settings_error('postsecret-ai', 'psai_qdrant_url_scheme', 'Qdrant URL should start with http:// or https://', 'error'); + } + + if (empty($out['QDRANT_VECTOR_SIZE']) || (int)$out['QDRANT_VECTOR_SIZE'] <= 0) { + add_settings_error('postsecret-ai', 'psai_qdrant_dim', 'Vector Size must be a positive integer (e.g., 1536).', 'error'); + } + } + + // You could block saving by returning the old value on critical errors. // For now we still save, but show errors/warnings. return $out; } @@ -92,24 +125,30 @@ private static function sanitizeOne(array $spec, $v) case 'bool': $val = (bool)$v; break; + case 'int': $val = is_numeric($v) ? (int)$v : (int)($spec['default'] ?? 0); break; + case 'float': $val = is_numeric($v) ? (float)$v : (float)($spec['default'] ?? 0.0); break; + case 'choice': $choices = $spec['choices'] ?? []; $val = in_array($v, $choices, true) ? $v : ($spec['default'] ?? ($choices[0] ?? '')); break; + case 'path': case 'str': default: $val = is_string($v) ? trim($v) : ''; break; } + if (isset($spec['min']) && is_numeric($spec['min']) && is_numeric($val)) $val = max($val, $spec['min']); if (isset($spec['max']) && is_numeric($spec['max']) && is_numeric($val)) $val = min($val, $spec['max']); + return $val; } @@ -151,7 +190,7 @@ public static function renderField(array $args): void case 'path': echo ''; $hint = !empty($spec['path_kind']) ? ' (' . $spec['path_kind'] . ')' : ''; - echo '

Path' . $hint . '. ' . $spec['help'] . '

'; + echo '

Path' . $hint . '. ' . esc_html($spec['help'] ?? '') . '

'; break; case 'str': 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 new file mode 100644 index 0000000..599e62e --- /dev/null +++ b/wp-content/plugins/postsecret-search/postsecret-search.php @@ -0,0 +1,197 @@ + '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', + ], + // Defaults come from Settings (ANN_TOP_K / ANN_MIN_SCORE), but we still declare schema defaults here. + '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((string)$request->get_param('query')); + + // Pull tuned defaults from Settings if caller passed the route defaults. + $limitReq = (int)$request->get_param('limit'); + $minScoreReq = (float)$request->get_param('min_score'); + + $limit = $limitReq === 24 ? (int)opt('ANN_TOP_K', 24) : $limitReq; + $min_score = abs($minScoreReq - 0.5) < 1e-9 ? (float)opt('ANN_MIN_SCORE', 0.55) : $minScoreReq; + + // Resolve OpenAI API key: Settings → env → const + $api_key = (string)opt('API_KEY', ''); + if ($api_key === '') { + $api_key = getenv('OPENAI_API_KEY') ?: (defined('OPENAI_API_KEY') ? constant('OPENAI_API_KEY') : ''); + } + if ($api_key === '') { + return new WP_Error('no_api_key', 'OpenAI API key not configured', ['status' => 500]); + } + + // Need EmbeddingService for query embeddings + if (!class_exists('PSAI\\EmbeddingService')) { + return new WP_Error('missing_dependency', 'EmbeddingService not available', ['status' => 500]); + } + + // Embedding model (keep default, but allow override via filter) + $model = apply_filters('psai/search/embedding_model', 'text-embedding-3-small', $request); + + // Build a reasonable default filter; callers can override/extend via filter. + $filters = ['status' => 'public']; + /** @var array $filters */ + $filters = apply_filters('psai/search/default_filters', $filters, $request); + + // Generate embedding for the query + $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]); + } + + // 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) { + // 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]); + } + + if (empty($results)) { + return new WP_REST_Response([ + 'query' => $query, + 'total' => 0, + 'items' => [], + ], 200); + } + + // Fetch light attachment metadata for each result + $items = []; + foreach ($results as $result) { + $secret_id = (int)$result['secret_id']; + $similarity = (float)$result['similarity']; + + // front image src + $src = wp_get_attachment_image_src($secret_id, 'secret-card'); + if (!$src) { + $fallback = wp_get_attachment_url($secret_id); + $src = [$fallback ?: '', 0, 0, true]; + } + + // paired "back" image (optional) + $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'); + $back_src = $back_image ? $back_image[0] : (wp_get_attachment_url($back_id) ?: null); + $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); +} + +/** + * Small helper to read settings (single-array option) with a default. + * Kept in this file to avoid a hard dependency beyond the option name. + * + * @param string $key + * @param mixed $default + * @return mixed + */ +function opt(string $key, $default = null) +{ + $all = get_option(Settings::OPTION, []); + if (is_array($all) && array_key_exists($key, $all) && $all[$key] !== '' && $all[$key] !== null) { + return $all[$key]; + } + return $default; +} \ No newline at end of file diff --git a/wp-content/plugins/postsecret-search/src/QdrantSearchService.php b/wp-content/plugins/postsecret-search/src/QdrantSearchService.php new file mode 100644 index 0000000..189fdbc --- /dev/null +++ b/wp-content/plugins/postsecret-search/src/QdrantSearchService.php @@ -0,0 +1,406 @@ + array_values(array_map(static fn($v) => (float)$v, (array)$src['embedding'])), + 'top' => $top, + 'filter' => $filter, + 'params' => ['hnsw_ef' => $hnswEf], + 'score_threshold' => $scoreThreshold, + ]; + + /** @var array $body */ + $body = apply_filters('psai/qdrant/search_body', $body, $collection, $secret_id); + + $res = self::qdrant_request('POST', "/collections/{$collection}/points/search", $body, self::qdrant_timeout_seconds()); + if (!$res || ($res['status'] ?? '') !== 'ok') { + return null; // treat as unavailable; let caller fallback + } + + $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 || $id === $secret_id) 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; + } + + /** + * Search by query embedding vector directly. + */ + public static function search_by_vector(array $vector, string $model, int $limit = 10, float $min_score = 0.5, array $filters = []): ?array + { + if (!self::qdrant_enabled()) { + return null; + } + + $base = self::qdrant_url(); + if ($base === null) { + return null; + } + + if ($limit === 10) { + $limit = (int)self::opt('ANN_TOP_K', self::ANN_TOP_K_DEFAULT); + } + if (abs($min_score - 0.5) < 1e-9) { + $min_score = (float)self::opt('ANN_MIN_SCORE', self::ANN_MIN_SCORE_DEFAULT); + } + + $collection = self::qdrant_collection($model); + $filter = self::build_qdrant_filter_query($filters); + $top = max(1, (int)$limit + 3); + $scoreThreshold = max(0.0, min(1.0, (float)$min_score)); + $hnswEf = (int)self::opt('ANN_HNSW_EF', self::ANN_HNSW_EF_DEFAULT); + + $body = [ + 'vector' => array_values(array_map(static fn($v) => (float)$v, $vector)), + 'top' => $top, + 'filter' => $filter, + 'params' => ['hnsw_ef' => $hnswEf], + '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::qdrant_timeout_seconds()); + 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 brute-force fallback (kept as-is). + */ + 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 || empty($source['embedding']) || !is_array($source['embedding'])) { + return []; + } + + $table = $wpdb->prefix . 'ps_text_embeddings'; + $model = (string)$source['model_version']; + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT secret_id, embedding FROM {$table} WHERE secret_id != %d AND model_version = %s", + $secret_id, + $model + ), + ARRAY_A + ); + 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) { + $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' => (float)round($sim, 4)]; + } + } + + usort($res, static fn($a, $b) => $b['similarity'] <=> $a['similarity']); + return array_slice($res, 0, max(0, (int)$limit)); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Internal helpers + // ───────────────────────────────────────────────────────────────────────────── + + /** Is Qdrant turned on in Settings? */ + private static function qdrant_enabled(): bool + { + return (bool)self::opt('QDRANT_ENABLE', true); + } + + /** Base URL: Settings first, then env/const. */ + private static function qdrant_url(): ?string + { + $url = (string)self::opt('QDRANT_URL', ''); + if ($url === '') { + $url = getenv(self::OPT_QDRANT_URL) ?: (defined(self::OPT_QDRANT_URL) ? constant(self::OPT_QDRANT_URL) : ''); + } + return is_string($url) && $url !== '' ? rtrim($url, '/') : null; + } + + /** API key: Settings first, then env/const. */ + private static function qdrant_api_key(): ?string + { + $key = (string)self::opt('QDRANT_API_KEY', ''); + if ($key === '') { + $key = getenv(self::OPT_QDRANT_API_KEY) ?: (defined(self::OPT_QDRANT_API_KEY) ? constant(self::OPT_QDRANT_API_KEY) : ''); + } + return is_string($key) && $key !== '' ? $key : null; + } + + /** Collection: prefer configured `QDRANT_COLLECTION`, else per-model. */ + private static function qdrant_collection(string $model): string + { + $configured = (string)self::opt('QDRANT_COLLECTION', ''); + if ($configured !== '') return $configured; + + $name = 'secrets_' . preg_replace('/[^a-z0-9]+/i', '_', $model); + /** @var string $name */ + $name = apply_filters('psai/qdrant/collection', $name, $model); + return $name; + } + + /** Qdrant timeout seconds from Settings. */ + private static function qdrant_timeout_seconds(): int + { + return (int)self::opt('ANN_TIMEOUT_SECONDS', self::HTTP_TIMEOUT_QDRANT_DEFAULT); + } + + /** + * Build Qdrant filter from simple associative array and exclude source ID. + */ + private static function build_qdrant_filter(array $filters, int $excludeId): ?array + { + $filter = self::build_qdrant_filter_query($filters); + if ($filter === null) $filter = []; + $filter['must_not'] = [['has_id' => ['values' => [$excludeId]]]]; + return $filter === [] ? null : $filter; + } + + /** + * Build Qdrant filter for queries (no exclusion). + */ + private static function build_qdrant_filter_query(array $filters): ?array + { + $must = []; + $should = []; + + foreach ($filters as $key => $value) { + if ($value === null || $value === '') continue; + + if (is_array($value)) { + $vals = array_values(array_filter($value, static fn($v) => $v !== null && $v !== '')); + if ($vals === []) continue; + $shouldMatches = array_map( + static fn($vv) => ['key' => $key, 'match' => ['value' => $vv]], + $vals + ); + $should = array_merge($should, $shouldMatches); + } else { + $must[] = ['key' => $key, 'match' => ['value' => $value]]; + } + } + + $filter = []; + if ($must !== []) $filter['must'] = $must; + if ($should !== []) $filter['should'] = $should; + + return $filter === [] ? null : $filter; + } + + /** + * Minimal Qdrant HTTP wrapper with optional api-key header. + */ + 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; + + $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' => $headers, + 'timeout' => $timeout ?? self::qdrant_timeout_seconds(), + ]; + + 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('[QdrantSearchService] 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("[QdrantSearchService] HTTP {$code}: " . substr($raw, 0, 300)); + return null; + } + + $json = json_decode($raw, true); + return is_array($json) ? $json : null; + } + + /** + * Fetch canonical embedding record for a Secret. + */ + 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), + ARRAY_A + ); + if (!$row) return null; + + $decoded = json_decode((string)($row['embedding'] ?? '[]'), true); + $row['embedding'] = is_array($decoded) ? $decoded : []; + return $row; + } + + /** Cosine similarity (for MySQL fallback). */ + private static function cosine_similarity(array $a, array $b): float + { + $na = count($a); + if ($na === 0 || $na !== count($b)) return 0.0; + + $dot = 0.0; + $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; + } + if ($ma <= 0.0 || $mb <= 0.0) return 0.0; + return $dot / (sqrt($ma) * sqrt($mb)); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Settings helper + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Read a setting from the single-array option, with a default. + * (Intentionally duplicated here to keep the service self-contained.) + * + * @param string $key + * @param mixed $default + * @return mixed + */ + private static function opt(string $key, $default = null) + { + $all = get_option(Settings::OPTION, []); + if (is_array($all) && array_key_exists($key, $all) && $all[$key] !== '' && $all[$key] !== null) { + return $all[$key]; + } + return $default; + } +} \ No newline at end of file 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 @@ - + + +