Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin/class-enqueue-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ public static function maybe_enqueue_sidebar_script() {
'readabilityHelpUrl' => esc_url_raw( edac_link_wrapper( 'https://a11ychecker.com/help3265', 'wordpress-general', 'content-analysis-sidebar', false ) ),
'dismissReasons' => IgnoreUI::get_reasons(),
'simplifiedSummaryPrompt' => get_option( 'edac_simplified_summary_prompt', 'none' ),
'aiAltAvailable' => \EqualizeDigital\AccessibilityChecker\AI\AltTextGenerator::is_available(),
]
);

Expand Down
354 changes: 354 additions & 0 deletions includes/classes/AI/AltTextGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
<?php
/**
* AI Alt Text Generator.
*
* @package EqualizeDigital\AccessibilityChecker
*/

namespace EqualizeDigital\AccessibilityChecker\AI;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Generates alt text suggestions for images using the WordPress AI connector
* (WordPress 7.0+ wp_ai_client_prompt) or the AI Services plugin.
*/
class AltTextGenerator {

/**
* Rule slugs that support AI-assisted alt text generation.
*/
const SUPPORTED_RULES = [ 'img_alt_missing', 'img_alt_empty', 'img_alt_invalid' ];

/**
* Number of alt text suggestions to request by default.
*/
const DEFAULT_NUM_SUGGESTIONS = 3;

/**
* Check whether any supported AI integration is available.
*
* @return bool
*/
public static function is_available(): bool {
return function_exists( 'wp_ai_client_prompt' ) || function_exists( 'ai_services' );
}

/**
* Generate alt text suggestions for a media attachment.
*
* @param int $attachment_id WordPress attachment post ID.
* @param int $num_suggestions Number of suggestions to generate (1–5).
* @return array|\WP_Error Array of suggestion objects or WP_Error on failure.
*/
public static function generate( int $attachment_id, int $num_suggestions = self::DEFAULT_NUM_SUGGESTIONS ) {
if ( ! self::is_available() ) {
return new \WP_Error(
'no_ai_service',
__( 'No AI service is available. Please configure the WordPress AI connector under Settings > Connectors, or install and configure the AI Services plugin.', 'accessibility-checker' )
);
}

if ( 'attachment' !== get_post_type( $attachment_id ) ) {
return new \WP_Error( 'invalid_attachment', __( 'The provided ID is not a valid media attachment.', 'accessibility-checker' ) );
}

$image_url = wp_get_attachment_image_url( $attachment_id, 'full' );
if ( ! $image_url ) {
return new \WP_Error( 'no_image_url', __( 'Could not retrieve an image URL for this attachment.', 'accessibility-checker' ) );
}

$num_suggestions = max( 1, min( 5, $num_suggestions ) );
$prompt = self::build_prompt( $num_suggestions );

if ( function_exists( 'wp_ai_client_prompt' ) ) {
return self::generate_with_wp_ai_client( $image_url, $prompt );
}

return self::generate_with_ai_services( $image_url, $prompt );
}

/**
* Build the structured prompt for alt text generation.
*
* @param int $num Number of suggestions to request.
* @return string
*/
private static function build_prompt( int $num ): string {
return sprintf(
'Analyze this image and generate %1$d distinct, accessibility-focused alt text suggestions.

Each suggestion must:
- Be under 125 characters (optimal for screen readers)
- Describe exactly what is visually visible in the image
- Focus on a different visual element or aspect across the %1$d suggestions (e.g., main subject, setting, action, or composition)
- Avoid starting with "Image of", "Photo of", "Picture of", or similar redundant phrases
- Use plain language, present tense, and active voice

Return ONLY a valid JSON array with exactly %1$d objects. Each object must have these keys:
- "alt": the alt text string (required, under 125 characters)
- "focus": a short noun phrase identifying the focal element of this suggestion (required)
- "explanation": one sentence explaining what aspect of the image this alt text emphasizes (required)

Example format:
[{"alt": "Worker harvesting ripe coffee cherries by hand", "focus": "harvester", "explanation": "Emphasizes the human labor and manual process."}]

Return only the JSON array with no markdown fencing or other text.',
$num
);
}

/**
* Determine the MIME type of an image from its URL.
*
* @param string $image_url Image URL.
* @return string MIME type string, defaulting to image/jpeg.
*/
private static function get_mime_type( string $image_url ): string {
$ext = strtolower( (string) pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) ?? '', PATHINFO_EXTENSION ) );
$mime_map = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
];

return $mime_map[ $ext ] ?? 'image/jpeg';
}

