ollama-client 0.2.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.
data/TESTING.md ADDED
@@ -0,0 +1,286 @@
1
+ # Testing Guide
2
+
3
+ This document explains how to test the `ollama-client` gem comprehensively.
4
+
5
+ ## Test Structure
6
+
7
+ The test suite is organized into focused spec files:
8
+
9
+ - `spec/ollama/client_spec.rb` - Basic client initialization and parameter validation
10
+ - `spec/ollama/client_generate_spec.rb` - Comprehensive tests for `generate()` method
11
+ - `spec/ollama/client_chat_spec.rb` - Comprehensive tests for `chat()` method
12
+ - `spec/ollama/client_list_models_spec.rb` - Tests for `list_models()` method
13
+ - `spec/ollama/client_model_suggestions_spec.rb` - Tests for model suggestion feature
14
+ - `spec/ollama/errors_spec.rb` - Tests for all error classes
15
+ - `spec/ollama/config_spec.rb` - Config class tests (in client_spec.rb)
16
+ - `spec/ollama/schema_validator_spec.rb` - Schema validation tests (in client_spec.rb)
17
+
18
+ ## Running Tests
19
+
20
+ ### Run All Tests
21
+ ```bash
22
+ bundle exec rspec
23
+ ```
24
+
25
+ ### Run Specific Test File
26
+ ```bash
27
+ bundle exec rspec spec/ollama/client_generate_spec.rb
28
+ ```
29
+
30
+ ### Run with Documentation Format
31
+ ```bash
32
+ bundle exec rspec --format documentation
33
+ ```
34
+
35
+ ### Run Specific Test
36
+ ```bash
37
+ bundle exec rspec spec/ollama/client_generate_spec.rb:45
38
+ ```
39
+
40
+ ### Run Tests Matching a Pattern
41
+ ```bash
42
+ bundle exec rspec -e "retry"
43
+ ```
44
+
45
+ ## Testing Strategy
46
+
47
+ ### 1. HTTP Mocking with WebMock
48
+
49
+ All HTTP requests are mocked using [WebMock](https://github.com/bblimke/webmock). This allows us to:
50
+ - Test without a real Ollama server
51
+ - Test error scenarios reliably
52
+ - Test retry logic deterministically
53
+ - Run tests in CI/CD without external dependencies
54
+
55
+ **Example:**
56
+ ```ruby
57
+ stub_request(:post, "http://localhost:11434/api/generate")
58
+ .to_return(status: 200, body: { response: '{"test":"value"}' }.to_json)
59
+ ```
60
+
61
+ ### 2. Test Coverage Areas
62
+
63
+ #### ✅ Success Cases
64
+ - Successful API calls return parsed JSON
65
+ - Schema validation passes
66
+ - Config defaults are applied correctly
67
+ - Model overrides work
68
+ - Options are merged correctly
69
+
70
+ #### ✅ Error Handling
71
+ - **404 (NotFoundError)**: Model not found, no retries, includes suggestions
72
+ - **500 (HTTPError)**: Retryable, retries up to config limit
73
+ - **400 (HTTPError)**: Non-retryable, fails immediately
74
+ - **TimeoutError**: Retries on timeout
75
+ - **InvalidJSONError**: Retries on JSON parse errors
76
+ - **SchemaViolationError**: Retries on schema validation failures
77
+ - **Connection Errors**: Retries on network failures
78
+
79
+ #### ✅ Retry Logic
80
+ - Retries up to `config.retries` times
81
+ - Only retries retryable errors (5xx, 408, 429)
82
+ - Raises `RetryExhaustedError` after max retries
83
+ - Succeeds if retry succeeds
84
+
85
+ #### ✅ Edge Cases
86
+ - JSON wrapped in markdown code blocks
87
+ - Plain JSON responses
88
+ - Empty model lists
89
+ - Missing response fields
90
+ - Malformed JSON
91
+
92
+ #### ✅ Model Suggestions
93
+ - Suggests similar models on 404
94
+ - Fuzzy matching on model names
95
+ - Limits suggestions to 5 models
96
+ - Handles model listing failures gracefully
97
+
98
+ ## Writing New Tests
99
+
100
+ ### Basic Test Structure
101
+
102
+ ```ruby
103
+ RSpec.describe Ollama::Client, "#method_name" do
104
+ let(:client) { described_class.new(config: config) }
105
+ let(:config) do
106
+ Ollama::Config.new.tap do |c|
107
+ c.base_url = "http://localhost:11434"
108
+ c.model = "test-model"
109
+ end
110
+ end
111
+
112
+ before do
113
+ WebMock.disable_net_connect!(allow_localhost: false)
114
+ end
115
+
116
+ after do
117
+ WebMock.reset!
118
+ end
119
+
120
+ it "does something" do
121
+ stub_request(:post, "http://localhost:11434/api/generate")
122
+ .to_return(status: 200, body: { response: '{}' }.to_json)
123
+
124
+ result = client.generate(prompt: "test", schema: { "type" => "object" })
125
+ expect(result).to eq({})
126
+ end
127
+ end
128
+ ```
129
+
130
+ ### Testing Retry Logic
131
+
132
+ ```ruby
133
+ it "retries on 500 errors" do
134
+ stub_request(:post, "http://localhost:11434/api/generate")
135
+ .to_return(status: 500, body: "Internal Server Error")
136
+ .times(config.retries + 1)
137
+
138
+ expect do
139
+ client.generate(prompt: "test", schema: schema)
140
+ end.to raise_error(Ollama::RetryExhaustedError)
141
+
142
+ expect(WebMock).to have_requested(:post, "http://localhost:11434/api/generate")
143
+ .times(config.retries + 1)
144
+ end
145
+ ```
146
+
147
+ ### Testing Success After Retry
148
+
149
+ ```ruby
150
+ it "succeeds on retry" do
151
+ stub_request(:post, "http://localhost:11434/api/generate")
152
+ .to_return(
153
+ { status: 500, body: "Internal Server Error" },
154
+ { status: 200, body: { response: '{"test":"value"}' }.to_json }
155
+ )
156
+
157
+ result = client.generate(prompt: "test", schema: schema)
158
+ expect(result).to eq("test" => "value")
159
+ expect(WebMock).to have_requested(:post, "http://localhost:11434/api/generate").twice
160
+ end
161
+ ```
162
+
163
+ ### Testing Error Details
164
+
165
+ ```ruby
166
+ it "raises error with correct details" do
167
+ stub_request(:post, "http://localhost:11434/api/generate")
168
+ .to_return(status: 404, body: "Not Found")
169
+
170
+ expect do
171
+ client.generate(prompt: "test", schema: schema)
172
+ end.to raise_error(Ollama::NotFoundError) do |error|
173
+ expect(error.requested_model).to eq("test-model")
174
+ expect(error.status_code).to eq(404)
175
+ end
176
+ end
177
+ ```
178
+
179
+ ## Integration Tests (Optional)
180
+
181
+ For integration tests that hit a real Ollama server, create a separate spec file:
182
+
183
+ ```ruby
184
+ # spec/integration/ollama_client_integration_spec.rb
185
+ RSpec.describe "Ollama Client Integration", :integration do
186
+ # Skip if OLLAMA_URL is not set
187
+ before(:all) do
188
+ skip "Set OLLAMA_URL environment variable to run integration tests" unless ENV["OLLAMA_URL"]
189
+ end
190
+
191
+ let(:client) do
192
+ config = Ollama::Config.new
193
+ config.base_url = ENV["OLLAMA_URL"] || "http://localhost:11434"
194
+ Ollama::Client.new(config: config)
195
+ end
196
+
197
+ it "can generate structured output" do
198
+ schema = {
199
+ "type" => "object",
200
+ "required" => ["test"],
201
+ "properties" => { "test" => { "type" => "string" } }
202
+ }
203
+
204
+ result = client.generate(
205
+ prompt: "Return a JSON object with test='hello'",
206
+ schema: schema
207
+ )
208
+
209
+ expect(result["test"]).to eq("hello")
210
+ end
211
+ end
212
+ ```
213
+
214
+ Run integration tests separately:
215
+ ```bash
216
+ bundle exec rspec --tag integration
217
+ ```
218
+
219
+ ## Test Coverage Metrics
220
+
221
+ To check test coverage, add `simplecov`:
222
+
223
+ ```ruby
224
+ # spec/spec_helper.rb
225
+ require "simplecov"
226
+ SimpleCov.start
227
+ ```
228
+
229
+ Then run:
230
+ ```bash
231
+ bundle exec rspec
232
+ open coverage/index.html
233
+ ```
234
+
235
+ ## Continuous Integration
236
+
237
+ The test suite is designed to run in CI without external dependencies:
238
+ - All tests use WebMock (no real Ollama server needed)
239
+ - Tests are deterministic and fast
240
+ - No flaky network-dependent tests
241
+
242
+ ## Best Practices
243
+
244
+ 1. **Always mock HTTP requests** - Don't make real network calls in unit tests
245
+ 2. **Test error paths** - Ensure all error scenarios are covered
246
+ 3. **Test retry logic** - Verify retries work correctly
247
+ 4. **Test edge cases** - JSON parsing, empty responses, etc.
248
+ 5. **Keep tests focused** - One assertion per test when possible
249
+ 6. **Use descriptive test names** - "it 'retries on 500 errors'"
250
+ 7. **Reset WebMock** - Always reset in `after` blocks
251
+
252
+ ## Debugging Tests
253
+
254
+ ### See WebMock Requests
255
+ ```ruby
256
+ puts WebMock::RequestRegistry.instance.requested_signatures
257
+ ```
258
+
259
+ ### Inspect Stubbed Requests
260
+ ```ruby
261
+ stub = stub_request(:post, "http://localhost:11434/api/generate")
262
+ .with { |req| puts req.body }
263
+ .to_return(status: 200, body: { response: '{}' }.to_json)
264
+ ```
265
+
266
+ ### Allow Real Requests (for debugging)
267
+ ```ruby
268
+ WebMock.allow_net_connect!
269
+ ```
270
+
271
+ ## Common Issues
272
+
273
+ ### "Real HTTP connections are disabled"
274
+ - Make sure `WebMock.disable_net_connect!` is called in `before` block
275
+ - Check that all requests are properly stubbed
276
+
277
+ ### "Unregistered request"
278
+ - The request URL or method doesn't match the stub
279
+ - Check the exact URL being called
280
+ - Use `WebMock.allow_net_connect!` temporarily to see the real request
281
+
282
+ ### Tests are flaky
283
+ - Ensure WebMock is reset in `after` blocks
284
+ - Don't share state between tests
285
+ - Use `let` instead of instance variables
286
+
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Advanced Example: Complex Nested Schemas and Validation
5
+ # Demonstrates: Deep nesting, arrays of objects, conditional validation, real-world data structures
6
+
7
+ require "json"
8
+ require_relative "../lib/ollama_client"
9
+
10
+ # Example 1: Financial Analysis Schema
11
+ class FinancialAnalyzer
12
+ def initialize(client:)
13
+ @client = client
14
+ @schema = {
15
+ "type" => "object",
16
+ "required" => ["analysis_date", "summary", "metrics", "recommendations"],
17
+ "properties" => {
18
+ "analysis_date" => {
19
+ "type" => "string",
20
+ "format" => "date-time"
21
+ },
22
+ "summary" => {
23
+ "type" => "string",
24
+ "minLength" => 50,
25
+ "maxLength" => 500
26
+ },
27
+ "metrics" => {
28
+ "type" => "object",
29
+ "required" => ["revenue", "profit_margin", "growth_rate"],
30
+ "properties" => {
31
+ "revenue" => {
32
+ "type" => "number",
33
+ "minimum" => 0
34
+ },
35
+ "profit_margin" => {
36
+ "type" => "number",
37
+ "minimum" => 0,
38
+ "maximum" => 100
39
+ },
40
+ "growth_rate" => {
41
+ "type" => "number"
42
+ },
43
+ "trend" => {
44
+ "type" => "string",
45
+ "enum" => ["increasing", "stable", "decreasing"]
46
+ }
47
+ }
48
+ },
49
+ "recommendations" => {
50
+ "type" => "array",
51
+ "minItems" => 1,
52
+ "maxItems" => 10,
53
+ "items" => {
54
+ "type" => "object",
55
+ "required" => ["action", "priority", "rationale"],
56
+ "properties" => {
57
+ "action" => {
58
+ "type" => "string"
59
+ },
60
+ "priority" => {
61
+ "type" => "string",
62
+ "enum" => ["low", "medium", "high", "critical"]
63
+ },
64
+ "rationale" => {
65
+ "type" => "string"
66
+ },
67
+ "estimated_impact" => {
68
+ "type" => "object",
69
+ "properties" => {
70
+ "revenue_impact" => {
71
+ "type" => "number"
72
+ },
73
+ "risk_level" => {
74
+ "type" => "string",
75
+ "enum" => ["low", "medium", "high"]
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ },
82
+ "risk_factors" => {
83
+ "type" => "array",
84
+ "items" => {
85
+ "type" => "object",
86
+ "required" => ["factor", "severity"],
87
+ "properties" => {
88
+ "factor" => { "type" => "string" },
89
+ "severity" => {
90
+ "type" => "string",
91
+ "enum" => ["low", "medium", "high", "critical"]
92
+ },
93
+ "mitigation" => { "type" => "string" }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ end
100
+
101
+ def analyze(data:)
102
+ prompt = <<~PROMPT
103
+ Analyze this financial data: #{data}
104
+
105
+ Return JSON with: summary (50-500 chars), metrics (revenue, profit_margin, growth_rate, trend),
106
+ recommendations array (action, priority, rationale), and optional risk_factors array.
107
+ PROMPT
108
+
109
+ @client.generate(prompt: prompt, schema: @schema)
110
+ end
111
+ end
112
+
113
+ # Example 2: Code Review Schema
114
+ class CodeReviewer
115
+ def initialize(client:)
116
+ @client = client
117
+ @schema = {
118
+ "type" => "object",
119
+ "required" => ["overall_score", "issues", "suggestions"],
120
+ "properties" => {
121
+ "overall_score" => {
122
+ "type" => "integer",
123
+ "minimum" => 0,
124
+ "maximum" => 100
125
+ },
126
+ "issues" => {
127
+ "type" => "array",
128
+ "items" => {
129
+ "type" => "object",
130
+ "required" => ["type", "severity", "location", "description"],
131
+ "properties" => {
132
+ "type" => {
133
+ "type" => "string",
134
+ "enum" => ["bug", "security", "performance", "style", "maintainability"]
135
+ },
136
+ "severity" => {
137
+ "type" => "string",
138
+ "enum" => ["low", "medium", "high", "critical"]
139
+ },
140
+ "location" => {
141
+ "type" => "object",
142
+ "properties" => {
143
+ "file" => { "type" => "string" },
144
+ "line" => { "type" => "integer" },
145
+ "column" => { "type" => "integer" }
146
+ }
147
+ },
148
+ "description" => { "type" => "string" },
149
+ "suggestion" => { "type" => "string" }
150
+ }
151
+ }
152
+ },
153
+ "suggestions" => {
154
+ "type" => "array",
155
+ "items" => {
156
+ "type" => "object",
157
+ "required" => ["category", "description"],
158
+ "properties" => {
159
+ "category" => {
160
+ "type" => "string",
161
+ "enum" => ["refactoring", "optimization", "documentation", "testing"]
162
+ },
163
+ "description" => { "type" => "string" },
164
+ "priority" => {
165
+ "type" => "string",
166
+ "enum" => ["low", "medium", "high"]
167
+ }
168
+ }
169
+ }
170
+ },
171
+ "strengths" => {
172
+ "type" => "array",
173
+ "items" => { "type" => "string" }
174
+ },
175
+ "estimated_effort" => {
176
+ "type" => "object",
177
+ "properties" => {
178
+ "hours" => { "type" => "number", "minimum" => 0 },
179
+ "complexity" => {
180
+ "type" => "string",
181
+ "enum" => ["simple", "moderate", "complex"]
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ end
188
+
189
+ def review(code:)
190
+ prompt = <<~PROMPT
191
+ Review this Ruby code: #{code}
192
+
193
+ Return JSON with: overall_score (0-100), issues array (type, severity, location, description),
194
+ suggestions array (category, description, priority), optional strengths array, optional estimated_effort.
195
+ PROMPT
196
+
197
+ @client.generate(prompt: prompt, schema: @schema)
198
+ end
199
+ end
200
+
201
+ # Example 3: Research Paper Analysis Schema
202
+ class ResearchAnalyzer
203
+ def initialize(client:)
204
+ @client = client
205
+ @schema = {
206
+ "type" => "object",
207
+ "required" => ["title", "key_findings", "methodology", "citations"],
208
+ "properties" => {
209
+ "title" => { "type" => "string" },
210
+ "key_findings" => {
211
+ "type" => "array",
212
+ "minItems" => 3,
213
+ "maxItems" => 10,
214
+ "items" => {
215
+ "type" => "object",
216
+ "required" => ["finding", "significance"],
217
+ "properties" => {
218
+ "finding" => { "type" => "string" },
219
+ "significance" => {
220
+ "type" => "string",
221
+ "enum" => ["low", "medium", "high", "breakthrough"]
222
+ },
223
+ "evidence" => { "type" => "string" }
224
+ }
225
+ }
226
+ },
227
+ "methodology" => {
228
+ "type" => "object",
229
+ "required" => ["type", "description"],
230
+ "properties" => {
231
+ "type" => {
232
+ "type" => "string",
233
+ "enum" => ["experimental", "observational", "theoretical", "computational", "mixed"]
234
+ },
235
+ "description" => { "type" => "string" },
236
+ "limitations" => {
237
+ "type" => "array",
238
+ "items" => { "type" => "string" }
239
+ }
240
+ }
241
+ },
242
+ "citations" => {
243
+ "type" => "array",
244
+ "items" => {
245
+ "type" => "object",
246
+ "required" => ["author", "title", "year"],
247
+ "properties" => {
248
+ "author" => { "type" => "string" },
249
+ "title" => { "type" => "string" },
250
+ "year" => {
251
+ "type" => "integer",
252
+ "minimum" => 1900,
253
+ "maximum" => 2100
254
+ },
255
+ "relevance" => {
256
+ "type" => "string",
257
+ "enum" => ["low", "medium", "high"]
258
+ }
259
+ }
260
+ }
261
+ },
262
+ "reproducibility_score" => {
263
+ "type" => "number",
264
+ "minimum" => 0,
265
+ "maximum" => 1
266
+ }
267
+ }
268
+ }
269
+ end
270
+
271
+ def analyze(paper_text:)
272
+ prompt = <<~PROMPT
273
+ Analyze this research paper: #{paper_text}
274
+
275
+ Return JSON with: title, key_findings array (3-10 items: finding, significance, evidence),
276
+ methodology (type, description, limitations), citations array (author, title, year, relevance),
277
+ optional reproducibility_score (0-1).
278
+ PROMPT
279
+
280
+ @client.generate(prompt: prompt, schema: @schema)
281
+ end
282
+ end
283
+
284
+ # Run examples
285
+ if __FILE__ == $PROGRAM_NAME
286
+ # Use longer timeout for complex schemas
287
+ config = Ollama::Config.new
288
+ config.timeout = 60 # 60 seconds for complex operations
289
+ client = Ollama::Client.new(config: config)
290
+
291
+ puts "=" * 60
292
+ puts "Example 1: Financial Analysis"
293
+ puts "=" * 60
294
+ financial_data = <<~DATA
295
+ Q4 2024 Financial Report:
296
+ - Revenue: $2.5M (up 15% from Q3)
297
+ - Operating expenses: $1.8M
298
+ - Net profit: $700K
299
+ - Customer base: 5,000 (up 20%)
300
+ - Churn rate: 2% (down from 3%)
301
+ DATA
302
+
303
+ analyzer = FinancialAnalyzer.new(client: client)
304
+ begin
305
+ puts "⏳ Analyzing financial data (this may take 30-60 seconds)..."
306
+ result = analyzer.analyze(data: financial_data)
307
+ puts JSON.pretty_generate(result)
308
+ rescue Ollama::TimeoutError => e
309
+ puts "⏱️ Timeout: #{e.message}"
310
+ puts " Try increasing timeout or using a faster model"
311
+ rescue Ollama::Error => e
312
+ puts "❌ Error: #{e.message}"
313
+ end
314
+
315
+ puts "\n" + "=" * 60
316
+ puts "Example 2: Code Review"
317
+ puts "=" * 60
318
+ code_sample = <<~RUBY
319
+ def calculate_total(items)
320
+ total = 0
321
+ items.each do |item|
322
+ total += item.price
323
+ end
324
+ total
325
+ end
326
+ RUBY
327
+
328
+ reviewer = CodeReviewer.new(client: client)
329
+ begin
330
+ puts "⏳ Reviewing code (this may take 30-60 seconds)..."
331
+ result = reviewer.review(code: code_sample)
332
+ puts JSON.pretty_generate(result)
333
+ rescue Ollama::TimeoutError => e
334
+ puts "⏱️ Timeout: #{e.message}"
335
+ puts " Try increasing timeout or using a faster model"
336
+ rescue Ollama::Error => e
337
+ puts "❌ Error: #{e.message}"
338
+ end
339
+
340
+ puts "\n" + "=" * 60
341
+ puts "Example 3: Research Paper Analysis"
342
+ puts "=" * 60
343
+ paper_abstract = <<~TEXT
344
+ This study investigates the impact of machine learning on financial forecasting.
345
+ We analyzed 10 years of market data using neural networks and found a 23% improvement
346
+ in prediction accuracy. The methodology involved training on historical data and
347
+ validating on out-of-sample periods. Key limitations include data quality and
348
+ model interpretability challenges.
349
+ TEXT
350
+
351
+ research_analyzer = ResearchAnalyzer.new(client: client)
352
+ begin
353
+ puts "⏳ Analyzing research paper (this may take 30-60 seconds)..."
354
+ result = research_analyzer.analyze(paper_text: paper_abstract)
355
+ puts JSON.pretty_generate(result)
356
+ rescue Ollama::TimeoutError => e
357
+ puts "⏱️ Timeout: #{e.message}"
358
+ puts " Try increasing timeout or using a faster model"
359
+ rescue Ollama::Error => e
360
+ puts "❌ Error: #{e.message}"
361
+ end
362
+ end
363
+