Skip to content

Commit 18d82c6

Browse files
committed
Init commit
0 parents  commit 18d82c6

9 files changed

+330
-0
lines changed

alt_text_review.info.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
name: 'Alt Text Review'
2+
type: module
3+
description: 'Provides AI-generated alt text suggestions for images without alt text in the media library and allows human review.'
4+
core_version_requirement: '^10'
5+
package: Media

alt_text_review.links.menu.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
alt_text_review.settings:
2+
title: 'Alt Text Review Configuration'
3+
description: 'Configure Alt Text Review settings'
4+
parent: system.admin_config_media
5+
route_name: alt_text_review.settings
6+
weight: 100
7+
8+
alt_text_review.review:
9+
title: 'Alt Text Review'
10+
description: 'Review AI-generated alt text for images'
11+
parent: system.admin_config_media
12+
route_name: alt_text_review.review
13+
weight: 100

alt_text_review.permissions.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
access alt text review:
2+
title: 'Access Alt Text Review UI'
3+
description: 'Access AI-generated alt text review and settings.'
4+
restrict: true

alt_text_review.routing.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
alt_text_review.review:
2+
path: '/admin/content/media/alt-text-review'
3+
defaults:
4+
_controller: '\Drupal\alt_text_review\Controller\AltTextReviewController::review'
5+
_title: 'Review Alt Text'
6+
requirements:
7+
_permission: 'access alt text review'
8+
9+
alt_text_review.settings:
10+
path: '/admin/config/media/alt-text-review'
11+
defaults:
12+
_form: '\Drupal\alt_text_review\Form\AltTextReviewSettingsForm'
13+
_title: 'Alt Text Review Settings'
14+
requirements:
15+
_permission: 'access alt text review'

alt_text_review.services.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
services:
2+
alt_text_review.alt_text_generator:
3+
class: Drupal\alt_text_review\Service\AltTextGenerator
4+
arguments: ['@http_client', '@config.factory', '@image.factory', '@file_system', '@logger.factory']
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Drupal\alt_text_review\Controller;
4+
5+
use Drupal\Core\Controller\ControllerBase;
6+
use Symfony\Component\DependencyInjection\ContainerInterface;
7+
use Drupal\alt_text_review\Service\AltTextGenerator;
8+
9+
class AltTextReviewController extends ControllerBase
10+
{
11+
12+
protected $generator;
13+
14+
public function __construct(AltTextGenerator $generator)
15+
{
16+
$this->generator = $generator;
17+
}
18+
19+
public static function create(ContainerInterface $container)
20+
{
21+
return new static(
22+
$container->get('alt_text_review.alt_text_generator')
23+
);
24+
}
25+
26+
public function review()
27+
{
28+
$query = \Drupal::entityQuery('media')
29+
->accessCheck(TRUE)
30+
->condition('bundle', 'image');
31+
$or = $query->orConditionGroup()
32+
->condition('field_media_image.alt', '')
33+
->condition('field_media_image.alt', NULL, 'IS NULL');
34+
$query->condition($or)
35+
->range(0, 1);
36+
$mids = $query->execute();
37+
38+
if (empty($mids)) {
39+
return ['#markup' => $this->t('All images have alt text.')];
40+
}
41+
42+
$mid = reset($mids);
43+
$media = $this->entityTypeManager()->getStorage('media')->load($mid);
44+
$file = $media->get('field_media_image')->entity;
45+
$uri = $file->getFileUri();
46+
47+
return $this->formBuilder()
48+
->getForm(\Drupal\alt_text_review\Form\AltTextReviewForm::class, $media, $uri);
49+
}
50+
}

