From 8dfeb93ae3c69bca1babaa0efb6e20fe67843ee2 Mon Sep 17 00:00:00 2001 From: Flatts Date: Wed, 1 Oct 2025 21:01:48 -0400 Subject: [PATCH 1/2] feat: replace legacy taxonomy and tag logic with facets Replaced the outdated taxonomy system with facet-based filtering (topics, feelings, meanings) stored as post meta. Removed `TaxonomyRoute` and `TaxonomyService` classes. Added migrations for facets and embeddings, enhancing semantic search capabilities. Updated related services to leverage facet-based logic and introduced the `EmbeddingService` for similarity matching. Refined attachment sync and metadata processing workflows for better integration. --- .../migrations/002_facets.php | 30 ++ .../migrations/003_embeddings.php | 42 ++ .../postsecret-admin/postsecret-admin.php | 2 - .../postsecret-admin/run-migrations.php | 60 +++ .../postsecret-admin/src/Model/Secret.php | 32 +- .../src/Routes/TaxonomyRoute.php | 37 -- .../src/Services/ModerationService.php | 50 +++ .../src/Services/SearchService.php | 106 ++++- .../src/Services/TaxonomyService.php | 34 -- .../plugins/postsecret-ai/postsecret-ai.php | 62 +++ .../postsecret-ai/src/AdminMetaBox.php | 55 ++- .../postsecret-ai/src/AttachmentSync.php | 30 +- .../postsecret-ai/src/EmbeddingService.php | 367 ++++++++++++++++++ .../plugins/postsecret-ai/src/Ingress.php | 19 +- .../plugins/postsecret-ai/src/Metadata.php | 124 +++++- .../plugins/postsecret-ai/src/Prompt.php | 261 +++++++------ .../plugins/postsecret-ai/src/SchemaGuard.php | 22 +- wp-content/themes/postsecret/parts/card.php | 28 +- .../themes/postsecret/single-secret.php | 28 +- 19 files changed, 1147 insertions(+), 242 deletions(-) create mode 100644 wp-content/plugins/postsecret-admin/migrations/002_facets.php create mode 100644 wp-content/plugins/postsecret-admin/migrations/003_embeddings.php create mode 100644 wp-content/plugins/postsecret-admin/run-migrations.php delete mode 100644 wp-content/plugins/postsecret-admin/src/Routes/TaxonomyRoute.php delete mode 100644 wp-content/plugins/postsecret-admin/src/Services/TaxonomyService.php create mode 100644 wp-content/plugins/postsecret-ai/src/EmbeddingService.php diff --git a/wp-content/plugins/postsecret-admin/migrations/002_facets.php b/wp-content/plugins/postsecret-admin/migrations/002_facets.php new file mode 100644 index 0000000..ecbaf4a --- /dev/null +++ b/wp-content/plugins/postsecret-admin/migrations/002_facets.php @@ -0,0 +1,30 @@ +prefix . 'ps_tag_alias'; + $wpdb->query( "DROP TABLE IF EXISTS $table_name_tag_alias" ); + + // Note: Facets are stored as post meta: + // - _ps_topics (array) + // - _ps_feelings (array) + // - _ps_meanings (array) + // No additional tables needed - WordPress post meta handles arrays natively. +} diff --git a/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php b/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php new file mode 100644 index 0000000..bb6a634 --- /dev/null +++ b/wp-content/plugins/postsecret-admin/migrations/003_embeddings.php @@ -0,0 +1,42 @@ +get_charset_collate(); + $table_name = $wpdb->prefix . 'ps_text_embeddings'; + + $sql = " + CREATE TABLE $table_name ( + secret_id bigint(20) unsigned NOT NULL, + model_version varchar(32) NOT NULL, + embedding longtext NOT NULL, + dimension smallint unsigned NOT NULL DEFAULT 1536, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (secret_id), + KEY model_version (model_version), + KEY updated_at (updated_at) + ) $charset_collate; + "; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); +} diff --git a/wp-content/plugins/postsecret-admin/postsecret-admin.php b/wp-content/plugins/postsecret-admin/postsecret-admin.php index 484aa5e..375a19c 100644 --- a/wp-content/plugins/postsecret-admin/postsecret-admin.php +++ b/wp-content/plugins/postsecret-admin/postsecret-admin.php @@ -23,7 +23,6 @@ use PostSecret\Admin\Routes\SearchRoute; use PostSecret\Admin\Routes\ReviewRoute; -use PostSecret\Admin\Routes\TaxonomyRoute; use PostSecret\Admin\Routes\BackfillRoute; use PostSecret\Admin\Routes\SettingsRoute; @@ -33,7 +32,6 @@ function postsecret_admin_bootstrap() { new SearchRoute(); new ReviewRoute(); - new TaxonomyRoute(); new BackfillRoute(); new SettingsRoute(); } diff --git a/wp-content/plugins/postsecret-admin/run-migrations.php b/wp-content/plugins/postsecret-admin/run-migrations.php new file mode 100644 index 0000000..b21613c --- /dev/null +++ b/wp-content/plugins/postsecret-admin/run-migrations.php @@ -0,0 +1,60 @@ + /wp-load.php +require_once __DIR__ . '/../../../wp-load.php'; + +if ( ! current_user_can( 'manage_options' ) && ! defined( 'WP_CLI' ) ) { + wp_die( 'Unauthorized' ); +} + +global $wpdb; + +echo "

PostSecret Database Migrations

\n"; + +$migrations_dir = __DIR__ . '/migrations/'; +$migrations = glob( $migrations_dir . '*.php' ); +sort( $migrations ); + +foreach ( $migrations as $migration_file ) { + $migration_name = basename( $migration_file ); + echo "

Running migration: {$migration_name}..."; + + // Create isolated scope and execute + $result = ( function() use ( $migration_file, $wpdb ) { + ob_start(); + try { + require $migration_file; + \PostSecret\Admin\Migrations\up(); + $output = ob_get_clean(); + return [ 'success' => true, 'output' => $output ]; + } catch ( \Throwable $e ) { + $output = ob_get_clean(); + return [ 'success' => false, 'error' => $e->getMessage(), 'output' => $output ]; + } + } )(); + + if ( $result['success'] ) { + echo " ✓ Success

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

\n"; + echo "

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

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

Migrations complete!

\n"; +echo "

← Back to Dashboard

