ruby_llm-contract 0.2.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc8b1278c5464978cfc50d87ac90cbde94c2a5920b00996365a2e366bb27f1e6
4
- data.tar.gz: daf51e9b66472464137d371439f503317b84706f167fa74c2118635ae82823b1
3
+ metadata.gz: c8111e9c77abab44b943129932bf6f225f619c930568bfe848c96b916cb72224
4
+ data.tar.gz: 4d54273d0b0dfd2fc4a44a34ff681aaa767956bbe89230659e975a91bf2a3821
5
5
  SHA512:
6
- metadata.gz: 7899b4c2df5e7824a5104c24698b728b017e996cced82022c26d81167e8876085fcec93396d13ce67aed7034cfbd0cfbde2e0ebd76376a8dd198d6a561d273d7
7
- data.tar.gz: 7ca10ee16ea71eda609439546b9f02b20e184e9c6a8861d88a172c0e75ca611cf3c2bddf8827b820b31aa9cc5baf88bdaf9b708732b78f3e6321438030186ef8
6
+ metadata.gz: d5226bdd266a5676bbf4bef0b71580387825f15eb77e9739426e6845b62d6410d22583d2f3f3ad47d2eacca8980fb0763a8a23bf1c1f04c723cae5d4f021ed68
7
+ data.tar.gz: 2ee39172b68a94fd8dae1b633d17cc1e0bfac4f82413a0d4233d50f1851b860527248887ca35ebf88ef6b1fc69de78f6fc2775712c75e272891f36d44ba30f57
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 (2026-03-23)
4
+
5
+ Fixes from first real-world integration (persona_tool).
6
+
7
+ - **`around_call` fires per-run** — not per-attempt. With retry_policy, callback fires once with final result. Signature: `around_call { |step, input, result| ... }`
8
+ - **`Result#trace` always `Trace` object** — never bare Hash. `result.trace.model` works on success AND failure.
9
+ - **`around_call` exception safe** — warns and returns result instead of crashing.
10
+ - **`model` DSL** — `model "gpt-4o-mini"` per-step. Priority: context > step DSL > global config.
11
+ - **Test adapter `raw_output` always String** — Hash/Array normalized to `.to_json`.
12
+ - **`Trace#dig`** — `trace.dig(:usage, :input_tokens)` works.
13
+
14
+ ## 0.2.1 (2026-03-23)
15
+
16
+ Production DX improvements from first real-world integration (persona_tool).
17
+
18
+ ### Features
19
+
20
+ - **`temperature` DSL** — `temperature 0.3` in step definition, overridable via `context: { temperature: 0.7 }`. RubyLLM handles per-model normalization natively.
21
+ - **`around_call` hook** — callback for logging, metrics, observability. Replaces need for custom middleware.
22
+ - **`build_messages` public** — inspect rendered prompt without running the step.
23
+ - **`stub_step` RSpec helper** — `stub_step(MyStep, response: { ... })` reduces test boilerplate. Auto-included via `require "ruby_llm/contract/rspec"`.
24
+ - **`estimate_cost` / `estimate_eval_cost`** — predict spend before API calls.
25
+
26
+ ### Fixes
27
+
28
+ - **Reload lifecycle** — `load_evals!` clears definitions before re-loading. Railtie hooks `config.to_prepare` for development reload. `define_eval` warns on duplicate name (suppressed during reload).
29
+ - **Pipeline eval cost** — uses `Pipeline::Trace#total_cost` (all steps), not just last step.
30
+ - **Adapter isolation** — `compare_models` and `run_all_own_evals` deep-dup context per run.
31
+ - **Offline mode** — cases without adapter return `:skipped` instead of crashing. Skipped cases excluded from score.
32
+ - **`expected_traits`** reachable from `define_eval` DSL via `add_case`.
33
+ - **`verify`** raises when both positional and `expect:` keyword provided.
34
+ - **`best_for`** excludes zero-score models from recommendation.
35
+ - **`print_summary`** replaces `pretty_print` (avoids `Kernel#pretty_print` shadow).
36
+ - **`CaseResult#to_h`** round-trips correctly (`name:` key).
37
+
38
+ ### Docs
39
+
40
+ - All 5 guides updated for v0.2 API
41
+ - Symbol keys documented
42
+ - Retry model priority documented
43
+ - Test adapter format documented
44
+
45
+ ### Stats
46
+
47
+ - 1077 tests, 0 failures
48
+ - 3 architecture review rounds, 32 findings fixed
49
+
3
50
  ## 0.2.0 (2026-03-23)
