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
1 + 5. Lightbox with Gallery Navigation (Combined)
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:
-
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?
-
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?
-
Metadata in lightbox? Should the lightbox show just the image, or also the title/tags underneath? (Keeping it image-only is cleaner and faster.)
-
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
-
Add subfields to
installations_photos_2repeater: -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 -
JS on slide change: - Listen to Slick's
afterChangeevent (already have Slick init inslick-init.js) - On slide change, read the new slide's data attributes and swap the sidebar HTML - Render each slide's metadata asdata-*attributes on the slide<img>or a wrapper<div> -
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:
-
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)
-
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?
-
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?
-
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:
-
Do you have a MailChimp account/audience already set up? We'll need API access to connect. If not, we can help create one.
-
Keep the Tori notification? Should the form still email Tori when someone subscribes, or just send directly to MailChimp?
-
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?
-
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:
-
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
-
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:
-
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)
-
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:
-
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.
-
Memory/array operations. FacetWP takes those IDs and intersects them with its own filtered post set (
FWP()->unfiltered_post_ids). Larger arrays = more processing. -
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.
Recommended Solution
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
- ClassifAI, AI Engine, etc. hook into the Media Library upload flow. KJP's images bypass the Media Library entirely (WP All Import flat files). A plugin would have nothing to analyze.
- Scale: 6,000+ images can't be processed through WP admin without timeouts. These plugins are designed for "analyze this image as I upload it," not "batch-process 6,000 existing images."
- Taxonomy control: Plugins would generate generic tags. KJP needs descriptions and taxonomy terms that fit their existing term structure exactly.
Recommended Architecture: Next.js App on Vercel
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 shareable — kjp-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 shortcuts — A 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.
- When a client approves a description, it goes into the "good examples" pool
- When a client edits then approves, both versions are stored: the AI's attempt and the client's corrected version. This is gold — it shows the model exactly what was wrong and what's right.
- When a client rejects with feedback, the rejected description + reason becomes a "bad example"
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
-
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?
-
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?
-
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?
-
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.)
-
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
1+5. Lightbox with Gallery Navigation (PhotoSwipe 5)
| 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
Taxonomy Cleanup (Recommended)
| 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
- All estimates are development hours only — no design comps assumed
- Item #2 data population is not included (see note above)
- Item #7 API costs ($60-180) are a pass-through to client, not included in hours
- Item #7 is the largest item (~60% of total hours) — can be phased/deferred if needed
- Items #4 and #6 are quick wins that can be done immediately
- Items #1+5 and #3 are medium-effort, straightforward implementations
- Item #2 requires client decision on scope before quoting data population