lato_cms 3.3.0 → 3.3.1

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: '094c93e8f96929b8abc542b096f60de09e69165f3158c3b17a1bedd0815b3645'
4
- data.tar.gz: 77ddc4feb76c37b9d1f6bb5f13230a18cc452a30d4435aee38188fb93853c74d
3
+ metadata.gz: 9a5b87d2c477cafb579b7ceea01c41e72071b3a789f425e184acbe99592f63ab
4
+ data.tar.gz: 123893a3b2a7c226c4910f41ab931b5bf7fd28b30b883293d811e6fc439d0155
5
5
  SHA512:
6
- metadata.gz: 91438e0eba689387d221a7f27ceb24fae3b249a3f154e5fff1ceca4f7812242e9278488e247c0f59adad09b30ab5ca02b383b90dc05a8ed3528f32449dc798c2
7
- data.tar.gz: 85b62b2734785e61ee3e4b88f74a2b487d6fd6bd6cd533f9a39dfe26bef85b7971dfbe8dc34b9de57c139bc5ee5c8889c8e2b43495e3799049a135cd9540a747
6
+ metadata.gz: 23968a738f1d1295e172d546617950e2b49c900b45ac6399080a01380c97ae7ebba48414277c58565205f8229dd758b798a286a3dfb6eac55d3c1091258fad12
7
+ data.tar.gz: 0a3d712b435da91e70a4a90edd8cefad81fa8105dface4946c4776490b920d3f2259c4857619d99af374e84b181aea38bc0524b2df1ef6f22532f80a141d16c5
data/README.md CHANGED
@@ -65,6 +65,14 @@ no redirect, and `Cache-Control: public, immutable`, which a cache in front of t
65
65
  actually use. The address carries the signature of the blob, so a replaced file is a
66
66
  different address and can never be served stale.
67
67
 
68
+ ⚠️ **`:proxy` needs something in front that splits byte ranges** — nginx with `slice`,
69
+ or a CDN that does the same. Active Storage's proxy controller serves one range at a
70
+ time, holding it in memory, and answers **416** to anything above
71
+ `streaming_chunk_max_size` (100 MB by default). A `<video>` opens with `Range: bytes=0-`,
72
+ that is the whole file: above that size the browser reads the 416 as "format not
73
+ supported", and below it the app loads tens of MB into memory per request. With slices of
74
+ 1 MB neither happens. Without such a front, stay on `:redirect`.
75
+
68
76
  Both modes ask for an `inline` disposition: a media of the library is content to show, and
69
77
  the default for a video would otherwise turn opening its address into a download.
70
78
 
@@ -81,16 +81,17 @@ module LatoCms
81
81
  end
82
82
  end
83
83
 
84
- # Regenerates one translatable attribute (alt_text or title, see the
85
- # route constraint) with the LLM. Runs as a Lato::Operation (see
86
- # GenerateMediaTextJob) rather than inline: an LLM call can take a while,
87
- # and blocking the request risks timing it out. The admin instead lands on
88
- # a live progress page.
84
+ # Regenerates the translatable attributes (alt_text and title, or a single
85
+ # one — see the route constraint) with the LLM. Runs as a Lato::Operation
86
+ # (see GenerateMediaTextJob) rather than inline: an LLM call can take a
87
+ # while, and blocking the request risks timing it out. The admin instead
88
+ # lands on a live progress page.
89
89
  def regenerate_text_action
90
90
  @media = query_media.find(params[:id])
91
- attribute = params[:attribute].to_s
91
+ attributes = params[:attribute] == "all" ? LatoCms.config.llm_media_attributes : [params[:attribute]]
92
+ attributes = attributes.map(&:to_s).select { |attribute| LatoCms.config.llm_generates?(attribute) }
92
93
 
93
- unless @media.image? && LatoCms.config.llm_generates?(attribute)
94
+ unless @media.image? && attributes.any?
94
95
  respond_to do |format|
95
96
  message = t("lato_cms.media_text_regenerate_unavailable")
96
97
  format.html { redirect_to lato_cms.media_update_path(@media), alert: message }
@@ -99,7 +100,7 @@ module LatoCms
99
100
  return
100
101
  end
101
102
 