src/Form/AltTextReviewForm.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Drupal\alt_text_review\Form;
4+
5+
use Drupal\Core\Form\FormBase;
6+
use Drupal\Core\Form\FormStateInterface;
7+
use Drupal\media\Entity\Media;
8+
9+
class AltTextReviewForm extends FormBase
10+
{
11+
12+
public function getFormId()
13+
{
14+
return 'alt_text_review_form';
15+
}
16+
17+
public function buildForm(array $form, FormStateInterface $form_state, Media $media = NULL, string $uri = NULL)
18+
{
19+
$suggestion = \Drupal::service('alt_text_review.alt_text_generator')->generateAltText($uri);
20+
21+
$form['image'] = [
22+
'#theme' => 'image',
23+
'#uri' => $media->get('field_media_image')->entity->getFileUri(),
24+
];
25+
$form['suggestion'] = [
26+
'#type' => 'textarea',
27+
'#title' => $this->t('AI suggestion'),
28+
'#default_value' => $suggestion,
29+
'#disabled' => TRUE,
30+
];
31+
$form['alt'] = [
32+
'#type' => 'textarea',
33+
'#title' => $this->t('Alt text'),
34+
'#default_value' => $suggestion,
35+
'#required' => TRUE,
36+
];
37+
$form['media_id'] = [
38+
'#type' => 'hidden',
39+
'#value' => $media->id(),
40+
];
41+
$form['actions']['submit'] = [
42+
'#type' => 'submit',
43+
'#value' => $this->t('Save alt text'),
44+
];
45+
return $form;
46+
}
47+
48+
public function submitForm(array &$form, FormStateInterface $form_state)
49+
{
50+
$media = Media::load($form_state->getValue('media_id'));
51+
$media->get('field_media_image')->alt = $form_state->getValue('alt');
52+
$media->save();
53+
$this->messenger()->addStatus($this->t('Alt text saved.'));
54+
$form_state->setRedirect('alt_text_review.review');
55+
}
56+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace Drupal\alt_text_review\Form;
4+
5+
use Drupal\Core\Form\ConfigFormBase;
6+
use Drupal\Core\Form\FormStateInterface;
7+
8+
class AltTextReviewSettingsForm extends ConfigFormBase
9+
{
10+
11+
/**
12+
* {@inheritdoc}
13+
*/
14+
public function getFormId()
15+
{
16+
return 'alt_text_review_settings';
17+
}
18+
19+
/**
20+
* {@inheritdoc}
21+
*/
22+
protected function getEditableConfigNames()
23+
{
24+
return ['alt_text_review.settings'];
25+
}
26+
27+
/**
28+
* {@inheritdoc}
29+
*/
30+
public function buildForm(array $form, FormStateInterface $form_state)
31+
{
32+
$config = $this->config('alt_text_review.settings');
33+
34+
$form['openai_api_key'] = [
35+
'#type' => 'textarea',
36+
'#title' => $this->t('OpenAI API key'),
37+
'#default_value' => $config->get('openai_api_key'),
38+
'#required' => TRUE,
39+
'#rows' => 2,
40+
];
41+
42+
$form['ai_prompt'] = [
43+
'#type' => 'textarea',
44+
'#title' => $this->t('AI Prompt'),
45+
'#description' => $this->t('The prompt sent to the AI. Use the token <code>[max_length]</code> to dynamically include the max character length setting below.'),
46+
'#default_value' => $config->get('ai_prompt') ?? 'Generate a concise alt text for this image, within [max_length] characters maximum.',
47+
'#rows' => 3,
48+
'#required' => TRUE,
49+
];
50+
51+
$form['alt_text_max_length'] = [
52+
'#type' => 'number',
53+
'#title' => $this->t('Alt text maximum character length'),
54+
'#description' => $this->t('The value that will replace the [max_length] token in the prompt.'),
55+
'#default_value' => $config->get('alt_text_max_length') ?? 128,
56+
'#min' => 50,
57+
'#required' => TRUE,
58+
];
59+
60+
$form['debug_mode'] = [
61+
'#type' => 'checkbox',
62+
'#title' => $this->t('Enable debug logging'),
63+
'#description' => $this->t('Log detailed API requests and responses. This should be disabled on a production site.'),
64+
'#default_value' => $config->get('debug_mode'),
65+
];
66+
67+
return parent::buildForm($form, $form_state);
68+
}
69+
70+
/**
71+
* {@inheritdoc}
72+
*/
73+
public function submitForm(array &$form, FormStateInterface $form_state)
74+
{
75+
$this->config('alt_text_review.settings')
76+
->set('openai_api_key', $form_state->getValue('openai_api_key'))
77+
->set('ai_prompt', $form_state->getValue('ai_prompt'))
78+
->set('alt_text_max_length', $form_state->getValue('alt_text_max_length'))
79+
->set('debug_mode', $form_state->getValue('debug_mode'))
80+
->save();
81+
parent::submitForm($form, $form_state);
82+
}
83+
}

src/Service/AltTextGenerator.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
namespace Drupal\alt_text_review\Service;
4+
5+
use Drupal\Core\Config\ConfigFactoryInterface;
6+
use Drupal\Core\File\FileSystemInterface;
7+
use Drupal\Core\Image\ImageFactory;
8+
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
9+
use GuzzleHttp\ClientInterface;
10+
use GuzzleHttp\Exception\RequestException;
11+
use GuzzleHttp\Psr7\Message;
12+
13+
class AltTextGenerator
14+
{
15+
16+
protected $httpClient;
17+
protected $configFactory;
18+
protected $imageFactory;
19+
protected $fileSystem;
20+
protected $logger;
21+
22+
public function __construct(ClientInterface $http_client, ConfigFactoryInterface $config_factory, ImageFactory $image_factory, FileSystemInterface $file_system, LoggerChannelFactoryInterface $logger_factory)
23+
{
24+
$this->httpClient = $http_client;
25+
$this->configFactory = $config_factory;
26+
$this->imageFactory = $image_factory;
27+
$this->fileSystem = $file_system;
28+
$this->logger = $logger_factory->get('alt_text_review');
29+
}
30+
31+
public function generateAltText(string $image_uri): string
32+
{
33+
$config = $this->configFactory->get('alt_text_review.settings');
34+
$api_key = trim($config->get('openai_api_key'));
35+
$debug_mode = $config->get('debug_mode') ?? FALSE;
36+
37+
if (empty($api_key)) {
38+
return 'Error: OpenAI API key is not configured.';
39+
}
40+
41+
if (!is_file($image_uri)) {
42+
$this->logger->error('Image file not found at URI: @uri', ['@uri' => $image_uri]);
43+
return 'Error: Image file not found.';
44+
}
45+
46+
$image_data = file_get_contents($image_uri);
47+
$mime_type = finfo_buffer(finfo_open(), $image_data, FILEINFO_MIME_TYPE);
48+
$base64 = base64_encode($image_data);
49+
$data_uri = 'data:' . $mime_type . ';base64,' . $base64;
50+
51+
$max_length = $config->get('alt_text_max_length') ?? 128;
52+
$max_tokens = (int) ceil($max_length / 4); #calculate a token budget (approx. 4 chars/token)
53+
$prompt_template = $config->get('ai_prompt') ?? 'Generate a concise alt text for this image, within [max_length] characters maximum.';
54+
$prompt_text = str_replace('[max_length]', $max_length, $prompt_template);
55+
56+
$request_options = [
57+
'headers' => [
58+
'Authorization' => 'Bearer ' . $api_key,
59+
'Content-Type' => 'application/json',
60+
],
61+
'json' => [
62+
'model' => 'gpt-4o-mini',
63+
'messages' => [
64+
[
65+
'role' => 'user',
66+
'content' => [
67+
['type' => 'text', 'text' => $prompt_text],
68+
['type' => 'image_url', 'image_url' => ['url' => $data_uri]],
69+
],
70+
],
71+
],
72+
'max_tokens' => $max_tokens,
73+
],
74+
];
75+
76+
if ($debug_mode) {
77+
$this->logger->debug('OpenAI Request Body: @json', ['@json' => json_encode($request_options['json'], JSON_PRETTY_PRINT)]);
78+
}
79+
80+
try {
81+
$response = $this->httpClient->post('https://api.openai.com/v1/chat/completions', $request_options);
82+
$body = $response->getBody()->getContents();
83+
if ($debug_mode) {
84+
$this->logger->debug('OpenAI Response: @body', ['@body' => $body]);
85+
}
86+
$data = json_decode($body, TRUE);
87+
return trim($data['choices'][0]['message']['content'] ?? '');
88+
}
89+
catch (RequestException $e) {
90+
$this->logger->error('OpenAI API request failed: @message', ['@message' => $e->getMessage()]);
91+
if ($debug_mode && $e->hasResponse()) {
92+
$this->logger->debug("--- DEBUG: FAILED REQUEST ---\n@request\n\n--- DEBUG: FAILED RESPONSE ---\n@response", [
93+
'@request' => Message::toString($e->getRequest()),
94+
'@response' => Message::toString($e->getResponse()),
95+
]);
96+
}
97+
return 'Error: The AI suggestion could not be generated.';
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)