\n"; diff --git a/wp-content/plugins/postsecret-admin/src/Model/Secret.php b/wp-content/plugins/postsecret-admin/src/Model/Secret.php index 9096a52..52bf0cb 100644 --- a/wp-content/plugins/postsecret-admin/src/Model/Secret.php +++ b/wp-content/plugins/postsecret-admin/src/Model/Secret.php @@ -11,12 +11,32 @@ class Secret { public int $id; public string $title; public string $content; - public array $tags = []; + public array $topics = []; + public array $feelings = []; + public array $meanings = []; - public function __construct( int $id, string $title, string $content, array $tags = [] ) { - $this->id = $id; - $this->title = $title; - $this->content = $content; - $this->tags = $tags; + public function __construct( + int $id, + string $title, + string $content, + array $topics = [], + array $feelings = [], + array $meanings = [] + ) { + $this->id = $id; + $this->title = $title; + $this->content = $content; + $this->topics = $topics; + $this->feelings = $feelings; + $this->meanings = $meanings; + } + + /** + * Get all facets combined. + * + * @return array + */ + public function get_all_facets(): array { + return array_merge( $this->topics, $this->feelings, $this->meanings ); } } diff --git a/wp-content/plugins/postsecret-admin/src/Routes/TaxonomyRoute.php b/wp-content/plugins/postsecret-admin/src/Routes/TaxonomyRoute.php deleted file mode 100644 index 48df014..0000000 --- a/wp-content/plugins/postsecret-admin/src/Routes/TaxonomyRoute.php +++ /dev/null @@ -1,37 +0,0 @@ - 'POST', - 'callback' => [ $this, 'handle_taxonomy' ], - 'permission_callback' => function () { - return current_user_can( 'manage_categories' ); - }, - ] - ); - } - - public function handle_taxonomy( \WP_REST_Request $request ) { - // TODO: Implement taxonomy management via TaxonomyService. - return rest_ensure_response( - [ - 'status' => 'success', - ] - ); - } -} diff --git a/wp-content/plugins/postsecret-admin/src/Services/ModerationService.php b/wp-content/plugins/postsecret-admin/src/Services/ModerationService.php index 8fb8ac9..90b3449 100644 --- a/wp-content/plugins/postsecret-admin/src/Services/ModerationService.php +++ b/wp-content/plugins/postsecret-admin/src/Services/ModerationService.php @@ -29,4 +29,54 @@ public function unpublish( int $secret_id ): bool { // TODO: Implement unpublish logic. return true; } + + /** + * Update facets for a secret. + * + * @param int $secret_id Secret attachment ID. + * @param array $topics Topics array. + * @param array $feelings Feelings array. + * @param array $meanings Meanings array. + * @return bool True on success. + */ + public function update_facets( int $secret_id, array $topics = [], array $feelings = [], array $meanings = [] ): bool { + // Normalize and sort each facet array + $topics = array_values( array_filter( array_map( 'sanitize_text_field', $topics ) ) ); + $feelings = array_values( array_filter( array_map( 'sanitize_text_field', $feelings ) ) ); + $meanings = array_values( array_filter( array_map( 'sanitize_text_field', $meanings ) ) ); + + sort( $topics ); + sort( $feelings ); + sort( $meanings ); + + // Update post meta + update_post_meta( $secret_id, '_ps_topics', $topics ); + update_post_meta( $secret_id, '_ps_feelings', $feelings ); + update_post_meta( $secret_id, '_ps_meanings', $meanings ); + + // Also update in payload for consistency + $payload = get_post_meta( $secret_id, '_ps_payload', true ); + if ( is_array( $payload ) ) { + $payload['topics'] = $topics; + $payload['feelings'] = $feelings; + $payload['meanings'] = $meanings; + update_post_meta( $secret_id, '_ps_payload', $payload ); + } + + return true; + } + + /** + * Get facets for a secret. + * + * @param int $secret_id Secret attachment ID. + * @return array Facets organized by type. + */ + public function get_facets( int $secret_id ): array { + return [ + 'topics' => get_post_meta( $secret_id, '_ps_topics', true ) ?: [], + 'feelings' => get_post_meta( $secret_id, '_ps_feelings', true ) ?: [], + 'meanings' => get_post_meta( $secret_id, '_ps_meanings', true ) ?: [], + ]; + } } diff --git a/wp-content/plugins/postsecret-admin/src/Services/SearchService.php b/wp-content/plugins/postsecret-admin/src/Services/SearchService.php index ee284b8..a27c798 100644 --- a/wp-content/plugins/postsecret-admin/src/Services/SearchService.php +++ b/wp-content/plugins/postsecret-admin/src/Services/SearchService.php @@ -11,13 +11,105 @@ class SearchService { /** * Execute a search query against secrets. * - * @param string $query Search query. - * @param array $tags Tag filters. - * @param int $page Page number. - * @return array Results. + * @param string $query Search query. + * @param array $facets Facet filters (topics, feelings, meanings). + * @param int $page Page number. + * @param int $per_page Results per page. + * @return array Results with posts and pagination info. */ - public function search( string $query, array $tags = [], int $page = 1 ): array { - // TODO: Implement actual search logic (WP_Query or custom DB). - return []; + public function search( string $query = '', array $facets = [], int $page = 1, int $per_page = 24 ): array { + $args = [ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + // Build meta query for facets + $meta_query = []; + + if ( ! empty( $facets['topics'] ) ) { + $meta_query[] = [ + 'key' => '_ps_topics', + 'value' => $facets['topics'], + 'compare' => 'IN', + ]; + } + + if ( ! empty( $facets['feelings'] ) ) { + $meta_query[] = [ + 'key' => '_ps_feelings', + 'value' => $facets['feelings'], + 'compare' => 'IN', + ]; + } + + if ( ! empty( $facets['meanings'] ) ) { + $meta_query[] = [ + 'key' => '_ps_meanings', + 'value' => $facets['meanings'], + 'compare' => 'IN', + ]; + } + + if ( ! empty( $meta_query ) ) { + $meta_query['relation'] = 'AND'; + $args['meta_query'] = $meta_query; + } + + // Add text search if query provided + if ( ! empty( $query ) ) { + $args['s'] = $query; + } + + $wp_query = new \WP_Query( $args ); + + return [ + 'posts' => $wp_query->posts, + 'total' => $wp_query->found_posts, + 'total_pages' => $wp_query->max_num_pages, + 'page' => $page, + 'per_page' => $per_page, + ]; + } + + /** + * Get all unique facet values for filtering UI. + * + * @param string $facet_type One of: topics, feelings, meanings. + * @return array Sorted unique values with counts. + */ + public function get_facet_values( string $facet_type ): array { + global $wpdb; + + $meta_key = '_ps_' . $facet_type; + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_value, COUNT(*) as count + FROM {$wpdb->postmeta} + WHERE meta_key = %s + GROUP BY meta_value + ORDER BY count DESC, meta_value ASC", + $meta_key + ) + ); + + $facets = []; + foreach ( $results as $row ) { + $values = maybe_unserialize( $row->meta_value ); + if ( is_array( $values ) ) { + foreach ( $values as $value ) { + if ( ! isset( $facets[ $value ] ) ) { + $facets[ $value ] = 0; + } + $facets[ $value ] += (int) $row->count; + } + } + } + + arsort( $facets ); + return $facets; } } diff --git a/wp-content/plugins/postsecret-admin/src/Services/TaxonomyService.php b/wp-content/plugins/postsecret-admin/src/Services/TaxonomyService.php deleted file mode 100644 index b9ca89f..0000000 --- a/wp-content/plugins/postsecret-admin/src/Services/TaxonomyService.php +++ /dev/null @@ -1,34 +0,0 @@ - 'reclassified'], get_edit_post_link($att, '')); + wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=reclassified')); + exit; + + } catch (\Throwable $e) { + update_post_meta($att, '_ps_last_error', substr($e->getMessage(), 0, 500)); + $url = add_query_arg(['psai_msg' => 'err'], get_edit_post_link($att, '')); + wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=err')); + exit; + } +}); + /* Small admin notice so you know the button worked */ add_action('admin_notices', function () { if (!is_admin() || !isset($_GET['psai_msg'])) return; $msg = sanitize_text_field($_GET['psai_msg']); if ($msg === 'ok') { echo '

PostSecret AI: Attachment normalized.

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

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

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

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

'; } elseif ($msg === 'bad_id') { diff --git a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php index f6305a0..d8286cc 100644 --- a/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php +++ b/wp-content/plugins/postsecret-ai/src/AdminMetaBox.php @@ -22,7 +22,9 @@ public static function add(): void public static function render(\WP_Post $post): void { // Core metas - $tags = get_post_meta($post->ID, '_ps_tags', true) ?: []; + $topics = get_post_meta($post->ID, '_ps_topics', true) ?: []; + $feelings = get_post_meta($post->ID, '_ps_feelings', true) ?: []; + $meanings = get_post_meta($post->ID, '_ps_meanings', true) ?: []; $model = get_post_meta($post->ID, '_ps_model', true); $pver = get_post_meta($post->ID, '_ps_prompt_version', true); $when = get_post_meta($post->ID, '_ps_updated_at', true); @@ -47,12 +49,19 @@ public static function render(\WP_Post $post): void elseif ($near) $status = 'Near-duplicate'; elseif ($payload) $status = 'Classified'; - // Action: Process now (normalizes + classifies if needed) + // Actions: Process now + Re-classify $proc_url = wp_nonce_url( admin_url('admin-post.php?action=psai_process_now&att=' . (int)$post->ID), 'psai_process_now_' . (int)$post->ID ); - echo '

Process now

'; + $reclassify_url = wp_nonce_url( + admin_url('admin-post.php?action=psai_reclassify&att=' . (int)$post->ID), + 'psai_reclassify_' . (int)$post->ID + ); + echo '

'; + echo 'Process now '; + echo 'Re-classify'; + echo '

'; echo '
'; @@ -64,18 +73,47 @@ public static function render(\WP_Post $post): void echo 'Review: ' . esc_html($review) . '
'; echo 'Vetted: ' . esc_html($vetted) . '

'; - // Tags - if ($tags && is_array($tags)) { - echo '

Tags:
'; - foreach ($tags as $t) echo '' . esc_html($t) . ' '; + // Facets + if ($topics && is_array($topics)) { + echo '

Topics:
'; + foreach ($topics as $t) echo '' . esc_html($t) . ' '; + echo '

'; + } + if ($feelings && is_array($feelings)) { + echo '

Feelings:
'; + foreach ($feelings as $f) echo '' . esc_html($f) . ' '; + echo '

'; + } + if ($meanings && is_array($meanings)) { + echo '

Meanings:
'; + foreach ($meanings as $m) echo '' . esc_html($m) . ' '; echo '

'; } + // Teaches Wisdom indicator + $teachesWisdom = get_post_meta($post->ID, '_ps_teaches_wisdom', true); + if ($teachesWisdom === '1') { + echo '

Teaches Wisdom: ✓ Yes

'; + } + // Model / prompt / timestamp echo '

Model: ' . esc_html($model ?: '—') . '
'; echo 'Prompt: ' . esc_html($pver ?: '—') . '
'; echo 'Updated: ' . esc_html($when ?: '—') . '

'; + // Embedding info + $embedding = \PSAI\EmbeddingService::get_embedding($post->ID); + if ($embedding) { + $dim = $embedding['dimension']; + $model_ver = esc_html($embedding['model_version']); + $updated = esc_html($embedding['updated_at']); + echo '

Embedding: ' . $dim . 'd vector
'; + echo 'Model: ' . $model_ver . '
'; + echo 'Generated: ' . $updated . '

'; + } else { + echo '

Embedding: Not generated

'; + } + // Quick visual metadata $orient = get_post_meta($post->ID, '_ps_orientation', true); $primary = get_post_meta($post->ID, '_ps_primary_hex', true); @@ -152,6 +190,9 @@ public static function assets($hook) $css = ' .psai-badge{display:inline-block;padding:2px 8px;border-radius:999px;background:#e9eff5} .psai-chip{display:inline-block;margin:2px 4px 0 0;padding:2px 8px;border-radius:12px;background:#f0f2f4;font-size:12px} + .psai-chip-topic{background:#e6f3ff;color:#0c4a6e} + .psai-chip-feeling{background:#fff4e6;color:#78350f} + .psai-chip-meaning{background:#f0fdf4;color:#14532d} .psai-dot{display:inline-block;font-weight:700} .psai-green{color:#008a20}.psai-blue{color:#2271b1}.psai-gray{color:#777}.psai-amber{color:#b95000}.psai-red{color:#b32d2e} .psai-box details{margin-top:6px} diff --git a/wp-content/plugins/postsecret-ai/src/AttachmentSync.php b/wp-content/plugins/postsecret-ai/src/AttachmentSync.php index 8c46aba..ba5d15b 100644 --- a/wp-content/plugins/postsecret-ai/src/AttachmentSync.php +++ b/wp-content/plugins/postsecret-ai/src/AttachmentSync.php @@ -7,27 +7,33 @@ /** * Syncs AI classification into attachment fields: * - Alt text -> from front/back artDescription (fallback: secretDescription) - * - Caption -> from tags (e.g., "#addiction #remorseful"), max 140 chars + * - Caption -> from facets (topics, feelings, meanings combined as hashtags), max 140 chars * - Description -> from secretDescription (objective summary) * * Rules: - * - Only fill when the field is empty (don’t overwrite manual edits). + * - Only fill when the field is empty (don't overwrite manual edits). * - Never include long transcriptions or anything when containsPII=true. - * - Back attachment gets “Back of postcard …” phrasing. + * - Back attachment gets "Back of postcard …" phrasing. */ final class AttachmentSync { public static function sync_from_payload(int $front_id, array $payload, ?int $back_id = null): void { $containsPII = (bool)($payload['moderation']['containsPII'] ?? false); - $tags = is_array($payload['tags'] ?? null) ? $payload['tags'] : []; + + // Combine all facets for caption + $topics = is_array($payload['topics'] ?? null) ? $payload['topics'] : []; + $feelings = is_array($payload['feelings'] ?? null) ? $payload['feelings'] : []; + $meanings = is_array($payload['meanings'] ?? null) ? $payload['meanings'] : []; + $allFacets = array_merge($topics, $feelings, $meanings); + $secretDesc = self::clean_str($payload['secretDescription'] ?? ''); // FRONT $frontSide = $payload['front'] ?? []; $frontArt = self::clean_str($frontSide['artDescription'] ?? ''); $frontAlt = $frontArt ?: $secretDesc; - $frontCaption = self::format_caption($tags); + $frontCaption = self::format_caption($allFacets); $frontDesc = $secretDesc; self::apply_if_empty($front_id, $frontAlt, $frontCaption, $frontDesc, 'front', $containsPII); @@ -37,7 +43,7 @@ public static function sync_from_payload(int $front_id, array $payload, ?int $ba $backSide = $payload['back'] ?? []; $backArt = self::clean_str($backSide['artDescription'] ?? ''); $altBack = $backArt ?: 'Back of postcard'; - $capBack = $frontCaption; // keep tags consistent + $capBack = $frontCaption; // keep facets consistent $descBack = $containsPII ? '' : self::clean_str($backArt); // stay minimal on back self::apply_if_empty($back_id, $altBack, $capBack, $descBack, 'back', $containsPII); @@ -53,7 +59,7 @@ private static function apply_if_empty(int $att_id, string $alt, string $caption update_post_meta($att_id, '_wp_attachment_image_alt', $alt); } - // CAPTION (tags → “#tag #tag …” up to 140 chars) + // CAPTION (facets → "#facet #facet …" up to 140 chars) $existingPost = get_post($att_id); $existingCap = is_object($existingPost) ? trim((string)$existingPost->post_excerpt) : ''; if ($existingCap === '' && $caption !== '') { @@ -71,12 +77,12 @@ private static function apply_if_empty(int $att_id, string $alt, string $caption } } - private static function format_caption(array $tags): string + private static function format_caption(array $facets): string { - if (empty($tags)) return ''; - // 3–5 tags is plenty for a caption - $tags = array_slice($tags, 0, 5); - $hashes = array_map(fn($t) => '#' . preg_replace('/[^a-z0-9_]/', '', strtolower((string)$t)), $tags); + if (empty($facets)) return ''; + // 3–6 facets is plenty for a caption + $facets = array_slice($facets, 0, 6); + $hashes = array_map(fn($t) => '#' . preg_replace('/[^a-z0-9_]/', '', strtolower((string)$t)), $facets); $cap = implode(' ', $hashes); // keep it terse if (mb_strlen($cap, 'UTF-8') > 140) { diff --git a/wp-content/plugins/postsecret-ai/src/EmbeddingService.php b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php new file mode 100644 index 0000000..d576da7 --- /dev/null +++ b/wp-content/plugins/postsecret-ai/src/EmbeddingService.php @@ -0,0 +1,367 @@ +getMessage()); + 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 + */ + private static function build_embedding_input(array $payload): string + { + $parts = []; + + // Secret description + if (!empty($payload['secretDescription'])) { + $parts[] = 'Secret: ' . $payload['secretDescription']; + } + + // Topics + if (!empty($payload['topics'])) { + $parts[] = 'Topics: ' . implode(', ', $payload['topics']); + } + + // Feelings + if (!empty($payload['feelings'])) { + $parts[] = 'Feelings: ' . implode(', ', $payload['feelings']); + } + + // Meanings + if (!empty($payload['meanings'])) { + $parts[] = 'Meanings: ' . implode(', ', $payload['meanings']); + } + + // Extracted text (front + back, truncated to ~2000 chars total) + $texts = []; + if (!empty($payload['front']['text']['fullText'])) { + $texts[] = $payload['front']['text']['fullText']; + } + if (!empty($payload['back']['text']['fullText'])) { + $texts[] = $payload['back']['text']['fullText']; + } + 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 implode('. ', $parts); + } + + /** + * 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 + */ + 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, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + ], + 'timeout' => 30, + 'body' => wp_json_encode($body), + ]); + + if (is_wp_error($res)) { + error_log('Embedding API 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('Embedding API HTTP ' . $code . ': ' . substr($raw, 0, 500)); + 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)); + return null; + } + + return $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 + { + $magnitude = sqrt(array_sum(array_map(fn($x) => $x * $x, $vector))); + + if ($magnitude == 0) { + return $vector; + } + + return array_map(fn($x) => $x / $magnitude, $vector); + } + + /** + * Store embedding in database. + * + * @param int $secret_id Attachment ID + * @param string $model Model version + * @param array $embedding Embedding vector + * @return bool Success + */ + private static function store_embedding(int $secret_id, string $model, array $embedding): bool + { + global $wpdb; + + $table_name = $wpdb->prefix . 'ps_text_embeddings'; + $dimension = count($embedding); + + // Convert to JSON for storage + $embedding_json = wp_json_encode($embedding); + + $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'] + ); + + return $result !== false; + } + + /** + * Get embedding for a Secret. + * + * @param int $secret_id Attachment ID + * @return array|null Embedding data or null if not found + */ + public static function get_embedding(int $secret_id): ?array + { + global $wpdb; + + $table_name = $wpdb->prefix . 'ps_text_embeddings'; + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM $table_name WHERE secret_id = %d", + $secret_id + ), + ARRAY_A + ); + + if (!$row) { + return null; + } + + // Decode embedding JSON + $row['embedding'] = json_decode($row['embedding'], true); + + return $row; + } + + /** + * 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 + { + global $wpdb; + + $source = self::get_embedding($secret_id); + if (!$source) { + return []; + } + + $table_name = $wpdb->prefix . 'ps_text_embeddings'; + + // 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", + $secret_id, + $source['model_version'] + ), + ARRAY_A + ); + + $source_vector = $source['embedding']; + $results = []; + + foreach ($rows as $row) { + $target_vector = json_decode($row['embedding'], true); + $similarity = self::cosine_similarity($source_vector, $target_vector); + + if ($similarity >= $min_similarity) { + $results[] = [ + 'secret_id' => (int)$row['secret_id'], + 'similarity' => round($similarity, 4), + ]; + } + } + + // Sort by similarity descending + usort($results, fn($a, $b) => $b['similarity'] <=> $a['similarity']); + + return array_slice($results, 0, $limit); + } + + /** + * 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 + { + if (count($a) !== 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]; + } + + $mag_a = sqrt($mag_a); + $mag_b = sqrt($mag_b); + + if ($mag_a == 0 || $mag_b == 0) { + return 0.0; + } + + return $dot / ($mag_a * $mag_b); + } + + /** + * Delete embedding for a Secret. + * + * @param int $secret_id Attachment ID + * @return bool Success + */ + public static function delete_embedding(int $secret_id): bool + { + global $wpdb; + + $table_name = $wpdb->prefix . 'ps_text_embeddings'; + + $result = $wpdb->delete( + $table_name, + ['secret_id' => $secret_id], + ['%d'] + ); + + return $result !== false; + } + + /** + * Get embedding statistics. + * + * @return array Stats: total count, model versions, etc. + */ + public static function get_stats(): array + { + 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, + ]; + } +} diff --git a/wp-content/plugins/postsecret-ai/src/Ingress.php b/wp-content/plugins/postsecret-ai/src/Ingress.php index 4911a46..e2810f8 100644 --- a/wp-content/plugins/postsecret-ai/src/Ingress.php +++ b/wp-content/plugins/postsecret-ai/src/Ingress.php @@ -160,11 +160,20 @@ public static function normalize_from_existing_payload(int $att_id): void function psai_store_result(int $att_id, array $payload, string $model): void { $promptVer = \PSAI\Prompt::VERSION . '#sha256:' . substr(hash('sha256', \PSAI\Prompt::TEXT), 0, 8); - $tags = array_values(array_filter(array_map('strval', $payload['tags'] ?? []))); - sort($tags); + + // Extract and normalize facets + $topics = array_values(array_filter(array_map('strval', $payload['topics'] ?? []))); + $feelings = array_values(array_filter(array_map('strval', $payload['feelings'] ?? []))); + $meanings = array_values(array_filter(array_map('strval', $payload['meanings'] ?? []))); + sort($topics); + sort($feelings); + sort($meanings); update_post_meta($att_id, '_ps_payload', $payload); - update_post_meta($att_id, '_ps_tags', $tags); + update_post_meta($att_id, '_ps_topics', $topics); + update_post_meta($att_id, '_ps_feelings', $feelings); + update_post_meta($att_id, '_ps_meanings', $meanings); + update_post_meta($att_id, '_ps_teaches_wisdom', (bool)($payload['teachesWisdom'] ?? false) ? '1' : '0'); update_post_meta($att_id, '_ps_model', $model); update_post_meta($att_id, '_ps_prompt_version', $promptVer); update_post_meta($att_id, '_ps_updated_at', wp_date('c')); @@ -195,7 +204,9 @@ function psai_update_manifest(int $att_id, array $payload): void $file = wp_basename(get_attached_file($att_id)); $entry = ['sourceImage' => $file, 'json' => $att_id . '.json']; - if (!empty($payload['tags'])) $entry['tags'] = array_values((array)$payload['tags']); + if (!empty($payload['topics'])) $entry['topics'] = array_values((array)$payload['topics']); + if (!empty($payload['feelings'])) $entry['feelings'] = array_values((array)$payload['feelings']); + if (!empty($payload['meanings'])) $entry['meanings'] = array_values((array)$payload['meanings']); // upsert by sourceImage $by = []; diff --git a/wp-content/plugins/postsecret-ai/src/Metadata.php b/wp-content/plugins/postsecret-ai/src/Metadata.php index 2587862..07e3016 100644 --- a/wp-content/plugins/postsecret-ai/src/Metadata.php +++ b/wp-content/plugins/postsecret-ai/src/Metadata.php @@ -48,6 +48,7 @@ private static function orientation_from_size(int $w, int $h): string /** * Returns [primary_hex, palette_hexes[]]. * Tries Imagick (fast, accurate) then GD (portable). Falls back to white. + * Filters palette to ensure minimum perceptual distance between colors. */ private static function palette_hexes(string $path, int $k = 5): array { @@ -66,8 +67,9 @@ private static function palette_hexes(string $path, int $k = 5): array $counts[$hex] = ($counts[$hex] ?? 0) + 1; } arsort($counts); - $hexes = array_slice(array_keys($counts), 0, max(1, $k)); - return [$hexes[0] ?? '#ffffff', $hexes]; + $hexes = array_keys($counts); + $filtered = self::filter_similar_colors($hexes, $k); + return [$filtered[0] ?? '#ffffff', $filtered]; } catch (\Throwable $e) { /* fall through */ } } @@ -113,8 +115,9 @@ private static function palette_hexes(string $path, int $k = 5): array imagedestroy($src); if ($counts) { arsort($counts); - $hexes = array_slice(array_keys($counts), 0, max(1, $k)); - return [$hexes[0], $hexes]; + $hexes = array_keys($counts); + $filtered = self::filter_similar_colors($hexes, $k); + return [$filtered[0], $filtered]; } } } @@ -124,6 +127,119 @@ private static function palette_hexes(string $path, int $k = 5): array return ['#ffffff', ['#ffffff']]; } + /** + * Filter palette to ensure minimum perceptual distance between colors. + * Uses Delta-E (CIE76) in LAB color space for perceptual accuracy. + * + * @param array $hexes Array of hex colors (sorted by frequency) + * @param int $k Maximum colors to return + * @param float $min_distance Minimum Delta-E distance (default: 20) + * @return array Filtered hex colors + */ + private static function filter_similar_colors(array $hexes, int $k = 5, float $min_distance = 20.0): array + { + if (empty($hexes)) return []; + + $filtered = []; + $filtered[] = $hexes[0]; // Always keep the primary color + + foreach ($hexes as $hex) { + if (count($filtered) >= $k) break; + + // Check distance to all already-selected colors + $too_close = false; + foreach ($filtered as $existing) { + $distance = self::color_distance($hex, $existing); + if ($distance < $min_distance) { + $too_close = true; + break; + } + } + + if (!$too_close) { + $filtered[] = $hex; + } + } + + return $filtered; + } + + /** + * Calculate perceptual color distance using Delta-E (CIE76) in LAB space. + * Simplified implementation for performance. + * + * @param string $hex1 First hex color + * @param string $hex2 Second hex color + * @return float Distance (0-100+, typically 0-50 for similar colors) + */ + private static function color_distance(string $hex1, string $hex2): float + { + $rgb1 = self::hex_to_rgb($hex1); + $rgb2 = self::hex_to_rgb($hex2); + + // Convert RGB to LAB (simplified) + $lab1 = self::rgb_to_lab($rgb1); + $lab2 = self::rgb_to_lab($rgb2); + + // Delta-E (CIE76) = sqrt((L2-L1)^2 + (a2-a1)^2 + (b2-b1)^2) + $dL = $lab2[0] - $lab1[0]; + $da = $lab2[1] - $lab1[1]; + $db = $lab2[2] - $lab1[2]; + + return sqrt($dL * $dL + $da * $da + $db * $db); + } + + /** + * Convert hex to RGB array. + */ + private static function hex_to_rgb(string $hex): array + { + $hex = ltrim($hex, '#'); + return [ + hexdec(substr($hex, 0, 2)), + hexdec(substr($hex, 2, 2)), + hexdec(substr($hex, 4, 2)), + ]; + } + + /** + * Convert RGB to LAB color space (simplified). + * Full conversion: RGB -> XYZ -> LAB + */ + private static function rgb_to_lab(array $rgb): array + { + // Normalize RGB to 0-1 + $r = $rgb[0] / 255.0; + $g = $rgb[1] / 255.0; + $b = $rgb[2] / 255.0; + + // Apply gamma correction + $r = ($r > 0.04045) ? pow(($r + 0.055) / 1.055, 2.4) : $r / 12.92; + $g = ($g > 0.04045) ? pow(($g + 0.055) / 1.055, 2.4) : $g / 12.92; + $b = ($b > 0.04045) ? pow(($b + 0.055) / 1.055, 2.4) : $b / 12.92; + + // Convert to XYZ (D65 illuminant) + $x = $r * 0.4124564 + $g * 0.3575761 + $b * 0.1804375; + $y = $r * 0.2126729 + $g * 0.7151522 + $b * 0.0721750; + $z = $r * 0.0193339 + $g * 0.1191920 + $b * 0.9503041; + + // Normalize for D65 white point + $x = $x / 0.95047; + $y = $y / 1.00000; + $z = $z / 1.08883; + + // Convert to LAB + $fx = ($x > 0.008856) ? pow($x, 1/3) : (7.787 * $x + 16/116); + $fy = ($y > 0.008856) ? pow($y, 1/3) : (7.787 * $y + 16/116); + $fz = ($z > 0.008856) ? pow($z, 1/3) : (7.787 * $z + 16/116); + + $L = 116 * $fy - 16; + $a = 500 * ($fx - $fy); + $b = 200 * ($fy - $fz); + + return [$L, $a, $b]; + } + private static function rgb_hex(int $r, int $g, int $b): string { $r = max(0, min(255, $r)); diff --git a/wp-content/plugins/postsecret-ai/src/Prompt.php b/wp-content/plugins/postsecret-ai/src/Prompt.php index 3ad7dec..da36a39 100644 --- a/wp-content/plugins/postsecret-ai/src/Prompt.php +++ b/wp-content/plugins/postsecret-ai/src/Prompt.php @@ -7,7 +7,7 @@ final class Prompt { // bump when TEXT changes - public const VERSION = '3.0.0'; + public const VERSION = '4.1.0'; public const TEXT = <<<'PROMPT' You are the PostSecret classifier. Be concise, neutral, and privacy-preserving. @@ -48,168 +48,116 @@ final class Prompt --- -## OUTPUT SCHEMA (exact key order) - -{ - "tags": ["", "..."], - "secretDescription": "", - "media": { - "type": "", - "defects": { - "overall": { - "sharpness": "", - "exposure": "", - "colorCast": "", - "severity": "", - "notes": "" - }, - "defects": [ - { - "code": "", - "severity": "", - "coverage": 0.00, - "confidence": 0.00, - "region": { "x": 0.00, "y": 0.00, "w": 0.00, "h": 0.00 }, - "where": "" - } - ] - }, - "defectSummary": "<≤120 chars; one clause summarizing top issues or empty string>" - }, - "front": { - "artDescription": "<12–30 words on front visual style and notable elements>", - "fontDescription": { - "style": "", - "notes": "" - }, - "text": { - "fullText": "" or null, - "language": "", - "handwriting": true - } - }, - "back": { - "artDescription": "<12–30 words on back visual style and notable elements>", - "fontDescription": { - "style": "", - "notes": "" - }, - "text": { - "fullText": "" or null, - "language": "", - "handwriting": false - } - }, - "moderation": { - "reviewStatus": "", - "labels": ["", "..."], - "nsfwScore": 0.00, - "containsPII": false, - "piiTypes": [] - }, - "confidence": { - "overall": 0.00, - "byField": { - "tags": 0.00, - "media.defects": 0.00, - "artDescription": 0.00, - "fontDescription": 0.00, - "moderation": 0.00 - } - } -} - ---- +Here's a clean, AI-first facet spec focused on **topics, meanings, and feelings**—no materials, colors, or layout/style. -Here’s a clean, AI-first tag spec focused on **topics, meaning, and feelings**—no materials, colors, or layout/style. - -# Tags (Global, High-Signal) +# Facets (Global, High-Signal) ## Purpose -Provide concise, searchable labels that help curators and readers find Secrets by **topic** (what it’s about), **meaning** (what it says/teaches), and **feeling** (how it sounds). Avoid surface/visual tags. +Provide concise, searchable labels organized into three distinct facets: +- **Topics**: What the secret is about (subjects, life domains, themes) +- **Feelings**: Emotional tone and stance expressed +- **Meanings**: Insights, lessons, or purposes conveyed + +These facets enable semantic search and embedding-based similarity. ## Output requirements -* **Count:** 3–8 total tags. -* **Mix:** **2–4 themes** + **0–2 tones**. -* **Format:** `lower_snake_case`, unique, **lexicographically sorted**. +* **Topics:** 2–4 items (themes from categories below) +* **Feelings:** 0–3 items (emotional tones, only if clear) +* **Meanings:** 0–2 items (insights/lessons, only if present) +* **Format:** `lower_snake_case`, unique within each array, **lexicographically sorted** * **Scope:** Reflect the **overall** Secret (front and back combined). No PII. --- -## Theme Categories (topics & meaning) +## Topic Categories -Pick the most specific themes that clearly fit. If nothing specific is evident, you may use one generic theme (e.g., a generic “confession/secrets” concept). +Pick 2–4 specific topics that clearly fit. Topics describe **what the secret is about** (subjects, life domains, themes). +If the secret is extremely short or ambiguous, you may return only one topic (e.g. confession). 1. **Relationships & Family** Romantic dynamics, breakups/divorce, parenting, pregnancy, family roles, betrayals, friendships, attachment/loneliness. + Examples: `romantic_relationship`, `infidelity`, `parenting`, `family_conflict`, `friendship`, `divorce` 2. **Identity & Belonging** Self-concept, social belonging/outsider feelings, values/faith/doubt, presentation, acceptance vs. concealment. + Examples: `identity`, `self_acceptance`, `belonging`, `faith`, `coming_out`, `outsider` 3. **Health & Mind** Physical/mental health experiences, disability, coping, grief/loss, substance use and recovery, fear/stress. + Examples: `mental_health`, `grief`, `loss`, `substance_use`, `anxiety`, `depression`, `coping` 4. **Life Stages & Pressure** School/work pressures, money/poverty/debt, aging, ambition, regret, shame/guilt about life choices. + Examples: `work_pressure`, `financial_stress`, `regret`, `shame`, `ambition`, `aging` 5. **Acts & Events** Confessions, transgressions, making amends, coming out/reveals, major life events (moves, weddings, funerals), consequences. + Examples: `confession`, `transgression`, `revelation`, `life_event`, `consequences` + +> You may coin a short, concrete topic within one category when needed. Keep it broadly useful (no PII; avoid niche jargon). + +--- + +## Feeling Categories -6. **Insight (wisdom/lesson/learning)** - Lessons learned, cautions/warnings, advice offered, growth/acceptance/forgiveness/redemption, resilience/resolve. +Add 0–3 feelings if emotion is clear from language or unmistakable context. Feelings describe **emotional tone and stance**. -> You may coin a short, concrete theme within one category when needed. Keep it broadly useful (no PII; avoid niche jargon). +* **Contrition/Responsibility**: `remorseful`, `guilty`, `apologetic`, `ashamed` +* **Hope/Resolve**: `hopeful`, `accepting`, `determined`, `resilient`, `forgiving` +* **Pain/Distress**: `despairing`, `anxious`, `overwhelmed`, `lonely`, `hurt` +* **Anger/Defiance**: `angry`, `bitter`, `defiant`, `resentful` +* **Nostalgia/Sadness**: `wistful`, `nostalgic`, `sad`, `melancholic` +* **Disclosure/Stance**: `confessional`, `conflicted`, `relieved`, `resigned` + +If emotion is ambiguous or unclear, omit feelings rather than guess. --- -## Tone Categories (feelings & stance) +## Meaning Categories + +Add 0–2 meanings if the secret conveys insight, lesson, or purpose. Meanings describe **what the secret teaches or expresses**. + +* **Lessons**: `life_lesson`, `cautionary`, `wisdom`, `realization` +* **Growth**: `personal_growth`, `acceptance`, `forgiveness`, `redemption` +* **Reflection**: `introspection`, `self_awareness`, `hindsight` +* **Communication**: `seeking_forgiveness`, `making_amends`, `disclosure`, `warning` -Add up to **two** tones if emotion is clear from language or unmistakable context. Otherwise, omit tones. +Look for cues like "I learned…", "If I could tell you…", "Don't…", "I realized…", "Now I know…" -* **Contrition/Responsibility** (e.g., remorse, guilt, apology) -* **Hope/Resolve** (e.g., hopeful, accepting, determined) -* **Pain/Distress** (e.g., despairing, anxious, overwhelmed) -* **Anger/Defiance** (e.g., angry, bitter, defiant) -* **Nostalgia/Sadness** (e.g., wistful, nostalgic, lonely) -* **Disclosure/Stance** (e.g., confessional, conflicted, relieved) +If no clear lesson or purpose, omit meanings. --- -## Tag Shape & Style +## Facet Shape & Style -* **Form:** short nouns/gerunds; 1–3 words joined by underscores. -* **Generalizable:** broadly useful to curators/readers; avoid hyper-specific one-offs. -* **Examples (schematic only):** +* **Form:** short nouns/gerunds; 1–3 words joined by underscores +* **Generalizable:** broadly useful to curators/readers; avoid hyper-specific one-offs +* **No PII:** Never include names, addresses, contact details, usernames, or doxxing hints +* **No clinical labels:** Don't assign diagnoses unless **explicitly** stated; prefer emotional feelings instead - * Themes: `relationship_topic`, `family_dynamic`, `work_pressure`, `financial_stress`, `identity_reveal`, `grief_event`, `life_lesson` - * Tones: `remorseful_tone`, `defiant_tone`, `hopeful_tone`, `nostalgic_tone` - --- ## Selection heuristics (flexible, not rigid) -1. **Themes first.** - Choose **2–4** themes that are explicit or unmistakable from text or imagery. Prefer **specific** over generic (`infidelity` > `love`). If nothing specific, use exactly one fallback: `secrets` **or** `confession`. +1. **Topics (required: 2–4)** + Choose specific topics that are explicit or unmistakable from text or imagery. Prefer **specific** over generic (`infidelity` > `romantic_relationship`). If nothing specific is evident, use exactly one generic fallback: `confession`. -2. **Insight when present.** - If the Secret teaches/reflects/advices, include **up to two** Insight tags (e.g., `life_lesson`, `cautionary`, `personal_growth`, `wisdom`). Look for cues like “I learned…”, “If I could tell you…”, “Don’t…”, “I realized…”. +2. **Feelings (optional: 0–3)** + Add feelings when emotion is clear from language (e.g., "I'm so sorry" → `remorseful`; "I'm done" → `resigned`; "I forgive you" → `forgiving`). If uncertain, omit rather than guess. -3. **Tones are optional.** - Add **0–2** tones when emotion is clear (e.g., “I’m so sorry” → `remorseful`; “I’m done” → `resigned`; “I forgive you” → `forgiving`). If uncertain, omit rather than guess. +3. **Meanings (optional: 0–2)** + Add meanings if the Secret teaches, reflects, advises, or conveys growth/redemption. Look for explicit cues. If absent, omit. -4. **Front/back reconciliation.** - Merge evidence from both sides, dedupe, and keep the **clearest** themes. For tones, keep at most **two** that best capture the overall feeling. +4. **Front/back reconciliation** + Merge evidence from both sides, dedupe within each facet array, and sort lexicographically. -5. **Signal over noise.** - Every tag should help retrieval or curation. Drop decorative or redundant choices. Stay within **3–6** total. +5. **Signal over noise** + Every item should help retrieval or curation. Drop decorative or redundant choices. -6. **Safety & PII.** - Never create tags that include names, addresses, contact details, usernames, or doxxing hints. Don’t assign clinical diagnoses unless **explicitly** stated; prefer emotional tones instead. - -7. **Formatting checks.** - Lowercase, underscores for spaces, sort lexicographically, no duplicates. +6. **Formatting checks** + Lowercase, underscores for spaces, sort lexicographically within each array, no duplicates within array. --- @@ -259,16 +207,91 @@ final class Prompt Set `confidence.byField` individually (0.00–1.00), then compute `confidence.overall` as weighted mean: -* `tags` 0.20, `media.defects` 0.20, `artDescription` 0.15, `fontDescription` 0.15, `moderation` 0.30. +* `facets` 0.20, `media.defects` 0.20, `artDescription` 0.15, `fontDescription` 0.15, `moderation` 0.30. Rubric: **0.90–1.00** crisp/unambiguous; **0.60–0.89** minor ambiguity; **0.30–0.59** multiple uncertainties; **<0.30** largely unreadable. --- -## Defaults (when side missing or unreadable) +## OUTPUT SCHEMA (exact key order) + +{ + "topics": ["", "..."], + "feelings": ["", "..."], + "meanings": ["", "..."], + "secretDescription": "", + "teachesWisdom": , + "media": { + "type": "", + "defects": { + "overall": { + "sharpness": "", + "exposure": "", + "colorCast": "", + "severity": "", + "notes": "" + }, + "defects": [ + { + "code": "", + "severity": "", + "coverage": 0.00, + "confidence": 0.00, + "region": { "x": 0.00, "y": 0.00, "w": 0.00, "h": 0.00 }, + "where": "" + } + ] + }, + "defectSummary": "<≤120 chars; one clause summarizing top issues or empty string>" + }, + "front": { + "artDescription": "<12–30 words on front visual style and notable elements>", + "fontDescription": { + "style": "", + "notes": "" + }, + "text": { + "fullText": "" or null, + "language": "" + } + }, + "back": { + "artDescription": "<12–30 words on back visual style and notable elements>", + "fontDescription": { + "style": "", + "notes": "" + }, + "text": { + "fullText": "" or null, + "language": "" + } + }, + "moderation": { + "reviewStatus": "", + "labels": ["", "..."], + "nsfwScore": 0.00, + "containsPII": false, + "piiTypes": [] + }, + "confidence": { + "overall": 0.00, + "byField": { + "facets": 0.00, + "media.defects": 0.00, + "artDescription": 0.00, + "fontDescription": 0.00, + "moderation": 0.00 + } + } +} + +--- +## Defaults (when side missing or unreadable) { - "tags": [], + "topics": [], + "feelings": [], + "meanings": [], "secretDescription": "", "media": { "type": "unknown", @@ -304,7 +327,7 @@ final class Prompt "confidence": { "overall": 0.00, "byField": { - "tags": 0.00, + "facets": 0.00, "media.defects": 0.00, "artDescription": 0.00, "fontDescription": 0.00, diff --git a/wp-content/plugins/postsecret-ai/src/SchemaGuard.php b/wp-content/plugins/postsecret-ai/src/SchemaGuard.php index 578d4c9..25cf4a3 100644 --- a/wp-content/plugins/postsecret-ai/src/SchemaGuard.php +++ b/wp-content/plugins/postsecret-ai/src/SchemaGuard.php @@ -38,13 +38,16 @@ final class SchemaGuard private const DEF_SIDE = [ 'artDescription' => '', 'fontDescription' => ['style' => 'unknown', 'notes' => ''], - 'text' => ['fullText' => null, 'language' => 'unknown', 'handwriting' => false], + 'text' => ['fullText' => null, 'language' => 'unknown'], ]; /** Full defaults shape */ private const DEF_PAYLOAD = [ - 'tags' => [], + 'topics' => [], + 'feelings' => [], + 'meanings' => [], 'secretDescription' => '', + 'teachesWisdom' => false, 'media' => [ 'type' => 'unknown', 'defects' => [ @@ -65,7 +68,7 @@ final class SchemaGuard 'confidence' => [ 'overall' => 0.00, 'byField' => [ - 'tags' => 0.00, + 'facets' => 0.00, 'media.defects' => 0.00, 'artDescription' => 0.00, 'fontDescription' => 0.00, @@ -96,12 +99,17 @@ public static function normalize($in): array $out = self::DEF_PAYLOAD; - // tags - $out['tags'] = self::norm_list($p['tags'] ?? [], maxLen: 8); + // facets + $out['topics'] = self::norm_list($p['topics'] ?? [], maxLen: 4); + $out['feelings'] = self::norm_list($p['feelings'] ?? [], maxLen: 3); + $out['meanings'] = self::norm_list($p['meanings'] ?? [], maxLen: 2); // secretDescription $out['secretDescription'] = self::norm_str($p['secretDescription'] ?? ''); + // teachesWisdom + $out['teachesWisdom'] = (bool)($p['teachesWisdom'] ?? false); + // media $out['media']['type'] = self::enum($p['media']['type'] ?? 'unknown', 'media.type'); $ov = $p['media']['defects']['overall'] ?? []; @@ -137,7 +145,7 @@ public static function normalize($in): array $out['confidence'] = [ 'overall' => self::f01($c['overall'] ?? 0.00), 'byField' => [ - 'tags' => self::f01($bf['tags'] ?? 0.00), + 'facets' => self::f01($bf['facets'] ?? 0.00), 'media.defects' => self::f01($bf['media.defects'] ?? 0.00), 'artDescription' => self::f01($bf['artDescription'] ?? 0.00), 'fontDescription' => self::f01($bf['fontDescription'] ?? 0.00), @@ -168,7 +176,6 @@ private static function norm_side($side): array $full = is_string($full) ? self::norm_text($full, 2000) : null; $lang = strtolower(self::norm_str($tx['language'] ?? 'unknown')); if ($lang === '') $lang = 'unknown'; - $hand = (bool)($tx['handwriting'] ?? false); return [ 'artDescription' => $art, @@ -176,7 +183,6 @@ private static function norm_side($side): array 'text' => [ 'fullText' => $full, 'language' => $lang, - 'handwriting' => $hand, ], ]; } diff --git a/wp-content/themes/postsecret/parts/card.php b/wp-content/themes/postsecret/parts/card.php index d6e06bb..dc69359 100644 --- a/wp-content/themes/postsecret/parts/card.php +++ b/wp-content/themes/postsecret/parts/card.php @@ -23,6 +23,32 @@
diff --git a/wp-content/themes/postsecret/single-secret.php b/wp-content/themes/postsecret/single-secret.php index f685653..99f5f94 100644 --- a/wp-content/themes/postsecret/single-secret.php +++ b/wp-content/themes/postsecret/single-secret.php @@ -19,7 +19,33 @@ From 9c8a0198195f03f9db46d6a1fa37bc8cc4967a79 Mon Sep 17 00:00:00 2001 From: Flatts Date: Wed, 1 Oct 2025 21:07:10 -0400 Subject: [PATCH 2/2] feat: add AI plugin and integrate faceted classification with embeddings Introduced the PostSecret AI plugin for advanced classification, metadata extraction, and semantic embeddings. Updated admin features to support facet-based classification (topics, feelings, meanings), similarity search, and enhanced moderation workflows. Added migrations for facets and embeddings, integrating them into search and backfill processes. Refined public search with facet filters and enhanced editor capabilities for managing facets. --- CLAUDE.md | 157 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6eb16e5..2bc8e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,9 @@ PostSecret is a WordPress-based archive, search, and moderation system for the P **Core Components:** - **Custom WordPress Theme** (`wp-content/themes/postsecret/`) - Public-facing archive, search, and detail pages -- **Admin Plugin** (`wp-content/plugins/postsecret-admin/`) - Moderation queues, taxonomy governance, audit logging, backfill jobs, and settings -- **MySQL Database** - Canonical secret records with full-text and tag indexes +- **Admin Plugin** (`wp-content/plugins/postsecret-admin/`) - Moderation queues, audit logging, backfill jobs, and settings +- **AI Plugin** (`wp-content/plugins/postsecret-ai/`) - Classification, faceted metadata extraction, and embeddings +- **MySQL Database** - Canonical secret records with full-text and facet indexes, plus embeddings table **Product Principles:** - Product-first, single-stack, accessible by default, privacy-preserving @@ -52,27 +53,33 @@ vendor/bin/phpcs **Bootstrap:** `postsecret-admin.php` initializes all route classes on `plugins_loaded` hook. **Key Components:** -- **Routes/** - Request handlers for admin endpoints (Search, Review, Taxonomy, Backfill, Settings) +- **Routes/** - Request handlers for admin endpoints (Search, Review, Backfill, Settings) - **Services/** - Business logic layer: - - `SearchService` - Full-text + tag search (tokenized, stemmed) - - `ModerationService` - Queue management and approval workflows - - `TaxonomyService` - Tag merge/alias operations + - `SearchService` - Full-text + facet search (supports topics, feelings, meanings) + - `ModerationService` - Queue management, approval workflows, and facet editing - `LoggingService` - Audit trail (actor, action, timestamp, context) - `ConfigService` - Policy thresholds and settings -- **Model/** - DTOs like `Secret` (id, title, content, tags) +- **Model/** - DTOs like `Secret` (id, title, content, topics, feelings, meanings) - **Util/** - Sanitization (`Sanitize`) and capability checks (`Caps`) - **CLI/** - WP-CLI commands via `class-ps-cli.php` - `wp postsecret backfill --batch= --rate=` -- **migrations/** - Database schema files (e.g., `001_init.php`) +- **migrations/** - Database schema files (e.g., `001_init.php`, `002_facets.php`, `003_embeddings.php`) **Database Tables:** - `ps_classification` - OCR text, descriptors, confidence scores, moderation state - `ps_audit_log` - Complete audit trail of privileged actions (append-only, immutable) -- `ps_tag_alias` - Tag normalization (alias → canonical) +- `ps_text_embeddings` - Semantic embeddings for similarity search (1536d vectors) - `ps_backfill_job`, `ps_backfill_item` - Backfill progress tracking, checkpoints, error quarantine **Canonical Secret Record Structure:** -Each Secret has: image pointers, extracted/approved text, tags, media descriptors (art/font/media), moderation state (pending/needs_review/approved/published/unpublished/flagged), confidence scores, provenance metadata. +Each Secret has: image pointers, extracted/approved text, **faceted classification (topics, feelings, meanings)**, media descriptors (art/font/media), moderation state (pending/needs_review/approved/published/unpublished/flagged), confidence scores, provenance metadata, semantic embedding. + +**Faceted Classification:** +- **Topics** (2-4): What the secret is about (subjects, life domains, themes) +- **Feelings** (0-3): Emotional tone and stance expressed +- **Meanings** (0-2): Insights, lessons, or purposes conveyed +- Stored as `_ps_topics`, `_ps_feelings`, `_ps_meanings` post meta (arrays) +- Enables multi-dimensional search and semantic clustering ### Theme Architecture (`postsecret`) @@ -95,9 +102,9 @@ Each Secret has: image pointers, extracted/approved text, tags, media descriptor ### Data Flow **Public Search:** -1. User submits query + optional tag filters +1. User submits query + optional facet filters (topics, feelings, meanings) 2. Theme calls `SearchService->search()` -3. Service executes tokenized/stemmed MySQL full-text query with tag JOIN +3. Service executes tokenized/stemmed MySQL full-text query with facet meta_query filters 4. Results sorted by relevance (default) or recency 5. Paginated results rendered via theme templates @@ -105,10 +112,19 @@ Each Secret has: image pointers, extracted/approved text, tags, media descriptor 1. Moderator accesses queue via `ReviewRoute` 2. `ModerationService` fetches items by state (needs_review, low_confidence, flagged, published) 3. Moderator reviews item with confidence indicators and policy signals -4. Actions: approve/publish/unpublish/re-review/edit tags (with capability + nonce checks) +4. Actions: approve/publish/unpublish/re-review/edit facets (with capability + nonce checks) 5. `LoggingService` records action (actor, target, before/after, timestamp, outcome) to append-only audit log 6. State transition committed; caches invalidated +**AI Classification Flow:** +1. Image uploaded via single-postcard uploader or backfill +2. `Classifier` sends image to OpenAI vision model with structured prompt +3. `SchemaGuard` normalizes response (facets, text extraction, moderation signals) +4. `Ingress` stores classification data as post meta +5. `EmbeddingService` generates semantic embedding from facets + text +6. Embedding stored in `ps_text_embeddings` for similarity search +7. `AttachmentSync` updates attachment alt text/caption with facets + **Backfill (Historical Import ~1M Secrets):** 1. Initiated via WP-CLI (`wp postsecret backfill`) or admin UI (`BackfillRoute`) 2. Jobs are resumable (checkpoint per batch), idempotent (hash/signature), bounded retries @@ -139,19 +155,21 @@ Each Secret has: image pointers, extracted/approved text, tags, media descriptor **i18n:** All strings wrapped in translation functions (`__()`, `_e()`, `esc_html__()`). Text domain: `postsecret` (theme), `postsecret-admin` (plugin). **Performance Targets:** -- p95 search ≤ 600 ms (server processing for text+tag queries) +- p95 search ≤ 600 ms (server processing for text + facet queries) - Cold cache ≤ 1.2 s (triggers alert if sustained) - Mobile time-to-first-useful-result ≤ 2.5 s (4G) - Core Web Vitals: LCP/INP/CLS in "Good" ranges - Uptime ≥ 99.9%; ≤ 0.25% 5xx error rate on public search endpoints - Admin queue load p95 ≤ 400 ms; item open p95 ≤ 300 ms +- Similarity search p95 ≤ 900 ms for top-10 results **Caching Strategy:** - Object cache for query fragments -- Short-TTL page/query caches (vary by q|tags|sort|page) +- Short-TTL page/query caches (vary by q|facets|sort|page) - HTTP caching headers on public routes -- Cache busting on publish/unpublish and tag merges +- Cache busting on publish/unpublish and facet edits - Image lazy-load + responsive srcset +- Embedding generation cached (only regenerated on re-classification) ## Testing @@ -167,8 +185,7 @@ vendor/bin/phpunit --filter=test_name ## Roles & Capabilities **Admin Role (MVP):** -- Full editorial control: review queues, approve/publish/unpublish, re-review, edit tags -- Taxonomy governance: merge/alias/delete tags +- Full editorial control: review queues, approve/publish/unpublish, re-review, edit facets - Settings: configure confidence thresholds, policy gates - Audit logs: view/export all privileged actions @@ -177,12 +194,12 @@ vendor/bin/phpunit --filter=test_name - `ps.review.queue` - View triage queues & item details - `ps.review.act` - Approve/reject/re-review items - `ps.publish` - Publish/unpublish items -- `ps.tags.merge` - Merge/alias/delete tags +- `ps.facets.edit` - Edit facets (topics, feelings, meanings) - `ps.logs.view` - View/export audit logs **Access Control Principles:** - Least privilege: assign minimal capabilities needed -- Separation of duties: publishing, taxonomy merges, settings are distinct powers +- Separation of duties: publishing, facet editing, settings are distinct powers - Explicit gating: all admin actions require both capability checks AND nonces - Auditability: every privileged action logged with actor, timestamp, target, outcome - Deny by default: `current_user_can()` checks on every route/action @@ -201,10 +218,10 @@ vendor/bin/phpunit --filter=test_name 3. Document synopsis with `@synopsis` docblock **Database migrations:** -1. Create new file in `migrations/` (e.g., `002_description.php`) -2. Implement `up()` function with SQL +1. Create new file in `migrations/` (e.g., `004_description.php`) +2. Implement `up()` function with SQL in `PostSecret\Admin\Migrations` namespace 3. Use `dbDelta()` for table creation/updates -4. Trigger via WP-CLI or admin migration UI +4. Run via `wp-content/plugins/postsecret-admin/run-migrations.php` **Adding a new service:** 1. Create class in `src/Services/` @@ -212,12 +229,11 @@ vendor/bin/phpunit --filter=test_name 3. Inject via constructor or use singleton pattern 4. Call from route handlers -**Taxonomy operations (merge/alias):** -1. Use `TaxonomyService->merge()` or `->alias()` methods -2. Mark deprecated tag as alias pointing to canonical -3. Reindex affected Secrets asynchronously -4. Log operation to audit trail with actor and rationale -5. Target: ≤1% duplicate/orphan tag operations +**Facet operations:** +1. Use `ModerationService->update_facets()` to update topics/feelings/meanings +2. Facets stored as arrays in `_ps_topics`, `_ps_feelings`, `_ps_meanings` post meta +3. SearchService supports filtering by any facet type +4. Embeddings automatically regenerate on re-classification **Handling bulk operations:** 1. Validate capability + nonce before processing @@ -229,13 +245,27 @@ vendor/bin/phpunit --filter=test_name ## Key Files Reference +**Admin Plugin:** - Plugin entry: `wp-content/plugins/postsecret-admin/postsecret-admin.php` -- Theme entry: `wp-content/themes/postsecret/functions.php` -- WP-CLI commands: `wp-content/plugins/postsecret-admin/cli/class-ps-cli.php` -- Database schema: `wp-content/plugins/postsecret-admin/migrations/001_init.php` - Search logic: `wp-content/plugins/postsecret-admin/src/Services/SearchService.php` - Moderation flow: `wp-content/plugins/postsecret-admin/src/Services/ModerationService.php` - Audit logging: `wp-content/plugins/postsecret-admin/src/Services/LoggingService.php` +- Database migrations: `wp-content/plugins/postsecret-admin/migrations/` +- WP-CLI commands: `wp-content/plugins/postsecret-admin/cli/class-ps-cli.php` + +**AI Plugin:** +- Plugin entry: `wp-content/plugins/postsecret-ai/postsecret-ai.php` +- Prompt (v4.1.0): `wp-content/plugins/postsecret-ai/src/Prompt.php` +- Classification: `wp-content/plugins/postsecret-ai/src/Classifier.php` +- Schema validation: `wp-content/plugins/postsecret-ai/src/SchemaGuard.php` +- Embeddings: `wp-content/plugins/postsecret-ai/src/EmbeddingService.php` +- Metadata (colors/orientation): `wp-content/plugins/postsecret-ai/src/Metadata.php` +- Storage: `wp-content/plugins/postsecret-ai/src/Ingress.php` + +**Theme:** +- Theme entry: `wp-content/themes/postsecret/functions.php` +- Secret card: `wp-content/themes/postsecret/parts/card.php` +- Single view: `wp-content/themes/postsecret/single-secret.php` ## Public UI Requirements @@ -243,27 +273,29 @@ vendor/bin/phpunit --filter=test_name - Input: debounced 250-400ms; Enter submits immediately; Esc clears text focus - Parsing: plain keywords, case-insensitive, ASCII folding (no boolean operators required at MVP) - Scope: queries run against approved text fields (OCR/model text) -- Facets: multi-select tags with counts; selected tags shown as removable chips +- Facets: multi-select filters for topics, feelings, meanings with counts; selected facets shown as removable chips - Sorting: relevance (default), recency - Pagination: server-side, deterministic ordering; 24 items/page (desktop), 12 (mobile) - URL state: all search states (query + facets + sort + page) encoded in URL and shareable **Result Cards:** - Image thumbnail (aspect-aware, lazy-loaded) with alt text from descriptors -- Key tags (up to 3 chips; overflow "+N") +- Key facets (up to 3 chips combined from topics/feelings/meanings; overflow "+N") - Text excerpt (first ~140 chars of approved text; ellipsis if truncated) - Safe indicators (e.g., "Content advisory" icon if applicable) - Click target: entire card opens detail view **Detail View:** - Large image with zoom/lightbox; alt text provided -- Tags, art/font/media descriptors, orientation +- Facets organized by type (Topics, Feelings, Meanings) +- Art/font/media descriptors, orientation - Approved extracted text; language label if non-English +- "Teaches Wisdom" indicator if applicable - Postmark/ingest date (if public-safe), canonical link -- Placeholder area for Phase 2 "Find similar" module +- "Find similar" button (uses embedding similarity) **Empty/Error States:** -- Zero results: guidance ("Try fewer tags", "Check spelling") + top tags +- Zero results: guidance ("Try fewer facets", "Check spelling") + popular facets - Partial results: non-blocking alert if facet fails; retry affordance - Errors: friendly message + retry; no stack traces; status logged server-side @@ -271,18 +303,24 @@ vendor/bin/phpunit --filter=test_name **Queues:** - Views: Needs Review, Low Confidence, Flagged, Published (read-only) -- Display: paginated tables/grids with thumbnail, key tags, confidence badge, review status, last action/actor, updated time -- Filters: tags (multi-select), status, confidence range slider, date range, text contains +- Display: paginated tables/grids with thumbnail, key facets, confidence badge, review status, last action/actor, updated time +- Filters: facets (multi-select by type), status, confidence range slider, date range, text contains - Sorting: updated time (default), confidence, recency - Batch size: 25 per page (configurable) -**Item Detail Panel:** -- Full image (zoom), approved text, language, descriptors (tags, art/font/media) -- Signals: confidence (overall + by-field), moderation labels, NSFW/self-harm flags, policy notes +**Item Detail Panel (AdminMetaBox):** +- Full image (zoom), approved text, language +- Facets displayed by type: Topics (blue), Feelings (amber), Meanings (green) +- "Teaches Wisdom" indicator if present +- Art/font/media descriptors, orientation, color palette +- Embedding status (dimension, model, generation timestamp) +- Signals: confidence (overall + by-field including facets), moderation labels, NSFW/self-harm flags +- Process now / Re-classify buttons - History: last 5 actions (actor, timestamp, summary); link to full audit log **Editorial Actions:** -- Single-item: Approve, Publish, Unpublish, Send to Re-review, Edit/Add Tags, Edit Text (approved field), Add Note (internal) +- Single-item: Approve, Publish, Unpublish, Send to Re-review, Edit Facets, Edit Text (approved field), Add Note (internal) +- Re-classify: Force new AI classification (regenerates facets + embeddings) - Guards: confirmation dialogs for Publish/Unpublish; policy interstitials for flagged content - Undo: 30-second inline undo for Publish/Unpublish where feasible - Provenance: all edits record actor, timestamp, rationale (optional note) @@ -303,16 +341,31 @@ vendor/bin/phpunit --filter=test_name - `docs/DEV_SETUP.md` - Detailed setup instructions - `docs/MODERATION_GUIDE.md` - Queue review workflows -- `docs/TAG_GOVERNANCE.md` - Taxonomy management guidelines - `README.md` - High-level project overview and architecture -## Future: Phase 2 Similarity Search +## Semantic Similarity Search + +**Implementation Status:** Core infrastructure complete, UI integration pending + +**Architecture:** +- Embeddings generated automatically on classification via `EmbeddingService` +- Input: "Secret: [desc]. Topics: [t1,t2]. Feelings: [f1]. Meanings: [m1]. Text: [extracted]" +- Model: `text-embedding-3-small` (1536 dimensions, OpenAI) +- Storage: `ps_text_embeddings` table (secret_id, model_version, embedding JSON, dimension, timestamps) +- Similarity: Cosine distance in LAB color space for perceptual accuracy + +**Methods:** +- `EmbeddingService::generate_and_store()` - Generate and store embedding +- `EmbeddingService::find_similar()` - Top-K similarity search with configurable threshold +- `EmbeddingService::get_stats()` - Embedding coverage statistics -**Out of MVP scope** - designed for pluggable integration without re-platforming: +**Future Scaling:** +- Current: In-memory cosine similarity (works for <10K Secrets) +- Phase 2: Export to vector DB (Qdrant, Weaviate, pgvector) for production scale +- Target: p95 ≤ 900 ms for top-10 similarity; ≥15% CTR on "Find similar" button +- Safety: only public-safe items are candidates; respects all policy gates -- Entry points: "Similar Secrets" module on detail page (lazy-loaded); "Find similar" button on result cards -- Signals: visual embedding similarity, text embedding similarity, tag overlap boost, freshness -- Ranking: cosine distance on embeddings; tag overlap and moderation safety boosts; near-duplicate suppression -- Storage: embeddings stored as artifacts linked to canonical Secret record (model name, dimension, timestamp) -- Target: p95 ≤ 900 ms for top-K similarity request; ≥15% CTR on detail pages -- Safety: only public-safe items are candidates; respects all policy gates \ No newline at end of file +**Color Palette:** +- Perceptual distance filtering (Delta-E ≥ 20) prevents similar colors +- RGB → LAB conversion for human-perceived color differences +- Ensures palette diversity (no more #58af67, #58af69, #5aae67) \ No newline at end of file