lluminary 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/lluminary/models/base.rb +45 -0
- data/lib/lluminary/schema.rb +30 -4
- data/lib/lluminary/schema_model.rb +86 -7
- data/spec/examples/text_emotion_analyzer_spec.rb +75 -0
- data/spec/lluminary/models/base_spec.rb +141 -0
- data/spec/lluminary/schema_model_spec.rb +234 -0
- data/spec/lluminary/schema_spec.rb +223 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 43ca21be47208b82183b4156be99d1e9816ea1013b2ee0c5258df158b0f4f2c1
|
4
|
+
data.tar.gz: 4f835024abc67bcd6bb9dd108fd49431eec6a152f168e31592b2d5317b982889
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2cc7b7322ec931bf1d0c5783b3dfbd26bb3c7fff841d834cc120d271c6ecef3e22472ba278c5cee8932685cfe71c850950a0e6305b88c0d997df7b3ebc0fdca4
|
7
|
+
data.tar.gz: b9f95c68e583a7819bb4b37e36c0b40d6856ce9f4261aea7efe78018823e950471a306f223033747e32d25fb81790a64af1f9cec70ee102be4a2811ccfcdede4
|
@@ -175,6 +175,18 @@ module Lluminary
|
|
175
175
|
end
|
176
176
|
when :hash
|
177
177
|
"object"
|
178
|
+
when :dictionary
|
179
|
+
if field[:value_type].nil?
|
180
|
+
"object"
|
181
|
+
elsif field[:value_type][:type] == :array
|
182
|
+
"object with array values"
|
183
|
+
elsif field[:value_type][:type] == :datetime
|
184
|
+
"object with datetime values in ISO8601 format"
|
185
|
+
elsif field[:value_type][:type] == :hash
|
186
|
+
"object with object values"
|
187
|
+
else
|
188
|
+
"object with #{field[:value_type][:type]} values"
|
189
|
+
end
|
178
190
|
else
|
179
191
|
field[:type].to_s
|
180
192
|
end
|
@@ -294,6 +306,8 @@ module Lluminary
|
|
294
306
|
generate_array_example(name, field)
|
295
307
|
when :hash
|
296
308
|
generate_hash_example(name, field)
|
309
|
+
when :dictionary
|
310
|
+
generate_dictionary_example(name, field)
|
297
311
|
end
|
298
312
|
end
|
299
313
|
|
@@ -333,6 +347,37 @@ module Lluminary
|
|
333
347
|
end
|
334
348
|
end
|
335
349
|
|
350
|
+
def generate_dictionary_example(name, field)
|
351
|
+
return {} unless field[:value_type]
|
352
|
+
|
353
|
+
case field[:value_type][:type]
|
354
|
+
when :string
|
355
|
+
{ "some_key" => "first value", "other_key" => "second value" }
|
356
|
+
when :integer
|
357
|
+
{ "some_key" => 1, "other_key" => 2 }
|
358
|
+
when :float
|
359
|
+
{ "some_key" => 1.0, "other_key" => 2.0 }
|
360
|
+
when :boolean
|
361
|
+
{ "some_key" => true, "other_key" => false }
|
362
|
+
when :datetime
|
363
|
+
{
|
364
|
+
"some_key" => "2024-01-01T12:00:00+00:00",
|
365
|
+
"other_key" => "2024-01-02T12:00:00+00:00"
|
366
|
+
}
|
367
|
+
when :array
|
368
|
+
if field[:value_type][:element_type]
|
369
|
+
inner_example = generate_array_example("item", field[:value_type])
|
370
|
+
{ "some_key" => inner_example, "other_key" => inner_example }
|
371
|
+
else
|
372
|
+
{ "some_key" => [], "other_key" => [] }
|
373
|
+
end
|
374
|
+
when :hash
|
375
|
+
example =
|
376
|
+
generate_hash_example(name.to_s.singularize, field[:value_type])
|
377
|
+
{ "some_key" => example, "other_key" => example }
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
336
381
|
def format_additional_validations(custom_validations)
|
337
382
|
descriptions = custom_validations.map { |v| v[:description] }.compact
|
338
383
|
return "" if descriptions.empty?
|
data/lib/lluminary/schema.rb
CHANGED
@@ -36,7 +36,7 @@ module Lluminary
|
|
36
36
|
field = { type: :array, description: description }
|
37
37
|
|
38
38
|
if block
|
39
|
-
element_schema =
|
39
|
+
element_schema = ElementTypeSchema.new
|
40
40
|
field[:element_type] = element_schema.instance_eval(&block)
|
41
41
|
end
|
42
42
|
|
@@ -58,6 +58,21 @@ module Lluminary
|
|
58
58
|
}
|
59
59
|
end
|
60
60
|
|
61
|
+
def dictionary(name, description: nil, &block)
|
62
|
+
unless block
|
63
|
+
raise ArgumentError, "Dictionary fields must be defined with a block"
|
64
|
+
end
|
65
|
+
|
66
|
+
element_schema = ElementTypeSchema.new
|
67
|
+
value_type = element_schema.instance_eval(&block)
|
68
|
+
|
69
|
+
@fields[name] = {
|
70
|
+
type: :dictionary,
|
71
|
+
description: description,
|
72
|
+
value_type: value_type
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
61
76
|
attr_reader :fields, :custom_validations
|
62
77
|
|
63
78
|
def validates(*args, **options)
|
@@ -90,8 +105,8 @@ module Lluminary
|
|
90
105
|
)
|
91
106
|
end
|
92
107
|
|
93
|
-
# Internal class for defining
|
94
|
-
class
|
108
|
+
# Internal class for defining element types for arrays and dictionaries (or any other container-type objects)
|
109
|
+
class ElementTypeSchema
|
95
110
|
def string(description: nil)
|
96
111
|
{ type: :string, description: description }
|
97
112
|
end
|
@@ -114,7 +129,7 @@ module Lluminary
|
|
114
129
|
|
115
130
|
def array(description: nil, &block)
|
116
131
|
field = { type: :array, description: description }
|
117
|
-
field[:element_type] =
|
132
|
+
field[:element_type] = ElementTypeSchema.new.instance_eval(
|
118
133
|
&block
|
119
134
|
) if block
|
120
135
|
field
|
@@ -130,6 +145,17 @@ module Lluminary
|
|
130
145
|
|
131
146
|
{ type: :hash, description: description, fields: nested_schema.fields }
|
132
147
|
end
|
148
|
+
|
149
|
+
def dictionary(description: nil, &block)
|
150
|
+
unless block
|
151
|
+
raise ArgumentError, "Dictionary fields must be defined with a block"
|
152
|
+
end
|
153
|
+
|
154
|
+
element_schema = ElementTypeSchema.new
|
155
|
+
value_type = element_schema.instance_eval(&block)
|
156
|
+
|
157
|
+
{ type: :dictionary, description: description, value_type: value_type }
|
158
|
+
end
|
133
159
|
end
|
134
160
|
end
|
135
161
|
end
|
@@ -71,14 +71,11 @@ module Lluminary
|
|
71
71
|
|
72
72
|
case field[:type]
|
73
73
|
when :hash
|
74
|
-
validate_hash_field(record, name
|
74
|
+
validate_hash_field(record, name, value, field)
|
75
75
|
when :array
|
76
|
-
validate_array_field(
|
77
|
-
|
78
|
-
|
79
|
-
value,
|
80
|
-
field[:element_type]
|
81
|
-
)
|
76
|
+
validate_array_field(record, name, value, field[:element_type])
|
77
|
+
when :dictionary
|
78
|
+
validate_dictionary_field(record, name, value, field[:value_type])
|
82
79
|
when :string
|
83
80
|
unless value.is_a?(String)
|
84
81
|
record.errors.add(name.to_s.capitalize, "must be a String")
|
@@ -113,6 +110,72 @@ module Lluminary
|
|
113
110
|
|
114
111
|
private
|
115
112
|
|
113
|
+
def validate_dictionary_field(
|
114
|
+
record,
|
115
|
+
name,
|
116
|
+
value,
|
117
|
+
value_type,
|
118
|
+
path = nil
|
119
|
+
)
|
120
|
+
field_name = path || name
|
121
|
+
|
122
|
+
unless value.is_a?(Hash)
|
123
|
+
record.errors.add(field_name, "must be a Hash")
|
124
|
+
return
|
125
|
+
end
|
126
|
+
|
127
|
+
value.each do |key, val|
|
128
|
+
current_path = "#{field_name}[#{key}]"
|
129
|
+
|
130
|
+
case value_type[:type]
|
131
|
+
when :string
|
132
|
+
unless val.is_a?(String)
|
133
|
+
record.errors.add(current_path, "must be a String")
|
134
|
+
end
|
135
|
+
when :integer
|
136
|
+
unless val.is_a?(Integer)
|
137
|
+
record.errors.add(current_path, "must be an Integer")
|
138
|
+
end
|
139
|
+
when :boolean
|
140
|
+
unless [true, false].include?(val)
|
141
|
+
record.errors.add(current_path, "must be true or false")
|
142
|
+
end
|
143
|
+
when :float
|
144
|
+
unless val.is_a?(Float)
|
145
|
+
record.errors.add(current_path, "must be a float")
|
146
|
+
end
|
147
|
+
when :datetime
|
148
|
+
unless val.is_a?(DateTime)
|
149
|
+
record.errors.add(current_path, "must be a DateTime")
|
150
|
+
end
|
151
|
+
when :hash
|
152
|
+
validate_hash_field(
|
153
|
+
record,
|
154
|
+
current_path,
|
155
|
+
val,
|
156
|
+
value_type,
|
157
|
+
current_path
|
158
|
+
)
|
159
|
+
when :array
|
160
|
+
validate_array_field(
|
161
|
+
record,
|
162
|
+
current_path,
|
163
|
+
val,
|
164
|
+
value_type[:element_type],
|
165
|
+
current_path
|
166
|
+
)
|
167
|
+
when :dictionary
|
168
|
+
validate_dictionary_field(
|
169
|
+
record,
|
170
|
+
current_path,
|
171
|
+
val,
|
172
|
+
value_type[:value_type],
|
173
|
+
current_path
|
174
|
+
)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
116
179
|
def validate_hash_field(
|
117
180
|
record,
|
118
181
|
name,
|
@@ -145,6 +208,14 @@ module Lluminary
|
|
145
208
|
field[:element_type],
|
146
209
|
current_path
|
147
210
|
)
|
211
|
+
when :dictionary
|
212
|
+
validate_dictionary_field(
|
213
|
+
record,
|
214
|
+
key,
|
215
|
+
field_value,
|
216
|
+
field[:value_type],
|
217
|
+
current_path
|
218
|
+
)
|
148
219
|
when :string
|
149
220
|
unless field_value.is_a?(String)
|
150
221
|
record.errors.add(current_path, "must be a String")
|
@@ -199,6 +270,14 @@ module Lluminary
|
|
199
270
|
element_type[:element_type],
|
200
271
|
current_path
|
201
272
|
)
|
273
|
+
when :dictionary
|
274
|
+
validate_dictionary_field(
|
275
|
+
record,
|
276
|
+
name,
|
277
|
+
element,
|
278
|
+
element_type[:value_type],
|
279
|
+
current_path
|
280
|
+
)
|
202
281
|
when :string
|
203
282
|
unless element.is_a?(String)
|
204
283
|
record.errors.add(current_path, "must be a String")
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "spec_helper"
|
3
|
+
require_relative "../../examples/text_emotion_analyzer"
|
4
|
+
|
5
|
+
RSpec.describe TextEmotionAnalyzer do
|
6
|
+
let(:sample_text) { <<~TEXT }
|
7
|
+
The sun was setting behind the mountains, casting long shadows across the valley.
|
8
|
+
Sarah felt a mix of emotions as she watched the last rays of light disappear.
|
9
|
+
There was a deep sense of peace, but also a tinge of sadness knowing this beautiful moment would soon be gone.
|
10
|
+
She smiled through her tears, grateful for the experience yet longing for it to last just a little longer.
|
11
|
+
TEXT
|
12
|
+
|
13
|
+
describe "input validation" do
|
14
|
+
it "accepts valid text input" do
|
15
|
+
expect { described_class.call!(text: sample_text) }.not_to raise_error
|
16
|
+
end
|
17
|
+
|
18
|
+
it "requires text to be present" do
|
19
|
+
expect { described_class.call!(text: "") }.to raise_error(
|
20
|
+
Lluminary::ValidationError
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "output validation" do
|
26
|
+
let(:result) { described_class.call(text: sample_text) }
|
27
|
+
|
28
|
+
it "returns a dictionary of emotion scores" do
|
29
|
+
expect(result.output.emotion_scores).to be_a(Hash)
|
30
|
+
expect(result.output.emotion_scores).not_to be_empty
|
31
|
+
end
|
32
|
+
|
33
|
+
it "returns float scores between 0.0 and 1.0" do
|
34
|
+
result.output.emotion_scores.each do |emotion, score|
|
35
|
+
expect(score).to be_a(Float)
|
36
|
+
expect(score).to be_between(0.0, 1.0)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
it "returns a dominant emotion" do
|
41
|
+
expect(result.output.dominant_emotion).to be_a(String)
|
42
|
+
expect(result.output.dominant_emotion).not_to be_empty
|
43
|
+
end
|
44
|
+
|
45
|
+
it "returns an analysis" do
|
46
|
+
expect(result.output.analysis).to be_a(String)
|
47
|
+
expect(result.output.analysis).not_to be_empty
|
48
|
+
end
|
49
|
+
|
50
|
+
it "returns valid JSON response" do
|
51
|
+
expect(result.output.raw_response).to be_a(String)
|
52
|
+
expect { JSON.parse(result.output.raw_response) }.not_to raise_error
|
53
|
+
json = JSON.parse(result.output.raw_response)
|
54
|
+
expect(json).to have_key("emotion_scores")
|
55
|
+
expect(json).to have_key("dominant_emotion")
|
56
|
+
expect(json).to have_key("analysis")
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
describe "emotion detection" do
|
61
|
+
it "detects multiple emotions in complex text" do
|
62
|
+
result = described_class.call(text: sample_text)
|
63
|
+
expect(result.output.emotion_scores.size).to be >= 2
|
64
|
+
end
|
65
|
+
|
66
|
+
it "identifies the highest scoring emotion as dominant" do
|
67
|
+
result = described_class.call(text: sample_text)
|
68
|
+
dominant_score =
|
69
|
+
result.output.emotion_scores[result.output.dominant_emotion]
|
70
|
+
result.output.emotion_scores.each do |emotion, score|
|
71
|
+
expect(score).to be <= dominant_score
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -1409,6 +1409,147 @@ RSpec.describe Lluminary::Models::Base do
|
|
1409
1409
|
expect(prompt).to include(expected_json)
|
1410
1410
|
end
|
1411
1411
|
end
|
1412
|
+
|
1413
|
+
context "with dictionary fields" do
|
1414
|
+
it "formats dictionary field description correctly" do
|
1415
|
+
task_class.output_schema do
|
1416
|
+
dictionary :emotion_scores,
|
1417
|
+
description: "Scores for each detected emotion" do
|
1418
|
+
float
|
1419
|
+
end
|
1420
|
+
end
|
1421
|
+
|
1422
|
+
prompt = model.format_prompt(task)
|
1423
|
+
|
1424
|
+
expected_description = <<~DESCRIPTION.chomp
|
1425
|
+
# emotion_scores
|
1426
|
+
Description: Scores for each detected emotion
|
1427
|
+
Type: object with float values
|
1428
|
+
Example: {"some_key":1.0,"other_key":2.0}
|
1429
|
+
DESCRIPTION
|
1430
|
+
|
1431
|
+
expect(prompt).to include(expected_description)
|
1432
|
+
end
|
1433
|
+
|
1434
|
+
it "formats dictionary of arrays field description correctly" do
|
1435
|
+
task_class.output_schema do
|
1436
|
+
dictionary :categories, description: "Categories and their items" do
|
1437
|
+
array { string }
|
1438
|
+
end
|
1439
|
+
end
|
1440
|
+
|
1441
|
+
prompt = model.format_prompt(task)
|
1442
|
+
|
1443
|
+
expected_description = <<~DESCRIPTION.chomp
|
1444
|
+
# categories
|
1445
|
+
Description: Categories and their items
|
1446
|
+
Type: object with array values
|
1447
|
+
Example: {"some_key":["first item","second item"],"other_key":["first item","second item"]}
|
1448
|
+
DESCRIPTION
|
1449
|
+
|
1450
|
+
expect(prompt).to include(expected_description)
|
1451
|
+
end
|
1452
|
+
|
1453
|
+
it "formats dictionary of objects field description correctly" do
|
1454
|
+
task_class.output_schema do
|
1455
|
+
dictionary :users, description: "User profiles" do
|
1456
|
+
hash do
|
1457
|
+
string :name
|
1458
|
+
integer :age
|
1459
|
+
end
|
1460
|
+
end
|
1461
|
+
end
|
1462
|
+
|
1463
|
+
prompt = model.format_prompt(task)
|
1464
|
+
|
1465
|
+
expected_description = <<~DESCRIPTION.chomp
|
1466
|
+
# users
|
1467
|
+
Description: User profiles
|
1468
|
+
Type: object with object values
|
1469
|
+
Example: {"some_key":{"name":"your name here","age":0},"other_key":{"name":"your name here","age":0}}
|
1470
|
+
DESCRIPTION
|
1471
|
+
|
1472
|
+
expect(prompt).to include(expected_description)
|
1473
|
+
end
|
1474
|
+
|
1475
|
+
it "generates correct JSON example for dictionary of floats" do
|
1476
|
+
task_class.output_schema do
|
1477
|
+
dictionary :scores, description: "Scores for each item" do
|
1478
|
+
float
|
1479
|
+
end
|
1480
|
+
end
|
1481
|
+
|
1482
|
+
prompt = model.format_prompt(task)
|
1483
|
+
|
1484
|
+
expected_json = <<~JSON.chomp
|
1485
|
+
{
|
1486
|
+
"scores": {
|
1487
|
+
"some_key": 1.0,
|
1488
|
+
"other_key": 2.0
|
1489
|
+
}
|
1490
|
+
}
|
1491
|
+
JSON
|
1492
|
+
|
1493
|
+
expect(prompt).to include(expected_json)
|
1494
|
+
end
|
1495
|
+
|
1496
|
+
it "generates correct JSON example for dictionary of arrays" do
|
1497
|
+
task_class.output_schema do
|
1498
|
+
dictionary :categories, description: "Categories and their items" do
|
1499
|
+
array { string }
|
1500
|
+
end
|
1501
|
+
end
|
1502
|
+
|
1503
|
+
prompt = model.format_prompt(task)
|
1504
|
+
|
1505
|
+
expected_json = <<~JSON.chomp
|
1506
|
+
{
|
1507
|
+
"categories": {
|
1508
|
+
"some_key": [
|
1509
|
+
"first item",
|
1510
|
+
"second item"
|
1511
|
+
],
|
1512
|
+
"other_key": [
|
1513
|
+
"first item",
|
1514
|
+
"second item"
|
1515
|
+
]
|
1516
|
+
}
|
1517
|
+
}
|
1518
|
+
JSON
|
1519
|
+
|
1520
|
+
expect(prompt).to include(expected_json)
|
1521
|
+
end
|
1522
|
+
|
1523
|
+
it "generates correct JSON example for dictionary of objects" do
|
1524
|
+
task_class.output_schema do
|
1525
|
+
dictionary :users, description: "User profiles" do
|
1526
|
+
hash do
|
1527
|
+
string :name
|
1528
|
+
integer :age
|
1529
|
+
end
|
1530
|
+
end
|
1531
|
+
end
|
1532
|
+
|
1533
|
+
prompt = model.format_prompt(task)
|
1534
|
+
|
1535
|
+
expected_json = <<~JSON.chomp
|
1536
|
+
{
|
1537
|
+
"users": {
|
1538
|
+
"some_key": {
|
1539
|
+
"name": "your name here",
|
1540
|
+
"age": 0
|
1541
|
+
},
|
1542
|
+
"other_key": {
|
1543
|
+
"name": "your name here",
|
1544
|
+
"age": 0
|
1545
|
+
}
|
1546
|
+
}
|
1547
|
+
}
|
1548
|
+
JSON
|
1549
|
+
|
1550
|
+
expect(prompt).to include(expected_json)
|
1551
|
+
end
|
1552
|
+
end
|
1412
1553
|
end
|
1413
1554
|
end
|
1414
1555
|
end
|
@@ -428,4 +428,238 @@ RSpec.describe Lluminary::SchemaModel do
|
|
428
428
|
)
|
429
429
|
end
|
430
430
|
end
|
431
|
+
|
432
|
+
describe "dictionary type enforcement" do
|
433
|
+
let(:fields) do
|
434
|
+
{
|
435
|
+
tags: {
|
436
|
+
type: :dictionary,
|
437
|
+
description: nil,
|
438
|
+
value_type: {
|
439
|
+
type: :string,
|
440
|
+
description: nil
|
441
|
+
}
|
442
|
+
}
|
443
|
+
}
|
444
|
+
end
|
445
|
+
let(:model_class) { described_class.build(fields: fields, validations: []) }
|
446
|
+
|
447
|
+
it "validates that value is a hash" do
|
448
|
+
instance = model_class.new(tags: "not a hash")
|
449
|
+
expect(instance.valid?).to be false
|
450
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
451
|
+
"Tags must be a Hash"
|
452
|
+
)
|
453
|
+
end
|
454
|
+
|
455
|
+
it "validates dictionary value types" do
|
456
|
+
instance = model_class.new(tags: { "tag1" => "valid", "tag2" => 123 })
|
457
|
+
expect(instance.valid?).to be false
|
458
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
459
|
+
"Tags[tag2] must be a String"
|
460
|
+
)
|
461
|
+
end
|
462
|
+
|
463
|
+
it "accepts valid dictionary values" do
|
464
|
+
instance =
|
465
|
+
model_class.new(tags: { "tag1" => "valid", "tag2" => "also valid" })
|
466
|
+
expect(instance.valid?).to be true
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
describe "nested dictionary validation" do
|
471
|
+
let(:fields) do
|
472
|
+
{
|
473
|
+
config: {
|
474
|
+
type: :hash,
|
475
|
+
description: nil,
|
476
|
+
fields: {
|
477
|
+
settings: {
|
478
|
+
type: :dictionary,
|
479
|
+
description: nil,
|
480
|
+
value_type: {
|
481
|
+
type: :integer,
|
482
|
+
description: nil
|
483
|
+
}
|
484
|
+
}
|
485
|
+
}
|
486
|
+
}
|
487
|
+
}
|
488
|
+
end
|
489
|
+
let(:model_class) { described_class.build(fields: fields, validations: []) }
|
490
|
+
|
491
|
+
it "validates dictionaries inside hashes" do
|
492
|
+
instance =
|
493
|
+
model_class.new(
|
494
|
+
config: {
|
495
|
+
settings: {
|
496
|
+
"timeout" => 30,
|
497
|
+
"retries" => "3" # should be integer
|
498
|
+
}
|
499
|
+
}
|
500
|
+
)
|
501
|
+
expect(instance.valid?).to be false
|
502
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
503
|
+
"Config[settings][retries] must be an Integer"
|
504
|
+
)
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
describe "dictionary of hashes validation" do
|
509
|
+
let(:fields) do
|
510
|
+
{
|
511
|
+
users: {
|
512
|
+
type: :dictionary,
|
513
|
+
description: nil,
|
514
|
+
value_type: {
|
515
|
+
type: :hash,
|
516
|
+
description: nil,
|
517
|
+
fields: {
|
518
|
+
name: {
|
519
|
+
type: :string,
|
520
|
+
description: nil
|
521
|
+
},
|
522
|
+
age: {
|
523
|
+
type: :integer,
|
524
|
+
description: nil
|
525
|
+
}
|
526
|
+
}
|
527
|
+
}
|
528
|
+
}
|
529
|
+
}
|
530
|
+
end
|
531
|
+
let(:model_class) { described_class.build(fields: fields, validations: []) }
|
532
|
+
|
533
|
+
it "validates hashes inside dictionaries" do
|
534
|
+
instance =
|
535
|
+
model_class.new(
|
536
|
+
users: {
|
537
|
+
"user1" => {
|
538
|
+
name: "Alice",
|
539
|
+
age: 30
|
540
|
+
},
|
541
|
+
"user2" => {
|
542
|
+
name: 123,
|
543
|
+
age: "invalid"
|
544
|
+
} # name should be string, age should be integer
|
545
|
+
}
|
546
|
+
)
|
547
|
+
expect(instance.valid?).to be false
|
548
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
549
|
+
"Users[user2][name] must be a String",
|
550
|
+
"Users[user2][age] must be an Integer"
|
551
|
+
)
|
552
|
+
end
|
553
|
+
end
|
554
|
+
|
555
|
+
describe "dictionary of arrays validation" do
|
556
|
+
let(:fields) do
|
557
|
+
{
|
558
|
+
categories: {
|
559
|
+
type: :dictionary,
|
560
|
+
description: nil,
|
561
|
+
value_type: {
|
562
|
+
type: :array,
|
563
|
+
description: nil,
|
564
|
+
element_type: {
|
565
|
+
type: :string,
|
566
|
+
description: nil
|
567
|
+
}
|
568
|
+
}
|
569
|
+
}
|
570
|
+
}
|
571
|
+
end
|
572
|
+
let(:model_class) { described_class.build(fields: fields, validations: []) }
|
573
|
+
|
574
|
+
it "validates arrays inside dictionaries" do
|
575
|
+
instance =
|
576
|
+
model_class.new(
|
577
|
+
categories: {
|
578
|
+
"fruits" => %w[apple banana],
|
579
|
+
"numbers" => ["one", 2, "three"] # should all be strings
|
580
|
+
}
|
581
|
+
)
|
582
|
+
expect(instance.valid?).to be false
|
583
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
584
|
+
"Categories[numbers][1] must be a String"
|
585
|
+
)
|
586
|
+
end
|
587
|
+
end
|
588
|
+
|
589
|
+
describe "array of dictionaries validation" do
|
590
|
+
let(:fields) do
|
591
|
+
{
|
592
|
+
configs: {
|
593
|
+
type: :array,
|
594
|
+
description: nil,
|
595
|
+
element_type: {
|
596
|
+
type: :dictionary,
|
597
|
+
description: nil,
|
598
|
+
value_type: {
|
599
|
+
type: :string,
|
600
|
+
description: nil
|
601
|
+
}
|
602
|
+
}
|
603
|
+
}
|
604
|
+
}
|
605
|
+
end
|
606
|
+
let(:model_class) { described_class.build(fields: fields, validations: []) }
|
607
|
+
|
608
|
+
it "validates dictionaries inside arrays" do
|
609
|
+
instance =
|
610
|
+
model_class.new(
|
611
|
+
configs: [
|
612
|
+
{ "key1" => "value1", "key2" => "value2" },
|
613
|
+
{ "key3" => "value3", "key4" => 123 } # should be string
|
614
|
+
]
|
615
|
+
)
|
616
|
+
expect(instance.valid?).to be false
|
617
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
618
|
+
"Configs[1][key4] must be a String"
|
619
|
+
)
|
620
|
+
end
|
621
|
+
end
|
622
|
+
|
623
|
+
describe "hash with dictionary validation" do
|
624
|
+
let(:fields) do
|
625
|
+
{
|
626
|
+
config: {
|
627
|
+
type: :hash,
|
628
|
+
description: "Configuration",
|
629
|
+
fields: {
|
630
|
+
name: {
|
631
|
+
type: :string,
|
632
|
+
description: nil
|
633
|
+
},
|
634
|
+
settings: {
|
635
|
+
type: :dictionary,
|
636
|
+
description: nil,
|
637
|
+
value_type: {
|
638
|
+
type: :boolean,
|
639
|
+
description: nil
|
640
|
+
}
|
641
|
+
}
|
642
|
+
}
|
643
|
+
}
|
644
|
+
}
|
645
|
+
end
|
646
|
+
let(:model_class) { described_class.build(fields: fields, validations: []) }
|
647
|
+
|
648
|
+
it "validates dictionaries inside hashes" do
|
649
|
+
instance =
|
650
|
+
model_class.new(
|
651
|
+
config: {
|
652
|
+
name: "test",
|
653
|
+
settings: {
|
654
|
+
"enabled" => true,
|
655
|
+
"active" => "true" # should be boolean
|
656
|
+
}
|
657
|
+
}
|
658
|
+
)
|
659
|
+
expect(instance.valid?).to be false
|
660
|
+
expect(instance.errors.full_messages).to contain_exactly(
|
661
|
+
"Config[settings][active] must be true or false"
|
662
|
+
)
|
663
|
+
end
|
664
|
+
end
|
431
665
|
end
|
@@ -294,6 +294,25 @@ RSpec.describe Lluminary::Schema do
|
|
294
294
|
}
|
295
295
|
)
|
296
296
|
end
|
297
|
+
|
298
|
+
it "supports dictionaries inside arrays" do
|
299
|
+
schema.array(:configs) { dictionary { string } }
|
300
|
+
|
301
|
+
expect(schema.fields[:configs]).to eq(
|
302
|
+
{
|
303
|
+
type: :array,
|
304
|
+
description: nil,
|
305
|
+
element_type: {
|
306
|
+
type: :dictionary,
|
307
|
+
description: nil,
|
308
|
+
value_type: {
|
309
|
+
type: :string,
|
310
|
+
description: nil
|
311
|
+
}
|
312
|
+
}
|
313
|
+
}
|
314
|
+
)
|
315
|
+
end
|
297
316
|
end
|
298
317
|
|
299
318
|
describe "#hash" do
|
@@ -414,6 +433,210 @@ RSpec.describe Lluminary::Schema do
|
|
414
433
|
}
|
415
434
|
)
|
416
435
|
end
|
436
|
+
|
437
|
+
it "supports dictionaries inside hashes" do
|
438
|
+
schema.hash(:config) do
|
439
|
+
string :name
|
440
|
+
dictionary :settings do
|
441
|
+
string
|
442
|
+
end
|
443
|
+
end
|
444
|
+
|
445
|
+
expect(schema.fields[:config]).to eq(
|
446
|
+
{
|
447
|
+
type: :hash,
|
448
|
+
description: nil,
|
449
|
+
fields: {
|
450
|
+
name: {
|
451
|
+
type: :string,
|
452
|
+
description: nil
|
453
|
+
},
|
454
|
+
settings: {
|
455
|
+
type: :dictionary,
|
456
|
+
description: nil,
|
457
|
+
value_type: {
|
458
|
+
type: :string,
|
459
|
+
description: nil
|
460
|
+
}
|
461
|
+
}
|
462
|
+
}
|
463
|
+
}
|
464
|
+
)
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
describe "#dictionary" do
|
469
|
+
it "adds a dictionary field to the schema" do
|
470
|
+
schema.dictionary(:tags) { string }
|
471
|
+
expect(schema.fields).to eq(
|
472
|
+
{
|
473
|
+
tags: {
|
474
|
+
type: :dictionary,
|
475
|
+
description: nil,
|
476
|
+
value_type: {
|
477
|
+
type: :string,
|
478
|
+
description: nil
|
479
|
+
}
|
480
|
+
}
|
481
|
+
}
|
482
|
+
)
|
483
|
+
end
|
484
|
+
|
485
|
+
it "adds a dictionary field with description" do
|
486
|
+
schema.dictionary(:tags, description: "A map of tags") { string }
|
487
|
+
expect(schema.fields).to eq(
|
488
|
+
{
|
489
|
+
tags: {
|
490
|
+
type: :dictionary,
|
491
|
+
description: "A map of tags",
|
492
|
+
value_type: {
|
493
|
+
type: :string,
|
494
|
+
description: nil
|
495
|
+
}
|
496
|
+
}
|
497
|
+
}
|
498
|
+
)
|
499
|
+
end
|
500
|
+
|
501
|
+
it "requires a name for the dictionary field" do
|
502
|
+
expect { schema.dictionary }.to raise_error(ArgumentError)
|
503
|
+
end
|
504
|
+
|
505
|
+
it "requires a block for dictionary fields" do
|
506
|
+
expect { schema.dictionary(:tags) }.to raise_error(
|
507
|
+
ArgumentError,
|
508
|
+
"Dictionary fields must be defined with a block"
|
509
|
+
)
|
510
|
+
end
|
511
|
+
|
512
|
+
it "accepts string type without a name" do
|
513
|
+
schema.dictionary(:tags) { string }
|
514
|
+
expect(schema.fields).to eq(
|
515
|
+
{
|
516
|
+
tags: {
|
517
|
+
type: :dictionary,
|
518
|
+
description: nil,
|
519
|
+
value_type: {
|
520
|
+
type: :string,
|
521
|
+
description: nil
|
522
|
+
}
|
523
|
+
}
|
524
|
+
}
|
525
|
+
)
|
526
|
+
end
|
527
|
+
|
528
|
+
it "accepts integer type without a name" do
|
529
|
+
schema.dictionary(:scores) { integer }
|
530
|
+
expect(schema.fields).to eq(
|
531
|
+
{
|
532
|
+
scores: {
|
533
|
+
type: :dictionary,
|
534
|
+
description: nil,
|
535
|
+
value_type: {
|
536
|
+
type: :integer,
|
537
|
+
description: nil
|
538
|
+
}
|
539
|
+
}
|
540
|
+
}
|
541
|
+
)
|
542
|
+
end
|
543
|
+
|
544
|
+
it "accepts boolean type without a name" do
|
545
|
+
schema.dictionary(:flags) { boolean }
|
546
|
+
expect(schema.fields).to eq(
|
547
|
+
{
|
548
|
+
flags: {
|
549
|
+
type: :dictionary,
|
550
|
+
description: nil,
|
551
|
+
value_type: {
|
552
|
+
type: :boolean,
|
553
|
+
description: nil
|
554
|
+
}
|
555
|
+
}
|
556
|
+
}
|
557
|
+
)
|
558
|
+
end
|
559
|
+
|
560
|
+
it "accepts float type without a name" do
|
561
|
+
schema.dictionary(:ratings) { float }
|
562
|
+
expect(schema.fields).to eq(
|
563
|
+
{
|
564
|
+
ratings: {
|
565
|
+
type: :dictionary,
|
566
|
+
description: nil,
|
567
|
+
value_type: {
|
568
|
+
type: :float,
|
569
|
+
description: nil
|
570
|
+
}
|
571
|
+
}
|
572
|
+
}
|
573
|
+
)
|
574
|
+
end
|
575
|
+
|
576
|
+
it "accepts datetime type without a name" do
|
577
|
+
schema.dictionary(:timestamps) { datetime }
|
578
|
+
expect(schema.fields).to eq(
|
579
|
+
{
|
580
|
+
timestamps: {
|
581
|
+
type: :dictionary,
|
582
|
+
description: nil,
|
583
|
+
value_type: {
|
584
|
+
type: :datetime,
|
585
|
+
description: nil
|
586
|
+
}
|
587
|
+
}
|
588
|
+
}
|
589
|
+
)
|
590
|
+
end
|
591
|
+
|
592
|
+
it "supports hashes as dictionary values" do
|
593
|
+
schema.dictionary(:users) do
|
594
|
+
hash do
|
595
|
+
string :name
|
596
|
+
integer :age
|
597
|
+
end
|
598
|
+
end
|
599
|
+
|
600
|
+
expect(schema.fields[:users]).to eq(
|
601
|
+
{
|
602
|
+
type: :dictionary,
|
603
|
+
description: nil,
|
604
|
+
value_type: {
|
605
|
+
type: :hash,
|
606
|
+
description: nil,
|
607
|
+
fields: {
|
608
|
+
name: {
|
609
|
+
type: :string,
|
610
|
+
description: nil
|
611
|
+
},
|
612
|
+
age: {
|
613
|
+
type: :integer,
|
614
|
+
description: nil
|
615
|
+
}
|
616
|
+
}
|
617
|
+
}
|
618
|
+
}
|
619
|
+
)
|
620
|
+
end
|
621
|
+
|
622
|
+
it "supports arrays as dictionary values" do
|
623
|
+
schema.dictionary(:categories) { array { string } }
|
624
|
+
|
625
|
+
expect(schema.fields[:categories]).to eq(
|
626
|
+
{
|
627
|
+
type: :dictionary,
|
628
|
+
description: nil,
|
629
|
+
value_type: {
|
630
|
+
type: :array,
|
631
|
+
description: nil,
|
632
|
+
element_type: {
|
633
|
+
type: :string,
|
634
|
+
description: nil
|
635
|
+
}
|
636
|
+
}
|
637
|
+
}
|
638
|
+
)
|
639
|
+
end
|
417
640
|
end
|
418
641
|
|
419
642
|
describe "primitive types" do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lluminary
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Doug Hughes
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|
@@ -241,6 +241,7 @@ files:
|
|
241
241
|
- spec/examples/quote_task_spec.rb
|
242
242
|
- spec/examples/sentiment_analysis_spec.rb
|
243
243
|
- spec/examples/summarize_text_spec.rb
|
244
|
+
- spec/examples/text_emotion_analyzer_spec.rb
|
244
245
|
- spec/lluminary/config_spec.rb
|
245
246
|
- spec/lluminary/models/base_spec.rb
|
246
247
|
- spec/lluminary/models/bedrock/amazon_nova_pro_v1_spec.rb
|