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,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
require_relative "../flux3_ai_prompt_tools"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "layouts"
|
|
9
|
+
require_relative "limits"
|
|
10
|
+
require_relative "reader"
|
|
11
|
+
|
|
12
|
+
module Flux3AiPromptTools
|
|
13
|
+
# Command-line entry point used by bin/flux3-prompt.
|
|
14
|
+
class CLI
|
|
15
|
+
def self.start(argv)
|
|
16
|
+
new(argv).start
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(argv)
|
|
20
|
+
@argv = argv.map(&:dup)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
command = @argv.shift
|
|
25
|
+
case command
|
|
26
|
+
when nil
|
|
27
|
+
$stdout.write(self.class.help_text)
|
|
28
|
+
2
|
|
29
|
+
when "help", "-h", "--help"
|
|
30
|
+
$stdout.write(self.class.help_text)
|
|
31
|
+
0
|
|
32
|
+
when "version", "-v", "--version"
|
|
33
|
+
$stdout.puts(VERSION)
|
|
34
|
+
0
|
|
35
|
+
when "schema"
|
|
36
|
+
schema_command
|
|
37
|
+
when "presets"
|
|
38
|
+
presets_command
|
|
39
|
+
when "assemble"
|
|
40
|
+
assemble_command
|
|
41
|
+
when "validate"
|
|
42
|
+
validate_command
|
|
43
|
+
when "batch"
|
|
44
|
+
batch_command
|
|
45
|
+
else
|
|
46
|
+
raise UsageError, "unknown command #{command.inspect}. See `flux3-prompt help`."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.help_text
|
|
51
|
+
<<~HELP
|
|
52
|
+
flux3-prompt #{VERSION}
|
|
53
|
+
|
|
54
|
+
Validate structured prompt inputs and assemble text-to-image prompt
|
|
55
|
+
manifests on this machine. This command does not generate images and
|
|
56
|
+
does not contact a network service.
|
|
57
|
+
|
|
58
|
+
Usage:
|
|
59
|
+
flux3-prompt assemble --subject TEXT [options]
|
|
60
|
+
flux3-prompt validate FILE [options]
|
|
61
|
+
flux3-prompt batch FILE [-o PATH] [options]
|
|
62
|
+
flux3-prompt schema
|
|
63
|
+
flux3-prompt presets
|
|
64
|
+
flux3-prompt version
|
|
65
|
+
flux3-prompt help
|
|
66
|
+
|
|
67
|
+
Assemble options:
|
|
68
|
+
--subject TEXT Required picture subject
|
|
69
|
+
--scene, --medium, --style, --lighting, --composition, --camera,
|
|
70
|
+
--palette, --mood TEXT Optional art direction
|
|
71
|
+
--detail TEXT Repeatable detail, weight 1
|
|
72
|
+
--negative TEXT Repeatable negative phrase
|
|
73
|
+
--aspect-ratio RATIO Stored metadata, for example 4:5
|
|
74
|
+
--seed N Stored integer metadata
|
|
75
|
+
--steps N Stored integer metadata from 1 through 150
|
|
76
|
+
--guidance N Stored numeric metadata from 0 through 30
|
|
77
|
+
--note TEXT Copied to the manifest, omitted from prompts
|
|
78
|
+
--preset NAME Local writing template
|
|
79
|
+
--id ID Optional record id
|
|
80
|
+
--layout NAME natural, tagged, or labeled (default natural)
|
|
81
|
+
--manifest Print the JSON record instead of prompt text
|
|
82
|
+
|
|
83
|
+
Batch and validate options:
|
|
84
|
+
--input-format FORMAT auto, json, or jsonl (default auto)
|
|
85
|
+
--format FORMAT json or jsonl output for batch
|
|
86
|
+
--layout NAME Default layout for records that do not set one
|
|
87
|
+
--keep-going Write failed records with ok false instead of stopping
|
|
88
|
+
-o, --output PATH Batch output file. Stdout is used when omitted
|
|
89
|
+
--max-prompt-chars N Default #{Limits::DEFAULT_MAX_PROMPT_CHARS}
|
|
90
|
+
--max-negative-chars N Default #{Limits::DEFAULT_MAX_NEGATIVE_CHARS}
|
|
91
|
+
--max-records N Default #{Limits::DEFAULT_MAX_RECORDS}
|
|
92
|
+
--max-input-bytes N Default #{Limits::DEFAULT_MAX_INPUT_BYTES}
|
|
93
|
+
|
|
94
|
+
Weighted details belong in JSON, as {"text":"wide rim","weight":1.2}.
|
|
95
|
+
FILE may be - when the document is piped to stdin.
|
|
96
|
+
|
|
97
|
+
Exit status is 0 for success, 1 for invalid input or records, and 2
|
|
98
|
+
for an invalid command line.
|
|
99
|
+
|
|
100
|
+
Homepage: https://flux3-ai.com/
|
|
101
|
+
HELP
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def schema_command
|
|
107
|
+
raise UsageError, "schema does not take arguments" unless @argv.empty?
|
|
108
|
+
|
|
109
|
+
$stdout.write(JSON.pretty_generate(Schema.document) + "\n")
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def presets_command
|
|
114
|
+
raise UsageError, "presets does not take arguments" unless @argv.empty?
|
|
115
|
+
|
|
116
|
+
$stdout.write(JSON.pretty_generate(Presets.list) + "\n")
|
|
117
|
+
0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def assemble_command
|
|
121
|
+
record = {}
|
|
122
|
+
options = { layout: Layouts::DEFAULT, manifest: false }
|
|
123
|
+
parser = OptionParser.new do |opts|
|
|
124
|
+
opts.banner = "Usage: flux3-prompt assemble --subject TEXT [options]"
|
|
125
|
+
opts.on("--id ID") { |value| record["id"] = value }
|
|
126
|
+
opts.on("--subject TEXT") { |value| record["subject"] = value }
|
|
127
|
+
opts.on("--scene TEXT") { |value| record["scene"] = value }
|
|
128
|
+
opts.on("--medium TEXT") { |value| record["medium"] = value }
|
|
129
|
+
opts.on("--style TEXT") { |value| record["style"] = value }
|
|
130
|
+
opts.on("--lighting TEXT") { |value| record["lighting"] = value }
|
|
131
|
+
opts.on("--composition TEXT") { |value| record["composition"] = value }
|
|
132
|
+
opts.on("--camera TEXT") { |value| record["camera"] = value }
|
|
133
|
+
opts.on("--palette TEXT") { |value| record["palette"] = value }
|
|
134
|
+
opts.on("--mood TEXT") { |value| record["mood"] = value }
|
|
135
|
+
opts.on("--detail TEXT") { |value| (record["details"] ||= []) << value }
|
|
136
|
+
opts.on("--negative TEXT") { |value| (record["negatives"] ||= []) << value }
|
|
137
|
+
opts.on("--aspect-ratio RATIO") { |value| record["aspect_ratio"] = value }
|
|
138
|
+
opts.on("--seed N", Integer) { |value| record["seed"] = value }
|
|
139
|
+
opts.on("--steps N", Integer) { |value| record["steps"] = value }
|
|
140
|
+
opts.on("--guidance N", Float) { |value| record["guidance"] = value }
|
|
141
|
+
opts.on("--note TEXT") { |value| record["note"] = value }
|
|
142
|
+
opts.on("--preset NAME") { |value| record["preset"] = value }
|
|
143
|
+
opts.on("--layout NAME") { |value| options[:layout] = value }
|
|
144
|
+
opts.on("--manifest", "Print the JSON record instead of the prompt text") { options[:manifest] = true }
|
|
145
|
+
add_text_budget_flags(opts, options)
|
|
146
|
+
end
|
|
147
|
+
parse!(parser)
|
|
148
|
+
raise UsageError, "assemble does not take positional arguments" unless @argv.empty?
|
|
149
|
+
|
|
150
|
+
check_layout!(options[:layout])
|
|
151
|
+
result = Flux3AiPromptTools.assemble(record, layout: options[:layout], limits: limits_from(options))
|
|
152
|
+
if options[:manifest]
|
|
153
|
+
$stdout.write(JSON.pretty_generate(result.to_h) + "\n")
|
|
154
|
+
else
|
|
155
|
+
text = result.prompt
|
|
156
|
+
text += "\n" unless text.end_with?("\n")
|
|
157
|
+
$stdout.write(text)
|
|
158
|
+
end
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_command
|
|
163
|
+
options = default_batch_options
|
|
164
|
+
parser = OptionParser.new do |opts|
|
|
165
|
+
opts.banner = "Usage: flux3-prompt validate FILE [options]"
|
|
166
|
+
add_common_batch_flags(opts, options)
|
|
167
|
+
end
|
|
168
|
+
parse!(parser)
|
|
169
|
+
raise UsageError, "validate requires a FILE or -" unless @argv.length == 1
|
|
170
|
+
|
|
171
|
+
check_choices!(options)
|
|
172
|
+
records = read_records(@argv.fetch(0), options)
|
|
173
|
+
Flux3AiPromptTools.build_manifest(records, layout: options[:layout], limits: limits_from(options))
|
|
174
|
+
$stdout.puts("valid: #{records.length} record(s)")
|
|
175
|
+
0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def batch_command
|
|
179
|
+
options = default_batch_options
|
|
180
|
+
parser = OptionParser.new do |opts|
|
|
181
|
+
opts.banner = "Usage: flux3-prompt batch FILE [-o PATH] [options]"
|
|
182
|
+
opts.on("-o", "--output PATH", "Write the manifest to PATH") { |value| options[:output] = value }
|
|
183
|
+
opts.on("--format FORMAT", "json or jsonl") { |value| options[:format] = value }
|
|
184
|
+
opts.on("--keep-going", "Write records that failed validation") { options[:keep_going] = true }
|
|
185
|
+
add_common_batch_flags(opts, options)
|
|
186
|
+
end
|
|
187
|
+
parse!(parser)
|
|
188
|
+
raise UsageError, "batch requires a FILE or -" unless @argv.length == 1
|
|
189
|
+
|
|
190
|
+
check_choices!(options)
|
|
191
|
+
format = resolve_format(options)
|
|
192
|
+
records = read_records(@argv.fetch(0), options)
|
|
193
|
+
manifest = Flux3AiPromptTools.build_manifest(
|
|
194
|
+
records,
|
|
195
|
+
layout: options[:layout],
|
|
196
|
+
limits: limits_from(options),
|
|
197
|
+
keep_going: options[:keep_going]
|
|
198
|
+
)
|
|
199
|
+
body = format == "json" ? manifest.to_json_document : manifest.to_jsonl
|
|
200
|
+
write_output(options[:output], body)
|
|
201
|
+
report_batch(manifest, options[:output])
|
|
202
|
+
manifest.ok? ? 0 : 1
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def default_batch_options
|
|
206
|
+
{
|
|
207
|
+
layout: Layouts::DEFAULT,
|
|
208
|
+
input_format: "auto",
|
|
209
|
+
output: nil,
|
|
210
|
+
format: nil,
|
|
211
|
+
keep_going: false
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def add_common_batch_flags(parser, options)
|
|
216
|
+
parser.on("--layout NAME", "natural, tagged, or labeled") { |value| options[:layout] = value }
|
|
217
|
+
parser.on("--input-format FORMAT", "auto, json, or jsonl") { |value| options[:input_format] = value }
|
|
218
|
+
add_text_budget_flags(parser, options)
|
|
219
|
+
add_input_budget_flags(parser, options)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def add_text_budget_flags(parser, options)
|
|
223
|
+
parser.on("--max-prompt-chars N", Integer, "Assembled prompt character limit") do |value|
|
|
224
|
+
raise UsageError, "max-prompt-chars must be a positive integer" unless value.positive?
|
|
225
|
+
|
|
226
|
+
options[:max_prompt_chars] = value
|
|
227
|
+
end
|
|
228
|
+
parser.on("--max-negative-chars N", Integer, "Assembled negative prompt character limit") do |value|
|
|
229
|
+
raise UsageError, "max-negative-chars must be a positive integer" unless value.positive?
|
|
230
|
+
|
|
231
|
+
options[:max_negative_chars] = value
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def add_input_budget_flags(parser, options)
|
|
236
|
+
parser.on("--max-records N", Integer, "Maximum records in one batch") do |value|
|
|
237
|
+
raise UsageError, "max-records must be a positive integer" unless value.positive?
|
|
238
|
+
|
|
239
|
+
options[:max_records] = value
|
|
240
|
+
end
|
|
241
|
+
parser.on("--max-input-bytes N", Integer, "Maximum input size in bytes") do |value|
|
|
242
|
+
raise UsageError, "max-input-bytes must be a positive integer" unless value.positive?
|
|
243
|
+
|
|
244
|
+
options[:max_input_bytes] = value
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def parse!(parser)
|
|
249
|
+
parser.parse!(@argv)
|
|
250
|
+
rescue OptionParser::ParseError => e
|
|
251
|
+
raise UsageError, e.message
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def check_choices!(options)
|
|
255
|
+
check_layout!(options[:layout])
|
|
256
|
+
return if %w[auto json jsonl].include?(options[:input_format])
|
|
257
|
+
|
|
258
|
+
raise UsageError, "input format must be auto, json, or jsonl"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_layout!(layout)
|
|
262
|
+
return if Layouts::NAMES.include?(layout)
|
|
263
|
+
|
|
264
|
+
raise UsageError, "layout must be one of: #{Layouts::NAMES.join(', ')}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def resolve_format(options)
|
|
268
|
+
format = options[:format]
|
|
269
|
+
if format
|
|
270
|
+
return format if %w[json jsonl].include?(format)
|
|
271
|
+
|
|
272
|
+
raise UsageError, "format must be json or jsonl"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
path = options[:output]
|
|
276
|
+
return "jsonl" if path.nil?
|
|
277
|
+
return "jsonl" if path.match?(/\.jsonl\z/i)
|
|
278
|
+
return "json" if path.match?(/\.json\z/i)
|
|
279
|
+
|
|
280
|
+
"json"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def read_records(path, options)
|
|
284
|
+
limits = limits_from(options)
|
|
285
|
+
if path == "-"
|
|
286
|
+
Reader.load(read_stdin(limits), format: options[:input_format], limits: limits)
|
|
287
|
+
else
|
|
288
|
+
Reader.load_file(path, format: options[:input_format], limits: limits)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def read_stdin(limits)
|
|
293
|
+
data = $stdin.binmode.read(limits.max_input_bytes + 1)
|
|
294
|
+
data = "" if data.nil?
|
|
295
|
+
if data.bytesize > limits.max_input_bytes
|
|
296
|
+
raise InputError, "input is larger than #{limits.max_input_bytes} bytes"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
data.force_encoding(Encoding::UTF_8)
|
|
300
|
+
raise InputError, "input is not valid UTF-8" unless data.valid_encoding?
|
|
301
|
+
|
|
302
|
+
data
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def limits_from(options)
|
|
306
|
+
Limits.new(
|
|
307
|
+
max_prompt_chars: options.fetch(:max_prompt_chars, Limits::DEFAULT_MAX_PROMPT_CHARS),
|
|
308
|
+
max_negative_chars: options.fetch(:max_negative_chars, Limits::DEFAULT_MAX_NEGATIVE_CHARS),
|
|
309
|
+
max_records: options.fetch(:max_records, Limits::DEFAULT_MAX_RECORDS),
|
|
310
|
+
max_input_bytes: options.fetch(:max_input_bytes, Limits::DEFAULT_MAX_INPUT_BYTES)
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def write_output(path, body)
|
|
315
|
+
if path.nil?
|
|
316
|
+
$stdout.write(body)
|
|
317
|
+
return
|
|
318
|
+
end
|
|
319
|
+
raise UsageError, "output path is a directory: #{path}" if File.directory?(path)
|
|
320
|
+
|
|
321
|
+
directory = File.dirname(path)
|
|
322
|
+
raise UsageError, "output directory does not exist: #{directory}" unless File.directory?(directory)
|
|
323
|
+
|
|
324
|
+
temporary = File.join(directory, ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
325
|
+
File.write(temporary, body, encoding: Encoding::UTF_8)
|
|
326
|
+
File.rename(temporary, path)
|
|
327
|
+
rescue StandardError
|
|
328
|
+
File.delete(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
|
|
329
|
+
raise
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def report_batch(manifest, path)
|
|
333
|
+
return if path.nil? && manifest.ok?
|
|
334
|
+
|
|
335
|
+
failed = manifest.records.count { |record| !record.ok }
|
|
336
|
+
if path
|
|
337
|
+
suffix = failed.zero? ? "" : " (#{failed} failed)"
|
|
338
|
+
warn "wrote #{manifest.records.length} record(s) to #{path}#{suffix}"
|
|
339
|
+
end
|
|
340
|
+
return if failed.zero?
|
|
341
|
+
|
|
342
|
+
manifest.records.each do |record|
|
|
343
|
+
record.errors.each do |issue|
|
|
344
|
+
warn "error: #{issue.path} #{issue.code}: #{issue.message}"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flux3AiPromptTools
|
|
4
|
+
# Base class for errors raised by this gem.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The command line itself is invalid. CLI exit status 2.
|
|
8
|
+
class UsageError < Error; end
|
|
9
|
+
|
|
10
|
+
# A file or document could not be read as JSON or JSONL. CLI exit status 1.
|
|
11
|
+
class InputError < Error
|
|
12
|
+
attr_reader :line
|
|
13
|
+
|
|
14
|
+
def initialize(message, line: nil)
|
|
15
|
+
@line = line
|
|
16
|
+
super(message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One validation problem on a record or on assembled text.
|
|
21
|
+
class Issue
|
|
22
|
+
attr_reader :path, :code, :message
|
|
23
|
+
|
|
24
|
+
def initialize(path, code, message)
|
|
25
|
+
@path = path
|
|
26
|
+
@code = code
|
|
27
|
+
@message = message
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{ "path" => path, "code" => code, "message" => message }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One or more field, budget, or batch-consistency problems.
|
|
37
|
+
class ValidationError < Error
|
|
38
|
+
attr_reader :issues
|
|
39
|
+
|
|
40
|
+
def initialize(issues)
|
|
41
|
+
unless issues.is_a?(Array) && issues.all? { |issue| issue.is_a?(Issue) }
|
|
42
|
+
raise ArgumentError, "issues must be an array of Issue"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@issues = issues.freeze
|
|
46
|
+
super(issues.map { |issue| "#{issue.path} #{issue.code}: #{issue.message}" }.join("\n"))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flux3AiPromptTools
|
|
4
|
+
# Shared limits for the validator, the JSON description, and the CLI help text.
|
|
5
|
+
module Fields
|
|
6
|
+
NAMES = %w[
|
|
7
|
+
id subject scene medium style lighting composition camera palette mood
|
|
8
|
+
details negatives aspect_ratio seed steps guidance note layout preset
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
STRING_LIMITS = {
|
|
12
|
+
"subject" => 400,
|
|
13
|
+
"scene" => 400,
|
|
14
|
+
"medium" => 120,
|
|
15
|
+
"style" => 200,
|
|
16
|
+
"lighting" => 300,
|
|
17
|
+
"composition" => 300,
|
|
18
|
+
"camera" => 200,
|
|
19
|
+
"palette" => 200,
|
|
20
|
+
"mood" => 200,
|
|
21
|
+
"note" => 500
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
DETAIL_TEXT_LIMIT = 160
|
|
25
|
+
MAX_DETAILS = 32
|
|
26
|
+
MAX_NEGATIVES = 32
|
|
27
|
+
MIN_WEIGHT_HUNDREDTHS = 10
|
|
28
|
+
MAX_WEIGHT_HUNDREDTHS = 200
|
|
29
|
+
MIN_GUIDANCE_HUNDREDTHS = 0
|
|
30
|
+
MAX_GUIDANCE_HUNDREDTHS = 3_000
|
|
31
|
+
MIN_STEPS = 1
|
|
32
|
+
MAX_STEPS = 150
|
|
33
|
+
MAX_ASPECT_SIDE = 64
|
|
34
|
+
ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/
|
|
35
|
+
ASPECT_PATTERN = /\A[1-9]\d?:[1-9]\d?\z/
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flux3AiPromptTools
|
|
4
|
+
# Budgets for assembled text and for a batch document.
|
|
5
|
+
class Limits
|
|
6
|
+
attr_reader :max_prompt_chars, :max_negative_chars, :max_records, :max_input_bytes
|
|
7
|
+
|
|
8
|
+
DEFAULT_MAX_PROMPT_CHARS = 2_000
|
|
9
|
+
DEFAULT_MAX_NEGATIVE_CHARS = 1_000
|
|
10
|
+
DEFAULT_MAX_RECORDS = 5_000
|
|
11
|
+
DEFAULT_MAX_INPUT_BYTES = 5_000_000
|
|
12
|
+
|
|
13
|
+
# Largest integer that every JSON consumer can round-trip as a number.
|
|
14
|
+
MAX_SEED = (2**53) - 1
|
|
15
|
+
|
|
16
|
+
def initialize(max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
|
|
17
|
+
max_negative_chars: DEFAULT_MAX_NEGATIVE_CHARS,
|
|
18
|
+
max_records: DEFAULT_MAX_RECORDS,
|
|
19
|
+
max_input_bytes: DEFAULT_MAX_INPUT_BYTES)
|
|
20
|
+
@max_prompt_chars = self.class.positive_integer!(max_prompt_chars, "max_prompt_chars")
|
|
21
|
+
@max_negative_chars = self.class.positive_integer!(max_negative_chars, "max_negative_chars")
|
|
22
|
+
@max_records = self.class.positive_integer!(max_records, "max_records")
|
|
23
|
+
@max_input_bytes = self.class.positive_integer!(max_input_bytes, "max_input_bytes")
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.positive_integer!(value, name)
|
|
28
|
+
unless value.is_a?(Integer) && value.positive?
|
|
29
|
+
raise ArgumentError, "#{name} must be a positive integer"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
DEFAULT = new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "assembler"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "layouts"
|
|
8
|
+
require_relative "limits"
|
|
9
|
+
require_relative "specification"
|
|
10
|
+
require_relative "validator"
|
|
11
|
+
require_relative "version"
|
|
12
|
+
|
|
13
|
+
module Flux3AiPromptTools
|
|
14
|
+
# A batch of assembled records in input order.
|
|
15
|
+
class Manifest
|
|
16
|
+
attr_reader :records, :layout
|
|
17
|
+
|
|
18
|
+
def initialize(records, layout:)
|
|
19
|
+
@records = records.dup.freeze
|
|
20
|
+
@layout = layout
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ok?
|
|
25
|
+
records.all?(&:ok)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
"generator" => "flux3_ai_prompt_tools/#{VERSION}",
|
|
31
|
+
"layout" => layout,
|
|
32
|
+
"ok" => ok?,
|
|
33
|
+
"records" => records.map(&:to_h)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_json_document
|
|
38
|
+
JSON.pretty_generate(to_h) + "\n"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_jsonl
|
|
42
|
+
records.map { |record| JSON.generate(record.to_h) }.join("\n") + (records.empty? ? "" : "\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.build(inputs, layout: Layouts::DEFAULT, limits: Limits::DEFAULT, keep_going: false)
|
|
46
|
+
unless inputs.is_a?(Array)
|
|
47
|
+
raise ArgumentError, "inputs must be an array of records"
|
|
48
|
+
end
|
|
49
|
+
unless layout.is_a?(String) && Layouts::NAMES.include?(layout)
|
|
50
|
+
raise ArgumentError, "layout must be one of: #{Layouts::NAMES.join(', ')}"
|
|
51
|
+
end
|
|
52
|
+
unless limits.is_a?(Limits)
|
|
53
|
+
raise ArgumentError, "limits must be a Flux3AiPromptTools::Limits"
|
|
54
|
+
end
|
|
55
|
+
raise InputError, "batch has no records" if inputs.empty?
|
|
56
|
+
if inputs.length > limits.max_records
|
|
57
|
+
raise InputError, "batch has #{inputs.length} records; limit is #{limits.max_records}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
validator = Validator.new(limits)
|
|
61
|
+
rows = inputs.each_with_index.map do |input, index|
|
|
62
|
+
pointer = "/records/#{index}"
|
|
63
|
+
clean, issues, explicit_id = validator.prepare(input, pointer: pointer)
|
|
64
|
+
{ index: index, clean: clean, issues: issues, explicit_id: explicit_id, assigned_id: nil }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
assign_ids!(rows)
|
|
68
|
+
all_issues = rows.flat_map { |row| row[:issues] }
|
|
69
|
+
results = []
|
|
70
|
+
fingerprints = {}
|
|
71
|
+
|
|
72
|
+
rows.each do |row|
|
|
73
|
+
if row[:issues].any?
|
|
74
|
+
results << failure_result(row, layout)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
spec = Specification.from_clean(row[:clean], id: row[:explicit_id] || row[:assigned_id])
|
|
79
|
+
result = Assembler.assemble(spec, layout: layout, limits: limits, raise_on_error: false)
|
|
80
|
+
unless result.ok
|
|
81
|
+
prefixed = result.errors.map { |issue| prefix_issue(issue, row[:index]) }
|
|
82
|
+
all_issues.concat(prefixed)
|
|
83
|
+
results << replace_errors(result, prefixed)
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
fingerprint = fingerprint_for(result)
|
|
88
|
+
if fingerprints.key?(fingerprint)
|
|
89
|
+
issue = Issue.new(
|
|
90
|
+
"/records/#{row[:index]}",
|
|
91
|
+
"duplicate_job",
|
|
92
|
+
"this record repeats the prompt, negative prompt, layout, and parameters of /records/#{fingerprints[fingerprint]}"
|
|
93
|
+
)
|
|
94
|
+
all_issues << issue
|
|
95
|
+
results << replace_errors(result, [issue])
|
|
96
|
+
else
|
|
97
|
+
fingerprints[fingerprint] = row[:index]
|
|
98
|
+
results << result
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise ValidationError, all_issues if !keep_going && all_issues.any?
|
|
103
|
+
|
|
104
|
+
new(results, layout: layout)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.assign_ids!(rows)
|
|
108
|
+
used = {}
|
|
109
|
+
rows.each do |row|
|
|
110
|
+
id = row[:explicit_id]
|
|
111
|
+
next if id.nil?
|
|
112
|
+
|
|
113
|
+
if used.key?(id)
|
|
114
|
+
row[:issues] << Issue.new(
|
|
115
|
+
"/records/#{row[:index]}/id",
|
|
116
|
+
"duplicate_id",
|
|
117
|
+
"id #{id.inspect} is already used by /records/#{used[id]}"
|
|
118
|
+
)
|
|
119
|
+
else
|
|
120
|
+
used[id] = row[:index]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
rows.each do |row|
|
|
125
|
+
next unless row[:explicit_id].nil?
|
|
126
|
+
|
|
127
|
+
row[:assigned_id] = next_id(used, row[:index])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
private_class_method :assign_ids!
|
|
131
|
+
|
|
132
|
+
def self.next_id(used, index)
|
|
133
|
+
number = 1
|
|
134
|
+
loop do
|
|
135
|
+
candidate = format("item-%04d", number)
|
|
136
|
+
unless used.key?(candidate)
|
|
137
|
+
used[candidate] = index
|
|
138
|
+
return candidate
|
|
139
|
+
end
|
|
140
|
+
number += 1
|
|
141
|
+
raise InputError, "could not assign a unique id" if number > 100_000
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
private_class_method :next_id
|
|
145
|
+
|
|
146
|
+
def self.failure_result(row, layout)
|
|
147
|
+
Result.new(
|
|
148
|
+
id: row[:explicit_id] || row[:assigned_id],
|
|
149
|
+
ok: false,
|
|
150
|
+
prompt: nil,
|
|
151
|
+
negative_prompt: nil,
|
|
152
|
+
layout: layout,
|
|
153
|
+
parameters: {
|
|
154
|
+
"aspect_ratio" => nil,
|
|
155
|
+
"seed" => nil,
|
|
156
|
+
"steps" => nil,
|
|
157
|
+
"guidance" => nil
|
|
158
|
+
},
|
|
159
|
+
counts: {
|
|
160
|
+
"prompt_chars" => nil,
|
|
161
|
+
"negative_chars" => nil,
|
|
162
|
+
"details" => nil,
|
|
163
|
+
"negatives" => nil
|
|
164
|
+
},
|
|
165
|
+
note: nil,
|
|
166
|
+
errors: row[:issues]
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
private_class_method :failure_result
|
|
170
|
+
|
|
171
|
+
def self.prefix_issue(issue, index)
|
|
172
|
+
Issue.new("/records/#{index}#{issue.path}", issue.code, issue.message)
|
|
173
|
+
end
|
|
174
|
+
private_class_method :prefix_issue
|
|
175
|
+
|
|
176
|
+
def self.replace_errors(result, errors)
|
|
177
|
+
Result.new(
|
|
178
|
+
id: result.id,
|
|
179
|
+
ok: false,
|
|
180
|
+
prompt: nil,
|
|
181
|
+
negative_prompt: nil,
|
|
182
|
+
layout: result.layout,
|
|
183
|
+
parameters: result.parameters,
|
|
184
|
+
counts: result.counts,
|
|
185
|
+
note: result.note,
|
|
186
|
+
errors: errors
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
private_class_method :replace_errors
|
|
190
|
+
|
|
191
|
+
def self.fingerprint_for(result)
|
|
192
|
+
[
|
|
193
|
+
result.prompt,
|
|
194
|
+
result.negative_prompt,
|
|
195
|
+
result.layout,
|
|
196
|
+
result.parameters["aspect_ratio"],
|
|
197
|
+
result.parameters["seed"],
|
|
198
|
+
result.parameters["steps"],
|
|
199
|
+
result.parameters["guidance"]
|
|
200
|
+
].map { |value| value.nil? ? "" : value.to_s }.join("\u001f")
|
|
201
|
+
end
|
|
202
|
+
private_class_method :fingerprint_for
|
|
203
|
+
end
|
|
204
|
+
end
|