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 +4 -4
- data/README.md +8 -0
- data/app/controllers/lato_cms/media_controller.rb +9 -8
- data/app/jobs/lato_cms/generate_media_text_job.rb +6 -5
- data/app/models/lato_cms/media.rb +52 -27
- data/app/views/lato_cms/media/_form_update.html.erb +18 -0
- data/app/views/lato_cms/media/_translations_field.html.erb +3 -17
- data/config/locales/en.yml +1 -1
- data/config/locales/it.yml +1 -1
- data/config/routes.rb +1 -1
- data/lib/lato_cms/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a5b87d2c477cafb579b7ceea01c41e72071b3a789f425e184acbe99592f63ab
|
|
4
|
+
data.tar.gz: 123893a3b2a7c226c4910f41ab931b5bf7fd28b30b883293d811e6fc439d0155
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
85
|
-
# route constraint) with the LLM. Runs as a Lato::Operation
|
|
86
|
-
# GenerateMediaTextJob) rather than inline: an LLM call can take a
|
|
87
|
-
# and blocking the request risks timing it out. The admin instead
|
|
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
|
-
|
|
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? &&
|
|
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:
|
|
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
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
|
216
|
-
# title) in every configured locale and merges
|
|
217
|
-
# translations for locales the LLM didn't return are
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
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"
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
def
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
raise "The LLM returned no usable #{
|
|
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 #{
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
"#{
|
|
258
|
-
"keys: #{
|
|
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
|
-
|
|
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(*
|
|
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)
|
|
3
|
-
|
|
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
|
-
<
|
|
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| %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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 %{
|
|
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
|
data/config/locales/it.yml
CHANGED
|
@@ -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: "
|
|
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
|
data/lib/lato_cms/version.rb
CHANGED