/**
* Generate using the WordPress AI Client (WordPress 7.0+).
*
* wp_ai_client_prompt() signature:

Check failure on line 126 in includes/classes/AI/AltTextGenerator.php

View workflow job for this annotation

GitHub Actions / Check code style

Doc comment long description must start with a capital letter
* wp_ai_client_prompt( string $prompt ) : Prompt_Builder_With_WP_Error
*
* Builder methods:
* ->with_file( string $file, ?string $mimeType = null ) : self
* ->generate_text() : string|WP_Error
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
*
* @param string $image_url Full URL of the image.
* @param string $prompt The generation prompt.
* @return array|\WP_Error
*/
private static function generate_with_wp_ai_client( string $image_url, string $prompt ) {
try {
$builder = wp_ai_client_prompt( $prompt );

if ( is_wp_error( $builder ) ) {
return $builder;
}

// Attach the image for multimodal input.
if ( method_exists( $builder, 'with_file' ) ) {
$builder = $builder->with_file( $image_url, self::get_mime_type( $image_url ) );
}

// generate_text() is the correct terminate method — returns string|WP_Error.
if ( method_exists( $builder, 'generate_text' ) ) {
$text = $builder->generate_text();
if ( is_wp_error( $text ) ) {
return $text;
}
return self::parse_suggestions( (string) $text );
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return new \WP_Error( 'missing_generate_text', __( 'The AI client does not support text generation.', 'accessibility-checker' ) );

} catch ( \Throwable $e ) {
return new \WP_Error( 'wp_ai_client_error', $e->getMessage() );
}
}

/**
* Generate using the AI Services plugin (Felix Arntz / felixarntz/ai-services).
*
* Response chain:
* $candidates->get(0)->get_content()->get_parts()->get(0)->get_text()
*
* @param string $image_url Full URL of the image.
* @param string $prompt The generation prompt.
* @return array|\WP_Error
*/
private static function generate_with_ai_services( string $image_url, string $prompt ) {
try {
$ai = ai_services();

// Request a service that supports both image understanding and text generation.
$service = $ai->get_available_service(
[
'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ],
]
);

if ( is_wp_error( $service ) ) {
// Fallback: any text-generation capable service.
$service = $ai->get_available_service(
[
'capabilities' => [ 'TEXT_GENERATION' ],
]
);
}

if ( is_wp_error( $service ) ) {
return new \WP_Error(
'no_configured_service',
__( 'No AI service is configured. Please add an API key under Settings > AI Services.', 'accessibility-checker' )
);
}

$model = $service->get_model(
[
'feature' => 'accessibility-checker-alt-text',
'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ],
]
);
Comment on lines +280 to +285

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The $service->get_model() call can return a WP_Error if no matching model is found or if capabilities are not met. Calling $model->generate_text() on a WP_Error object will result in a fatal error (or a TypeError caught by the Throwable block). We should defensively check if $model is a WP_Error before proceeding.

			$model = $service->get_model(
				[
					'feature'      => 'accessibility-checker-alt-text',
					'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ],
				]
			);

			if ( is_wp_error( $model ) ) {
				return $model;
			}


if ( is_wp_error( $model ) ) {
return $model;
}

// Build Content parts: image URL + text prompt.
$content_args = [
[
'role' => 'user',
'parts' => [
[
'type' => 'image_url',
'url' => $image_url,
],
[
'type' => 'text',
'text' => $prompt,
],
],
],
];

$candidates = $model->generate_text( $content_args );

if ( is_wp_error( $candidates ) ) {
return $candidates;
}

$text = self::extract_text_from_candidates( $candidates );
if ( is_wp_error( $text ) ) {
return $text;
}

return self::parse_suggestions( $text );

} catch ( \Throwable $e ) {
return new \WP_Error( 'ai_services_error', $e->getMessage() );
}
}

/**
* Extract the generated text string from an ai-services Candidates object.
*
* Traversal chain per the ai-services API:
* Candidates->get(0)->get_content()->get_parts()->get(0)->get_text()
*
* @param mixed $candidates Candidates object returned by generate_text().
* @return string|\WP_Error
*/
private static function extract_text_from_candidates( $candidates ) {
if ( is_string( $candidates ) ) {
return $candidates;
}

try {
// Standard ai-services Candidates object traversal.
if ( method_exists( $candidates, 'get' ) ) {
$candidate = $candidates->get( 0 );
if ( $candidate && method_exists( $candidate, 'get_content' ) ) {
$content = $candidate->get_content();
if ( $content && method_exists( $content, 'get_parts' ) ) {
$parts = $content->get_parts();
if ( $parts && method_exists( $parts, 'get' ) ) {
$part = $parts->get( 0 );
if ( $part && method_exists( $part, 'get_text' ) ) {
return (string) $part->get_text();
}
}
}
}
}

// Convenience shortcut if the plugin exposes it.
if ( method_exists( $candidates, 'get_first_candidate_text' ) ) {
$text = $candidates->get_first_candidate_text();
if ( is_string( $text ) ) {
return $text;
}
}

// Last resort: direct string methods on the object itself.
foreach ( [ 'get_text', 'getText', '__toString' ] as $method ) {
if ( method_exists( $candidates, $method ) ) {
$text = $candidates->$method();
if ( is_string( $text ) ) {
return $text;
}
}
}
} catch ( \Throwable $e ) {
return new \WP_Error( 'text_extraction_error', $e->getMessage() );
}

return new \WP_Error(
'unexpected_response',
sprintf(
/* translators: %s: PHP class name of the unexpected response object */
__( 'Unexpected AI response type (%s). Please report this to the plugin author.', 'accessibility-checker' ),
is_object( $candidates ) ? get_class( $candidates ) : gettype( $candidates )
)
);
}

/**
* Parse JSON suggestion objects from the AI response text.
*
* @param string $text Raw text from the AI response.
* @return array|\WP_Error
*/
private static function parse_suggestions( string $text ) {
// Strip markdown code fences if the AI wrapped the JSON.
$text = preg_replace( '/^```(?:json)?\s*/m', '', $text );
$text = preg_replace( '/\s*```\s*$/m', '', $text );
$text = trim( $text );

$data = json_decode( $text, true, 3 );

if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
return new \WP_Error(
'parse_error',
__( 'Could not parse the AI response. Please try again.', 'accessibility-checker' )
);
}

$suggestions = [];
foreach ( $data as $item ) {
if ( ! is_array( $item ) || empty( $item['alt'] ) ) {
continue;
}
$suggestions[] = [
'alt' => sanitize_text_field( $item['alt'] ),
'focus' => sanitize_text_field( $item['focus'] ?? '' ),
'explanation' => sanitize_text_field( $item['explanation'] ?? '' ),
];
}

if ( empty( $suggestions ) ) {
return new \WP_Error(
'no_suggestions',
__( 'No valid alt text suggestions were generated. Please try again.', 'accessibility-checker' )
);
}

return $suggestions;
}
}
Loading
Loading