From 8dfeb93ae3c69bca1babaa0efb6e20fe67843ee2 Mon Sep 17 00:00:00 2001
From: Flatts 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 " ✓ SuccessPostSecret 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 "" . esc_html( $result['output'] ) . "
\n";
+ }
+ } else {
+ echo " ✗ Failed
Error: " . esc_html( $result['error'] ) . "
\n"; + if ( ! empty( $result['output'] ) ) { + echo "" . esc_html( $result['output'] ) . "\n"; + } + } +} + +echo "
Migrations complete!
\n"; +echo "\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.
PostSecret AI: Attachment re-classified with latest AI model and prompt.
PostSecret AI: There was an error. See the meta box for details.
'; + echo 'Process now '; + echo 'Re-classify'; + echo '
'; 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 '
Feelings:
';
+ foreach ($feelings as $f) echo '' . esc_html($f) . ' ';
+ echo '
Meanings:
';
+ foreach ($meanings as $m) echo '' . esc_html($m) . ' ';
echo '
Teaches Wisdom: ✓ Yes
'; + } + // Model / prompt / timestamp echo 'Model: ' . esc_html($model ?: '—') . '
';
echo 'Prompt: ' . esc_html($pver ?: '—') . '
';
echo 'Updated: ' . esc_html($when ?: '—') . '
Embedding: ' . $dim . 'd vector
';
+ echo 'Model: ' . $model_ver . '
';
+ echo 'Generated: ' . $updated . '
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": ["