flux3_ai_prompt_tools 0.1.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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/Rakefile +11 -0
- data/bin/flux3-prompt +23 -0
- data/examples/batch.jsonl +2 -0
- data/examples/kettle.json +16 -0
- data/flux3_ai_prompt_tools.gemspec +42 -0
- data/lib/flux3_ai_prompt_tools/assembler.rb +192 -0
- data/lib/flux3_ai_prompt_tools/cli.rb +349 -0
- data/lib/flux3_ai_prompt_tools/errors.rb +49 -0
- data/lib/flux3_ai_prompt_tools/fields.rb +37 -0
- data/lib/flux3_ai_prompt_tools/layouts.rb +8 -0
- data/lib/flux3_ai_prompt_tools/limits.rb +37 -0
- data/lib/flux3_ai_prompt_tools/manifest.rb +204 -0
- data/lib/flux3_ai_prompt_tools/numbers.rb +37 -0
- data/lib/flux3_ai_prompt_tools/presets.rb +53 -0
- data/lib/flux3_ai_prompt_tools/reader.rb +111 -0
- data/lib/flux3_ai_prompt_tools/schema.rb +108 -0
- data/lib/flux3_ai_prompt_tools/specification.rb +89 -0
- data/lib/flux3_ai_prompt_tools/validator.rb +368 -0
- data/lib/flux3_ai_prompt_tools/version.rb +5 -0
- data/lib/flux3_ai_prompt_tools.rb +51 -0
- data/test/assembler_test.rb +115 -0
- data/test/cli_test.rb +168 -0
- data/test/helper.rb +30 -0
- data/test/manifest_test.rb +112 -0
- data/test/packaging_test.rb +84 -0
- data/test/reader_test.rb +71 -0
- data/test/validator_test.rb +135 -0
- metadata +80 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "fields"
|
|
5
|
+
require_relative "limits"
|
|
6
|
+
require_relative "numbers"
|
|
7
|
+
require_relative "presets"
|
|
8
|
+
require_relative "layouts"
|
|
9
|
+
|
|
10
|
+
module Flux3AiPromptTools
|
|
11
|
+
# Checks one prompt record and returns a clean hash when every field is usable.
|
|
12
|
+
class Validator
|
|
13
|
+
def initialize(limits = Limits::DEFAULT)
|
|
14
|
+
unless limits.is_a?(Limits)
|
|
15
|
+
raise ArgumentError, "limits must be a Flux3AiPromptTools::Limits"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@limits = limits
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns [clean_hash_or_nil, issues, explicit_id_or_nil].
|
|
22
|
+
# clean_hash is present only when issues is empty. explicit_id is the
|
|
23
|
+
# caller-supplied id when that field itself is valid.
|
|
24
|
+
def prepare(input, pointer:)
|
|
25
|
+
issues = []
|
|
26
|
+
base = pointer.to_s
|
|
27
|
+
unless input.is_a?(Hash)
|
|
28
|
+
issues << Issue.new(base.empty? ? "/" : base, "type", "record must be an object")
|
|
29
|
+
return [nil, issues, nil]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
normalized = normalize_keys(input, base, issues)
|
|
33
|
+
normalized.each_key do |name|
|
|
34
|
+
next if Fields::NAMES.include?(name)
|
|
35
|
+
|
|
36
|
+
issues << Issue.new(field_path(base, name), "unknown_field", "unknown field #{name.inspect}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
coerced = {}
|
|
40
|
+
coerce_fields(normalized, coerced, issues, base)
|
|
41
|
+
explicit_id = coerced["id"]
|
|
42
|
+
return [nil, issues, explicit_id] unless issues.empty?
|
|
43
|
+
|
|
44
|
+
[merge_clean(normalized, coerced), issues, explicit_id]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def normalize_keys(input, pointer, issues)
|
|
50
|
+
normalized = {}
|
|
51
|
+
input.each do |key, value|
|
|
52
|
+
name = case key
|
|
53
|
+
when String then key
|
|
54
|
+
when Symbol then key.to_s
|
|
55
|
+
else
|
|
56
|
+
issues << Issue.new(pointer.empty? ? "/" : pointer, "type", "field names must be strings")
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
if normalized.key?(name)
|
|
60
|
+
issues << Issue.new(field_path(pointer, name), "duplicate_field", "field #{name} was given more than once")
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
normalized[name] = value
|
|
64
|
+
end
|
|
65
|
+
normalized
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def coerce_fields(normalized, coerced, issues, pointer)
|
|
69
|
+
coerce_id(normalized, coerced, issues, pointer)
|
|
70
|
+
coerce_subject(normalized, coerced, issues, pointer)
|
|
71
|
+
Fields::STRING_LIMITS.each_key do |name|
|
|
72
|
+
next if name == "subject"
|
|
73
|
+
|
|
74
|
+
coerce_optional_string(normalized, coerced, issues, pointer, name)
|
|
75
|
+
end
|
|
76
|
+
coerce_preset(normalized, coerced, issues, pointer)
|
|
77
|
+
coerce_layout(normalized, coerced, issues, pointer)
|
|
78
|
+
coerce_details(normalized, coerced, issues, pointer)
|
|
79
|
+
coerce_negatives(normalized, coerced, issues, pointer)
|
|
80
|
+
coerce_aspect_ratio(normalized, coerced, issues, pointer)
|
|
81
|
+
coerce_seed(normalized, coerced, issues, pointer)
|
|
82
|
+
coerce_steps(normalized, coerced, issues, pointer)
|
|
83
|
+
coerce_guidance(normalized, coerced, issues, pointer)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def coerce_id(normalized, coerced, issues, pointer)
|
|
87
|
+
return unless normalized.key?("id") && !normalized["id"].nil?
|
|
88
|
+
|
|
89
|
+
value = normalized["id"]
|
|
90
|
+
path = field_path(pointer, "id")
|
|
91
|
+
unless value.is_a?(String)
|
|
92
|
+
issues << Issue.new(path, "type", "id must be a string")
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
stripped = value.strip
|
|
97
|
+
if stripped.match?(Fields::ID_PATTERN)
|
|
98
|
+
coerced["id"] = stripped
|
|
99
|
+
else
|
|
100
|
+
issues << Issue.new(path, "format", "id must start with a letter or digit and then use only letters, digits, dots, underscores, or hyphens (64 characters max)")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def coerce_subject(normalized, coerced, issues, pointer)
|
|
105
|
+
path = field_path(pointer, "subject")
|
|
106
|
+
if !normalized.key?("subject") || normalized["subject"].nil?
|
|
107
|
+
issues << Issue.new(path, "required", "subject is required")
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
text = coerce_text(normalized["subject"], path, issues, Fields::STRING_LIMITS.fetch("subject"))
|
|
112
|
+
coerced["subject"] = text unless text.nil?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def coerce_optional_string(normalized, coerced, issues, pointer, name)
|
|
116
|
+
return unless normalized.key?(name) && !normalized[name].nil?
|
|
117
|
+
|
|
118
|
+
text = coerce_text(normalized[name], field_path(pointer, name), issues, Fields::STRING_LIMITS.fetch(name))
|
|
119
|
+
coerced[name] = text unless text.nil?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def coerce_text(value, path, issues, limit)
|
|
123
|
+
unless value.is_a?(String)
|
|
124
|
+
issues << Issue.new(path, "type", "must be a string")
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
candidate = value.dup
|
|
129
|
+
candidate.force_encoding(Encoding::UTF_8) if candidate.encoding == Encoding::ASCII_8BIT
|
|
130
|
+
unless candidate.valid_encoding?
|
|
131
|
+
issues << Issue.new(path, "encoding", "must be valid UTF-8")
|
|
132
|
+
return nil
|
|
133
|
+
end
|
|
134
|
+
if candidate.match?(/[\u0000-\u001F\u007F\u2028\u2029]/)
|
|
135
|
+
issues << Issue.new(path, "control_character", "must not contain control characters or line separators")
|
|
136
|
+
return nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
stripped = candidate.strip
|
|
140
|
+
if stripped.empty?
|
|
141
|
+
issues << Issue.new(path, "blank", "must not be blank")
|
|
142
|
+
return nil
|
|
143
|
+
end
|
|
144
|
+
if stripped.length > limit
|
|
145
|
+
issues << Issue.new(path, "too_long", "is #{stripped.length} characters; limit is #{limit}")
|
|
146
|
+
return nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
stripped
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def coerce_preset(normalized, coerced, issues, pointer)
|
|
153
|
+
return unless normalized.key?("preset") && !normalized["preset"].nil?
|
|
154
|
+
|
|
155
|
+
name = normalized["preset"]
|
|
156
|
+
if name.is_a?(String) && Presets::DATA.key?(name)
|
|
157
|
+
coerced["preset"] = name
|
|
158
|
+
else
|
|
159
|
+
issues << Issue.new(
|
|
160
|
+
field_path(pointer, "preset"),
|
|
161
|
+
"unknown_preset",
|
|
162
|
+
"preset must be one of: #{Presets::NAMES.join(', ')}"
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def coerce_layout(normalized, coerced, issues, pointer)
|
|
168
|
+
return unless normalized.key?("layout") && !normalized["layout"].nil?
|
|
169
|
+
|
|
170
|
+
layout = normalized["layout"]
|
|
171
|
+
if layout.is_a?(String) && Layouts::NAMES.include?(layout)
|
|
172
|
+
coerced["layout"] = layout
|
|
173
|
+
else
|
|
174
|
+
issues << Issue.new(
|
|
175
|
+
field_path(pointer, "layout"),
|
|
176
|
+
"unknown_layout",
|
|
177
|
+
"layout must be one of: #{Layouts::NAMES.join(', ')}"
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def coerce_details(normalized, coerced, issues, pointer)
|
|
183
|
+
return unless normalized.key?("details") && !normalized["details"].nil?
|
|
184
|
+
|
|
185
|
+
path = field_path(pointer, "details")
|
|
186
|
+
items = normalized["details"]
|
|
187
|
+
unless items.is_a?(Array)
|
|
188
|
+
issues << Issue.new(path, "type", "details must be an array")
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
if items.length > Fields::MAX_DETAILS
|
|
192
|
+
issues << Issue.new(path, "too_many", "details has #{items.length} items; limit is #{Fields::MAX_DETAILS}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
coerced_items = []
|
|
196
|
+
items.each_with_index do |item, index|
|
|
197
|
+
detail = coerce_detail(item, "#{path}/#{index}", issues)
|
|
198
|
+
coerced_items << detail unless detail.nil?
|
|
199
|
+
end
|
|
200
|
+
coerced["details"] = coerced_items if coerced_items.length == items.length
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def coerce_detail(item, path, issues)
|
|
204
|
+
case item
|
|
205
|
+
when String
|
|
206
|
+
text = coerce_text(item, path, issues, Fields::DETAIL_TEXT_LIMIT)
|
|
207
|
+
return nil if text.nil?
|
|
208
|
+
|
|
209
|
+
{ "text" => text, "hundredths" => 100 }
|
|
210
|
+
when Hash
|
|
211
|
+
coerce_detail_hash(item, path, issues)
|
|
212
|
+
else
|
|
213
|
+
issues << Issue.new(path, "type", "detail must be a string or an object with text and optional weight")
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def coerce_detail_hash(item, path, issues)
|
|
219
|
+
normalized = {}
|
|
220
|
+
item.each do |key, value|
|
|
221
|
+
name = case key
|
|
222
|
+
when String then key
|
|
223
|
+
when Symbol then key.to_s
|
|
224
|
+
else
|
|
225
|
+
issues << Issue.new(path, "type", "detail field names must be strings")
|
|
226
|
+
next
|
|
227
|
+
end
|
|
228
|
+
unless %w[text weight].include?(name)
|
|
229
|
+
issues << Issue.new("#{path}/#{name}", "unknown_field", "unknown detail field #{name.inspect}")
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
if normalized.key?(name)
|
|
233
|
+
issues << Issue.new("#{path}/#{name}", "duplicate_field", "detail field #{name} was given more than once")
|
|
234
|
+
next
|
|
235
|
+
end
|
|
236
|
+
normalized[name] = value
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
text = coerce_text(normalized["text"], "#{path}/text", issues, Fields::DETAIL_TEXT_LIMIT)
|
|
240
|
+
hundredths = 100
|
|
241
|
+
if normalized.key?("weight") && !normalized["weight"].nil?
|
|
242
|
+
hundredths = coerce_weight(normalized["weight"], "#{path}/weight", issues)
|
|
243
|
+
end
|
|
244
|
+
return nil if text.nil? || hundredths.nil?
|
|
245
|
+
|
|
246
|
+
{ "text" => text, "hundredths" => hundredths }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def coerce_weight(value, path, issues)
|
|
250
|
+
hundredths = Numbers.hundredths(value)
|
|
251
|
+
if hundredths.nil? || hundredths < Fields::MIN_WEIGHT_HUNDREDTHS || hundredths > Fields::MAX_WEIGHT_HUNDREDTHS
|
|
252
|
+
issues << Issue.new(path, "range", "weight must be a number from 0.1 through 2.0 with at most two decimal places")
|
|
253
|
+
return nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
hundredths
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def coerce_negatives(normalized, coerced, issues, pointer)
|
|
260
|
+
return unless normalized.key?("negatives") && !normalized["negatives"].nil?
|
|
261
|
+
|
|
262
|
+
path = field_path(pointer, "negatives")
|
|
263
|
+
items = normalized["negatives"]
|
|
264
|
+
unless items.is_a?(Array)
|
|
265
|
+
issues << Issue.new(path, "type", "negatives must be an array")
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
if items.length > Fields::MAX_NEGATIVES
|
|
269
|
+
issues << Issue.new(path, "too_many", "negatives has #{items.length} items; limit is #{Fields::MAX_NEGATIVES}")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
coerced_items = []
|
|
273
|
+
items.each_with_index do |item, index|
|
|
274
|
+
text = coerce_text(item, "#{path}/#{index}", issues, Fields::DETAIL_TEXT_LIMIT)
|
|
275
|
+
coerced_items << text unless text.nil?
|
|
276
|
+
end
|
|
277
|
+
coerced["negatives"] = coerced_items if coerced_items.length == items.length
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def coerce_aspect_ratio(normalized, coerced, issues, pointer)
|
|
281
|
+
return unless normalized.key?("aspect_ratio") && !normalized["aspect_ratio"].nil?
|
|
282
|
+
|
|
283
|
+
path = field_path(pointer, "aspect_ratio")
|
|
284
|
+
value = normalized["aspect_ratio"]
|
|
285
|
+
unless value.is_a?(String) && value.match?(Fields::ASPECT_PATTERN)
|
|
286
|
+
issues << Issue.new(path, "format", "aspect_ratio must look like 16:9 with each side from 1 through #{Fields::MAX_ASPECT_SIDE}")
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
width, height = value.split(":").map(&:to_i)
|
|
291
|
+
if width > Fields::MAX_ASPECT_SIDE || height > Fields::MAX_ASPECT_SIDE
|
|
292
|
+
issues << Issue.new(path, "range", "aspect_ratio sides must be from 1 through #{Fields::MAX_ASPECT_SIDE}")
|
|
293
|
+
return
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
coerced["aspect_ratio"] = value
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def coerce_seed(normalized, coerced, issues, pointer)
|
|
300
|
+
return unless normalized.key?("seed") && !normalized["seed"].nil?
|
|
301
|
+
|
|
302
|
+
path = field_path(pointer, "seed")
|
|
303
|
+
value = normalized["seed"]
|
|
304
|
+
unless value.is_a?(Integer)
|
|
305
|
+
issues << Issue.new(path, "type", "seed must be an integer")
|
|
306
|
+
return
|
|
307
|
+
end
|
|
308
|
+
if value.negative? || value > Limits::MAX_SEED
|
|
309
|
+
issues << Issue.new(path, "range", "seed must be from 0 through #{Limits::MAX_SEED}")
|
|
310
|
+
return
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
coerced["seed"] = value
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def coerce_steps(normalized, coerced, issues, pointer)
|
|
317
|
+
return unless normalized.key?("steps") && !normalized["steps"].nil?
|
|
318
|
+
|
|
319
|
+
path = field_path(pointer, "steps")
|
|
320
|
+
value = normalized["steps"]
|
|
321
|
+
unless value.is_a?(Integer)
|
|
322
|
+
issues << Issue.new(path, "type", "steps must be an integer")
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
unless value.between?(Fields::MIN_STEPS, Fields::MAX_STEPS)
|
|
326
|
+
issues << Issue.new(path, "range", "steps must be from #{Fields::MIN_STEPS} through #{Fields::MAX_STEPS}")
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
coerced["steps"] = value
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def coerce_guidance(normalized, coerced, issues, pointer)
|
|
334
|
+
return unless normalized.key?("guidance") && !normalized["guidance"].nil?
|
|
335
|
+
|
|
336
|
+
path = field_path(pointer, "guidance")
|
|
337
|
+
hundredths = Numbers.hundredths(normalized["guidance"])
|
|
338
|
+
if hundredths.nil? || hundredths < Fields::MIN_GUIDANCE_HUNDREDTHS || hundredths > Fields::MAX_GUIDANCE_HUNDREDTHS
|
|
339
|
+
issues << Issue.new(path, "range", "guidance must be a number from 0 through 30 with at most two decimal places")
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
coerced["guidance"] = Numbers.from_hundredths(hundredths)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def merge_clean(normalized, coerced)
|
|
347
|
+
clean = {}
|
|
348
|
+
preset_name = coerced["preset"]
|
|
349
|
+
clean.merge!(Presets.copy(preset_name)) if preset_name
|
|
350
|
+
|
|
351
|
+
normalized.each_key do |name|
|
|
352
|
+
next if name == "preset"
|
|
353
|
+
if normalized[name].nil?
|
|
354
|
+
clean.delete(name)
|
|
355
|
+
else
|
|
356
|
+
clean[name] = coerced.fetch(name)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
clean["details"] ||= []
|
|
360
|
+
clean["negatives"] ||= []
|
|
361
|
+
clean
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def field_path(pointer, name)
|
|
365
|
+
"#{pointer}/#{name}"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "flux3_ai_prompt_tools/version"
|
|
4
|
+
require_relative "flux3_ai_prompt_tools/errors"
|
|
5
|
+
require_relative "flux3_ai_prompt_tools/fields"
|
|
6
|
+
require_relative "flux3_ai_prompt_tools/limits"
|
|
7
|
+
require_relative "flux3_ai_prompt_tools/numbers"
|
|
8
|
+
require_relative "flux3_ai_prompt_tools/layouts"
|
|
9
|
+
require_relative "flux3_ai_prompt_tools/presets"
|
|
10
|
+
require_relative "flux3_ai_prompt_tools/validator"
|
|
11
|
+
require_relative "flux3_ai_prompt_tools/specification"
|
|
12
|
+
require_relative "flux3_ai_prompt_tools/assembler"
|
|
13
|
+
require_relative "flux3_ai_prompt_tools/reader"
|
|
14
|
+
require_relative "flux3_ai_prompt_tools/manifest"
|
|
15
|
+
require_relative "flux3_ai_prompt_tools/schema"
|
|
16
|
+
|
|
17
|
+
# Offline validation and assembly of text-to-image prompt manifests.
|
|
18
|
+
module Flux3AiPromptTools
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def assemble(input, layout: Layouts::DEFAULT, limits: Limits::DEFAULT)
|
|
22
|
+
spec = Specification.parse(input, limits: limits)
|
|
23
|
+
Assembler.assemble(spec, layout: layout, limits: limits, raise_on_error: true)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns an array of Issue. An empty array means the record assembled cleanly.
|
|
27
|
+
def validate(input, layout: Layouts::DEFAULT, limits: Limits::DEFAULT)
|
|
28
|
+
clean, issues, explicit_id = Validator.new(limits).prepare(input, pointer: "")
|
|
29
|
+
return issues unless issues.empty?
|
|
30
|
+
|
|
31
|
+
spec = Specification.from_clean(clean, id: explicit_id)
|
|
32
|
+
result = Assembler.assemble(spec, layout: layout, limits: limits, raise_on_error: false)
|
|
33
|
+
result.errors
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_manifest(inputs, layout: Layouts::DEFAULT, limits: Limits::DEFAULT, keep_going: false)
|
|
37
|
+
Manifest.build(inputs, layout: layout, limits: limits, keep_going: keep_going)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_file(path, format: "auto", limits: Limits::DEFAULT)
|
|
41
|
+
Reader.load_file(path, format: format, limits: limits)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def schema
|
|
45
|
+
Schema.document
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def presets
|
|
49
|
+
Presets.list
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helper"
|
|
4
|
+
|
|
5
|
+
class AssemblerTest < Flux3Test
|
|
6
|
+
def setup
|
|
7
|
+
@record = {
|
|
8
|
+
"subject" => "a ceramic pour-over kettle",
|
|
9
|
+
"scene" => "morning light",
|
|
10
|
+
"medium" => "photograph",
|
|
11
|
+
"style" => "editorial",
|
|
12
|
+
"lighting" => "soft window light",
|
|
13
|
+
"composition" => "three-quarter view",
|
|
14
|
+
"camera" => "85mm lens",
|
|
15
|
+
"palette" => "cream and walnut",
|
|
16
|
+
"mood" => "quiet",
|
|
17
|
+
"details" => [
|
|
18
|
+
"steam above the spout",
|
|
19
|
+
{ "text" => "wood grain", "weight" => 1.25 }
|
|
20
|
+
],
|
|
21
|
+
"negatives" => ["text", "watermark"]
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_natural_layout_is_deterministic
|
|
26
|
+
expected = "a ceramic pour-over kettle, morning light. photograph, editorial style. " \
|
|
27
|
+
"Lighting: soft window light. Composition: three-quarter view. Camera: 85mm lens. " \
|
|
28
|
+
"Palette: cream and walnut. Mood: quiet. " \
|
|
29
|
+
"Details: steam above the spout; wood grain (emphasis 1.25)."
|
|
30
|
+
first = Flux3AiPromptTools.assemble(@record, layout: "natural")
|
|
31
|
+
second = Flux3AiPromptTools.assemble(@record, layout: "natural")
|
|
32
|
+
assert_equal expected, first.prompt
|
|
33
|
+
assert_equal expected, second.prompt
|
|
34
|
+
assert_equal "text, watermark", first.negative_prompt
|
|
35
|
+
assert_equal expected.length, first.counts["prompt_chars"]
|
|
36
|
+
assert_equal 2, first.counts["details"]
|
|
37
|
+
assert_equal 2, first.counts["negatives"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_tagged_layout_renders_weight_syntax
|
|
41
|
+
result = Flux3AiPromptTools.assemble(@record, layout: "tagged")
|
|
42
|
+
assert_equal(
|
|
43
|
+
"a ceramic pour-over kettle, morning light, photograph, editorial, soft window light, " \
|
|
44
|
+
"three-quarter view, 85mm lens, cream and walnut, quiet, steam above the spout, (wood grain:1.25)",
|
|
45
|
+
result.prompt
|
|
46
|
+
)
|
|
47
|
+
assert_equal "tagged", result.layout
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_labeled_layout_uses_lines_and_skips_nothing_that_was_set
|
|
51
|
+
result = Flux3AiPromptTools.assemble(@record, layout: "labeled")
|
|
52
|
+
assert_equal <<~PROMPT.chomp, result.prompt
|
|
53
|
+
Subject: a ceramic pour-over kettle
|
|
54
|
+
Scene: morning light
|
|
55
|
+
Medium: photograph
|
|
56
|
+
Style: editorial
|
|
57
|
+
Lighting: soft window light
|
|
58
|
+
Composition: three-quarter view
|
|
59
|
+
Camera: 85mm lens
|
|
60
|
+
Palette: cream and walnut
|
|
61
|
+
Mood: quiet
|
|
62
|
+
Details: steam above the spout; wood grain (emphasis 1.25)
|
|
63
|
+
PROMPT
|
|
64
|
+
refute_includes result.prompt, "watermark"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_record_layout_overrides_the_call
|
|
68
|
+
record = @record.merge("layout" => "labeled")
|
|
69
|
+
result = Flux3AiPromptTools.assemble(record, layout: "natural")
|
|
70
|
+
assert_equal "labeled", result.layout
|
|
71
|
+
assert_includes result.prompt, "Subject: a ceramic pour-over kettle"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_preset_product_still_fills_only_missing_fields
|
|
75
|
+
result = Flux3AiPromptTools.assemble(
|
|
76
|
+
{ "preset" => "product_still", "subject" => "a ceramic pour-over kettle", "mood" => "precise" }
|
|
77
|
+
)
|
|
78
|
+
assert_equal(
|
|
79
|
+
"a ceramic pour-over kettle. photograph. " \
|
|
80
|
+
"Lighting: soft diffused light with a gentle shadow under the subject. " \
|
|
81
|
+
"Composition: centered subject with clean negative space. " \
|
|
82
|
+
"Camera: standard product lens, sharp focus on the subject. Mood: precise.",
|
|
83
|
+
result.prompt
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def test_character_budget_withholds_the_prompt
|
|
88
|
+
error = assert_raises(Flux3AiPromptTools::ValidationError) do
|
|
89
|
+
Flux3AiPromptTools.assemble(
|
|
90
|
+
{ "subject" => "a bowl", "negatives" => ["logo"] },
|
|
91
|
+
limits: Flux3AiPromptTools::Limits.new(max_prompt_chars: 4, max_negative_chars: 2)
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
assert_equal %w[too_long too_long], error.issues.map(&:code)
|
|
95
|
+
assert_equal ["/prompt", "/negative_prompt"], error.issues.map(&:path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_validate_reports_budget_without_raising
|
|
99
|
+
issues = Flux3AiPromptTools.validate(
|
|
100
|
+
{ "subject" => "a bowl" },
|
|
101
|
+
limits: Flux3AiPromptTools::Limits.new(max_prompt_chars: 3)
|
|
102
|
+
)
|
|
103
|
+
assert_equal ["too_long"], codes(issues)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_generator_metadata_has_no_network_client_fields
|
|
107
|
+
payload = Flux3AiPromptTools.assemble({ "subject" => "a bowl", "id" => "bowl-1" }).to_h
|
|
108
|
+
assert_equal "flux3_ai_prompt_tools/#{Flux3AiPromptTools::VERSION}", payload["generator"]
|
|
109
|
+
assert_equal "bowl-1", payload["id"]
|
|
110
|
+
assert_equal true, payload["ok"]
|
|
111
|
+
assert_empty payload["errors"]
|
|
112
|
+
refute payload.key?("endpoint")
|
|
113
|
+
refute payload.key?("api_key")
|
|
114
|
+
end
|
|
115
|
+
end
|