lato_cms 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 335542f7d3246d07af36c41b161ab638d26f9f923823d3bedc255a943bac2197
4
- data.tar.gz: a36f8acf15e683688a5344090fc7cc023d12b57371411dcbcfaba436f4f04d1a
3
+ metadata.gz: 0f9ff4673b5fb6fd0cf73a6cb74878abb42d06e060e54df3f21a969e875afc26
4
+ data.tar.gz: 5825b755ceef65a720e190d31373a209a43d7620666117faa74d446df7a7668a
5
5
  SHA512:
6
- metadata.gz: '04776471890c80fc97fb2ff07dd3b27eaffdc54dbe7273ed5ae7f099a7bed4083fa21c26dc96d460a245ce49741b46076c9dd720cbb7516f3edea34521742759'
7
- data.tar.gz: 8730a9ead03c286df9540e72c1ea1eb13ac696c536331b5dad038efa26598bacee29ae1d998243f38536b8efadfa97af9db64c6881e1adb74d5524c13a3a62e0
6
+ metadata.gz: 735617a5f9e02a8f661bb784d4f958bfc7f720837387e6046586738228957d585bdd67ae11481e2613d845aebfc14c7adf8576d94d0f50b98ad113eae4816ecb
7
+ data.tar.gz: 56fb22fef815efe46da9774ecf9795413543b737264d470d384beac492308692e5ddc836c0f98e4ead2848a199c32e48e1568b918a5a7321d71b219f6dfb9934
@@ -10,7 +10,7 @@ module LatoCms
10
10
  query_media.order(created_at: :desc),
11
11
  columns: %i[name media_type actions],
12
12
  sortable_columns: %i[name media_type created_at],
13
- searchable_columns: %i[name alt_text],
13
+ searchable_columns: %i[name alt_text title],
14
14
  default_sort_by: 'created_at|DESC',
15
15
  pagination: 20
16
16
  )
@@ -62,27 +62,30 @@ module LatoCms
62
62
  end
63
63
  end
64
64
 
65
- # Runs as a Lato::Operation (see GenerateAltTextJob) rather than inline:
66
- # an LLM call can take a while, and blocking the request risks timing it
67
- # out. The admin instead lands on a live progress page.
68
- def regenerate_alt_text_action
65
+ # Regenerates one translatable attribute (alt_text or title, see the
66
+ # route constraint) with the LLM. Runs as a Lato::Operation (see
67
+ # GenerateMediaTextJob) rather than inline: an LLM call can take a while,
68
+ # and blocking the request risks timing it out. The admin instead lands on
69
+ # a live progress page.
70
+ def regenerate_text_action
69
71
  @media = query_media.find(params[:id])
72
+ attribute = params[:attribute].to_s
70
73
 
71
- unless @media.image? && LatoCms.config.llm_configured?
74
+ unless @media.image? && LatoCms.config.llm_generates?(attribute)
72
75
  respond_to do |format|
73
- message = t('lato_cms.media_alt_text_regenerate_unavailable')
76
+ message = t("lato_cms.media_text_regenerate_unavailable")
74
77
  format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
75
78
  format.json { render json: { error: message }, status: :unprocessable_entity }
76
79
  end
77
80
  return
78
81
  end
79
82
 
80
- operation = Lato::Operation.generate('LatoCms::GenerateAltTextJob', { media_id: @media.id }, @session.user_id)
83
+ operation = Lato::Operation.generate("LatoCms::GenerateMediaTextJob", { media_id: @media.id, attributes: [attribute] }, @session.user_id)
81
84
 
82
85
  if operation.start
83
86
  redirect_to lato.operation_path(operation)
84
87
  else
85
- redirect_to lato_cms.media_update_path(@media), alert: t('lato_cms.media_alt_text_regenerate_failed')
88
+ redirect_to lato_cms.media_update_path(@media), alert: t("lato_cms.media_text_regenerate_failed")
86
89
  end
87
90
  end
88
91
 
@@ -106,7 +109,7 @@ module LatoCms
106
109
  private
107
110
 
108
111
  def create_params
109
- params.require(:media).permit(:file, :name, :alt_text)
112
+ params.require(:media).permit(:file, :name, :alt_text, :title)
110
113
  end
111
114
 
