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 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 @@
- ', ', ', '' ); ?> + '; + $facet_links = array_map( + function( $facet ) { + return '' . esc_html( $facet ) . ''; + }, + array_slice( $all_facets, 0, 3 ) // Show first 3 + ); + echo implode( ', ', $facet_links ); + if ( count( $all_facets ) > 3 ) { + echo ' +' . ( count( $all_facets ) - 3 ) . ''; + } + echo ''; + endif; + ?>
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 @@
- ', ', ', '' ); ?> + '; + echo '' . esc_html__( 'Topics:', 'postsecret' ) . ' '; + echo '' . esc_html( implode( ', ', $topics ) ) . ''; + echo ''; + endif; + + if ( ! empty( $feelings ) && is_array( $feelings ) ) : + echo '
'; + echo '' . esc_html__( 'Feelings:', 'postsecret' ) . ' '; + echo '' . esc_html( implode( ', ', $feelings ) ) . ''; + echo '
'; + endif; + + if ( ! empty( $meanings ) && is_array( $meanings ) ) : + echo '
'; + echo '' . esc_html__( 'Meanings:', 'postsecret' ) . ' '; + echo '' . esc_html( implode( ', ', $meanings ) ) . ''; + echo '
'; + endif; + ?>