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
data/test/cli_test.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helper"
|
|
4
|
+
|
|
5
|
+
class CliTest < Flux3Test
|
|
6
|
+
def test_help_and_version
|
|
7
|
+
stdout, stderr, status = run_cli("help")
|
|
8
|
+
assert_equal 0, status.exitstatus
|
|
9
|
+
assert_includes stdout, "flux3-prompt"
|
|
10
|
+
assert_includes stdout, "https://flux3-ai.com/"
|
|
11
|
+
assert_equal "", stderr
|
|
12
|
+
|
|
13
|
+
stdout, _stderr, status = run_cli("version")
|
|
14
|
+
assert_equal 0, status.exitstatus
|
|
15
|
+
assert_equal "#{Flux3AiPromptTools::VERSION}\n", stdout
|
|
16
|
+
|
|
17
|
+
_stdout, stderr, status = run_cli
|
|
18
|
+
assert_equal 2, status.exitstatus
|
|
19
|
+
assert_includes stderr, ""
|
|
20
|
+
assert_includes stdout_of_no_args, "Usage:"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_assemble_prints_prompt_and_manifest
|
|
24
|
+
stdout, stderr, status = run_cli(
|
|
25
|
+
"assemble",
|
|
26
|
+
"--preset", "product_still",
|
|
27
|
+
"--subject", "a matte cream kettle",
|
|
28
|
+
"--negative", "text",
|
|
29
|
+
"--negative", "watermark",
|
|
30
|
+
"--aspect-ratio", "4:5",
|
|
31
|
+
"--seed", "1204"
|
|
32
|
+
)
|
|
33
|
+
assert_equal 0, status.exitstatus, stderr
|
|
34
|
+
assert_equal "", stderr
|
|
35
|
+
assert_includes stdout, "a matte cream kettle."
|
|
36
|
+
assert_includes stdout, "photograph."
|
|
37
|
+
refute_includes stdout, "watermark"
|
|
38
|
+
|
|
39
|
+
stdout, stderr, status = run_cli(
|
|
40
|
+
"assemble",
|
|
41
|
+
"--subject", "a matte cream kettle",
|
|
42
|
+
"--negative", "watermark",
|
|
43
|
+
"--manifest"
|
|
44
|
+
)
|
|
45
|
+
assert_equal 0, status.exitstatus, stderr
|
|
46
|
+
payload = JSON.parse(stdout)
|
|
47
|
+
assert_equal "watermark", payload["negative_prompt"]
|
|
48
|
+
assert_equal "a matte cream kettle.", payload["prompt"]
|
|
49
|
+
assert_equal true, payload["ok"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_assemble_exit_statuses
|
|
53
|
+
_stdout, stderr, status = run_cli("assemble")
|
|
54
|
+
assert_equal 1, status.exitstatus
|
|
55
|
+
assert_includes stderr, "/subject required"
|
|
56
|
+
|
|
57
|
+
_stdout, stderr, status = run_cli("assemble", "--subject", "a bowl", "--seed", "nope")
|
|
58
|
+
assert_equal 2, status.exitstatus
|
|
59
|
+
assert_match(/seed/, stderr)
|
|
60
|
+
|
|
61
|
+
_stdout, stderr, status = run_cli("assemble", "--subject", "a bowl", "--layout", "poem")
|
|
62
|
+
assert_equal 2, status.exitstatus
|
|
63
|
+
assert_match(/layout/, stderr)
|
|
64
|
+
|
|
65
|
+
_stdout, stderr, status = run_cli("nope")
|
|
66
|
+
assert_equal 2, status.exitstatus
|
|
67
|
+
assert_match(/unknown command/, stderr)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_validate_and_batch_examples
|
|
71
|
+
stdout, stderr, status = run_cli("validate", File.join(ROOT, "examples", "kettle.json"))
|
|
72
|
+
assert_equal 0, status.exitstatus, stderr
|
|
73
|
+
assert_equal "valid: 1 record(s)\n", stdout
|
|
74
|
+
|
|
75
|
+
stdout, stderr, status = run_cli("validate", File.join(ROOT, "examples", "batch.jsonl"))
|
|
76
|
+
assert_equal 0, status.exitstatus, stderr
|
|
77
|
+
assert_equal "valid: 2 record(s)\n", stdout
|
|
78
|
+
|
|
79
|
+
with_tmpdir do |dir|
|
|
80
|
+
jsonl_path = File.join(dir, "out.jsonl")
|
|
81
|
+
_stdout, stderr, status = run_cli(
|
|
82
|
+
"batch", File.join(ROOT, "examples", "batch.jsonl"),
|
|
83
|
+
"--format", "jsonl",
|
|
84
|
+
"-o", jsonl_path
|
|
85
|
+
)
|
|
86
|
+
assert_equal 0, status.exitstatus, stderr
|
|
87
|
+
assert_match(/wrote 2 record/, stderr)
|
|
88
|
+
lines = File.readlines(jsonl_path, chomp: true)
|
|
89
|
+
assert_equal 2, lines.length
|
|
90
|
+
first = JSON.parse(lines[0])
|
|
91
|
+
second = JSON.parse(lines[1])
|
|
92
|
+
assert_equal "kettle-01", first["id"]
|
|
93
|
+
assert_equal "natural", first["layout"]
|
|
94
|
+
assert_includes first["prompt"], "a matte cream ceramic pour-over kettle"
|
|
95
|
+
refute_includes first["prompt"], "Local reminder"
|
|
96
|
+
assert_equal "cup-02", second["id"]
|
|
97
|
+
assert_equal "tagged", second["layout"]
|
|
98
|
+
assert_equal "3:4", second["parameters"]["aspect_ratio"]
|
|
99
|
+
refute_includes second["prompt"], "\n"
|
|
100
|
+
|
|
101
|
+
json_path = File.join(dir, "out.json")
|
|
102
|
+
_stdout, stderr, status = run_cli(
|
|
103
|
+
"batch", File.join(ROOT, "examples", "kettle.json"),
|
|
104
|
+
"-o", json_path
|
|
105
|
+
)
|
|
106
|
+
assert_equal 0, status.exitstatus, stderr
|
|
107
|
+
document = JSON.parse(File.read(json_path))
|
|
108
|
+
assert_equal true, document["ok"]
|
|
109
|
+
assert_equal "kettle-01", document["records"][0]["id"]
|
|
110
|
+
assert_equal "Local reminder only. This file is not sent anywhere.", document["records"][0]["note"]
|
|
111
|
+
refute_includes document["records"][0]["prompt"], "Local reminder"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def test_batch_stdin_keep_going_and_strict_writes_nothing
|
|
116
|
+
stdout, stderr, status = run_cli(
|
|
117
|
+
"batch", "-",
|
|
118
|
+
"--input-format", "jsonl",
|
|
119
|
+
stdin: "{\"subject\":\"a bowl\"}\n{\"subject\":\"a cup\",\"layout\":\"labeled\"}\n"
|
|
120
|
+
)
|
|
121
|
+
assert_equal 0, status.exitstatus, stderr
|
|
122
|
+
rows = stdout.lines.map { |line| JSON.parse(line) }
|
|
123
|
+
assert_equal %w[item-0001 item-0002], rows.map { |row| row["id"] }
|
|
124
|
+
assert_includes rows[1]["prompt"], "Subject: a cup"
|
|
125
|
+
|
|
126
|
+
with_tmpdir do |dir|
|
|
127
|
+
source = File.join(dir, "mixed.jsonl")
|
|
128
|
+
File.write(source, "{\"subject\":\"a bowl\"}\n{\"mood\":\"quiet\"}\n")
|
|
129
|
+
output = File.join(dir, "mixed-out.jsonl")
|
|
130
|
+
_stdout, stderr, status = run_cli("batch", source, "--keep-going", "-o", output)
|
|
131
|
+
assert_equal 1, status.exitstatus
|
|
132
|
+
assert_match(/1 failed/, stderr)
|
|
133
|
+
rows = File.readlines(output, chomp: true).map { |line| JSON.parse(line) }
|
|
134
|
+
assert_equal true, rows[0]["ok"]
|
|
135
|
+
assert_equal false, rows[1]["ok"]
|
|
136
|
+
assert_nil rows[1]["prompt"]
|
|
137
|
+
|
|
138
|
+
blocked = File.join(dir, "blocked.jsonl")
|
|
139
|
+
_stdout, stderr, status = run_cli("batch", source, "-o", blocked)
|
|
140
|
+
assert_equal 1, status.exitstatus
|
|
141
|
+
assert_includes stderr, "/records/1/subject required"
|
|
142
|
+
refute File.exist?(blocked)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_missing_file_and_schema_presets
|
|
147
|
+
_stdout, stderr, status = run_cli("validate", "missing-file.json")
|
|
148
|
+
assert_equal 1, status.exitstatus
|
|
149
|
+
assert_match(/does not exist/, stderr)
|
|
150
|
+
|
|
151
|
+
stdout, stderr, status = run_cli("schema")
|
|
152
|
+
assert_equal 0, status.exitstatus, stderr
|
|
153
|
+
schema = JSON.parse(stdout)
|
|
154
|
+
assert_equal ["subject"], schema["required"]
|
|
155
|
+
assert_equal false, schema["additionalProperties"]
|
|
156
|
+
assert_equal Flux3AiPromptTools::Presets::NAMES, schema["properties"]["preset"]["enum"]
|
|
157
|
+
|
|
158
|
+
stdout, _stderr, status = run_cli("presets")
|
|
159
|
+
assert_equal 0, status.exitstatus
|
|
160
|
+
names = JSON.parse(stdout).map { |preset| preset["name"] }
|
|
161
|
+
assert_equal Flux3AiPromptTools::Presets::NAMES, names
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stdout_of_no_args
|
|
165
|
+
stdout, _stderr, _status = run_cli
|
|
166
|
+
stdout
|
|
167
|
+
end
|
|
168
|
+
end
|
data/test/helper.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
4
|
+
|
|
5
|
+
require "minitest/autorun"
|
|
6
|
+
require "flux3_ai_prompt_tools"
|
|
7
|
+
require "json"
|
|
8
|
+
require "open3"
|
|
9
|
+
require "rbconfig"
|
|
10
|
+
require "tmpdir"
|
|
11
|
+
|
|
12
|
+
class Flux3Test < Minitest::Test
|
|
13
|
+
ROOT = File.expand_path("..", __dir__)
|
|
14
|
+
|
|
15
|
+
def run_cli(*args, stdin: nil)
|
|
16
|
+
env = { "RUBYLIB" => File.join(ROOT, "lib") }
|
|
17
|
+
command = [RbConfig.ruby, File.join(ROOT, "bin", "flux3-prompt"), *args.map(&:to_s)]
|
|
18
|
+
Open3.capture3(env, *command, stdin_data: stdin)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with_tmpdir
|
|
22
|
+
Dir.mktmpdir("flux3-prompt") do |dir|
|
|
23
|
+
yield dir
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def codes(issues)
|
|
28
|
+
issues.map(&:code)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helper"
|
|
4
|
+
|
|
5
|
+
class ManifestTest < Flux3Test
|
|
6
|
+
def test_auto_ids_skip_explicit_ids_and_preserve_order
|
|
7
|
+
manifest = Flux3AiPromptTools.build_manifest(
|
|
8
|
+
[
|
|
9
|
+
{ "id" => "item-0001", "subject" => "a bowl" },
|
|
10
|
+
{ "subject" => "a cup" },
|
|
11
|
+
{ "subject" => "a plate", "layout" => "tagged" }
|
|
12
|
+
]
|
|
13
|
+
)
|
|
14
|
+
assert_equal %w[item-0001 item-0002 item-0003], manifest.records.map(&:id)
|
|
15
|
+
assert_equal "natural", manifest.records[1].layout
|
|
16
|
+
assert_equal "tagged", manifest.records[2].layout
|
|
17
|
+
assert_equal "a plate", manifest.records[2].prompt
|
|
18
|
+
assert manifest.ok?
|
|
19
|
+
document = JSON.parse(manifest.to_json_document)
|
|
20
|
+
assert_equal true, document["ok"]
|
|
21
|
+
assert_equal 3, document["records"].length
|
|
22
|
+
lines = manifest.to_jsonl.lines
|
|
23
|
+
assert_equal 3, lines.length
|
|
24
|
+
assert_equal "a cup.", JSON.parse(lines[1])["prompt"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_duplicate_id_and_duplicate_job
|
|
28
|
+
error = assert_raises(Flux3AiPromptTools::ValidationError) do
|
|
29
|
+
Flux3AiPromptTools.build_manifest(
|
|
30
|
+
[
|
|
31
|
+
{ "id" => "bowl", "subject" => "a bowl", "seed" => 1 },
|
|
32
|
+
{ "id" => "bowl", "subject" => "a cup" },
|
|
33
|
+
{ "subject" => "a bowl", "seed" => 1 },
|
|
34
|
+
{ "subject" => "a bowl", "seed" => 2 }
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
assert_includes error.issues.map(&:code), "duplicate_id"
|
|
39
|
+
assert_includes error.issues.map(&:code), "duplicate_job"
|
|
40
|
+
|
|
41
|
+
manifest = Flux3AiPromptTools.build_manifest(
|
|
42
|
+
[
|
|
43
|
+
{ "subject" => "a bowl", "seed" => 1 },
|
|
44
|
+
{ "subject" => "a bowl", "seed" => 1 },
|
|
45
|
+
{ "subject" => "a bowl", "seed" => 2 }
|
|
46
|
+
],
|
|
47
|
+
keep_going: true
|
|
48
|
+
)
|
|
49
|
+
assert_equal false, manifest.ok?
|
|
50
|
+
assert_equal true, manifest.records[0].ok
|
|
51
|
+
assert_equal false, manifest.records[1].ok
|
|
52
|
+
assert_nil manifest.records[1].prompt
|
|
53
|
+
assert_equal "duplicate_job", manifest.records[1].errors.first.code
|
|
54
|
+
assert_equal true, manifest.records[2].ok
|
|
55
|
+
assert_equal 2, manifest.records[2].parameters["seed"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_keep_going_reports_field_errors_without_dropping_later_records
|
|
59
|
+
manifest = Flux3AiPromptTools.build_manifest(
|
|
60
|
+
[
|
|
61
|
+
{ "mood" => "quiet" },
|
|
62
|
+
{ "subject" => "a bowl" }
|
|
63
|
+
],
|
|
64
|
+
keep_going: true
|
|
65
|
+
)
|
|
66
|
+
assert_equal false, manifest.records[0].ok
|
|
67
|
+
assert_equal "required", manifest.records[0].errors.first.code
|
|
68
|
+
assert_equal "/records/0/subject", manifest.records[0].errors.first.path
|
|
69
|
+
assert_equal "a bowl.", manifest.records[1].prompt
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_strict_batch_collects_every_record_error
|
|
73
|
+
error = assert_raises(Flux3AiPromptTools::ValidationError) do
|
|
74
|
+
Flux3AiPromptTools.build_manifest(
|
|
75
|
+
[
|
|
76
|
+
{ "subject" => "" },
|
|
77
|
+
{ "subject" => "a bowl", "aspect_ratio" => "wide" }
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
assert_equal ["/records/0/subject", "/records/1/aspect_ratio"], error.issues.map(&:path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_length_errors_are_prefixed_in_a_batch
|
|
85
|
+
error = assert_raises(Flux3AiPromptTools::ValidationError) do
|
|
86
|
+
Flux3AiPromptTools.build_manifest(
|
|
87
|
+
[{ "subject" => "a bowl" }],
|
|
88
|
+
limits: Flux3AiPromptTools::Limits.new(max_prompt_chars: 3)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
assert_equal "/records/0/prompt", error.issues.first.path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_empty_and_oversized_batches
|
|
95
|
+
assert_raises(Flux3AiPromptTools::InputError) do
|
|
96
|
+
Flux3AiPromptTools.build_manifest([])
|
|
97
|
+
end
|
|
98
|
+
assert_raises(Flux3AiPromptTools::InputError) do
|
|
99
|
+
Flux3AiPromptTools.build_manifest(
|
|
100
|
+
[{ "subject" => "a bowl" }, { "subject" => "a cup" }],
|
|
101
|
+
limits: Flux3AiPromptTools::Limits.new(max_records: 1)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_bad_layout_argument
|
|
107
|
+
error = assert_raises(ArgumentError) do
|
|
108
|
+
Flux3AiPromptTools.build_manifest([{ "subject" => "a bowl" }], layout: "poem")
|
|
109
|
+
end
|
|
110
|
+
assert_match(/layout/, error.message)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require_relative "helper"
|
|
5
|
+
|
|
6
|
+
class PackagingTest < Flux3Test
|
|
7
|
+
def test_gemspec_metadata_and_packaged_source
|
|
8
|
+
spec = Gem::Specification.load(File.join(ROOT, "flux3_ai_prompt_tools.gemspec"))
|
|
9
|
+
assert_equal "flux3_ai_prompt_tools", spec.name
|
|
10
|
+
assert_equal Flux3AiPromptTools::VERSION, spec.version.to_s
|
|
11
|
+
assert_equal ["Andy Wang"], spec.authors
|
|
12
|
+
assert_nil spec.email
|
|
13
|
+
assert_equal "https://flux3-ai.com/", spec.homepage
|
|
14
|
+
assert_equal "https://flux3-ai.com/", spec.metadata["homepage_uri"]
|
|
15
|
+
assert_equal "MIT", spec.license
|
|
16
|
+
assert_equal "https://rubygems.org", spec.metadata["allowed_push_host"]
|
|
17
|
+
refute spec.metadata.key?("rubygems_mfa_required")
|
|
18
|
+
assert_nil spec.metadata["rubygems_mfa_required"]
|
|
19
|
+
assert_nil spec.metadata["source_code_uri"]
|
|
20
|
+
assert_empty spec.runtime_dependencies
|
|
21
|
+
assert_equal Gem::Requirement.new(">= 3.1.0"), spec.required_ruby_version
|
|
22
|
+
%w[
|
|
23
|
+
lib/flux3_ai_prompt_tools.rb
|
|
24
|
+
bin/flux3-prompt
|
|
25
|
+
README.md
|
|
26
|
+
LICENSE
|
|
27
|
+
CHANGELOG.md
|
|
28
|
+
examples/kettle.json
|
|
29
|
+
examples/batch.jsonl
|
|
30
|
+
test/validator_test.rb
|
|
31
|
+
test/cli_test.rb
|
|
32
|
+
flux3_ai_prompt_tools.gemspec
|
|
33
|
+
].each do |path|
|
|
34
|
+
assert_includes spec.files, path
|
|
35
|
+
end
|
|
36
|
+
assert_operator spec.summary.length, :<=, 144
|
|
37
|
+
refute_match(/@/, spec.summary)
|
|
38
|
+
refute_match(/@/, spec.description)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_readme_describes_real_behavior_and_limits
|
|
42
|
+
readme = File.read(File.join(ROOT, "README.md"))
|
|
43
|
+
words = readme.scan(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/)
|
|
44
|
+
assert_operator words.length, :>=, 200
|
|
45
|
+
assert_includes readme, "https://flux3-ai.com/"
|
|
46
|
+
assert_includes readme, "does not generate images"
|
|
47
|
+
assert_includes readme, "not a Black Forest Labs product"
|
|
48
|
+
assert_includes readme, "flux_3_org"
|
|
49
|
+
assert_includes readme, "## Limitations"
|
|
50
|
+
assert_includes readme, "rake test"
|
|
51
|
+
assert_includes readme, "gem install --user-install flux3_ai_prompt_tools-0.1.0.gem"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_library_and_executable_do_not_reference_network_clients
|
|
55
|
+
roots = [File.join(ROOT, "lib"), File.join(ROOT, "bin")]
|
|
56
|
+
forbidden = %w[Net::HTTP OpenURI TCPSocket UDPSocket Resolv net/http open-uri socket telemetry api_key]
|
|
57
|
+
offenders = []
|
|
58
|
+
roots.each do |root|
|
|
59
|
+
Dir.glob(File.join(root, "**/*")).each do |path|
|
|
60
|
+
next unless File.file?(path)
|
|
61
|
+
|
|
62
|
+
text = File.read(path)
|
|
63
|
+
forbidden.each do |token|
|
|
64
|
+
offenders << "#{path} contains #{token}" if text.include?(token)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
assert_empty offenders
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_examples_round_trip_through_the_library
|
|
72
|
+
kettle = Flux3AiPromptTools.load_file(File.join(ROOT, "examples", "kettle.json"))
|
|
73
|
+
result = Flux3AiPromptTools.assemble(kettle.fetch(0))
|
|
74
|
+
assert_equal "kettle-01", result.id
|
|
75
|
+
assert_includes result.prompt, "editorial product photography style"
|
|
76
|
+
refute_includes result.prompt, "Local reminder"
|
|
77
|
+
assert_equal ["text", "watermark", "extra handle"], result.negative_prompt.split(", ")
|
|
78
|
+
|
|
79
|
+
batch = Flux3AiPromptTools.load_file(File.join(ROOT, "examples", "batch.jsonl"))
|
|
80
|
+
manifest = Flux3AiPromptTools.build_manifest(batch)
|
|
81
|
+
assert_equal 2, manifest.records.length
|
|
82
|
+
assert manifest.ok?
|
|
83
|
+
end
|
|
84
|
+
end
|
data/test/reader_test.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helper"
|
|
4
|
+
|
|
5
|
+
class ReaderTest < Flux3Test
|
|
6
|
+
def test_json_object_array_jsonl_and_bom
|
|
7
|
+
object = Flux3AiPromptTools::Reader.load('{"subject":"a bowl"}', format: "json")
|
|
8
|
+
assert_equal [{ "subject" => "a bowl" }], object
|
|
9
|
+
|
|
10
|
+
array = Flux3AiPromptTools::Reader.load('[{"subject":"a bowl"},{"subject":"a cup"}]', format: "auto")
|
|
11
|
+
assert_equal 2, array.length
|
|
12
|
+
|
|
13
|
+
jsonl = Flux3AiPromptTools::Reader.load("\n{\"subject\":\"a bowl\"}\n\n{\"subject\":\"a cup\"}\n", format: "jsonl")
|
|
14
|
+
assert_equal %w[a\ bowl a\ cup], jsonl.map { |row| row["subject"] }
|
|
15
|
+
|
|
16
|
+
bom = Flux3AiPromptTools::Reader.load("\uFEFF{\"subject\":\"a bowl\"}", format: "auto")
|
|
17
|
+
assert_equal "a bowl", bom.first["subject"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_jsonl_reports_the_line_number
|
|
21
|
+
error = assert_raises(Flux3AiPromptTools::InputError) do
|
|
22
|
+
Flux3AiPromptTools::Reader.load("{\"subject\":\"ok\"}\nnot-json\n", format: "jsonl", filename: "batch.jsonl")
|
|
23
|
+
end
|
|
24
|
+
assert_equal 2, error.line
|
|
25
|
+
assert_match(/batch.jsonl:2/, error.message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_rejects_non_objects_empty_input_and_oversize_files
|
|
29
|
+
assert_raises(Flux3AiPromptTools::InputError) do
|
|
30
|
+
Flux3AiPromptTools::Reader.load("[]", format: "json")
|
|
31
|
+
end
|
|
32
|
+
assert_raises(Flux3AiPromptTools::InputError) do
|
|
33
|
+
Flux3AiPromptTools::Reader.load("[1]", format: "json")
|
|
34
|
+
end
|
|
35
|
+
assert_raises(Flux3AiPromptTools::InputError) do
|
|
36
|
+
Flux3AiPromptTools::Reader.load(" ", format: "auto")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
with_tmpdir do |dir|
|
|
40
|
+
path = File.join(dir, "too-big.json")
|
|
41
|
+
File.binwrite(path, "{\"subject\":\"a bowl\"}")
|
|
42
|
+
error = assert_raises(Flux3AiPromptTools::InputError) do
|
|
43
|
+
Flux3AiPromptTools::Reader.load_file(
|
|
44
|
+
path,
|
|
45
|
+
limits: Flux3AiPromptTools::Limits.new(max_input_bytes: 4)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
assert_match(/bytes/, error.message)
|
|
49
|
+
|
|
50
|
+
missing = assert_raises(Flux3AiPromptTools::InputError) do
|
|
51
|
+
Flux3AiPromptTools::Reader.load_file(File.join(dir, "missing.json"))
|
|
52
|
+
end
|
|
53
|
+
assert_match(/does not exist/, missing.message)
|
|
54
|
+
|
|
55
|
+
binary = File.join(dir, "bad.json")
|
|
56
|
+
File.binwrite(binary, "\xff\xfe")
|
|
57
|
+
assert_raises(Flux3AiPromptTools::InputError) do
|
|
58
|
+
Flux3AiPromptTools::Reader.load_file(binary)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_extension_selects_the_reader
|
|
64
|
+
with_tmpdir do |dir|
|
|
65
|
+
path = File.join(dir, "rows.jsonl")
|
|
66
|
+
File.write(path, "{\"subject\":\"a bowl\"}\n{\"subject\":\"a cup\"}\n")
|
|
67
|
+
rows = Flux3AiPromptTools.load_file(path)
|
|
68
|
+
assert_equal 2, rows.length
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helper"
|
|
4
|
+
|
|
5
|
+
class ValidatorTest < Flux3Test
|
|
6
|
+
def test_subject_is_required_and_symbol_keys_work
|
|
7
|
+
issues = Flux3AiPromptTools.validate({ style: "quiet" })
|
|
8
|
+
assert_equal ["required"], codes(issues)
|
|
9
|
+
assert_equal "/subject", issues.first.path
|
|
10
|
+
|
|
11
|
+
result = Flux3AiPromptTools.assemble({ subject: " a red bowl " })
|
|
12
|
+
assert_equal "a red bowl.", result.prompt
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_blank_optional_string_is_an_error
|
|
16
|
+
issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "mood" => " " })
|
|
17
|
+
assert_equal ["blank"], codes(issues)
|
|
18
|
+
assert_equal "/mood", issues.first.path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_control_characters_and_line_separators_are_rejected
|
|
22
|
+
["a\nbowl", "a\u0000bowl", "a\u007Fbowl", "a\u2028bowl", "a\u2029bowl"].each do |subject|
|
|
23
|
+
issues = Flux3AiPromptTools.validate({ "subject" => subject })
|
|
24
|
+
assert_equal ["control_character"], codes(issues), subject.inspect
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_invalid_utf8_is_rejected
|
|
29
|
+
subject = "a bowl".b
|
|
30
|
+
subject.setbyte(0, 0xFF)
|
|
31
|
+
issues = Flux3AiPromptTools.validate({ "subject" => subject })
|
|
32
|
+
assert_equal ["encoding"], codes(issues)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_unicode_subject_is_measured_in_characters
|
|
36
|
+
subject = "一只红碗"
|
|
37
|
+
result = Flux3AiPromptTools.assemble({ "subject" => subject })
|
|
38
|
+
assert_equal "一只红碗.", result.prompt
|
|
39
|
+
assert_equal subject.length + 1, result.counts["prompt_chars"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_string_length_limit
|
|
43
|
+
issues = Flux3AiPromptTools.validate({ "subject" => "a" * 401 })
|
|
44
|
+
assert_equal ["too_long"], codes(issues)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_unknown_and_duplicate_fields
|
|
48
|
+
issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "model" => "none", :subject => "other" })
|
|
49
|
+
assert_includes codes(issues), "unknown_field"
|
|
50
|
+
assert_includes codes(issues), "duplicate_field"
|
|
51
|
+
refute_includes JSON.generate(issues.map(&:to_h)), "endpoint"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_null_clears_a_preset_field
|
|
55
|
+
result = Flux3AiPromptTools.assemble(
|
|
56
|
+
{ "preset" => "product_still", "subject" => "a bowl", "lighting" => nil }
|
|
57
|
+
)
|
|
58
|
+
refute_includes result.prompt, "Lighting:"
|
|
59
|
+
assert_includes result.prompt, "photograph."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_unknown_preset_and_layout
|
|
63
|
+
issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "preset" => "cinematic", "layout" => "poem" })
|
|
64
|
+
assert_equal %w[unknown_preset unknown_layout], codes(issues)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_detail_weights_and_bad_detail_shapes
|
|
68
|
+
result = Flux3AiPromptTools.assemble(
|
|
69
|
+
{
|
|
70
|
+
"subject" => "a bowl",
|
|
71
|
+
"details" => ["speckled glaze", { "text" => "wide rim", "weight" => 1.2 }]
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
assert_includes result.prompt, "speckled glaze"
|
|
75
|
+
assert_includes result.prompt, "wide rim (emphasis 1.2)"
|
|
76
|
+
|
|
77
|
+
issues = Flux3AiPromptTools.validate(
|
|
78
|
+
{
|
|
79
|
+
"subject" => "a bowl",
|
|
80
|
+
"details" => [{ "text" => "rim", "weight" => 2.01, "boost" => true }, ""]
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
assert_includes codes(issues), "range"
|
|
84
|
+
assert_includes codes(issues), "unknown_field"
|
|
85
|
+
assert_includes codes(issues), "blank"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_numeric_metadata_bounds
|
|
89
|
+
ok = Flux3AiPromptTools.assemble(
|
|
90
|
+
{ "subject" => "a bowl", "seed" => 0, "steps" => 150, "guidance" => 3.5, "aspect_ratio" => "64:1" }
|
|
91
|
+
)
|
|
92
|
+
assert_equal 0, ok.parameters["seed"]
|
|
93
|
+
assert_equal 150, ok.parameters["steps"]
|
|
94
|
+
assert_in_delta 3.5, ok.parameters["guidance"], 0.0001
|
|
95
|
+
assert_equal "64:1", ok.parameters["aspect_ratio"]
|
|
96
|
+
|
|
97
|
+
bad = Flux3AiPromptTools.validate(
|
|
98
|
+
{
|
|
99
|
+
"subject" => "a bowl",
|
|
100
|
+
"seed" => 1.0,
|
|
101
|
+
"steps" => 0,
|
|
102
|
+
"guidance" => 30.01,
|
|
103
|
+
"aspect_ratio" => "65:1"
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
assert_equal %w[range type range range], codes(bad)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_seed_above_the_safe_json_integer_is_rejected
|
|
110
|
+
issues = Flux3AiPromptTools.validate({ "subject" => "a bowl", "seed" => Flux3AiPromptTools::Limits::MAX_SEED + 1 })
|
|
111
|
+
assert_equal ["range"], codes(issues)
|
|
112
|
+
|
|
113
|
+
result = Flux3AiPromptTools.assemble({ "subject" => "a bowl", "seed" => Flux3AiPromptTools::Limits::MAX_SEED })
|
|
114
|
+
assert_equal Flux3AiPromptTools::Limits::MAX_SEED, result.parameters["seed"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_internal_spaces_are_preserved_and_terminal_punctuation_is_kept
|
|
118
|
+
result = Flux3AiPromptTools.assemble({ "subject" => "Watch the rim." })
|
|
119
|
+
assert_equal "Watch the rim.", result.prompt
|
|
120
|
+
spaced = Flux3AiPromptTools.assemble({ "subject" => "a red bowl" })
|
|
121
|
+
assert_equal "a red bowl.", spaced.prompt
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def test_note_is_stored_and_not_rendered
|
|
125
|
+
result = Flux3AiPromptTools.assemble({ "subject" => "a bowl", "note" => "private reminder" })
|
|
126
|
+
assert_equal "private reminder", result.note
|
|
127
|
+
refute_includes result.prompt, "private"
|
|
128
|
+
refute_includes result.negative_prompt, "private"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def test_non_hash_record
|
|
132
|
+
issues = Flux3AiPromptTools.validate(["a bowl"])
|
|
133
|
+
assert_equal ["type"], codes(issues)
|
|
134
|
+
end
|
|
135
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: flux3_ai_prompt_tools
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Wang
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Offline Ruby library and command-line tool that validates structured prompt
|
|
15
|
+
specifications and assembles text-to-image prompts into JSON and JSONL
|
|
16
|
+
manifests. It does not generate images, call a model API, or send data
|
|
17
|
+
over the network. It is an independent utility by Andy Wang and is not a
|
|
18
|
+
Black Forest Labs product, an official FLUX client, or the flux_3_org gem.
|
|
19
|
+
email:
|
|
20
|
+
executables:
|
|
21
|
+
- flux3-prompt
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
- LICENSE
|
|
27
|
+
- README.md
|
|
28
|
+
- Rakefile
|
|
29
|
+
- bin/flux3-prompt
|
|
30
|
+
- examples/batch.jsonl
|
|
31
|
+
- examples/kettle.json
|
|
32
|
+
- flux3_ai_prompt_tools.gemspec
|
|
33
|
+
- lib/flux3_ai_prompt_tools.rb
|
|
34
|
+
- lib/flux3_ai_prompt_tools/assembler.rb
|
|
35
|
+
- lib/flux3_ai_prompt_tools/cli.rb
|
|
36
|
+
- lib/flux3_ai_prompt_tools/errors.rb
|
|
37
|
+
- lib/flux3_ai_prompt_tools/fields.rb
|
|
38
|
+
- lib/flux3_ai_prompt_tools/layouts.rb
|
|
39
|
+
- lib/flux3_ai_prompt_tools/limits.rb
|
|
40
|
+
- lib/flux3_ai_prompt_tools/manifest.rb
|
|
41
|
+
- lib/flux3_ai_prompt_tools/numbers.rb
|
|
42
|
+
- lib/flux3_ai_prompt_tools/presets.rb
|
|
43
|
+
- lib/flux3_ai_prompt_tools/reader.rb
|
|
44
|
+
- lib/flux3_ai_prompt_tools/schema.rb
|
|
45
|
+
- lib/flux3_ai_prompt_tools/specification.rb
|
|
46
|
+
- lib/flux3_ai_prompt_tools/validator.rb
|
|
47
|
+
- lib/flux3_ai_prompt_tools/version.rb
|
|
48
|
+
- test/assembler_test.rb
|
|
49
|
+
- test/cli_test.rb
|
|
50
|
+
- test/helper.rb
|
|
51
|
+
- test/manifest_test.rb
|
|
52
|
+
- test/packaging_test.rb
|
|
53
|
+
- test/reader_test.rb
|
|
54
|
+
- test/validator_test.rb
|
|
55
|
+
homepage: https://flux3-ai.com/
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
homepage_uri: https://flux3-ai.com/
|
|
60
|
+
allowed_push_host: https://rubygems.org
|
|
61
|
+
post_install_message:
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: 3.1.0
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 3.4.20
|
|
77
|
+
signing_key:
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: Validate structured inputs and assemble offline text-to-image prompt manifests.
|
|
80
|
+
test_files: []
|