102
- operation = Lato::Operation.generate("LatoCms::GenerateMediaTextJob", { media_id: @media.id, attributes: [attribute] }, @session.user_id)
103
+ operation = Lato::Operation.generate("LatoCms::GenerateMediaTextJob", { media_id: @media.id, attributes: attributes }, @session.user_id)
103
104
 
104
105
  if operation.start
105
106
  redirect_to lato.operation_path(operation)
@@ -1,9 +1,10 @@
1
1
  module LatoCms
2
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.
3
+ # LLM in a single call, whatever the number of attributes: the image travels
4
+ # in the request, so one call per attribute meant uploading it twice (see
5
+ # Media#generate_texts!). `attributes` defaults to every attribute enabled in
6
+ # config, which is what both the automatic post-upload run and the
7
+ # "Regenerate with AI" action ask for.
7
8
  #
8
9
  # Doubles as a plain background job (post-upload call site, see
9
10
  # Media#enqueue_text_generation: fire-and-forget, best effort, never blocks
@@ -19,7 +20,7 @@ module LatoCms
19
20
  return unless media
20
21
 
21
22
  attributes = Array(params[:attributes]).presence || LatoCms.config.llm_media_attributes
22
- attributes.each { |attribute| media.generate_text!(attribute, raise_on_error: operation?) }
23
+ media.generate_texts!(attributes, raise_on_error: operation?)
23
24
  save_operation_output_message(I18n.t("lato_cms.media_text_regenerated")) if operation?
24
25
  end
25
26
  end
@@ -212,50 +212,68 @@ module LatoCms
212
212
  Rails.logger.warn("LatoCms: failed to generate video poster for media #{id}: #{e.message}")
213
213
  end
214
214
 
215
- # Asks the configured OpenAI-compatible LLM for `attribute` (alt_text or
216
- # title) in every configured locale and merges it in (existing
217
- # translations for locales the LLM didn't return are kept, the rest are
218
- # replaced individually). No-op unless the LLM is configured and enabled
219
- # for that attribute (see LatoCms::Config#llm_generates?) and this media
220
- # is an image, since the LLM has to look at the file.
215
+ # Asks the configured OpenAI-compatible LLM for the given attributes
216
+ # (alt_text, title, or both) in every configured locale and merges the
217
+ # result in — existing translations for locales the LLM didn't return are
218
+ # kept, the rest are replaced individually. Attributes switched off in
219
+ # config are skipped; no-op unless the LLM is configured and this media is
220
+ # an image, since the model has to look at the file.
221
+ #
222
+ # All the requested attributes travel in ONE request: the image is the
223
+ # expensive part of the payload (it goes as a base64 data URI, see
224
+ # request_completion), so asking per attribute uploaded the same file twice
225
+ # and paid for it twice.
221
226
  #
222
227
  # Best effort by default (`raise_on_error: false`): any failure is logged
223
228
  # and swallowed, since the automatic post-upload call site (see
224
229
  # GenerateMediaTextJob) must never break the upload over a flaky LLM. The
225
- # manual "Regenerate with AI" actions run as a Lato::Operation the admin
226
- # is actively watching, so they pass `raise_on_error: true` to have
227
- # failures surface there instead of disappearing silently.
228
- def generate_text!(attribute, raise_on_error: false)
229
- attribute = attribute.to_s
230
- raise ArgumentError, "unknown translatable attribute: #{attribute}" unless TRANSLATABLE_ATTRIBUTES.include?(attribute)
231
- return unless image? && file.attached? && LatoCms.config.llm_generates?(attribute)
230
+ # manual "Regenerate with AI" action runs as a Lato::Operation the admin is
231
+ # actively watching, so it passes `raise_on_error: true` to have failures
232
+ # surface there instead of disappearing silently.
233
+ def generate_texts!(attributes = LatoCms.config.llm_media_attributes, raise_on_error: false)
234
+ attributes = Array(attributes).map(&:to_s)
235
+ unknown = attributes - TRANSLATABLE_ATTRIBUTES
236
+ raise ArgumentError, "unknown translatable attribute: #{unknown.join(", ")}" if unknown.any?
237
+
238
+ attributes = attributes.select { |attribute| LatoCms.config.llm_generates?(attribute) }
239
+ return if attributes.empty? || !image? || !file.attached?
232
240
 
