ruby_llm-contract 1.2.0 → 1.3.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 +4 -4
- data/CHANGELOG.md +23 -1
- data/lib/ruby_llm/contract/contract/schema_validator/node.rb +16 -0
- data/lib/ruby_llm/contract/contract/schema_validator/type_rule.rb +4 -4
- data/lib/ruby_llm/contract/contract/schema_validator.rb +44 -1
- data/lib/ruby_llm/contract/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2532bf3e16d6e810c3caf8ac7d3d94e9ed43f87cdb9945c26ca33977d619ba39
|
|
4
|
+
data.tar.gz: f281f6b10532ef145821e60f9de352cc7bb2f07946ff146497ec537b28d430c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c53ac500520e51a63134c7060a214944abb7839753ac22007d2034d3ab338eb0f20d6b8fd06c86904a3ecd5e026f16f83e9d83b58cd051a6ff35ce2b208e7334
|
|
7
|
+
data.tar.gz: 9ae24e6b2719f40a2045deb8f419fc3d3adf92bd98c51d9360d08db440d189997f2f0866a8ca6ae5eecc611226d5869c8c57c1321e32390a188063c7aec05c12
|
data/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
### Changed - may turn a green CI red
|
|
6
|
+
|
|
7
|
+
- **A non-null value under `anyOf` / `oneOf` is now validated against the branches.**
|
|
8
|
+
0.10.3 taught the validator to accept `nil` through a null branch, but a non-null
|
|
9
|
+
value was never checked at all: the child node carried the composition wrapper,
|
|
10
|
+
which has no `type` or `properties`, so nothing ran. A nullable object block
|
|
11
|
+
(`any_of :block do object do ... end; null end`) missing a required field, with
|
|
12
|
+
a wrong-typed field or with an extra key came back `:ok`. Measured in an adopter:
|
|
13
|
+
a page-analysis block without `score` was `:ok` and became a 0/10 fail downstream.
|
|
14
|
+
`anyOf` now passes when at least one branch validates and `oneOf` when exactly one
|
|
15
|
+
does. When none does, the errors of the one branch whose type fits the value are
|
|
16
|
+
reported (`missing required field: block.score`); otherwise
|
|
17
|
+
`block: expected one of object, null, got String`. A `oneOf` value matching two
|
|
18
|
+
branches fails with `matches 2 oneOf branches, expected exactly 1`. Steps whose
|
|
19
|
+
model already returns schema-conforming output (OpenAI strict mode) are
|
|
20
|
+
unaffected; output that slipped through before now fails as `:validation_failed`.
|
|
21
|
+
- **`type` given as an array is checked.** `type: ["string", "null"]` accepted any
|
|
22
|
+
value; it now accepts a value of any listed type and reports
|
|
23
|
+
`x: expected string or null, got Integer` otherwise. `"null"` is a known type.
|
|
24
|
+
|
|
3
25
|
## 1.2.0 (2026-10-10)
|
|
4
26
|
|
|
5
|
-
Everything new is opt-in
|
|
27
|
+
Everything new is opt-in. Two changes under "Changed" can affect existing steps: the temperature fix and `retry_on` rejecting conditions it cannot match.
|
|
6
28
|
|
|
7
29
|
### Added
|
|
8
30
|
|
|
@@ -29,6 +29,22 @@ module RubyLLM
|
|
|
29
29
|
Array(schema[:required]).map(&:to_s)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def composition
|
|
33
|
+
SchemaValidator::COMPOSITION_KEYWORDS.each do |keyword|
|
|
34
|
+
branches = schema[keyword]
|
|
35
|
+
return [keyword, branches] if branches.is_a?(Array) && !branches.empty?
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def with_schema(branch_schema)
|
|
41
|
+
with(schema: branch_schema)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def label
|
|
45
|
+
path || "root"
|
|
46
|
+
end
|
|
47
|
+
|
|
32
48
|
def items_schema
|
|
33
49
|
schema[:items]
|
|
34
50
|
end
|
|
@@ -9,12 +9,12 @@ module RubyLLM
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def validate(node)
|
|
12
|
-
|
|
12
|
+
expected_types = Array(node.schema[:type]).map(&:to_s)
|
|
13
13
|
value = node.value
|
|
14
|
-
return
|
|
15
|
-
return if type_valid?(
|
|
14
|
+
return if expected_types.empty?
|
|
15
|
+
return if expected_types.any? { |type| type_valid?(type, value) }
|
|
16
16
|
|
|
17
|
-
@errors << "#{node.path}: expected #{
|
|
17
|
+
@errors << "#{node.path}: expected #{expected_types.join(" or ")}, got #{value.class}"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
private
|
|
@@ -25,8 +25,10 @@ module RubyLLM
|
|
|
25
25
|
"number" => ->(value) { value.is_a?(Numeric) },
|
|
26
26
|
"boolean" => ->(value) { value.is_a?(TrueClass) || value.is_a?(FalseClass) },
|
|
27
27
|
"array" => ->(value) { value.is_a?(Array) },
|
|
28
|
-
"object" => ->(value) { value.is_a?(Hash) }
|
|
28
|
+
"object" => ->(value) { value.is_a?(Hash) },
|
|
29
|
+
"null" => lambda(&:nil?)
|
|
29
30
|
}.freeze
|
|
31
|
+
COMPOSITION_KEYWORDS = %i[anyOf oneOf].freeze
|
|
30
32
|
|
|
31
33
|
def self.validate(parsed_output, schema)
|
|
32
34
|
new(parsed_output, schema).validate
|
|
@@ -59,11 +61,52 @@ module RubyLLM
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
def validate_node(node)
|
|
64
|
+
return validate_composition(node) if node.composition
|
|
65
|
+
|
|
62
66
|
@scalar_rules.validate(node)
|
|
63
67
|
@object_rules.validate(node) { |child| validate_node(child) } if node.hash? && node.object_schema?
|
|
64
68
|
validate_array(node) if node.array?
|
|
65
69
|
end
|
|
66
70
|
|
|
71
|
+
# anyOf passes when at least one branch validates, oneOf when exactly one
|
|
72
|
+
# does. When none does, the errors of the one branch whose type fits the
|
|
73
|
+
# value are the useful ones; otherwise the value matches no branch type.
|
|
74
|
+
def validate_composition(node)
|
|
75
|
+
keyword, branches = node.composition
|
|
76
|
+
branch_errors = branches.map { |branch| errors_for(node.with_schema(branch)) }
|
|
77
|
+
passing = branch_errors.count(&:empty?)
|
|
78
|
+
return if keyword == :anyOf ? passing.positive? : passing == 1
|
|
79
|
+
|
|
80
|
+
if passing > 1
|
|
81
|
+
@errors << "#{node.label}: matches #{passing} oneOf branches, expected exactly 1"
|
|
82
|
+
else
|
|
83
|
+
report_no_matching_branch(node, branches, branch_errors)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def report_no_matching_branch(node, branches, branch_errors)
|
|
88
|
+
fitting = branches.each_index.select { |i| branch_type_fits?(branches[i], node.value) }
|
|
89
|
+
return @errors.concat(branch_errors[fitting.first]) if fitting.size == 1
|
|
90
|
+
|
|
91
|
+
types = branches.flat_map { |branch| branch_types(branch) }.uniq
|
|
92
|
+
@errors << "#{node.label}: expected one of #{types.join(", ")}, got #{node.value.class}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def errors_for(node)
|
|
96
|
+
before = @errors.size
|
|
97
|
+
validate_node(node)
|
|
98
|
+
@errors.slice!(before..)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def branch_type_fits?(branch, value)
|
|
102
|
+
branch_types(branch).any? { |type| TYPE_CHECKS.fetch(type, ->(_) { true }).call(value) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def branch_types(branch)
|
|
106
|
+
types = Array(branch[:type]).map(&:to_s)
|
|
107
|
+
types.empty? && branch.key?(:properties) ? ["object"] : types
|
|
108
|
+
end
|
|
109
|
+
|
|
67
110
|
def validate_array(node)
|
|
68
111
|
items_schema = node.items_schema
|
|
69
112
|
return unless items_schema.is_a?(Hash)
|