KJP Website Updates/Upgrades — Working Doc

Client: Kurt Johnson Photography Date: 2026-02-11 Status: Solutions & Questions

Site Stats: - 8,372 published photography posts - 486 published installations (many with 20-30 photos each in slider) - 6,124 photography posts (73%) have NO description - Tags/type taxonomy: well-populated (99%+ coverage) - Color taxonomy: 59% coverage | Region taxonomy: 45% coverage


Client Request: Two related asks — - (#1) Hovering/clicking images in the archive grid shows them larger with full crop - (#5) Enlarged view has next/prev arrows to browse through the portfolio

Current Behavior: - Archive grid (loop-photo.php) wraps each image in an <a> that links directly to the single photography page — no lightbox, no hover preview - Single photography page shows one image with sidebar metadata — no enlarge/zoom capability

Recommended Solution: PhotoSwipe 5

PhotoSwipe is the right pick here. Reasons: - MIT license — no commercial licensing fees (Fancybox requires a paid license for commercial use) - Zero dependencies — doesn't need jQuery, but works alongside it. This matters since the site has no build tools - Built for photography — designed specifically for image galleries, handles large hi-res images with pinch-to-zoom on mobile - Gallery navigation — built-in prev/next arrows + swipe gestures - Lightweight — ~5kb gzipped - CDN available — can load from cdnjs, no build step needed

How it would work: 1. On the photography archive, each grid image gets a data-pswp-src attribute pointing to the full-size image 2. Click any image → PhotoSwipe opens in a fullscreen lightbox showing the uncropped image 3. Left/right arrows (and swipe on mobile) navigate through all visible images on the page 4. The current <a href> to the single page becomes the lightbox trigger instead 5. Could add a "View Details" link inside the lightbox that goes to the single page if they still want access to the full metadata view

Integration points: - Enqueue PhotoSwipe CSS/JS in functions.php (or load from CDN in header.php) - Modify loop-photo.php to add lightbox data attributes - Small JS init script to bind PhotoSwipe to the grid - FacetWP re-init: need to rebind PhotoSwipe after FacetWP AJAX reloads (on filter/page change) using the facetwp-loaded event already in header.php

Questions for Client:

  1. Click vs hover? The Henry Domke reference appears to be hover-triggered. PhotoSwipe is click-triggered (industry standard for lightboxes). We'd recommend click to open lightbox — hover previews are janky on touch devices and can be annoying on desktop when you're just scrolling. Is click OK, or is hover important to you?

  2. What should the arrows browse? The current page shows 49 images per page. Should lightbox arrows cycle through those 49? Or just be a way to see the current image larger, without browsing?

  3. Metadata in lightbox? Should the lightbox show just the image, or also the title/tags underneath? (Keeping it image-only is cleaner and faster.)

  4. Both archives? Should this work on Photography archive AND Installations archive, or just Photography?


2. Installation Slider — Dynamic Sidebar Info

Client Request: On single installation pages, when clicking through the photo slider, the right sidebar (title, description, type, institution, tags) should update to match the current photo.

Current Architecture: - single-installations.php has a Slick slider pulling from installations_photos_2 ACF repeater - The repeater stores only a photo filename per row — zero per-photo metadata - Sidebar pulls title, content, type, institution, tags from the parent installation post — same info for every slide - Installations have significant photo counts: up to 30 photos per slider, most have 10-20+ - 486 total installations

The Core Problem: The slider photos are pictures of the physical installation — different angles of a hospital lobby, waiting room, hallway, etc. The metadata on the sidebar describes the installation project itself. When the client says they want "all the info to change when the photo changes," they likely mean they want to identify which artwork is shown in each photo.

This is a data architecture change. The repeater needs per-photo metadata.

Recommended Solution: Extend the Repeater + JS Sidebar Swap

  1. Add subfields to installations_photos_2 repeater: - title (text) — the artwork name/number - description (textarea) — optional caption about this photo - type (select or taxonomy) — installation type for this view - tags (text or taxonomy) — relevant tags

  2. JS on slide change: - Listen to Slick's afterChange event (already have Slick init in slick-init.js) - On slide change, read the new slide's data attributes and swap the sidebar HTML - Render each slide's metadata as data-* attributes on the slide <img> or a wrapper <div>

  3. Template changes: - single-installations.php — output per-slide data attributes from the repeater - Sidebar section becomes a JS-swappable container

Data Population Challenge: 486 installations × 10-30 photos each = potentially 5,000-15,000 repeater rows that would need metadata added. This is the real cost — not the dev work, but the content entry.

Questions for Client:

  1. What info should change per photo? Is it just the artwork title/number? Or do you want full separate tags, type, and description per photo? (The current sidebar shows: title, description, type, institution, tags)

  2. Does institution change per slide? An installation is AT an institution — that probably stays the same across all photos. Is it really just the artwork identification that needs to change?

  3. Who populates the data? If we add these fields, someone needs to fill them in for 486 installations. Is the KJP team willing to do that data entry? Or is this something we'd need to handle?

  4. Could this be simpler? Would it work to just show an artwork title/number per slide (one extra field), rather than full tags/type/description? That's much less data entry and still gives context for each photo.


3. Newsletter Form → MailChimp Integration + Name Fields

Client Request: - Auto-add subscribers to MailChimp list (instead of emailing Tori) - Collect first + last name in addition to email - On the main page (homepage)

Current Setup: - Gravity Forms (Form 2) in footer sitewide + 2_column_email_signup_cta_module page module - Form 2 only collects email address - Currently sends email notification to Tori on submission - Massive spam problem: Q1 maintenance found 98% of entries were spam (7,473 of 7,613) - No Gravity Forms MailChimp add-on currently installed

Recommended Solution: Gravity Forms MailChimp Add-On

This is the cleanest path: 1. Install GF MailChimp Add-On — official Gravity Forms extension, included with their Elite license (which they likely have given GF is already active) 2. Add first name + last name fields to Form 2 3. Configure the feed — map Email → MailChimp email, First Name → FNAME, Last Name → LNAME 4. Add reCAPTCHA v3 to the form — invisible to users, kills the spam problem 5. Keep or remove Tori notification — client's choice 6. CSS updates — the footer form styling needs to accommodate the new name fields (currently a single-line horizontal form with just email + submit arrow). Will need a layout adjustment.

Design consideration: The current footer form is a clean single-line layout (email + arrow button). Adding first/last name fields means either: - Stacking the fields vertically (takes more footer space) - A two-row layout (names on row 1, email + submit on row 2) - Keeping it compact with placeholders instead of labels

The homepage module version (2_column_email_signup_cta_module) has more room and can handle extra fields more naturally.

Questions for Client:

  1. Do you have a MailChimp account/audience already set up? We'll need API access to connect. If not, we can help create one.

  2. Keep the Tori notification? Should the form still email Tori when someone subscribes, or just send directly to MailChimp?

  3. Sitewide or homepage only? The footer form appears on every page. The page module only shows where it's placed. Should BOTH get the name fields + MailChimp integration, or just the homepage?

  4. Name fields required? Should first/last name be required, or can people subscribe with just an email?


4. Exclude Team Members from Analytics

Client Request: Remove KJP team member visits from GA4 tracking.

Current Setup: - GA4 property G-RF1XX48Q59 loaded via gtag.js in header.php - No internal traffic filtering currently configured

Recommended Solution: GA4 Internal Traffic Rules + Browser Extension

This is a GA4 admin configuration, not a code change. Two-pronged approach:

  1. GA4 Internal Traffic Filter: - GA4 Admin → Data Streams → Web → Configure Tag Settings → Define Internal Traffic - Add their office IP address(es) as "internal" - Then Admin → Data Settings → Data Filters → activate the Internal Traffic filter - This excludes all traffic from those IPs from reports

  2. Google Analytics Opt-out Extension (for remote/home workers): - Team members install the GA Opt-out Browser Add-on - Works regardless of IP address — handles people working from home, coffee shops, phones, etc.

Recommended combo: Set the office IP filter for the main location, and have remote team members install the browser extension. Covers both scenarios.

Questions for Client:

  1. Office IP: Do you have a fixed office location with a static IP? (We can look it up if you tell us your internet provider/location)

  2. How many team members? And do they work primarily from an office or from home? This determines whether IP filtering alone is sufficient or if we need the browser extension approach too.


6. Search Results Limit — Increase from 200

Client Request: Keyword search on Photography archive caps at 200 results. Want all results included.

Current Setup: - FacetWP "search" facet uses SearchWP (swp_default engine) as its search backend - SearchWP 4.5.7 is active, indexing photography posts with high weight on title, content, tags, type, and color - posts_per_page is 49, with FacetWP handling pagination - 8,372 total photography posts — a search for "flower" likely matches thousands - SearchWP index table: 78MB | Tokens table: 4MB

Why the 200 Limit Exists

Found the exact source. It's FacetWP, not SearchWP. In FacetWP's SearchWP integration file (facetwp/includes/integrations/searchwp/searchwp.php), there's a hardcoded cap:

function run_query( $args ) {
    $overrides = [ 'posts_per_page' => 200, 'fields' => 'ids', 'facetwp' => true ];
    $args = array_merge( $args, $overrides );
    return new \SWP_Query( $args );
}

FacetWP intentionally caps SearchWP queries at 200 IDs. This is a performance guard — here's why they do it:

  1. SearchWP scoring is expensive. For every matching post, SearchWP calculates a relevance score by joining its index table (78MB) with its tokens table. Scoring 200 posts is fast. Scoring 3,000+ posts for a broad term like "flower" takes proportionally longer.

  2. Memory/array operations. FacetWP takes those IDs and intersects them with its own filtered post set (FWP()->unfiltered_post_ids). Larger arrays = more processing.

  3. The 200 represents "top 200 by relevance." SearchWP doesn't just grab random 200 — it returns the 200 most relevant results by score. FacetWP figured most searches don't need deeper than 200. For a generic blog, that's true. For a photography portfolio with thousands of images sharing keywords, it's not.

Performance Impact of Increasing It

What changes in the request cycle when we increase from 200 to, say, 2000:

Step At 200 At 2000 Impact
SearchWP scoring query Scores top 200 matches Scores top 2000 matches +0.5-2 seconds on broad terms
Result set in memory ~200 integers (~1.6KB) ~2000 integers (~16KB) Negligible
FacetWP array intersection Fast Still fast Negligible
Final render query WHERE IN (49 ids) — always 49 per page Same — still 49 per page No change
FacetWP count display "200 Results" "2000 Results" Just a number

Bottom line: The only real cost is SearchWP's scoring step. On a broad search ("flower" across 8,372 posts), going from 200 to 2000 adds roughly 0.5-2 seconds to the initial search request. Pagination after that is unchanged.

For context: the current search already takes 1-3 seconds (SearchWP scoring + FacetWP AJAX). Adding 0.5-2 seconds makes it 2-5 seconds on worst-case broad terms. Narrow terms (matching <200 posts) see zero difference.

Use SearchWP's per_page filter to override FacetWP's hardcoded 200. This fires after FacetWP sets its override, so it wins:

// Override FacetWP's 200-result cap on SearchWP queries
add_filter( 'searchwp\query\per_page', function( $per_page, $args ) {
    // Only override when FacetWP is making the request
    if ( isset( $args['facetwp'] ) && $args['facetwp'] ) {
        return 2000;
    }
    return $per_page;
}, 10, 2 );

Why 2000 instead of unlimited (-1): - 2000 covers any realistic search scenario on this site - Unlimited (-1) would force SearchWP to score ALL matches — if "flower" matches 4,000 posts, it scores all 4,000. That could push search response into the 5-8 second range. - 2000 is a safe middle ground: effectively unlimited for users (nobody's paginating past result 2000) without the worst-case performance hit

This is a one-line fix. ~1 hour to implement and test across several search terms.

Questions for Client

None really — this is a straightforward fix. We'd recommend 2000 as the cap and test it. If they notice search feeling slow on certain terms, we can tune it down.


7. AI-Generated Tags, Keywords & Descriptions

Client Request: Is there an AI plugin that can auto-generate tags/keywords and write descriptions for images that don't have them?

Current Data (from production)

What Coverage Gap
Descriptions (post_content) 2,248 of 8,372 (27%) 6,124 posts missing
Tags taxonomy 8,366 of 8,372 (99.9%) Basically complete
Type taxonomy 8,371 of 8,372 (99.9%) Basically complete
Color taxonomy 4,956 of 8,372 (59%) 3,416 posts missing
Region taxonomy 3,742 of 8,372 (45%) 4,630 posts missing

The real gaps are descriptions (73% missing), region (55% missing), and color (41% missing). Tags and type are already well-populated — the client may not realize that.

Image Storage (Important for Architecture)

Images are NOT in the WordPress Media Library. They're flat .jpg files stored at:

/wp-content/uploads/wpallimport/files/[filename].jpg

Each photography post has an ACF field featured_image that stores just the filename (e.g., 2566.jpg, 3277-2.jpg). The full URL is constructed by prepending the path. 7,647 of 8,372 posts have a featured_image value set.

This means any solution needs to fetch images by URL — it can't use WordPress attachment APIs.

Why NOT a Plugin

Vercel is a strong fit here — better than a raw script. It gives us a deployed web app with a UI for QA, background processing via cron, and zero infrastructure to manage.

Why Vercel over a local Python script: - Client-facing dashboard — KJP can log in, see progress, review descriptions, approve/reject. Way better than sending CSVs back and forth. - Always running — Vercel Cron processes images in the background over hours/days. No need to keep a terminal open or babysit a script. - Prompt iteration is easy — update the prompt in the UI or env vars, redeploy instantly. - Reusable — this becomes a tool we can point at other clients' sites. Photography, art, product catalogs — same pattern. - Deployed and shareablekjp-descriptions.vercel.app or whatever. Anyone on the team can access it.

Architecture

┌─────────────────────────────────────────────────────┐
│  Next.js App (Vercel)                                │
│                                                      │
│  ┌──────────────┐  ┌─────────────┐  ┌─────────────┐ │
│  │ Dashboard     │  │ Review UI   │  │ Settings    │ │
│  │ - progress    │  │ - image     │  │ - prompt    │ │
│  │ - stats       │  │ - generated │  │ - model     │ │
│  │ - errors      │  │   desc      │  │ - batch sz  │ │
│  │ - start/stop  │  │ - approve   │  │ - taxonomy  │ │
│  │               │  │ - reject    │  │   terms     │ │
│  │               │  │ - edit      │  │             │ │
│  └──────────────┘  └─────────────┘  └─────────────┘ │
│                                                      │
│  API Routes:                                         │
│  ┌──────────────────────────────────────────────────┐│
│  │ /api/process-batch  — Vercel Cron triggers this  ││
│  │ /api/review         — approve/reject/edit        ││
│  │ /api/publish        — write approved → WordPress ││
│  │ /api/stats          — progress data              ││
│  │ /api/sync-posts     — pull post list from WP     ││
│  └──────────────────────────────────────────────────┘│
│                                                      │
│  Database (Vercel Postgres):                         │
│  ┌──────────────────────────────────────────────────┐│
│  │ posts:  id, wp_post_id, filename, status,        ││
│  │         description, color, region, created_at   ││
│  │                                                  ││
│  │ status: pending → processing → review →          ││
│  │         approved → published   (or rejected)     ││
│  └──────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────┘
         │                              │
         │ fetch image                  │ write back
         ▼                              ▼
┌─────────────────┐            ┌─────────────────┐
│ KJP Production   │            │ WP REST API     │
│ /wp-content/     │            │ or WP-CLI       │
│ uploads/         │            │ over SSH         │
│ wpallimport/     │            │                 │
│ files/2566.jpg   │            │ → post_content  │
└─────────────────┘            │ → color term    │
         │                      │ → region term   │
         ▼                      └─────────────────┘
┌─────────────────┐
│ Vision API       │
│ (Claude/OpenAI)  │
│                  │
│ → description    │
│ → color          │
│ → region         │
└─────────────────┘

How It Works

1. Sync Phase — Pull post data from WordPress - API route hits WP REST API (or WP-CLI over SSH) to get all photography posts - Stores post ID, title, filename, current description (if any), current taxonomies - Identifies which posts are missing descriptions, color, region - This runs once upfront, and can be re-synced anytime

2. Process Phase — Vercel Cron runs in background - Cron job triggers /api/process-batch every 2-5 minutes - Each invocation processes 5-10 images (well within Vercel's 60s function timeout on Pro) - For each image: - Fetches from https://kurtjohnsonphotography.com/wp-content/uploads/wpallimport/files/{filename} - Sends to Vision API with the assembled prompt (base prompt + learned rules + examples — see Learning System below) - Parses response into description + color + region - Saves to Vercel Postgres with status review - If an image fails (404, API error), logs the error and moves on - Picks up where it left off — only processes pending status rows

Processing rate: - 10 images per batch, every 2 minutes = 300 images/hour - Full backfill of 6,124 images: ~20 hours of background processing - Runs unattended — start it and come back tomorrow

3. Review Phase — Client QA with Feedback Loop (See detailed Review UI and Learning System sections below)

4. Publish Phase — Write approved descriptions to WordPress - "Publish All Approved" button (or auto-publish setting) - API route takes approved rows and writes to WordPress: - post_content via WP REST API (POST /wp/v2/photography/{id}) - Color/region taxonomy via REST API term assignment - Marks rows as published in Vercel Postgres - Reversible — we have the original state logged


The Review UI (Detailed)

This is the core of the client experience. The review screen shows one image at a time with a simple approve/reject flow. The key insight: rejections teach the system.

┌──────────────────────────────────────────────────────────────┐
│  Review Queue                          47 of 50 remaining   │
│                                                              │
│  ┌─────────────────────────┐  ┌────────────────────────────┐ │
│  │                         │  │                            │ │
│  │                         │  │  Generated Description:    │ │
│  │      [ PHOTO ]          │  │                            │ │
│  │                         │  │  "Delicate purple coneflow-│ │
│  │                         │  │  ers reach toward a soft   │ │
│  │                         │  │  summer sky, their petals  │ │
│  │                         │  │  catching the warm after-  │ │
│  │                         │  │  noon light. A calming,    │ │
│  │                         │  │  grounding image ideal for │ │
│  │                         │  │  patient recovery spaces." │ │
│  │                         │  │                            │ │
│  │                         │  │  Color: Purple             │ │
│  │                         │  │  Region: Midwest           │ │
│  └─────────────────────────┘  └────────────────────────────┘ │
│                                                              │
│     ┌────────────────┐    ┌────────────────┐                 │
│     │   ✓  Approve   │    │   ✗  Reject    │                 │
│     └────────────────┘    └────────────────┘                 │
│                                                              │
│  ── On Reject, this panel slides open: ──────────────────── │
│                                                              │
│  What's wrong? (select all that apply)                       │
│                                                              │
│  ☐ Too generic / vague — lacks specific detail               │
│  ☐ Too flowery / over-written                                │
│  ☐ Too clinical / dry                                        │
│  ☐ Missed the main subject of the photo                      │
│  ☐ Inaccurate — describes something not in the image         │
│  ☐ Wrong color classification                                │
│  ☐ Wrong region                                              │
│  ☐ Too long                                                  │
│  ☐ Too short                                                 │
│                                                              │
│  What would be better? (optional — free text)                │
│  ┌──────────────────────────────────────────────────────┐    │
│  │ Should mention the specific flower type and that      │    │
│  │ it's a close-up macro shot. Less about healthcare.    │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  ┌─────────────────────────┐                                 │
│  │  Submit Feedback & Next  │                                 │
│  └─────────────────────────┘                                 │
│                                                              │
│  ── Or edit directly: ──────────────────────────────────────│
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │ [Editable description text field, pre-filled with     │    │
│  │  the generated description so client can tweak it]    │    │
│  └──────────────────────────────────────────────────────┘    │
│  ┌──────────────────────┐                                    │
│  │  Save Edit & Approve  │                                    │
│  └──────────────────────┘                                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Three actions per image: 1. Approve — description is good as-is. One click, moves to next image. 2. Reject + Feedback — description is bad. Select reason(s), optionally write what would be better. Feedback feeds the learning system. 3. Edit + Approve — description is close but needs tweaks. Client edits the text directly, then approves. The edited version becomes a "gold standard" example for the learning system.

Additional features: - Keyboard shortcutsA to approve, R to reject, for next. Client can fly through reviews. - Progress bar — "47 of 50 remaining" so they know how much is left. - Filter by status — show all, just pending review, just rejected, just approved. - Bulk approve — once confidence is high, select multiple and approve in batch. - Side-by-side comparison — for images that already have descriptions (the 2,248), show the existing description next to the AI-generated one.


The Learning System — How Feedback Improves the Agent

This is the key differentiator. Client feedback doesn't just filter good from bad — it actively teaches the system to write better descriptions.

How it works:

The prompt sent to the Vision API isn't static. It's assembled from three layers:

┌─────────────────────────────────────────────┐
│  ASSEMBLED PROMPT (sent to Vision API)       │
│                                              │
│  Layer 1: BASE PROMPT                        │
│  "You are writing descriptions for KJP..."   │
│  (the foundation — rarely changes)           │
│                                              │
│  Layer 2: LEARNED RULES                      │
│  "DO: Mention specific flower/plant species" │
│  "DO: Focus on mood and atmosphere"          │
│  "DON'T: Reference healthcare settings"      │
│  "DON'T: Use the word 'serene'"              │
│  (auto-generated from rejection patterns)    │
│                                              │
│  Layer 3: FEW-SHOT EXAMPLES                  │
│  "GOOD: [image of sunflowers] →              │
│   'Bright sunflowers stand tall against...'" │
│  "BAD: [image of forest] →                   │
│   'A beautiful nature scene' (too generic)"  │
│  (pulled from approved + rejected reviews)   │
│                                              │
└─────────────────────────────────────────────┘

Layer 1 — Base Prompt (manual, set in Settings) The foundation prompt that describes KJP, the tone, the format. We write this. It lives in the Settings UI and can be edited anytime.

Layer 2 — Learned Rules (auto-generated from feedback patterns) As the client rejects descriptions, the system accumulates rejection reasons. When a pattern emerges, it generates a rule:

Rejection Pattern Generated Rule
5+ rejections tagged "too generic" "Be specific: name the plant species, describe the composition (close-up, wide angle), mention unique visual features."
3+ rejections tagged "too flowery" "Keep descriptions straightforward and warm. Avoid poetic metaphors or overly lyrical language."
3+ rejections tagged "missed the main subject" "Start by identifying the primary subject of the photograph before describing the scene or mood."
Client writes "stop mentioning healthcare" in free text 2+ times "Do not reference healthcare settings, hospitals, or patient care in the description. Let the image speak for itself."

These rules get appended to the prompt automatically. The Settings UI shows all active rules so we can review/edit/remove them.

How rules are generated: After each review batch, an API route analyzes the rejection data: - Groups rejections by reason category - If a category hits a threshold (e.g., 3+ occurrences), generates a prompt rule - Free-text feedback gets summarized into actionable instructions - New rules are shown in the dashboard for admin review before activating

Layer 3 — Few-Shot Examples (from approved + edited descriptions) The best teaching material is the client's own edits.

The system selects 3-5 of the most relevant examples to include in each prompt: - 2-3 approved descriptions (positive examples) - 1-2 rejected-with-correction pairs (negative → positive examples) - Selected based on visual similarity if possible (e.g., if processing a flower photo, include flower examples)

The feedback loop in practice:

Batch 1 (50 images):
  Prompt = Base only
  Result: 60% approved, 40% rejected
  Client feedback: "too generic," "stop mentioning healthcare"
  → System generates 2 rules, selects 5 few-shot examples

Batch 2 (50 images):
  Prompt = Base + 2 rules + 5 examples
  Result: 80% approved, 20% rejected
  Client feedback: "getting better, but some are too long"
  → System adds 1 rule, updates examples pool

Batch 3 (50 images):
  Prompt = Base + 3 rules + 8 examples
  Result: 92% approved, 8% rejected
  → System is dialed in. Ready for bulk processing.

Batch 4+ (5,500 images):
  Prompt = Base + 3 rules + 10 best examples
  Expected: 90%+ approval rate
  Client spot-checks, bulk approves, we publish.

The Prompt Evolution View

The Settings page includes a "Prompt Evolution" tab that shows how the prompt has changed over time:

┌────────────────────────────────────────────────────────────┐
│  Prompt Evolution                                          │
│                                                            │
│  Version 1 (Feb 12) — Base prompt only                     │
│  ├── 50 processed → 30 approved (60%), 20 rejected         │
│  ├── Top rejection reason: "Too generic" (12x)             │
│  └── Generated rule: "Be specific about species..."        │
│                                                            │
│  Version 2 (Feb 13) — Added specificity rule               │
│  ├── 50 processed → 40 approved (80%), 10 rejected         │
│  ├── Top rejection reason: "References healthcare" (6x)    │
│  └── Generated rule: "Don't mention healthcare..."         │
│                                                            │
│  Version 3 (Feb 14) — Added healthcare rule                │
│  ├── 50 processed → 46 approved (92%), 4 rejected          │
│  ├── No strong patterns in rejections                      │
│  └── Ready for bulk processing ✓                           │
│                                                            │
│  Current Prompt: [View Full Prompt]                         │
│  Active Rules: 3                                           │
│  Example Pool: 12 approved, 4 rejected pairs               │
│                                                            │
└────────────────────────────────────────────────────────────┘

This gives both us and the client visibility into why the system is getting better and what drove each change.


Database Schema (Vercel Postgres)

-- Core table: tracks every image through the pipeline
CREATE TABLE photos (
  id              SERIAL PRIMARY KEY,
  wp_post_id      INTEGER NOT NULL,
  filename        TEXT NOT NULL,
  image_url       TEXT NOT NULL,

  -- Current WordPress data (synced)
  wp_title        TEXT,
  wp_description  TEXT,          -- existing description if any
  wp_color        TEXT,          -- existing color term if any
  wp_region       TEXT,          -- existing region term if any

  -- AI-generated data
  ai_description  TEXT,
  ai_color        TEXT,
  ai_region       TEXT,
  ai_model        TEXT,          -- which model generated this
  ai_prompt_ver   INTEGER,       -- which prompt version was used

  -- Client edits (if they modified the AI output)
  edited_description TEXT,       -- NULL if approved as-is

  -- Status tracking
  status          TEXT DEFAULT 'pending',
    -- pending → processing → review → approved → published
    -- or: pending → processing → review → rejected

  processed_at    TIMESTAMP,
  reviewed_at     TIMESTAMP,
  published_at    TIMESTAMP,

  created_at      TIMESTAMP DEFAULT NOW()
);

-- Feedback from client reviews — drives the learning system
CREATE TABLE feedback (
  id              SERIAL PRIMARY KEY,
  photo_id        INTEGER REFERENCES photos(id),
  action          TEXT NOT NULL,   -- 'approved', 'rejected', 'edited'

  -- Rejection reasons (checkboxes)
  too_generic     BOOLEAN DEFAULT FALSE,
  too_flowery     BOOLEAN DEFAULT FALSE,
  too_clinical    BOOLEAN DEFAULT FALSE,
  missed_subject  BOOLEAN DEFAULT FALSE,
  inaccurate      BOOLEAN DEFAULT FALSE,
  wrong_color     BOOLEAN DEFAULT FALSE,
  wrong_region    BOOLEAN DEFAULT FALSE,
  too_long        BOOLEAN DEFAULT FALSE,
  too_short       BOOLEAN DEFAULT FALSE,

  -- Free-text feedback
  comment         TEXT,

  created_at      TIMESTAMP DEFAULT NOW()
);

-- Prompt rules — auto-generated from feedback patterns
CREATE TABLE prompt_rules (
  id              SERIAL PRIMARY KEY,
  rule_text       TEXT NOT NULL,      -- "Be specific about plant species..."
  source          TEXT,               -- "too_generic (12 rejections)"
  active          BOOLEAN DEFAULT TRUE,
  prompt_version  INTEGER,            -- which version this was added to
  created_at      TIMESTAMP DEFAULT NOW()
);

-- Prompt versions — snapshot of each prompt iteration
CREATE TABLE prompt_versions (
  id              SERIAL PRIMARY KEY,
  version         INTEGER NOT NULL,
  base_prompt     TEXT NOT NULL,
  rules           JSONB,              -- active rules at this version
  examples        JSONB,              -- selected few-shot examples
  stats           JSONB,              -- approval rate, rejection patterns
  created_at      TIMESTAMP DEFAULT NOW()
);

Key Implementation Details

Vercel Plan: Pro plan needed for: - 60-second function timeout (Hobby is 10s — too tight for Vision API calls) - Vercel Cron at 2-minute intervals (Hobby only allows daily) - Vercel Postgres included

Vision API Integration: - Use Anthropic SDK (@anthropic-ai/sdk for Node/Next.js) or OpenAI SDK - Claude and OpenAI both accept image URLs directly — no need to download first - Structured output (JSON mode) to get clean description + color + region parsing - The prompt is assembled at request time from base + rules + examples

WordPress Authentication: - WP REST API with Application Password (built into WP 5.6+) - Or: a custom REST endpoint with a secret key for write-back - Store credentials in Vercel environment variables

Rule Generation: - After each review session (or on a threshold trigger), an API route runs - Queries feedback table for rejection patterns - Groups by reason, checks thresholds - For free-text feedback: sends the accumulated comments to Claude with "Summarize these pieces of feedback into a single, actionable prompt instruction" - Saves new rules, bumps prompt version - Admin can review/edit/disable rules before they go live


The Phased Rollout

Phase 0: Build (our time, before client sees anything) - Build the Next.js app, deploy to Vercel - Set up Vercel Postgres schema - Wire up WP REST API authentication - Build the sync, process, review, and publish flows - Write the base prompt (v1) - Deploy to kjp-descriptions.vercel.app

Phase 1: Calibration — 50 images (client involved) - Sync posts from WordPress - Process first 50 images with base prompt (v1) - Walk client through the review UI - Client reviews all 50: approve, reject with reasons, or edit - System generates initial rules from feedback - We review the rules, tune anything that's off - Process another 50 with the improved prompt (v2) - Client reviews again — approval rate should jump - Goal: get to 85%+ approval rate before moving on - Maybe 2-3 rounds, 1-2 weeks elapsed time (depends on client availability)

Phase 2: Expanded Test — 500 images - Enable Vercel Cron to process in background - 500 images processed over ~2 hours - Client spot-checks 50-100 in the review UI - Fine-tune any remaining issues - First bulk publish to WordPress — client sees descriptions go live - Goal: confirm end-to-end works, approval rate holds at 90%+

Phase 3: Full Backfill — remaining ~5,500 images - Cron processes overnight (~18 hours) - Client spot-checks a random sample of ~100 - Bulk approve → bulk publish to WordPress - Goal: all 6,124 posts have descriptions

Phase 4: Ongoing (optional) - Daily cron checks for new photography posts (from WP All Import) - Auto-processes new images, drops in review queue - Client approves as they come in (or enables auto-publish once trust is high) - Goal: hands-off going forward

Success Criteria

How we know this is working:

Metric Target
Approval rate by Phase 2 90%+
Client edits needed <10% of descriptions
Processing errors <1% of images
Client review time per image <10 seconds (approve is one click)
Full backfill complete Within 1 week of Phase 3 start
Descriptions live on site 95%+ of photography posts

Taxonomy Cleanup Opportunity

While investigating, found some data quality issues in existing taxonomy terms:

Color taxonomy has dead/duplicate terms: - "Pruple" (0 posts) — typo of "Purple" - "Yelllow" (0 posts) — typo of "Yellow" - "Gold" (0 posts), "Grasses" (0 posts), "Gray" (0 posts), "Black" (0 posts) — unused

Region taxonomy has unused terms: - "East Coast" (0), "Great Lakes" (0), "Mid-Atlantic" (0), "Northeast" (0)

Clean these up before running the AI backfill so we're working with a tight, correct set.

Cost Estimates

API costs (the images, not our time):

Model Per Image 6,124 Images Quality
Claude Sonnet (vision) ~$0.01 ~$60 Good
Claude Opus (vision) ~$0.03 ~$180 Best
GPT-4o ~$0.01 ~$60 Good
GPT-4o mini ~$0.003 ~$18 Acceptable

Recommend starting calibration with Opus/GPT-4o for best quality, then potentially dropping to Sonnet/4o for the bulk run if quality is comparable.

What We're Building (Scope)

Pages (4): - / — Dashboard: progress stats, start/stop processing, prompt version, approval rate chart - /review — Review queue: image + description side-by-side, approve/reject+feedback/edit - /settings — Prompt editor, active rules manager, model selection, batch config - /settings/evolution — Prompt evolution timeline: version history, what changed and why

API Routes (7): - /api/sync-posts — Pull post list from WordPress - /api/process-batch — Process N images (Vercel Cron target), assembles prompt from base+rules+examples - /api/review — Submit approve/reject/edit for a single image - /api/feedback/analyze — Analyze rejection patterns, propose new rules - /api/publish — Write approved descriptions to WordPress - /api/stats — Dashboard data + approval rate trends - /api/reprocess — Re-run rejected images with updated prompt

Database: 4 tables in Vercel Postgres (photos, feedback, prompt_rules, prompt_versions)

This is a focused internal tool. The complexity isn't in the app itself — it's in the learning loop and prompt engineering. The app is the container that makes the iterative process smooth for the client.

Reusability

This becomes a pattern we can reuse: - Any photography/art client with a large catalog - Product descriptions for e-commerce - Alt text generation for accessibility compliance - Portfolio sites that need metadata backfills

Swap the prompt, point at a different WordPress site, deploy. The structure is the same.

Questions for Client

  1. Voice/tone guidance? Does the client have examples of descriptions they love? The existing descriptions on the 2,248 posts that DO have them — are those the gold standard, or do they want something different?

  2. Color + Region gaps too? The client asked about tags/keywords, but those are already 99% covered. The real gaps are descriptions, color (41% missing), and region (55% missing). Region is hard to determine from an image alone (a forest could be anywhere) — should the AI attempt it, or leave region for manual assignment?

  3. Taxonomy cleanup first? There are typos and unused terms in the color/region taxonomies (e.g., "Pruple", "Yelllow"). Should we clean those up as part of this project?

  4. Ongoing or one-time? Is this a one-time backfill, or should new imports automatically get descriptions too? (The Vercel app makes ongoing processing trivial — it's already built in.)

  5. WP REST API access? We'll need to set up an Application Password or custom auth endpoint for the Vercel app to write back to WordPress. Is that OK, or do they have security concerns?



Hour Estimates

Task Hours
Research/setup: Enqueue PhotoSwipe via CDN, test on site 1-2
Modify loop-photo.php — add lightbox data attributes, swap <a> behavior 2-3
JS init script — bind PhotoSwipe to grid, configure gallery navigation 2-3
FacetWP integration — rebind PhotoSwipe on facetwp-loaded after AJAX filter/page 1-2
CSS — lightbox styling, arrow positioning, mobile responsiveness 1-2
Testing — cross-browser, mobile/touch, pagination edge cases 1-2

Subtotal: 8-14 hours (dev only, no design)

Assumptions: - Click-to-open (not hover) — hover adds complexity - Photography archive only (add 2-3 hours if Installations archive too) - Image-only lightbox (add 2-3 hours if metadata shown in lightbox) - No design comp needed — PhotoSwipe has clean defaults


2. Installation Slider — Dynamic Sidebar Info

Task Hours
ACF field changes — add subfields to installations_photos_2 repeater 2-3
Template updates — single-installations.php output data attributes per slide 2-3
JS — Slick afterChange listener, sidebar HTML swap, transition animation 3-4
CSS — sidebar transition styling 1-2
Testing — slider navigation, edge cases (single photo, missing data) 1-2

Subtotal: 9-14 hours (dev only)

NOT included — Data population: 486 installations × 10-30 photos each = 5,000-15,000 repeater rows that need metadata filled in. This is content entry, not development. Options: - KJP team populates the data manually (their time, not ours) - We populate it (would need a separate quote — significant hours depending on scope) - We build a WP All Import template so they can populate via CSV (add 3-5 hours)

The data population is the elephant in the room. The dev work is straightforward, but without per-photo data in the fields, the feature has nothing to display. Need to discuss who does this and what info changes per slide.


3. Newsletter → MailChimp + Name Fields + Spam Protection

Task Hours
Install GF MailChimp Add-On, connect to MailChimp API 1-2
Add first name + last name fields to Form 2 0.5-1
Configure MailChimp feed mapping (email, FNAME, LNAME) 0.5-1
Add reCAPTCHA v3 to Form 2 (invisible, kills spam) 0.5-1
CSS — rework footer form layout for additional fields 2-3
CSS — homepage module form layout if applicable 1-2
Testing — submission flow, MailChimp sync, spam protection 1-2

Subtotal: 6-10 hours

Assumptions: - KJP has or creates a MailChimp account/audience - Both footer and homepage module get updated (if homepage only, subtract 1-2 hours) - 2 design options for footer layout (stacked vs. two-row), client picks one


4. Exclude Team from Analytics

Task Hours
Configure GA4 internal traffic rules (office IP) 0.5-1
Activate data filter in GA4 Admin 0.5
Document browser extension install for remote workers 0.5
Walk client through setup / provide instructions 0.5-1

Subtotal: 1-2 hours

Assumptions: - KJP provides GA4 admin access - KJP provides office IP address (or we help them find it) - We provide written instructions for the browser extension


6. Search Results — Increase from 200

Task Hours
Add searchwp\query\per_page filter (the fix) 0.5
Test across multiple search terms (broad + narrow) 0.5-1
Performance check — compare search response times before/after 0.5-1
Adjust cap if performance issues (2000 → lower) 0-0.5

Subtotal: 1-2 hours


7. AI Description Tool (Vercel App with Learning Feedback Loop)

See separate detailed plan: KJP-AI-Description-Tool-Plan.md

Phase 0: Build the Application

Task Hours
App scaffolding + infra
Next.js project setup, Vercel deployment, env config 2-3
Vercel Postgres setup, schema (4 tables) 2-3
WordPress REST API auth setup (Application Password) 1-2
Core features
Dashboard page — progress stats, start/stop, approval rate chart 3-5
Review UI — image + description side-by-side, approve/reject/edit flow 6-8
Rejection feedback panel — checkboxes, free text, submission 2-3
Settings page — prompt editor, model selection, batch config 3-4
Prompt evolution view — version history, stats per version 2-3
API routes
/api/sync-posts — pull photography posts from WordPress 2-3
/api/process-batch — Vision API integration, prompt assembly (base + rules + examples) 4-6
/api/review — approve/reject/edit submission handling 2-3
/api/feedback/analyze — rejection pattern analysis, rule generation 3-5
/api/publish — write approved descriptions + taxonomy to WordPress 2-3
/api/reprocess — re-run rejected images with updated prompt 1-2
/api/stats — dashboard data + trends 1-2
Testing + polish
End-to-end testing (sync → process → review → publish) 2-3
Error handling, retry logic, edge cases 2-3
Mobile responsiveness for review UI 1-2

Phase 0 Subtotal: 34-52 hours

Phase 1: Calibration (with client)

Task Hours
Write + refine base prompt (v1) 2-3
Process first 50 images, QA the output ourselves 1-2
Client review session support (2-3 rounds) 2-4
Review generated rules, tune/edit, bump prompt versions 2-3
Re-process rejected images with improved prompt 1-2

Phase 1 Subtotal: 8-14 hours (spread over 1-2 weeks)

Phase 2: Expanded Test (500 images)

Task Hours
Enable cron, monitor processing 1-2
Support client spot-check, handle feedback 1-2
First publish to WordPress, verify on live site 1-2

Phase 2 Subtotal: 3-6 hours

Phase 3: Full Backfill (~5,500 images)

Task Hours
Run full backfill, monitor for errors 1-2
Support client final spot-check 1-2
Bulk publish to WordPress 1-2
Verify on live site, spot-check SEO 1-2

Phase 3 Subtotal: 4-8 hours

Phase 4: Ongoing (Optional)

Task Hours
Add daily cron for new WP All Import posts 2-3
Auto-publish toggle / threshold config 1-2
Testing with sample imports 1-2

Phase 4 Subtotal: 4-7 hours

Item 7 Total: 53-87 hours (Phase 0-3 mandatory, Phase 4 optional)

Plus API costs (passed to client): ~$60-180


QA, Testing & Launch (All Items)

Task Hours
Cross-browser testing (lightbox, forms, slider) 2-3
Mobile/tablet testing 1-2
Deploy to production (rsync theme changes) 1-2
Post-launch verification 1-2
Environment management buffer 1

Subtotal: 6-10 hours


Task Hours
Delete typo/unused color terms (Pruple, Yelllow, etc.) 0.5
Delete unused region terms (East Coast, Great Lakes, etc.) 0.5
Verify no posts are affected 0.5

Subtotal: 1-2 hours


Project Totals

# Item Hours
1+5 Lightbox + Gallery Navigation 8-14
2 Installation Dynamic Sidebar (dev only) 9-14
3 MailChimp + Name Fields + Spam Protection 6-10
4 Exclude Team from Analytics 1-2
6 Search Results Limit Fix 1-2
7 AI Description Tool (Phases 0-3) 49-80
7+ AI Description Tool — Phase 4 Ongoing (optional) 4-7
QA, Testing & Launch 6-10
Taxonomy Cleanup 1-2
--- ------ -------
TOTAL (without Phase 4) 81-134
TOTAL (with Phase 4) 85-141

Quick Summary (Copy/Paste Ready)

KJP WEBSITE UPDATES — ESTIMATE SUMMARY

| Item                                    | Hours    |
|-----------------------------------------|----------|
| Lightbox + Gallery Navigation (#1+5)    | 8-14     |
| Installation Dynamic Sidebar (#2)       | 9-14     |
| MailChimp + Name Fields + Spam (#3)     | 6-10     |
| Exclude Team from Analytics (#4)        | 1-2      |
| Search Results Limit Fix (#6)           | 1-2      |
| AI Description Tool (#7, Phases 0-3)    | 49-80    |
| AI Description Tool — Ongoing (opt.)    | 4-7      |
| QA, Testing & Launch                    | 6-10     |
| Taxonomy Cleanup                        | 1-2      |
|-----------------------------------------|----------|
| TOTAL (without ongoing)                 | 81-134   |
| TOTAL (with ongoing)                    | 85-141   |

Notes:
- Item #2 hours are DEV ONLY — data population for 486
  installations (5,000-15,000 repeater rows) not included.
  Discuss with client who handles this.
- Item #7 API costs (~$60-180) passed through to client.
- Item #4 is GA4 configuration, not code changes.
- All estimates assume no design comps needed.

Assumptions & Notes