112
115
  # :file is intentionally not permitted here: the underlying file is
@@ -114,8 +117,8 @@ module LatoCms
114
117
  # many pages, so replacing it in place would silently change what renders
115
118
  # everywhere it's referenced). A different file means a new Media.
116
119
  def update_params
117
- alt_text_keys = LatoCms.config.locales.map { |locale| :"alt_text_#{locale}" }
118
- params.require(:media).permit(:name, *alt_text_keys)
120
+ translation_keys = LatoCms::Media::TRANSLATABLE_ATTRIBUTES.product(LatoCms.config.locales).map { |attribute, locale| :"#{attribute}_#{locale}" }
121
+ params.require(:media).permit(:name, *translation_keys)
119
122
  end
120
123
  end
121
124
  end
@@ -0,0 +1,26 @@
1
+ module LatoCms
2
+ # Generates alt text and/or title for an image Media via an OpenAI-compatible
3
+ # LLM, one LLM call per attribute (each has its own prompt, see
4
+ # LatoCms::Config#llm_prompt). `attributes` defaults to every attribute
5
+ # enabled in config (the automatic post-upload run); the manual "Regenerate
6
+ # with AI" buttons pass the single attribute they sit next to.
7
+ #
8
+ # Doubles as a plain background job (post-upload call site, see
9
+ # Media#enqueue_text_generation: fire-and-forget, best effort, never blocks
10
+ # the upload) and as a Lato::Operation-backed job (the admin actions, see
11
+ # MediaController#regenerate_text_action: the admin is watching, so failures
12
+ # should surface instead of being swallowed). Inheriting Lato::ApplicationJob
13
+ # makes both work from the same perform: `operation?` is only true when a
14
+ # `_operation_id` was injected by Lato::Operation#start.
15
+ class GenerateMediaTextJob < Lato::ApplicationJob
16
+ def perform(params = {})
17
+ params = params.with_indifferent_access
18
+ media = LatoCms::Media.find_by(id: params[:media_id])
19
+ return unless media
20
+
21
+ attributes = Array(params[:attributes]).presence || LatoCms.config.llm_media_attributes
22
+ attributes.each { |attribute| media.generate_text!(attribute, raise_on_error: operation?) }
23
+ save_operation_output_message(I18n.t("lato_cms.media_text_regenerated")) if operation?
24
+ end
25
+ end
26
+ end
@@ -10,14 +10,23 @@ module LatoCms
10
10
 
11
11
  MEDIA_TYPES = %w[image video document file].freeze
12
12
 