233
241
  begin
234
- translations = parse_translations(request_completion(llm_prompt(attribute)))
235
- raise "The LLM returned no usable #{attribute.humanize.downcase}" if translations.blank?
242
+ generated = parse_translations(request_completion(llm_prompt(*attributes)), attributes)
243
+ raise "The LLM returned no usable #{attributes.to_sentence}" if generated.blank?
236
244
 
237
- public_send("#{attribute}_translations=", translations_of(attribute).merge(translations))
245
+ generated.each { |attribute, translations| public_send("#{attribute}_translations=", translations_of(attribute).merge(translations)) }
238
246
  save!
239
247
  rescue StandardError => e
240
- Rails.logger.warn("LatoCms: failed to generate #{attribute} for media #{id}: #{e.message}")
248
+ Rails.logger.warn("LatoCms: failed to generate #{attributes.join(", ")} for media #{id}: #{e.message}")
241
249
  raise if raise_on_error
242
250
  end
243
251
  end
244
252
 
245
- # Full prompt sent for `attribute`: the custom-or-default task prompt (see
246
- # LatoCms::Config#llm_prompt) with {languages}/{urls} substituted, plus the
247
- # engine's fixed response-format contract, appended here rather than left
248
- # to the prompt so parsing never depends on how a custom prompt is worded.
249
- def llm_prompt(attribute)
253
+ def generate_text!(attribute, raise_on_error: false)
254
+ generate_texts!([attribute], raise_on_error: raise_on_error)
255
+ end
256
+
257
+ # Full prompt sent for `attributes`: one custom-or-default task prompt per
258
+ # attribute (see LatoCms::Config#llm_prompt) with {languages}/{urls}
259
+ # substituted, plus the engine's fixed response-format contract, appended
260
+ # here rather than left to the prompts so parsing never depends on how a
261
+ # custom one is worded.
262
+ def llm_prompt(*attributes)
263
+ attributes = attributes.flatten.map(&:to_s).presence || LatoCms.config.llm_media_attributes.map(&:to_s)
250
264
  variables = {
251
265
  "languages" => llm_locales.join(", "),
252
266
  "urls" => usage_urls.presence&.join(", ") || "none"
253
267
  }
254
268
  placeholders = /\{(#{LatoCms::Config::LLM_PROMPT_VARIABLES.join("|")})\}/
255
- task = LatoCms.config.llm_prompt(attribute).gsub(placeholders) { variables[Regexp.last_match(1)] }
269
+ tasks = attributes.map do |attribute|
270
+ task = LatoCms.config.llm_prompt(attribute).gsub(placeholders) { variables[Regexp.last_match(1)] }
271
+ "#{attribute}: #{task}"
272
+ end
256
273
 
257
- "#{task}\n\nRespond with a single JSON object only, no markdown, no extra text, with exactly these " \
258
- "keys: #{llm_locales.join(", ")}. Each value is the text written in that language."
274
+ "#{tasks.join("\n\n")}\n\nRespond with a single JSON object only, no markdown, no extra text, with exactly " \
275
+ "these keys: #{attributes.join(", ")}. Each value is an object with exactly these keys: " \
276
+ "#{llm_locales.join(", ")}, each holding the text written in that language."
259
277
  end
260
278
 
261
279
  # Fixed small variant used across admin UI (Media index, picker grid, field
@@ -365,11 +383,18 @@ module LatoCms
365
383
  }])
366
384
  end
367
385
 
368
- def parse_translations(content)
386
+ # { attribute => { locale => text } }, keeping only what was asked for and
387
+ # what the app is configured to speak.
388
+ def parse_translations(content, attributes)
369
389
  return {} if content.blank?
370
390
 
371
391
  parsed = JSON.parse(content[/\{.*\}/m] || content)
372
- parsed.slice(*llm_locales).transform_values(&:to_s)
392
+ parsed.slice(*attributes).each_with_object({}) do |(attribute, translations), result|
393
+ next unless translations.is_a?(Hash)
394
+
395
+ translations = translations.slice(*llm_locales).transform_values(&:to_s).compact_blank
396
+ result[attribute] = translations if translations.any?
397
+ end
373
398
  rescue JSON::ParserError