4
51
 
5
52
  Contracts for LLM quality. Know which model to use, what it costs, and when accuracy drops.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ruby_llm-contract (0.2.0)
4
+ ruby_llm-contract (0.2.2)
5
5
  dry-types (~> 1.7)
6
6
  ruby_llm (~> 1.0)
7
7
  ruby_llm-schema (~> 0.3)
@@ -165,7 +165,7 @@ CHECKSUMS
165
165
  rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
166
166
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
167
167
  ruby_llm (1.14.0) sha256=57c6f7034fc4a44504ea137d70f853b07824f1c1cdbe774ab3ab3522e7098deb
168
- ruby_llm-contract (0.2.0)
168
+ ruby_llm-contract (0.2.2)
169
169
  ruby_llm-schema (0.3.0) sha256=a591edc5ca1b7f0304f0e2261de61ba4b3bea17be09f5cf7558153adfda3dec6
170
170
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
171
171
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
@@ -20,7 +20,7 @@ module RubyLLM
20
20
 
21
21
  def normalize_response(response)
22
22
  case response
23
- when Hash, Array then response
23
+ when Hash, Array then response.to_json
24
24
  when nil then ""
25
25
  else response.to_s
26
26
  end
@@ -25,6 +25,13 @@ module RubyLLM
25
25
  public_send(key)
26
26
  end
27
27
 
28
+ def dig(key, *rest)
29
+ value = self[key]
30
+ return value if rest.empty? || value.nil?
31
+
32
+ value.dig(*rest)
33
+ end
34
+
28
35
  def to_h