13
- # alt_text is stored as a single JSON-serialized {locale => text} hash in
14
- # the `alt_text` column (kept as one column rather than a translations
15
- # table since it's the only translatable attribute Media has). `alt_text`
16
- # itself is overridden below to read/write the current I18n.locale's
17
- # entry; `alt_text_en`, `alt_text_it`, etc. (one per configured locale)
18
- # are handled dynamically so forms can render/submit them like any other
19
- # attribute without predefining a method per locale.
20
- ALT_TEXT_ACCESSOR = /\Aalt_text_(?<locale>[a-z]{2}(?:_[A-Z]{2})?)(?<setter>=)?\z/
13
+ # alt_text and title are each stored as a single JSON-serialized
14
+ # {locale => text} hash in their own text column (kept inline rather than
15
+ # in a translations table since these are Media's only translatable
16
+ # attributes). `alt_text`/`title` themselves are overridden below to
17
+ # read/write the current I18n.locale's entry; `alt_text_en`, `title_it`,
18
+ # etc. (one per configured locale) are handled dynamically so forms can
19
+ # render/submit them like any other attribute without predefining a
20
+ # method per locale.
21
+ TRANSLATABLE_ATTRIBUTES = %w[alt_text title].freeze
22
+ TRANSLATION_ACCESSOR = /\A(?<attribute>#{TRANSLATABLE_ATTRIBUTES.join("|")})_(?<locale>[a-z]{2}(?:_[A-Z]{2})?)(?<setter>=)?\z/
23
+
24
+ TRANSLATABLE_ATTRIBUTES.each do |attribute|
25
+ define_method(attribute) { |locale = I18n.locale| translations_of(attribute)[locale.to_s].presence }
26
+ define_method("#{attribute}=") { |value| write_translation(attribute, I18n.locale, value) }
27
+ define_method("#{attribute}_translations") { translations_of(attribute) }
28
+ define_method("#{attribute}_translations=") { |hash| self[attribute] = hash.stringify_keys.to_json }
29
+ end
21
30
 
22
31
  has_one_attached :file
23
32
  has_one_attached :poster_file
@@ -32,7 +41,7 @@ module LatoCms
32
41
  before_validation :set_media_type, on: :create
33
42
 
34
43
  after_create_commit :enqueue_poster_generation, if: :video?
35
- after_create_commit :enqueue_alt_text_generation, if: -> { image? && LatoCms.config.llm_configured? }
44
+ after_create_commit :enqueue_text_generation, if: -> { image? && LatoCms.config.llm_media_attributes.any? }
36
45
 
37
46
  scope :of_type, ->(type) { where(media_type: type) if type.present? }
38
47
 
@@ -73,37 +82,33 @@ module LatoCms
73
82
  file.filename.to_s if file.attached?
74
83
  end
75
84
 
76
- def alt_text(locale = I18n.locale)
77
- alt_text_translations[locale.to_s].presence
78
- end
79
-
80
- def alt_text=(value)
81
- self.alt_text_translations = alt_text_translations.merge(I18n.locale.to_s => value)
82
- end
83
-
84
- def alt_text_translations
85
- JSON.parse(self[:alt_text].presence || '{}')
85
+ def translations_of(attribute)
86
+ JSON.parse(self[attribute].presence || "{}")
86
87
  rescue JSON::ParserError
87
88
  {}
88
89
  end
89
90
 
90
- def alt_text_translations=(hash)
91
- self[:alt_text] = hash.stringify_keys.to_json
92
- end
93
-
94
91
  def method_missing(name, *args)
95
- match = ALT_TEXT_ACCESSOR.match(name.to_s)
92
+ match = TRANSLATION_ACCESSOR.match(name.to_s)
96
93
  return super unless match
97
94
 
98
95
  if match[:setter]
99
- self.alt_text_translations = alt_text_translations.merge(match[:locale] => args.first)
96
+ write_translation(match[:attribute], match[:locale], args.first)
100
97
  else
101
- alt_text_translations[match[:locale]].presence
98
+ translations_of(match[:attribute])[match[:locale]].presence
102
99
  end
103
100
  end
104
101
 
105
102
  def respond_to_missing?(name, include_private = false)
106
- ALT_TEXT_ACCESSOR.match?(name.to_s) || super
103
+ TRANSLATION_ACCESSOR.match?(name.to_s) || super
104
+ end
105
+
106
+ # Frontend URLs of the pages using this media through any field: the
107
+ # absolute URLs handed to the LLM as `{urls}` prompt context, so the model
108
+ # can see where the image is shown. Pages without a frontend URL are left
109
+ # out. Empty right after upload, since nothing references a new media yet.
110
+ def usage_urls
111
+ LatoCms::Page.where(id: page_fields.select(:page_id)).where.not(frontend_url: [nil, ""]).distinct.order(:frontend_url).pluck(:frontend_url)
107
112
  end
108
113
 
109
114
  def url
@@ -133,29 +138,50 @@ module LatoCms
133
138
  Rails.logger.warn("LatoCms: failed to generate video poster for media #{id}: #{e.message}")
134
139
  end
135
140
 
136
- # Asks the configured OpenAI-compatible LLM for alt text in every
137
- # configured locale and merges it in (existing translations for locales
138
- # the LLM didn't return, or that it's re-run for, are kept/replaced
139
- # individually). No-op unless an LLM is configured (see
140
- # LatoCms::Config#llm_configured?) and this media is an image.
141
+ # Asks the configured OpenAI-compatible LLM for `attribute` (alt_text or
142
+ # title) in every configured locale and merges it in (existing
143
+ # translations for locales the LLM didn't return are kept, the rest are
144
+ # replaced individually). No-op unless the LLM is configured and enabled
145
+ # for that attribute (see LatoCms::Config#llm_generates?) and this media
146
+ # is an image, since the LLM has to look at the file.
141
147
  #
142
148
  # Best effort by default (`raise_on_error: false`): any failure is logged
143
149
  # and swallowed, since the automatic post-upload call site (see
144
- # GenerateAltTextJob) must never break the upload over a flaky LLM. The
145
- # manual "Regenerate with AI" action runs as a Lato::Operation the admin
146
- # is actively watching, so it passes `raise_on_error: true` to have
150
+ # GenerateMediaTextJob) must never break the upload over a flaky LLM. The
151
+ # manual "Regenerate with AI" actions run as a Lato::Operation the admin
152
+ # is actively watching, so they pass `raise_on_error: true` to have
147
153
  # failures surface there instead of disappearing silently.
148
- def generate_alt_text!(raise_on_error: false)
149
- return unless image? && file.attached? && LatoCms.config.llm_configured?
154
+ def generate_text!(attribute, raise_on_error: false)
155
+ attribute = attribute.to_s
156
+ raise ArgumentError, "unknown translatable attribute: #{attribute}" unless TRANSLATABLE_ATTRIBUTES.include?(attribute)
157
+ return unless image? && file.attached? && LatoCms.config.llm_generates?(attribute)
158
+
159
+ begin
160
+ translations = parse_translations(request_completion(llm_prompt(attribute)))
161
+ raise "The LLM returned no usable #{attribute.humanize.downcase}" if translations.blank?
162
+
163
+ public_send("#{attribute}_translations=", translations_of(attribute).merge(translations))
164
+ save!
165
+ rescue StandardError => e
166
+ Rails.logger.warn("LatoCms: failed to generate #{attribute} for media #{id}: #{e.message}")
167
+ raise if raise_on_error
168
+ end
169
+ end
150
170
 
151
- translations = fetch_alt_text_translations
152
- raise 'The LLM returned no usable alt text' if translations.blank?
171
+ # Full prompt sent for `attribute`: the custom-or-default task prompt (see
172
+ # LatoCms::Config#llm_prompt) with {languages}/{urls} substituted, plus the
173
+ # engine's fixed response-format contract, appended here rather than left
174
+ # to the prompt so parsing never depends on how a custom prompt is worded.
175
+ def llm_prompt(attribute)
176
+ variables = {
177
+ "languages" => llm_locales.join(", "),
178
+ "urls" => usage_urls.presence&.join(", ") || "none"
179
+ }
180
+ placeholders = /\{(#{LatoCms::Config::LLM_PROMPT_VARIABLES.join("|")})\}/
181
+ task = LatoCms.config.llm_prompt(attribute).gsub(placeholders) { variables[Regexp.last_match(1)] }
153
182
 
154
- self.alt_text_translations = alt_text_translations.merge(translations)
155
- save!
156
- rescue StandardError => e
157
- Rails.logger.warn("LatoCms: failed to generate alt text for media #{id}: #{e.message}")
158
- raise if raise_on_error
183
+ "#{task}\n\nRespond with a single JSON object only, no markdown, no extra text, with exactly these " \
184
+ "keys: #{llm_locales.join(", ")}. Each value is the text written in that language."
159
185
  end
160
186
 
161
187
  # Fixed small variant used across admin UI (Media index, picker grid, field
@@ -212,6 +238,8 @@ module LatoCms
212
238
  name: name,
213
239
  alt_text: alt_text,
214
240
  alt_text_translations: alt_text_translations,
241
+ title: title,
242
+ title_translations: title_translations,
215
243
  media_type: media_type,
216
244
  filename: filename,
217
245
  content_type: file.attached? ? file.content_type : nil,
@@ -243,41 +271,36 @@ module LatoCms
243
271
  LatoCms::GenerateVideoPosterJob.perform_later(id)
244
272
  end
245
273
 
246
- def enqueue_alt_text_generation
247
- LatoCms::GenerateAltTextJob.perform_later(media_id: id)
274
+ def enqueue_text_generation
275
+ LatoCms::GenerateMediaTextJob.perform_later(media_id: id)
248
276
  end
249
277
 
250
- def fetch_alt_text_translations
251
- locales = LatoCms.config.locales.map(&:to_s)
252
- content = request_alt_text_completion(locales)
253
- parse_alt_text_response(content, locales)
278
+ def write_translation(attribute, locale, value)
279
+ public_send("#{attribute}_translations=", translations_of(attribute).merge(locale.to_s => value))
280
+ end
281
+
282
+ def llm_locales
283
+ LatoCms.config.locales.map(&:to_s)
254
284
  end
255
285
 
256
286
  # Sends the image as a base64 data URI rather than a URL: the file may be
257
287
  # stored on a service (or behind auth) the LLM can't reach, so this works
258
288
  # regardless of storage backend or app visibility settings.
259
- def request_alt_text_completion(locales)
289
+ def request_completion(prompt)
260
290
  LatoCms::LlmClient.chat(messages: [{
261
- role: 'user',
291
+ role: "user",
262
292
  content: [
263
- { type: 'text', text: alt_text_prompt(locales) },
264
- { type: 'image_url', image_url: { url: "data:#{file.content_type};base64,#{Base64.strict_encode64(file.download)}" } }
293
+ { type: "text", text: prompt },
294
+ { type: "image_url", image_url: { url: "data:#{file.content_type};base64,#{Base64.strict_encode64(file.download)}" } }
265
295
  ]
266
296
  }])
267
297
  end
268
298
 
269
- def alt_text_prompt(locales)
270
- "Write a concise, descriptive alt text for this image, for the HTML <img alt> attribute " \
271
- "(accessibility use, not a caption). Respond with a single JSON object only, no markdown, " \
272
- "no extra text, with exactly these keys: #{locales.join(', ')}. Each value is the alt text " \
273
- "written in that language."
274
- end
275
-
276
- def parse_alt_text_response(content, locales)
299
+ def parse_translations(content)
277
300
  return {} if content.blank?
278
301
 
279
302
  parsed = JSON.parse(content[/\{.*\}/m] || content)
280
- parsed.slice(*locales).transform_values(&:to_s)
303
+ parsed.slice(*llm_locales).transform_values(&:to_s)
281
304
  rescue JSON::ParserError
282
305
  {}
283
306
  end
@@ -141,6 +141,7 @@ module LatoCms
141
141
  media_id: m.id,
142
142
  name: m.name,
143
143
  alt_text: m.alt_text(page.locale),
144
+ title: m.title(page.locale),
144
145
  filename: m.filename,
145
146
  content_type: m.file.attached? ? m.file.content_type : nil,
146
147
  byte_size: m.file.attached? ? m.file.byte_size : nil,
@@ -31,42 +31,9 @@
31
31
  </div>
32
32
 
33
33
  <% if media.image? %>
34
- <div class="mb-3">
35
- <div class="d-flex align-items-center justify-content-between">
36
- <label class="form-label mb-0"><%= t('lato_cms.media_alt_text') %></label>
37
- <% if LatoCms.config.llm_configured? %>
38
- <%= link_to t('lato_cms.media_alt_text_regenerate'), lato_cms.media_regenerate_alt_text_action_path(media),
39
- class: 'btn btn-sm btn-outline-secondary',
40
- data: {
41
- lato_action_target: 'trigger',
42
- turbo_method: :post,
43
- turbo_frame: 'lato_operation',
44
- turbo_confirm: t('lato_cms.media_alt_text_regenerate_confirm'),
45
- action_title: t('lato_cms.media_alt_text_regenerate')
46
- } %>
47
- <% end %>
48
- </div>
49
-
50
- <ul class="nav nav-tabs">
51
- <% LatoCms.config.locales.each_with_index do |locale, index| %>
52
- <li class="nav-item">
53
- <button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
54
- data-bs-target="#<%= dom_id(media, "alt_text_#{locale}_pane") %>" type="button">
55
- <%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
56
- </button>
57
- </li>
58
- <% end %>
59
- </ul>
60
-
61
- <div class="tab-content border border-top-0 rounded-bottom p-3">
62
- <% LatoCms.config.locales.each_with_index do |locale, index| %>
63
- <div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "alt_text_#{locale}_pane") %>">
64
- <%= lato_form_item_input_text form, :"alt_text_#{locale}" %>
65
- </div>
66
- <% end %>
67
- </div>
68
- </div>
34
+ <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
69
35
  <% end %>
36
+ <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "title" %>
70
37
 
71
38
  <div class="d-flex justify-content-end">
72
39
  <%= lato_form_submit form, t('lato_cms.cta_update'), class: %w[btn-success] %>
@@ -0,0 +1,41 @@
1
+ <%# One tab per configured locale for a translatable Media attribute
2
+ (alt_text/title), with its own "Regenerate with AI" button when the LLM
3
+ is configured and enabled for that attribute. Images only: the LLM has
4
+ to look at the file to describe it. %>
5
+ <% label = t("lato_cms.media_#{attribute}") %>
6
+
7
+ <div class="mb-3">
8
+ <div class="d-flex align-items-center justify-content-between">
9
+ <label class="form-label mb-0"><%= label %></label>
10
+ <% if media.image? && LatoCms.config.llm_generates?(attribute) %>
11
+ <%= link_to t("lato_cms.media_text_regenerate"), lato_cms.media_regenerate_text_action_path(media, attribute: attribute),
12
+ class: "btn btn-sm btn-outline-secondary",
13
+ data: {
14
+ lato_action_target: "trigger",
15
+ turbo_method: :post,
16
+ turbo_frame: "lato_operation",
17
+ turbo_confirm: t("lato_cms.media_text_regenerate_confirm", field: label.downcase),
18
+ action_title: "#{t('lato_cms.media_text_regenerate')} - #{label}"
19
+ } %>
20
+ <% end %>
21
+ </div>
22
+
23
+ <ul class="nav nav-tabs">
24
+ <% LatoCms.config.locales.each_with_index do |locale, index| %>
25
+ <li class="nav-item">
26
+ <button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
27
+ data-bs-target="#<%= dom_id(media, "#{attribute}_#{locale}_pane") %>" type="button">
28
+ <%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
29
+ </button>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+
34
+ <div class="tab-content border border-top-0 rounded-bottom p-3">
35
+ <% LatoCms.config.locales.each_with_index do |locale, index| %>
36
+ <div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>">
37
+ <%= lato_form_item_input_text form, :"#{attribute}_#{locale}" %>
38
+ </div>
39
+ <% end %>
40
+ </div>
41
+ </div>
@@ -101,11 +101,12 @@ en:
101
101
  media_update_title: Edit media
102
102
  media_open_original: Open the original file in a new tab
103
103
  media_alt_text: Alt text
104
- media_alt_text_regenerate: Regenerate with AI
105
- media_alt_text_regenerate_confirm: "This will overwrite the current alt text in every language with a new AI-generated one. Continue?"
106
- media_alt_text_regenerated: Alt text regenerated
107
- media_alt_text_regenerate_unavailable: Alt text can only be regenerated for images, and only when an LLM is configured
108
- media_alt_text_regenerate_failed: Failed to start alt text regeneration
104
+ media_title: Title
105
+ media_text_regenerate: Regenerate with AI
106
+ media_text_regenerate_confirm: "This will overwrite the current %{field} in every language with a new AI-generated one. Continue?"
107
+ media_text_regenerated: Text regenerated
108
+ media_text_regenerate_unavailable: AI generation is only available for images, and only when an LLM is configured and enabled for this field
109
+ media_text_regenerate_failed: Failed to start AI generation
109
110
  media_created: Media uploaded successfully
110
111
  media_updated: Media updated successfully
111
112
  media_deleted: Media deleted successfully
@@ -101,11 +101,12 @@ it:
101
101
  media_update_title: Modifica media
102
102
  media_open_original: Apri il file originale in una nuova scheda
103
103
  media_alt_text: Testo alternativo
104
- media_alt_text_regenerate: Rigenera con AI
105
- media_alt_text_regenerate_confirm: "Il testo alternativo attuale in ogni lingua verrà sostituito con uno generato dall'AI. Continuare?"
106
- media_alt_text_regenerated: Testo alternativo rigenerato
107
- media_alt_text_regenerate_unavailable: Il testo alternativo può essere rigenerato solo per le immagini e solo se è configurato un LLM
108
- media_alt_text_regenerate_failed: Impossibile avviare la rigenerazione del testo alternativo
104
+ media_title: Titolo
105
+ media_text_regenerate: Rigenera con AI
106
+ media_text_regenerate_confirm: "Il %{field} attuale in ogni lingua verrà sostituito con uno generato dall'AI. Continuare?"
107
+ media_text_regenerated: Testo rigenerato
108
+ media_text_regenerate_unavailable: La generazione AI è disponibile solo per le immagini e solo se un LLM è configurato e abilitato per questo campo
109
+ media_text_regenerate_failed: Impossibile avviare la generazione AI
109
110
  media_created: Media caricato con successo
110
111
  media_updated: Media aggiornato con successo
111
112
  media_deleted: Media eliminato con successo
data/config/routes.rb CHANGED
@@ -32,7 +32,7 @@ LatoCms::Engine.routes.draw do
32
32
  post 'create', to: 'media#create_action', as: :media_create_action
33
33
  get ':id/update', to: 'media#update', as: :media_update
34
34
  patch ':id/update', to: 'media#update_action', as: :media_update_action
35
- post ':id/regenerate-alt-text', to: 'media#regenerate_alt_text_action', as: :media_regenerate_alt_text_action
35
+ post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title/ }
36
36
  delete ':id', to: 'media#destroy_action', as: :media_destroy_action
37
37
  end
38
38
  end
@@ -0,0 +1,6 @@
1
+ class AddTitleToLatoCmsMedia < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # JSON-serialized {locale => title} hash, same shape as alt_text.
4
+ add_column :lato_cms_media, :title, :text
5
+ end
6
+ end
@@ -3,11 +3,32 @@ module LatoCms
3
3
  # This class contains the default configuration of the engine.
4
4
  ##
5
5
  class Config
6
- attr_accessor :locales, :templates_path, :admin_roles, :llm_api_url, :llm_model, :llm_api_key
6
+ attr_accessor :locales, :templates_path, :admin_roles, :llm_api_url, :llm_model, :llm_api_key,
7
+ :llm_generate_alt_text, :llm_generate_title, :llm_alt_text_prompt, :llm_title_prompt
8
+
9
+ # Placeholders substituted at request time in both default and custom LLM
10
+ # prompts (see LatoCms::Media#llm_prompt):
11
+ # - {languages}: comma-separated LatoCms.config.locales
12
+ # - {urls}: comma-separated frontend URLs of the pages using the media, so
13
+ # the model can look at where the image is shown ("none" when it isn't
14
+ # used anywhere yet, e.g. right after upload)
15
+ LLM_PROMPT_VARIABLES = %w[languages urls].freeze
16
+
17
+ # Task prompts used when the host app sets no custom one. The response
18
+ # format contract (a JSON object keyed by locale) is appended by the
19
+ # engine to every prompt, custom ones included, so it's not part of these.
20
+ DEFAULT_LLM_PROMPTS = {
21
+ alt_text: "Write a concise, descriptive alt text for this image, for the HTML <img alt> attribute " \
22
+ "(accessibility use, not a caption). The website is published in these languages: {languages}. " \
23
+ "The image is used on these pages, use them as context for what it depicts and why: {urls}.",
24
+ title: "Write a short, human-readable title for this image, for the HTML <img title> attribute " \
25
+ "(a few words, no trailing period, not a full description). The website is published in these " \
26
+ "languages: {languages}. The image is used on these pages, use them as context: {urls}."
27
+ }.freeze
7
28
 
8
29
  def initialize
9
30
  @locales = [:en]
10
- @templates_path = 'config/lato_cms'
31
+ @templates_path = "config/lato_cms"
11
32
 
12
33
  # Admin roles exposed on Lato::User#lato_cms_admin_role and rendered
13
34
  # as a select by lato_users. Ordered map of role key => integer value;
@@ -16,16 +37,44 @@ module LatoCms
16
37
  # (create, update, delete) and translation links.
17
38
  @admin_roles = { none: 0, operator: 1, admin: 2 }
18
39
 
19
- # Optional OpenAI-compatible endpoint used to auto-generate alt text
20
- # for uploaded images (see LatoCms::Media#generate_alt_text!). All
40
+ # Optional OpenAI-compatible endpoint used to auto-generate alt text and
41
+ # title for uploaded images (see LatoCms::Media#generate_text!). All
21
42
  # three must be set for the feature to activate; unset by default.
22
43
  @llm_api_url = nil
23
44
  @llm_model = nil
24
45
  @llm_api_key = nil
46
+
47
+ # Which Media attributes the LLM generates (post-upload and via the
48
+ # "Regenerate with AI" buttons). Both on once an LLM is configured;
49
+ # switch one off to skip it, each costs one LLM call per upload.
50
+ @llm_generate_alt_text = true
51
+ @llm_generate_title = true
52
+
53
+ # Custom task prompts, nil falls back to DEFAULT_LLM_PROMPTS. May embed
54
+ # LLM_PROMPT_VARIABLES placeholders.
55
+ @llm_alt_text_prompt = nil
56
+ @llm_title_prompt = nil
25
57
  end
26
58
 
27
59
  def llm_configured?
28
60
  llm_api_url.present? && llm_model.present? && llm_api_key.present?
29
61
  end
62
+
63
+ # Media attributes the LLM generates, as configured. Empty unless an LLM
64
+ # is configured, so callers need a single check.
65
+ def llm_media_attributes
66
+ return [] unless llm_configured?
67
+
68
+ { alt_text: llm_generate_alt_text, title: llm_generate_title }.select { |_attribute, enabled| enabled }.keys
69
+ end
70
+
71
+ def llm_generates?(attribute)
72
+ llm_media_attributes.include?(attribute.to_sym)
73
+ end
74
+
75
+ def llm_prompt(attribute)
76
+ attribute = attribute.to_sym
77
+ public_send("llm_#{attribute}_prompt").presence || DEFAULT_LLM_PROMPTS.fetch(attribute)
78
+ end
30
79
  end
31
- end
80
+ end
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.1.5"
2
+ VERSION = "3.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lato_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.5
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante
@@ -128,7 +128,7 @@ files:
128
128
  - app/helpers/lato_cms/pages_helper.rb
129
129
  - app/jobs/lato_cms/application_job.rb
130
130
  - app/jobs/lato_cms/export_sitemap_csv_job.rb
131
- - app/jobs/lato_cms/generate_alt_text_job.rb
131
+ - app/jobs/lato_cms/generate_media_text_job.rb
132
132
  - app/jobs/lato_cms/generate_video_poster_job.rb
133
133
  - app/jobs/lato_cms/translate_component_fields_job.rb
134
134
  - app/mailers/lato_cms/application_mailer.rb
@@ -140,6 +140,7 @@ files:
140
140
  - app/models/lato_cms/template_manager.rb
141
141
  - app/views/lato_cms/media/_form_create.html.erb
142
142
  - app/views/lato_cms/media/_form_update.html.erb
143
+ - app/views/lato_cms/media/_translations_field.html.erb
143
144
  - app/views/lato_cms/media/create.html.erb
144
145
  - app/views/lato_cms/media/index.html.erb
145
146
  - app/views/lato_cms/media/picker_action.html.erb
@@ -185,6 +186,7 @@ files:
185
186
  - db/migrate/20260728175413_create_lato_cms_media.rb
186
187
  - db/migrate/20260728175414_create_lato_cms_page_field_media.rb
187
188
  - db/migrate/20260729120000_change_lato_cms_media_alt_text_to_translations.rb
189
+ - db/migrate/20260907120000_add_title_to_lato_cms_media.rb
188
190
  - lib/lato_cms.rb
189
191
  - lib/lato_cms/config.rb
190
192
  - lib/lato_cms/engine.rb
@@ -1,21 +0,0 @@
1
- module LatoCms
2
- # Generates alt text for an image Media via an OpenAI-compatible LLM.
3
- # Doubles as a plain background job (automatic post-upload call site, see
4
- # Media#enqueue_alt_text_generation: fire-and-forget, best effort, never
5
- # blocks the upload) and as a Lato::Operation-backed job (the "Regenerate
6
- # with AI" admin action, see MediaController#regenerate_alt_text_action:
7
- # the admin is watching, so failures should surface instead of being
8
- # swallowed). Inheriting Lato::ApplicationJob makes both work from the
9
- # same perform: `operation?` is only true when a `_operation_id` was
10
- # injected by Lato::Operation#start.
11
- class GenerateAltTextJob < Lato::ApplicationJob
12
- def perform(params = {})
13
- params = params.with_indifferent_access
14
- media = LatoCms::Media.find_by(id: params[:media_id])
15
- return unless media
16
-
17
- media.generate_alt_text!(raise_on_error: operation?)
18
- save_operation_output_message(I18n.t('lato_cms.media_alt_text_regenerated')) if operation?
19
- end
20
- end
21
- end