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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flux3AiPromptTools
4
+ VERSION = "0.1.0"
5
+ 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