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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/Gemfile.lock +3 -3
- data/README.md +64 -316
- data/examples/00_basics.rb +110 -428
- data/examples/01_fallback_showcase.rb +208 -0
- data/examples/02_real_llm_minimal.rb +45 -0
- data/examples/03_summarize_with_keywords.rb +128 -0
- data/examples/04_summarize_and_translate.rb +196 -0
- data/examples/05_eval_dataset.rb +144 -0
- data/examples/06_retry_variants.rb +147 -0
- data/examples/README.md +20 -128
- data/lib/ruby_llm/contract/adapters/ruby_llm.rb +22 -1
- data/lib/ruby_llm/contract/cost_calculator.rb +39 -0
- data/lib/ruby_llm/contract/eval/model_comparison.rb +4 -4
- data/lib/ruby_llm/contract/eval/retry_optimizer.rb +7 -3
- data/lib/ruby_llm/contract/step/base.rb +18 -1
- data/lib/ruby_llm/contract/step/dsl.rb +38 -0
- data/lib/ruby_llm/contract/step/limit_checker.rb +2 -2
- data/lib/ruby_llm/contract/token_estimator.rb +20 -3
- data/lib/ruby_llm/contract/version.rb +1 -1
- data/ruby_llm-contract.gemspec +6 -5
- metadata +14 -16
- data/examples/01_classify_threads.rb +0 -220
- data/examples/02_generate_comment.rb +0 -203
- data/examples/03_target_audience.rb +0 -201
- data/examples/04_real_llm.rb +0 -410
- data/examples/05_output_schema.rb +0 -258
- data/examples/07_keyword_extraction.rb +0 -239
- data/examples/08_translation.rb +0 -353
- data/examples/09_eval_dataset.rb +0 -287
- data/examples/10_reddit_full_showcase.rb +0 -363
|
@@ -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
|
-
# =============================================================================
|