29
36
  { trace_id: @trace_id, total_latency_ms: @total_latency_ms,
30
37
  total_usage: @total_usage, step_traces: @step_traces,
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Contract
5
+ module RSpec
6
+ module Helpers
7
+ # Stub a step to return a canned response without API calls.
8
+ #
9
+ # stub_step(ClassifyTicket, response: { priority: "high" })
10
+ # result = ClassifyTicket.run("test")
11
+ # result.parsed_output # => {priority: "high"}
12
+ #
13
+ # For multiple sequential responses:
14
+ # stub_step(ClassifyTicket, responses: [{ a: 1 }, { a: 2 }])
15
+ #
16
+ def stub_step(step_class, response: nil, responses: nil)
17
+ adapter = if responses
18
+ Adapters::Test.new(responses: responses.map { |r| r.is_a?(String) ? r : r.to_json })
19
+ else
20
+ content = response.is_a?(String) ? response : response.to_json
21
+ Adapters::Test.new(response: content)
22
+ end
23
+ RubyLLM::Contract.configure { |c| c.default_adapter = adapter }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,3 +4,8 @@ require "ruby_llm/contract"
4
4
 
5
5
  require_relative "rspec/satisfy_contract"
6
6
  require_relative "rspec/pass_eval"
7
+ require_relative "rspec/helpers"
8
+
9
+ RSpec.configure do |config|
10
+ config.include RubyLLM::Contract::RSpec::Helpers
11
+ end if defined?(::RSpec)
@@ -63,14 +63,24 @@ module RubyLLM
63
63
  def run(input, context: {})
64
64
  warn_unknown_context_keys(context)
65
65
  adapter = resolve_adapter(context)
66
- default_model = context[:model] || RubyLLM::Contract.configuration.default_model
66
+ default_model = context[:model] || model || RubyLLM::Contract.configuration.default_model
67
67
  policy = retry_policy
68
68
 
69
- if policy
70
- run_with_retry(input, adapter: adapter, default_model: default_model, policy: policy)
71
- else
72
- run_once(input, adapter: adapter, model: default_model)
73
- end
69
+ result = if policy
70
+ run_with_retry(input, adapter: adapter, default_model: default_model, policy: policy)
71
+ else
72
+ run_once(input, adapter: adapter, model: default_model, context_temperature: context[:temperature])
73
+ end
74
+
75
+ invoke_around_call(input, result)
76
+ end
77
+
78
+ def build_messages(input)
79
+ dynamic = prompt.arity >= 1
80
+ ast = Prompt::Builder.build(input: dynamic ? input : nil, &prompt)
81
+ variables = dynamic ? {} : { input: input }
82
+ variables.merge!(input.transform_keys(&:to_sym)) if !dynamic && input.is_a?(Hash)
83
+ Prompt::Renderer.render(ast, variables: variables)
74
84
  end
75
85
 
76
86
  private
@@ -91,18 +101,30 @@ module RubyLLM
91
101
  "{ |c| c.default_adapter = ... } or pass context: { adapter: ... }"
92
102
  end
93
103
 
94
- def run_once(input, adapter:, model:)
104
+ def run_once(input, adapter:, model:, context_temperature: nil)
105
+ effective_temp = context_temperature || temperature
95
106
  Runner.new(
96
107
  input_type: input_type, output_type: output_type,
97
108
  prompt_block: prompt, contract_definition: effective_contract,
98
109
  adapter: adapter, model: model, output_schema: output_schema,
99
- max_output: max_output, max_input: max_input, max_cost: max_cost
110
+ max_output: max_output, max_input: max_input, max_cost: max_cost,
111
+ temperature: effective_temp
100
112
  ).call(input)
101
113
  rescue ArgumentError => e
102
114
  Result.new(status: :input_error, raw_output: nil, parsed_output: nil,
103
115
  validation_errors: [e.message])
104
116
  end
105
117
 
118
+ def invoke_around_call(input, result)
119
+ return result unless around_call
120
+
121
+ around_call.call(self, input, result)
122
+ result
123
+ rescue StandardError => e
124
+ warn "[ruby_llm-contract] around_call raised #{e.class}: #{e.message}"
125
+ result
126
+ end
127
+
106
128
  def effective_contract
107
129
  base = contract
108
130
  extra = class_validates
@@ -118,14 +140,6 @@ module RubyLLM
118
140
  )
119
141
  end
120
142
 
121
- def build_messages(input)
122
- dynamic = prompt.arity >= 1
123
- ast = Prompt::Builder.build(input: dynamic ? input : nil, &prompt)
124
- variables = dynamic ? {} : { input: input }
125
- variables.merge!(input.transform_keys(&:to_sym)) if !dynamic && input.is_a?(Hash)
126
- Prompt::Renderer.render(ast, variables: variables)
127
- end
128
-
129
143
  def json_compatible_type?(type)
130
144
  type == RubyLLM::Contract::Types::Hash || type == Hash ||
131
145
  type == RubyLLM::Contract::Types::Array || type == Array ||
@@ -127,6 +127,46 @@ module RubyLLM
127
127
  end
128
128
  end
129
129
 
130
+ def model(name = nil)
131
+ if name
132
+ return @model = name
133
+ end
134
+
135
+ if defined?(@model)
136
+ @model
137
+ elsif superclass.respond_to?(:model)
138
+ superclass.model
139
+ end
140
+ end
141
+
142
+ def temperature(value = nil)
143
+ if value
144
+ unless value.is_a?(Numeric) && value >= 0 && value <= 2
145
+ raise ArgumentError, "temperature must be 0.0-2.0, got #{value}"
146
+ end
147
+
148
+ return @temperature = value
149
+ end
150
+
151
+ if defined?(@temperature)
152
+ @temperature
153
+ elsif superclass.respond_to?(:temperature)
154
+ superclass.temperature
155
+ end
156
+ end
157
+
158
+ def around_call(&block)
159
+ if block
160
+ return @around_call = block
161
+ end
162
+
163
+ if defined?(@around_call) && @around_call
164
+ @around_call
165
+ elsif superclass.respond_to?(:around_call)
166
+ superclass.around_call
167
+ end
168
+ end
169
+
130
170
  def retry_policy(models: nil, attempts: nil, retry_on: nil, &block)
