A media library that’s grown for years without a tagging discipline is a familiar problem: hundreds or thousands of images, most untagged, most missing alt text, no practical way to find “the ones with a red background” without opening each one. Lesson 2 built the pattern for describing and tagging a single image. This lesson runs that pattern across an existing library, and, just as important, builds in a human review step before any suggestion actually changes a live attachment. AI suggesting tags for a site’s entire media library without a human ever looking at the suggestions first is how mislabeled, unreviewed metadata quietly creeps into search and filtering across a whole site.
Lesson 2 of this course for the underlying with_file() plus generate_text()
pattern this lesson runs at scale. Basic familiarity with WP_Query and custom admin
pages is assumed rather than re-taught here.
Step 1: Finding media that actually needs attention
Querying every attachment on a large site and reprocessing all of them wastes calls on images that are already tagged. Target the ones missing alt text first:
// File: my-plugin/class-media-auto-tag.php
function my_plugin_get_untagged_images( int $limit = 20 ): array {
return get_posts(
array(
'post_type' => 'attachment',
'post_mime_type' => 'image',
'post_status' => 'inherit',
'posts_per_page' => $limit,
'meta_query' => array(
array(
'key' => '_wp_attachment_image_alt',
'compare' => 'NOT EXISTS',
),
),
)
);
}
NOT EXISTS catches images that have never had alt text set at all, which is usually
the bulk of a neglected library. A separate pass with 'compare' => '=' and an empty
string value catches attachments where the meta key exists but was saved blank, worth
running as a follow-up query if the first pass doesn’t cover everything you expected.
Step 2: Generating a suggestion, not a final answer
Reuse Lesson 2’s description-and-tags pattern, but store the result as a pending
suggestion rather than writing straight to _wp_attachment_image_alt:
// File: my-plugin/class-media-auto-tag.php
use WordPress\AiClient\Files\DTO\File;
function my_plugin_suggest_tags_for_attachment( int $attachment_id ): void {
$path = get_attached_file( $attachment_id );
$mime = get_post_mime_type( $attachment_id );
if ( ! $path || ! file_exists( $path ) ) {
return;
}
$file = new File( $path, $mime );
$response = wp_ai_client_prompt()
->with_text(
'Respond with JSON only: {"alt_text": "one plain sentence", ' .
'"tags": ["3 to 6 short lowercase keywords"]}'
)
->with_file( $file )
->generate_text();
$parsed = json_decode( $response, true );
if ( ! is_array( $parsed ) || empty( $parsed['alt_text'] ) ) {
return;
}
update_post_meta( $attachment_id, '_my_plugin_suggested_alt', sanitize_text_field( $parsed['alt_text'] ) );
update_post_meta( $attachment_id, '_my_plugin_suggested_tags', wp_json_encode( $parsed['tags'] ?? array() ) );
}
Notice _my_plugin_suggested_alt, not _wp_attachment_image_alt. Nothing a visitor or
editor currently sees changes yet, the suggestion sits in its own meta keys until
someone reviews it.
Step 3: A minimal review list
A single admin page listing pending suggestions with an approve/reject action per image is enough, this doesn’t need to be elaborate:
// File: my-plugin/class-media-auto-tag.php
add_action( 'admin_menu', function () {
add_media_page(
__( 'AI Tag Suggestions', 'my-plugin' ),
__( 'AI Tag Suggestions', 'my-plugin' ),
'upload_files',
'my-plugin-tag-suggestions',
'my_plugin_render_suggestions_page'
);
} );
function my_plugin_render_suggestions_page(): void {
$pending = get_posts(
array(
'post_type' => 'attachment',
'posts_per_page' => 50,
'meta_query' => array(
array( 'key' => '_my_plugin_suggested_alt', 'compare' => 'EXISTS' ),
),
)
);
echo '<div class="wrap"><h1>' . esc_html__( 'AI Tag Suggestions', 'my-plugin' ) . '</h1>';
foreach ( $pending as $attachment ) {
$alt = get_post_meta( $attachment->ID, '_my_plugin_suggested_alt', true );
$tags = json_decode( get_post_meta( $attachment->ID, '_my_plugin_suggested_tags', true ), true ) ?: array();
echo '<div style="display:flex;gap:16px;align-items:center;border-bottom:1px solid #ccc;padding:12px 0;">';
echo wp_get_attachment_image( $attachment->ID, array( 80, 80 ) );
echo '<div><strong>' . esc_html( $alt ) . '</strong><br>' . esc_html( implode( ', ', $tags ) ) . '</div>';
echo '<form method="post">';
wp_nonce_field( 'my_plugin_apply_suggestion_' . $attachment->ID );
echo '<input type="hidden" name="attachment_id" value="' . esc_attr( $attachment->ID ) . '">';
echo '<button class="button button-primary" name="my_plugin_action" value="approve">' . esc_html__( 'Approve', 'my-plugin' ) . '</button> ';
echo '<button class="button" name="my_plugin_action" value="reject">' . esc_html__( 'Reject', 'my-plugin' ) . '</button>';
echo '</form></div>';
}
echo '</div>';
}
This is deliberately plain HTML rather than a polished data table, the point is having a real place a human looks before anything is applied, not building a finished UI component.
Step 4: Applying only what’s approved
// File: my-plugin/class-media-auto-tag.php
add_action( 'admin_init', function () {
if ( empty( $_POST['my_plugin_action'] ) || empty( $_POST['attachment_id'] ) ) {
return;
}
$attachment_id = absint( $_POST['attachment_id'] );
check_admin_referer( 'my_plugin_apply_suggestion_' . $attachment_id );
if ( ! current_user_can( 'upload_files' ) ) {
return;
}
if ( 'approve' === $_POST['my_plugin_action'] ) {
$alt = get_post_meta( $attachment_id, '_my_plugin_suggested_alt', true );
$tags = json_decode( get_post_meta( $attachment_id, '_my_plugin_suggested_tags', true ), true ) ?: array();
update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
wp_set_object_terms( $attachment_id, array_map( 'sanitize_text_field', $tags ), 'post_tag', true );
}
delete_post_meta( $attachment_id, '_my_plugin_suggested_alt' );
delete_post_meta( $attachment_id, '_my_plugin_suggested_tags' );
} );
Rejecting or approving both clear the pending suggestion meta, the difference is only
whether the real alt text and tags get written first. Nothing touches
_wp_attachment_image_alt outside this one approval branch.
It would be less code to skip the pending-suggestion step and write straight to
_wp_attachment_image_alt from Step 2. The reason not to: a vision-adjacent pattern
built on a general-purpose method (Lesson 2’s honest caveat still applies here) will
occasionally misread an image, and alt text is an accessibility feature, wrong alt text
actively hurts screen reader users rather than just looking sloppy. A one-click human
review step is a small cost for avoiding that.
Test it: generate and review a real suggestion
wp-env run cli wp eval '
$images = my_plugin_get_untagged_images( 1 );
if ( $images ) {
my_plugin_suggest_tags_for_attachment( $images[0]->ID );
echo "Suggested for attachment " . $images[0]->ID . "\n";
}
'
Then visit Media > AI Tag Suggestions in wp-admin, you should see that image with a suggested alt text and tag list, and working Approve / Reject buttons.
The single-attachment function above is the building block, running it across an
entire library needs the batching approach the next lesson builds, calling it in a tight
loop over a large get_posts() result inside one page load will time out well before
finishing.
Recap
Auto-tagging at library scale reuses Lesson 2’s with_file() plus generate_text()
pattern one attachment at a time, but stores the result as a separate, clearly-named
pending suggestion rather than overwriting real metadata directly. A minimal admin
review screen lets a human approve or reject each suggestion, and only an approval
writes to _wp_attachment_image_alt and the post_tag taxonomy. That review step
matters specifically because the underlying vision pattern is a workaround, not a
guaranteed-accurate dedicated feature.
Resources & further reading
- PHP AI Client SDK repository, GitHub
- WP_Query meta_query documentation, developer.wordpress.org
- wp_insert_attachment() function reference, developer.wordpress.org