ruby_llm-contract 0.7.1 → 0.8.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.
@@ -1,258 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # =============================================================================
4
- # EXAMPLE 5: Declarative output schema (ruby_llm-schema)
5
- #
6
- # Replace manual invariants with a schema DSL.
7
- # The schema is sent to the LLM provider for structured output enforcement.
8
- #
9
- # With Test adapter: schema defines expectations, parsing is auto-inferred.
10
- # With RubyLLM adapter: schema is also enforced server-side by the provider.
11
- # =============================================================================
12
-
13
- require_relative "../lib/ruby_llm/contract"
14
-
15
- # =============================================================================
16
- # STEP 1: BEFORE — legacy approach with output_type + manual invariants
17
- #
18
- # Every enum, range, and required field is a separate invariant.
19
- # Works, but verbose. This is what you'd write WITHOUT output_schema.
20
- # =============================================================================
21
-
22
- RubyLLM::Contract.configure do |c|
23
- c.default_adapter = RubyLLM::Contract::Adapters::Test.new(
24
- response: '{"intent": "sales", "confidence": 0.95}'
25
- )
26
- end
27
-
28
- class ClassifyIntentBefore < RubyLLM::Contract::Step::Base
29
- input_type String
30
- output_type Hash
31
-
32
- prompt do
33
- system "Classify the user's intent."
34
- rule "Return JSON only."
35
- user "{input}"
36
- end
37
-
38
- validate("must include intent") { |o| o[:intent].to_s != "" }
39
- validate("intent must be allowed") { |o| %w[sales support billing].include?(o[:intent]) }
40
- validate("confidence must be a number") { |o| o[:confidence].is_a?(Numeric) }
41
- validate("confidence in range") { |o| o[:confidence]&.between?(0.0, 1.0) }
42
- end
43
-
44
- result = ClassifyIntentBefore.run("I want to buy")
45
- result.status # => :ok
46
- result.parsed_output # => {intent: "sales", confidence: 0.95}
47
-
48
- # =============================================================================
49
- # STEP 2: AFTER — output_schema replaces structural invariants
50
- #
51
- # Same constraints, but declared as a schema.
52
- # No `output_type`, no `parse :json`, no structural invariants.
53
- # =============================================================================
54
-
55
- class ClassifyIntentAfter < RubyLLM::Contract::Step::Base
56
- input_type String
57
-
58
- output_schema do
59
- string :intent, enum: %w[sales support billing]
60
- number :confidence, minimum: 0.0, maximum: 1.0
61
- end
62
-
63
- prompt do
64
- system "Classify the user's intent."
65
- rule "Return JSON only."
66
- user "{input}"
67
- end
68
- end
69
-
70
- result = ClassifyIntentAfter.run("I want to buy")
71
- result.status # => :ok
72
- result.parsed_output # => {intent: "sales", confidence: 0.95}
73
-
74
- # =============================================================================
75
- # STEP 3: Schema + invariants — best of both worlds
76
- #
77
- # Schema handles structure (types, enums, ranges).
78
- # Invariants handle business logic (cross-validation, conditionals).
79
- # =============================================================================
80
-
81
- RubyLLM::Contract.configure do |c|
82
- c.default_adapter = RubyLLM::Contract::Adapters::Test.new(
83
- response: '{"category": "account", "priority": "urgent", "summary": "Projects disappeared"}'
84
- )
85
- end
86
-
87
- class AnalyzeTicket < RubyLLM::Contract::Step::Base
88
- input_type RubyLLM::Contract::Types::Hash.schema(
89
- title: RubyLLM::Contract::Types::String,
90
- body: RubyLLM::Contract::Types::String
91
- )
92
-
93
- output_schema do
94
- string :category, enum: %w[billing technical feature_request account other]
95
- string :priority, enum: %w[low medium high urgent]
96
- string :summary, description: "One-sentence summary"
97
- end
98
-
99
- prompt do
100
- system "Analyze support tickets."
101
- rule "Return JSON with category, priority, and summary."
102
- user "Title: {title}\n\nBody: {body}"
103
- end
104
-
105
- # Schema handles: valid category, valid priority, summary present
106
- # Validate handles: cross-validation with input
107
- validate("urgent requires justification") do |output, input|
108
- next true unless output[:priority] == "urgent"
109
-
110
- body = input[:body].downcase
111
- body.include?("data loss") || body.include?("security") || body.include?("deleted")
112
- end
113
- end
114
-
115
- # Justified urgent:
116
- result = AnalyzeTicket.run({
117
- title: "Projects disappeared",
118
- body: "All my projects are gone. This is a data loss emergency."
119
- })
120
- result.status # => :ok
121
-
122
- # Unjustified urgent:
123
- RubyLLM::Contract.configure do |c|
124
- c.default_adapter = RubyLLM::Contract::Adapters::Test.new(
125
- response: '{"category": "technical", "priority": "urgent", "summary": "Page is slow"}'
126
- )
127
- end
128
-
129
- result = AnalyzeTicket.run({
130
- title: "Slow page",
131
- body: "Dashboard takes 5 seconds to load."
132
- })
133
- result.status # => :validation_failed
134
- result.validation_errors # => ["urgent requires justification"]
135
-
136
- # =============================================================================
137
- # STEP 4: Complex schema — nested objects, arrays, optional fields
138
- # =============================================================================
139
-
140
- RubyLLM::Contract.configure do |c|
141
- c.default_adapter = RubyLLM::Contract::Adapters::Test.new(
142
- response: '{"locale": "en", "groups": [{"who": "Freelancers", "pain_points": ["invoicing", "time tracking"]}]}'
143
- )
144
- end
145
-
146
- class ProfileAudience < RubyLLM::Contract::Step::Base
147
- input_type RubyLLM::Contract::Types::Hash.schema(
148
- product: RubyLLM::Contract::Types::String,
149
- market: RubyLLM::Contract::Types::String
150
- )
151
-
152
- output_schema do
153
- string :locale, description: "ISO 639-1 language code"
154
- array :groups, min_items: 1, max_items: 4 do
155
- string :who, description: "Audience segment name"
156
- array :pain_points, of: :string, min_items: 1
157
- end
158
- end
159
-
160
- prompt do
161
- system "Generate target audience profiles."
162
- user "Product: {product}, Market: {market}"
163
- end
164
- end
165
-
166
- result = ProfileAudience.run({ product: "InvoiceApp", market: "US freelancers" })
167
- result.status # => :ok
168
- result.parsed_output # => {locale: "en", groups: [{who: "Freelancers", pain_points: [...]}]}
169
-
170
- # =============================================================================
171
- # STEP 5: Schema is provider-agnostic
172
- #
173
- # With Test adapter: schema auto-infers JSON parsing, no provider enforcement.
174
- # With RubyLLM adapter: schema is ALSO sent to provider (structured output).
175
- # The step definition doesn't change.
176
- # =============================================================================
177
-
178
- # To use with a real LLM and get provider-side enforcement:
179
- #
180
- # RubyLLM.configure { |c| c.openai_api_key = ENV["OPENAI_API_KEY"] }
181
- # adapter = RubyLLM::Contract::Adapters::RubyLLM.new
182
- # result = ClassifyIntentAfter.run("I want to buy",
183
- # context: { adapter: adapter, model: "gpt-4.1-mini" })
184
- #
185
- # The provider enforces the schema — the model MUST return valid JSON
186
- # matching the schema. Parse errors become nearly impossible.
187
-
188
- # =============================================================================
189
- # STEP 6: Pipeline with schemas — each step has its own schema
190
- #
191
- # Pipeline + output_schema = fully typed, provider-enforced multi-step flow.
192
- # Each step declares its output schema. Data threads automatically.
193
- # =============================================================================
194
-
195
- RubyLLM::Contract.configure do |c|
196
- c.default_adapter = RubyLLM::Contract::Adapters::Test.new(
197
- response: '{"category": "billing", "priority": "high", "summary": "Payment page broken"}'
198
- )
199
- end
200
-
201
- class TriageTicket < RubyLLM::Contract::Step::Base
202
- input_type RubyLLM::Contract::Types::Hash.schema(title: RubyLLM::Contract::Types::String, body: RubyLLM::Contract::Types::String)
203
-
204
- output_schema do
205
- string :category, enum: %w[billing technical feature_request account other]
206
- string :priority, enum: %w[low medium high urgent]
207
- string :summary
208
- end
209
-
210
- prompt do
211
- system "Triage support ticket."
212
- user "Title: {title}\nBody: {body}"
213
- end
214
- end
215
-
216
- class SuggestAction < RubyLLM::Contract::Step::Base
217
- input_type Hash
218
-
219
- output_schema do
220
- string :action
221
- string :team, enum: %w[engineering support billing product]
222
- boolean :escalate
223
- end
224
-
225
- prompt do
226
- system "Suggest action for a triaged ticket."
227
- user "Category: {category}, Priority: {priority}, Summary: {summary}"
228
- end
229
- end
230
-
231
- class TicketPipeline < RubyLLM::Contract::Pipeline::Base
232
- step TriageTicket, as: :triage
233
- step SuggestAction, as: :action
234
- end
235
-
236
- # Both steps share the test adapter, so they get the same canned response.
237
- # With a real LLM, step 2 would get a different response based on step 1's output.
238
- result = TicketPipeline.run(
239
- { title: "Payment page broken", body: "Error 500 on checkout" }
240
- )
241
- result.ok? # => true
242
- result.outputs_by_step[:triage] # => {category: "billing", priority: "high", summary: "..."}
243
- result.outputs_by_step[:action] # => same canned response (test adapter)
244
- result.step_results.length # => 2
245
-
246
- # =============================================================================
247
- # SUMMARY
248
- #
249
- # Step 1: BEFORE — output_type + parse :json + structural invariants
250
- # Step 2: AFTER — output_schema replaces all of that
251
- # Step 3: Schema + invariants — schema for structure, invariants for logic
252
- # Step 4: Complex schemas — nested objects, arrays, constraints
253
- # Step 5: Provider-agnostic — same schema works with Test and RubyLLM
254
- # Step 6: Pipeline + schemas — fully typed multi-step composition
255
- #
256
- # output_schema is optional. Existing steps with output_type + invariants
257
- # continue to work unchanged.
258
- # =============================================================================
@@ -1,239 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # =============================================================================
4
- # EXAMPLE 7: Keyword Extraction with probability scoring
5
- #
6
- # One article in, up to 15 keywords out — each with a relevance
7
- # probability. Schema enforces structure (array bounds, number range).
8
- # Invariants enforce logic (sorted, no duplicates, keywords from text).
9
- #
10
- # Shows:
11
- # - Array output_schema with nested objects
12
- # - min_items / max_items constraints
13
- # - number range (probability 0.0–1.0)
14
- # - Invariant: sorted order (schema can't express this)
15
- # - Invariant: uniqueness (schema can't express this)
16
- # - Invariant: cross-validation — keywords must appear in source text
17
- # - retry_policy for model escalation
18
- # =============================================================================
19
-
20
- require_relative "../lib/ruby_llm/contract"
21
-
22
- # =============================================================================
23
- # STEP DEFINITION
24
- # =============================================================================
25
-
26
- class ExtractKeywords < RubyLLM::Contract::Step::Base
27
- input_type String
28
-
29
- output_schema do
30
- array :keywords, min_items: 1, max_items: 15 do
31
- string :keyword, description: "1-3 word keyword or phrase"
32
- number :probability, minimum: 0.0, maximum: 1.0
33
- end
34
- end
35
-
36
- prompt do
37
- system "Extract the most relevant keywords from the article."
38
- rule "Return up to 15 keywords, each with a relevance probability (0.0 to 1.0)."
39
- rule "Sort by probability descending (most relevant first)."
40
- rule "Each keyword must be 1-3 words."
41
- rule "Keywords must actually appear in or directly relate to the text."
42
-
43
- example input: "Ruby on Rails is a web framework written in Ruby.",
44
- output: '{"keywords":[{"keyword":"Ruby on Rails","probability":0.95},{"keyword":"web framework","probability":0.85},{"keyword":"Ruby","probability":0.75}]}'
45
-
46
- user "{input}"
47
- end
48
-
49
- validate("sorted by probability descending") do |o|
50
- probs = o[:keywords].map { |k| k[:probability] }
51
- probs == probs.sort.reverse
52
- end
53
-
54
- validate("no duplicate keywords") do |o|
55
- words = o[:keywords].map { |k| k[:keyword].downcase.strip }
56
- words.uniq.length == words.length
57
- end
58
-
59
- validate("keywords relate to source text") do |output, input|
60
- text = input.downcase
61
- matches = output[:keywords].count { |k| text.include?(k[:keyword].downcase) }
62
- matches >= (output[:keywords].length * 0.7).ceil
63
- end
64
-
65
- retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini]
66
- end
67
-
68
- # =============================================================================
69
- # TEST WITH CANNED RESPONSES
70
- # =============================================================================
71
-
72
- article = <<~ARTICLE
73
- Artificial intelligence is transforming the way developers build software.
74
- Machine learning models, particularly large language models like GPT and Claude,
75
- are being integrated into development workflows for code generation, testing,
76
- and documentation. Ruby developers are adopting gems like ruby_llm to interact
77
- with these models through a clean API. The challenge remains in ensuring output
78
- quality — without contracts and validation, LLM responses can hallucinate or
79
- produce structurally invalid data that breaks downstream systems.
80
- ARTICLE
81
-
82
- puts "=" * 60
83
- puts "KEYWORD EXTRACTION"
84
- puts "=" * 60
85
-
86
- # Happy path — good keywords
87
- good_response = {
88
- keywords: [
89
- { keyword: "artificial intelligence", probability: 0.95 },
90
- { keyword: "machine learning", probability: 0.90 },
91
- { keyword: "large language models", probability: 0.88 },
92
- { keyword: "Ruby developers", probability: 0.82 },
93
- { keyword: "code generation", probability: 0.78 },
94
- { keyword: "output quality", probability: 0.72 },
95
- { keyword: "ruby_llm", probability: 0.70 },
96
- { keyword: "LLM responses", probability: 0.65 },
97
- { keyword: "validation", probability: 0.60 }
98
- ]
99
- }.to_json
100
-
101
- adapter = RubyLLM::Contract::Adapters::Test.new(response: good_response)
102
- result = ExtractKeywords.run(article, context: { adapter: adapter })
103
-
104
- puts "\n--- Happy path ---"
105
- puts "Status: #{result.status}"
106
- result.parsed_output[:keywords].each do |k|
107
- bar = "#" * (k[:probability] * 20).round
108
- puts " #{k[:probability].to_s.ljust(5)} #{bar.ljust(20)} #{k[:keyword]}"
109
- end
110
-
111
- # Bad: unsorted probabilities
112
- puts "\n--- Invariant catches: unsorted ---"
113
- unsorted = {
114
- keywords: [
115
- { keyword: "Ruby", probability: 0.60 },
116
- { keyword: "AI", probability: 0.95 },
117
- { keyword: "testing", probability: 0.80 }
118
- ]
119
- }.to_json
120
-
121
- r2 = ExtractKeywords.run(article, context: { adapter: RubyLLM::Contract::Adapters::Test.new(response: unsorted) })
122
- puts "Status: #{r2.status}"
123
- puts "Errors: #{r2.validation_errors}"
124
-
125
- # Bad: duplicate keywords
126
- puts "\n--- Invariant catches: duplicates ---"
127
- dupes = {
128
- keywords: [
129
- { keyword: "machine learning", probability: 0.95 },
130
- { keyword: "Machine Learning", probability: 0.90 },
131
- { keyword: "AI", probability: 0.80 }
132
- ]
133
- }.to_json
134
-
135
- r3 = ExtractKeywords.run(article, context: { adapter: RubyLLM::Contract::Adapters::Test.new(response: dupes) })
136
- puts "Status: #{r3.status}"
137
- puts "Errors: #{r3.validation_errors}"
138
-
139
- # Bad: hallucinated keywords not in text
140
- puts "\n--- Invariant catches: hallucinated keywords ---"
141
- hallucinated = {
142
- keywords: [
143
- { keyword: "blockchain", probability: 0.95 },
144
- { keyword: "cryptocurrency", probability: 0.90 },
145
- { keyword: "NFT marketplace", probability: 0.85 },
146
- { keyword: "artificial intelligence", probability: 0.80 }
147
- ]
148
- }.to_json
149
-
150
- r4 = ExtractKeywords.run(article, context: { adapter: RubyLLM::Contract::Adapters::Test.new(response: hallucinated) })
151
- puts "Status: #{r4.status}"
152
- puts "Errors: #{r4.validation_errors}"
153
-
154
- # =============================================================================
155
- # PIPELINE: Article → Keywords → Related Topics
156
- # =============================================================================
157
-
158
- puts "\n\n#{"=" * 60}"
159
- puts "PIPELINE: Article → Keywords → Related Topics"
160
- puts "=" * 60
161
-
162
- class SuggestRelatedTopics < RubyLLM::Contract::Step::Base
163
- input_type Hash
164
-
165
- output_schema do
166
- array :topics, min_items: 3, max_items: 5 do
167
- string :title
168
- string :angle, description: "Unique angle or hook for the topic"
169
- end
170
- end
171
-
172
- prompt do
173
- system "Suggest related article topics based on the extracted keywords."
174
- rule "Each topic must have a unique angle, not just repeat the keywords."
175
- rule "Topics should be interesting to the same audience."
176
- user "Keywords: {keywords}"
177
- end
178
-
179
- validate("topics have unique titles") do |o|
180
- titles = o[:topics].map { |t| t[:title].downcase }
181
- titles.uniq.length == titles.length
182
- end
183
-
184
- validate("angles are substantive") do |o|
185
- o[:topics].all? { |t| t[:angle].to_s.split.length >= 5 }
186
- end
187
- end
188
-
189
- class ArticlePipeline < RubyLLM::Contract::Pipeline::Base
190
- step ExtractKeywords, as: :keywords
191
- step SuggestRelatedTopics, as: :topics
192
- end
193
-
194
- topics_response = {
195
- topics: [
196
- { title: "Building LLM-Powered Ruby Gems",
197
- angle: "How to structure a Ruby gem that wraps LLM APIs with type safety" },
198
- { title: "Contract-First AI Development",
199
- angle: "Why treating LLM outputs like API responses improves reliability" },
200
- { title: "Testing AI Features Without API Calls",
201
- angle: "Deterministic testing patterns for LLM integrations using canned adapters" }
202
- ]
203
- }.to_json
204
-
205
- adapter_kw = RubyLLM::Contract::Adapters::Test.new(response: good_response)
206
- adapter_tp = RubyLLM::Contract::Adapters::Test.new(response: topics_response)
207
-
208
- # Run steps individually (different adapters per step)
209
- r_kw = ExtractKeywords.run(article, context: { adapter: adapter_kw })
210
- r_tp = SuggestRelatedTopics.run(r_kw.parsed_output, context: { adapter: adapter_tp })
211
-
212
- puts "\nKeywords → Topics pipeline:"
213
- puts " Keywords: #{r_kw.parsed_output[:keywords].length} extracted"
214
- puts " Topics:"
215
- r_tp.parsed_output[:topics].each do |t|
216
- puts " #{t[:title]}"
217
- puts " → #{t[:angle]}"
218
- end
219
-
220
- # =============================================================================
221
- # SUMMARY
222
- #
223
- # Schema handles:
224
- # - Array with 1-15 items (min_items, max_items)
225
- # - Each item has keyword (string) + probability (number 0.0-1.0)
226
- #
227
- # Invariants handle:
228
- # - Sorted by probability (schema can't express ordering)
229
- # - No duplicates (schema can't express uniqueness)
230
- # - Keywords from source text (schema can't see input)
231
- #
232
- # Pipeline:
233
- # - Extract keywords → suggest related topics
234
- # - Each step has its own schema + invariants
235
- #
236
- # Model escalation:
237
- # - retry_policy { escalate "nano", "mini" }
238
- # - If nano returns unsorted or hallucinated keywords, mini retries
239
- # =============================================================================