131
171
  if block || models || attempts
132
172
  return @retry_policy = RetryPolicy.new(models: models, attempts: attempts, retry_on: retry_on, &block)
@@ -6,12 +6,12 @@ module RubyLLM
6
6
  class Result
7
7
  attr_reader :status, :raw_output, :parsed_output, :validation_errors, :trace
8
8
 
9
- def initialize(status:, raw_output:, parsed_output:, validation_errors: [], trace: {})
9
+ def initialize(status:, raw_output:, parsed_output:, validation_errors: [], trace: nil)
10
10
  @status = status
11
11
  @raw_output = raw_output
12
12
  @parsed_output = parsed_output
13
13
  @validation_errors = validation_errors.freeze
14
- @trace = trace.freeze
14
+ @trace = normalize_trace(trace)
15
15
  freeze
16
16
  end
17
17
 
@@ -23,6 +23,19 @@ module RubyLLM
23
23
  @status != :ok
24
24
  end
25
25
 
26
+ private
27
+
28
+ def normalize_trace(trace)
29
+ case trace
30
+ when Trace then trace
31
+ when Hash then Trace.new(**trace)
32
+ when nil then Trace.new
33
+ else trace
34
+ end.freeze
35
+ end
36
+
37
+ public
38
+
26
39
  def to_s
27
40
  if ok?
28
41
  "#{@status} (#{@trace})"
@@ -8,7 +8,7 @@ module RubyLLM
8
8
 
9
9
  def initialize(input_type:, output_type:, prompt_block:, contract_definition:,
10
10
  adapter:, model:, output_schema: nil, max_output: nil,
11
- max_input: nil, max_cost: nil)
11
+ max_input: nil, max_cost: nil, temperature: nil)
12
12
  @input_type = input_type
13
13
  @output_type = output_type
14
14
  @prompt_block = prompt_block
@@ -19,6 +19,7 @@ module RubyLLM
19
19
  @max_output = max_output
20
20
  @max_input = max_input
21
21
  @max_cost = max_cost
22
+ @temperature = temperature
22
23
  end
23
24
 
24
25
  def call(input)
@@ -84,6 +85,7 @@ module RubyLLM
84
85
  { model: @model }.tap do |opts|
85
86
  opts[:schema] = @output_schema if @output_schema
86
87
  opts[:max_tokens] = @max_output if @max_output
88
+ opts[:temperature] = @temperature if @temperature
87
89
  end
88
90
  end
89
91
 
@@ -26,6 +26,13 @@ module RubyLLM
26
26
  public_send(key)
27
27
  end
28
28
 
29
+ def dig(key, *rest)
30
+ value = self[key]
31
+ return value if rest.empty? || value.nil?
32
+
33
+ value.dig(*rest)
34
+ end
35
+
29
36
  def key?(key)
30
37
  KNOWN_KEYS.include?(key.to_sym) && !public_send(key).nil?
31
38
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-contract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna
@@ -129,6 +129,7 @@ files:
129
129
  - lib/ruby_llm/contract/railtie.rb
130
130
  - lib/ruby_llm/contract/rake_task.rb
131
131
  - lib/ruby_llm/contract/rspec.rb
132
+ - lib/ruby_llm/contract/rspec/helpers.rb
132
133
  - lib/ruby_llm/contract/rspec/pass_eval.rb
133
134
  - lib/ruby_llm/contract/rspec/satisfy_contract.rb
134
135
  - lib/ruby_llm/contract/step.rb