374
399
  {}
375
400
  end
@@ -33,6 +33,24 @@
33
33
  <%= lato_form_item_input_text form, :name, required: true %>
34
34
  </div>
35
35
 
36
+ <%# One action for every attribute the LLM is enabled for: the image
37
+ travels in the request, so asking per attribute uploaded the same
38
+ file once per attribute. %>
39
+ <% llm_attributes = media.image? ? LatoCms.config.llm_media_attributes : [] %>
40
+ <% if llm_attributes.any? %>
41
+ <div class="d-flex justify-content-end mb-2">
42
+ <%= link_to t("lato_cms.media_text_regenerate"), lato_cms.media_regenerate_text_action_path(media, attribute: "all"),
43
+ class: "btn btn-sm btn-outline-secondary",
44
+ data: {
45
+ lato_action_target: "trigger",
46
+ turbo_method: :post,
47
+ turbo_frame: "lato_operation",
48
+ turbo_confirm: t("lato_cms.media_text_regenerate_confirm", fields: llm_attributes.map { |attribute| t("lato_cms.media_#{attribute}").downcase }.join(", ")),
49
+ action_title: t("lato_cms.media_text_regenerate")
50
+ } %>
51
+ </div>
52
+ <% end %>
53
+
36
54
  <% if media.image? %>
37
55
  <%= render "lato_cms/media/translations_field", form: form, media: media, attribute: "alt_text" %>
38
56
  <% end %>
@@ -1,24 +1,10 @@
1
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. %>
2
+ (alt_text/title). The "Regenerate with AI" action is not here: it covers
3
+ every enabled attribute in one LLM call (see _form_update). %>
5
4
  <% label = t("lato_cms.media_#{attribute}") %>
6
5
 
7
6
  <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>
7
+ <label class="form-label"><%= label %></label>
22
8
 
23
9
  <ul class="nav nav-tabs">
24
10
  <% LatoCms.config.locales.each_with_index do |locale, index| %>
@@ -105,7 +105,7 @@ en:
105
105
  media_alt_text: Alt text
106
106
  media_title: Title
107
107
  media_text_regenerate: Regenerate with AI
108
- media_text_regenerate_confirm: "This will overwrite the current %{field} in every language with a new AI-generated one. Continue?"
108
+ media_text_regenerate_confirm: "This will overwrite the current values of %{fields} in every language with AI-generated ones. Continue?"
109
109
  media_text_regenerated: Text regenerated
110
110
  media_text_regenerate_unavailable: AI generation is only available for images, and only when an LLM is configured and enabled for this field
111
111
  media_text_regenerate_failed: Failed to start AI generation
@@ -105,7 +105,7 @@ it:
105
105
  media_alt_text: Testo alternativo
106
106
  media_title: Titolo
107
107
  media_text_regenerate: Rigenera con AI
108
- media_text_regenerate_confirm: "Il %{field} attuale in ogni lingua verrà sostituito con uno generato dall'AI. Continuare?"
108
+ media_text_regenerate_confirm: "I valori attuali di %{fields} in ogni lingua verranno sostituiti con testi generati dall'AI. Continuare?"
109
109
  media_text_regenerated: Testo rigenerato
110
110
  media_text_regenerate_unavailable: La generazione AI è disponibile solo per le immagini e solo se un LLM è configurato e abilitato per questo campo
111
111
  media_text_regenerate_failed: Impossibile avviare la generazione AI
data/config/routes.rb CHANGED
@@ -34,7 +34,7 @@ LatoCms::Engine.routes.draw do
34
34
  get ':id/update', to: 'media#update', as: :media_update
35
35
  patch ':id/update', to: 'media#update_action', as: :media_update_action
36
36
  patch ':id/replace-file', to: 'media#replace_file_action', as: :media_replace_file_action
37
- post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title/ }
37
+ post ':id/regenerate/:attribute', to: 'media#regenerate_text_action', as: :media_regenerate_text_action, constraints: { attribute: /alt_text|title|all/ }
38
38
  delete ':id', to: 'media#destroy_action', as: :media_destroy_action
39
39
  end
40
40
  end
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.3.0"
2
+ VERSION = "3.3.1"
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.3.0
4
+ version: 3.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante