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 @@
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 @@