ai_record_finder 0.1.2 → 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.
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "json"
5
+
6
+ RSpec.describe AIRecordFinder::Providers do
7
+ # Wires a provider to a Faraday test connection that records the outgoing
8
+ # request into `captured` and replies with a canned response. The connection
9
+ # is built from the configured base URL so the relative endpoint path is
10
+ # resolved exactly as it would be in production.
11
+ def stubbed(klass, configuration, expected_path:, response: nil, raw_body: nil, status: 200)
12
+ captured = {}
13
+ stubs = Faraday::Adapter::Test::Stubs.new
14
+ stubs.post(expected_path) do |env|
15
+ captured[:url] = env.url.to_s
16
+ captured[:headers] = env.request_headers.to_h
17
+ captured[:body] = JSON.parse(env.body)
18
+ [status, { "Content-Type" => "application/json" }, raw_body || JSON.generate(response)]
19
+ end
20
+ connection = Faraday.new(url: configuration.api_base_url) { |f| f.adapter :test, stubs }
21
+ [klass.new(configuration: configuration, connection: connection), captured]
22
+ end
23
+
24
+ def config_for(provider)
25
+ AIRecordFinder::Configuration.new.tap do |c|
26
+ c.api_key = "secret-key"
27
+ c.provider = provider
28
+ end
29
+ end
30
+
31
+ describe AIRecordFinder::Providers::OpenAI do
32
+ it "posts an OpenAI chat-completions request and extracts the message content" do
33
+ provider, captured = stubbed(
34
+ described_class, config_for(:openai),
35
+ expected_path: "/v1/chat/completions",
36
+ response: { "choices" => [{ "message" => { "content" => '{"filters":[]}' } }] }
37
+ )
38
+
39
+ result = provider.chat_completion(system_prompt: "SYS", user_prompt: "USR")
40
+
41
+ expect(result).to eq('{"filters":[]}')
42
+ # Guards the latent bug: a leading-slash path drops the "/v1" prefix.
43
+ expect(captured[:url]).to eq("https://api.openai.com/v1/chat/completions")
44
+ expect(captured[:headers]["Authorization"]).to eq("Bearer secret-key")
45
+ expect(captured[:body]).to eq(
46
+ "model" => "gpt-4o-mini",
47
+ "temperature" => 0.0,
48
+ "messages" => [
49
+ { "role" => "system", "content" => "SYS" },
50
+ { "role" => "user", "content" => "USR" }
51
+ ]
52
+ )
53
+ end
54
+
55
+ it "raises AIResponseError on a non-2xx response, surfacing the API message" do
56
+ provider, = stubbed(
57
+ described_class, config_for(:openai),
58
+ expected_path: "/v1/chat/completions",
59
+ status: 401,
60
+ response: { "error" => { "message" => "Invalid API key" } }
61
+ )
62
+
63
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
64
+ .to raise_error(AIRecordFinder::AIResponseError, /Invalid API key/)
65
+ end
66
+
67
+ it "raises AIResponseError when the response has no choices" do
68
+ provider, = stubbed(
69
+ described_class, config_for(:openai),
70
+ expected_path: "/v1/chat/completions",
71
+ response: { "unexpected" => true }
72
+ )
73
+
74
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
75
+ .to raise_error(AIRecordFinder::AIResponseError, /missing choices/)
76
+ end
77
+
78
+ it "raises AIResponseError when choices is an empty array" do
79
+ provider, = stubbed(
80
+ described_class, config_for(:openai),
81
+ expected_path: "/v1/chat/completions",
82
+ response: { "choices" => [] }
83
+ )
84
+
85
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
86
+ .to raise_error(AIRecordFinder::AIResponseError, /missing choices/)
87
+ end
88
+
89
+ it "raises AIResponseError when content is null (tool_calls-only reply)" do
90
+ provider, = stubbed(
91
+ described_class, config_for(:openai),
92
+ expected_path: "/v1/chat/completions",
93
+ response: { "choices" => [{ "message" => { "role" => "assistant", "content" => nil, "tool_calls" => [] } }] }
94
+ )
95
+
96
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
97
+ .to raise_error(AIRecordFinder::AIResponseError, /missing choices/)
98
+ end
99
+
100
+ it "raises AIResponseError when the first choice has no message" do
101
+ provider, = stubbed(
102
+ described_class, config_for(:openai),
103
+ expected_path: "/v1/chat/completions",
104
+ response: { "choices" => [{ "finish_reason" => "stop" }] }
105
+ )
106
+
107
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
108
+ .to raise_error(AIRecordFinder::AIResponseError, /missing choices/)
109
+ end
110
+
111
+ it "surfaces the HTTP status when an error response has no message" do
112
+ provider, = stubbed(
113
+ described_class, config_for(:openai),
114
+ expected_path: "/v1/chat/completions",
115
+ status: 429,
116
+ response: { "error" => { "type" => "rate_limit_error" } }
117
+ )
118
+
119
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
120
+ .to raise_error(AIRecordFinder::AIResponseError, /HTTP 429/)
121
+ end
122
+
123
+ it "surfaces the HTTP status when an error body is not JSON" do
124
+ provider, = stubbed(
125
+ described_class, config_for(:openai),
126
+ expected_path: "/v1/chat/completions",
127
+ status: 502,
128
+ raw_body: "<html>Bad Gateway</html>"
129
+ )
130
+
131
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
132
+ .to raise_error(AIRecordFinder::AIResponseError, /HTTP 502/)
133
+ end
134
+
135
+ it "raises AIResponseError when a successful body is not valid JSON" do
136
+ provider, = stubbed(
137
+ described_class, config_for(:openai),
138
+ expected_path: "/v1/chat/completions",
139
+ raw_body: "not json at all"
140
+ )
141
+
142
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
143
+ .to raise_error(AIRecordFinder::AIResponseError, /not valid JSON/)
144
+ end
145
+
146
+ it "wraps Faraday transport errors as AIResponseError" do
147
+ cfg = config_for(:openai)
148
+ stubs = Faraday::Adapter::Test::Stubs.new
149
+ stubs.post("/v1/chat/completions") { raise Faraday::TimeoutError }
150
+ connection = Faraday.new(url: cfg.api_base_url) { |f| f.adapter :test, stubs }
151
+ provider = described_class.new(configuration: cfg, connection: connection)
152
+
153
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
154
+ .to raise_error(AIRecordFinder::AIResponseError, /AI request failed/)
155
+ end
156
+ end
157
+
158
+ describe AIRecordFinder::Providers::Anthropic do
159
+ it "posts a native Messages request and extracts the text block" do
160
+ provider, captured = stubbed(
161
+ described_class, config_for(:anthropic),
162
+ expected_path: "/v1/messages",
163
+ response: { "content" => [{ "type" => "text", "text" => '{"filters":[]}' }] }
164
+ )
165
+
166
+ result = provider.chat_completion(system_prompt: "SYS", user_prompt: "USR")
167
+
168
+ expect(result).to eq('{"filters":[]}')
169
+ expect(captured[:url]).to eq("https://api.anthropic.com/v1/messages")
170
+ expect(captured[:headers]["x-api-key"]).to eq("secret-key")
171
+ expect(captured[:headers]["anthropic-version"]).to eq("2023-06-01")
172
+ expect(captured[:headers]).not_to have_key("Authorization")
173
+ expect(captured[:body]).to eq(
174
+ "model" => "claude-sonnet-4-6",
175
+ "max_tokens" => 1024,
176
+ "temperature" => 0.0,
177
+ "system" => "SYS",
178
+ "messages" => [{ "role" => "user", "content" => "USR" }]
179
+ )
180
+ end
181
+
182
+ it "skips non-text blocks and returns the first text block" do
183
+ provider, = stubbed(
184
+ described_class, config_for(:anthropic),
185
+ expected_path: "/v1/messages",
186
+ response: {
187
+ "content" => [
188
+ { "type" => "thinking", "thinking" => "..." },
189
+ { "type" => "text", "text" => "the answer" }
190
+ ]
191
+ }
192
+ )
193
+
194
+ expect(provider.chat_completion(system_prompt: "s", user_prompt: "u")).to eq("the answer")
195
+ end
196
+
197
+ it "returns the first text block when several are present" do
198
+ provider, = stubbed(
199
+ described_class, config_for(:anthropic),
200
+ expected_path: "/v1/messages",
201
+ response: {
202
+ "content" => [
203
+ { "type" => "text", "text" => "first" },
204
+ { "type" => "text", "text" => "second" }
205
+ ]
206
+ }
207
+ )
208
+
209
+ expect(provider.chat_completion(system_prompt: "s", user_prompt: "u")).to eq("first")
210
+ end
211
+
212
+ it "raises AIResponseError when the content array is empty" do
213
+ provider, = stubbed(
214
+ described_class, config_for(:anthropic),
215
+ expected_path: "/v1/messages",
216
+ response: { "content" => [] }
217
+ )
218
+
219
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
220
+ .to raise_error(AIRecordFinder::AIResponseError, /missing content text block/)
221
+ end
222
+
223
+ it "raises AIResponseError when a text block has no text value" do
224
+ provider, = stubbed(
225
+ described_class, config_for(:anthropic),
226
+ expected_path: "/v1/messages",
227
+ response: { "content" => [{ "type" => "text" }] }
228
+ )
229
+
230
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
231
+ .to raise_error(AIRecordFinder::AIResponseError, /missing content text block/)
232
+ end
233
+
234
+ it "raises AIResponseError surfacing the Anthropic error message" do
235
+ provider, = stubbed(
236
+ described_class, config_for(:anthropic),
237
+ expected_path: "/v1/messages",
238
+ status: 400,
239
+ response: { "type" => "error", "error" => { "type" => "invalid_request_error", "message" => "max_tokens: required" } }
240
+ )
241
+
242
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
243
+ .to raise_error(AIRecordFinder::AIResponseError, /max_tokens: required/)
244
+ end
245
+
246
+ it "raises AIResponseError when no text block is present" do
247
+ provider, = stubbed(
248
+ described_class, config_for(:anthropic),
249
+ expected_path: "/v1/messages",
250
+ response: { "content" => [{ "type" => "tool_use", "id" => "x" }] }
251
+ )
252
+
253
+ expect { provider.chat_completion(system_prompt: "s", user_prompt: "u") }
254
+ .to raise_error(AIRecordFinder::AIResponseError, /missing content text block/)
255
+ end
256
+
257
+ it "respects custom max_tokens and anthropic_version" do
258
+ cfg = config_for(:anthropic)
259
+ cfg.max_tokens = 256
260
+ cfg.anthropic_version = "2024-10-22"
261
+ provider, captured = stubbed(
262
+ described_class, cfg,
263
+ expected_path: "/v1/messages",
264
+ response: { "content" => [{ "type" => "text", "text" => "x" }] }
265
+ )
266
+
267
+ provider.chat_completion(system_prompt: "s", user_prompt: "u")
268
+
269
+ expect(captured[:body]["max_tokens"]).to eq(256)
270
+ expect(captured[:headers]["anthropic-version"]).to eq("2024-10-22")
271
+ end
272
+ end
273
+
274
+ describe ".build" do
275
+ it "returns the OpenAI provider for :openai" do
276
+ expect(described_class.build(configuration: config_for(:openai)))
277
+ .to be_a(AIRecordFinder::Providers::OpenAI)
278
+ end
279
+
280
+ it "returns the Anthropic provider for :anthropic" do
281
+ expect(described_class.build(configuration: config_for(:anthropic)))
282
+ .to be_a(AIRecordFinder::Providers::Anthropic)
283
+ end
284
+
285
+ it "raises ConfigurationError for an unknown provider" do
286
+ expect { described_class.build(configuration: config_for(:gemini)) }
287
+ .to raise_error(AIRecordFinder::ConfigurationError, /Unknown provider/)
288
+ end
289
+
290
+ it "raises ConfigurationError when the API key is missing" do
291
+ cfg = AIRecordFinder::Configuration.new
292
+ cfg.provider = :anthropic
293
+
294
+ expect { described_class.build(configuration: cfg) }
295
+ .to raise_error(AIRecordFinder::ConfigurationError, /Missing API key/)
296
+ end
297
+ end
298
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai_record_finder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jijo Bose
@@ -90,11 +90,9 @@ files:
90
90
  - ".rspec"
91
91
  - CHANGELOG.md
92
92
  - CODE_OF_CONDUCT.md
93
- - LICENSE.txt
93
+ - LICENSE
94
94
  - README.md
95
95
  - Rakefile
96
- - ai_record_finder-0.1.0.gem
97
- - ai_record_finder-0.1.1.gem
98
96
  - docs/DEVELOPER_GUIDE.md
99
97
  - docs/HOME.md
100
98
  - lib/ai_record_finder.rb
@@ -104,6 +102,10 @@ files:
104
102
  - lib/ai_record_finder/dsl_parser.rb
105
103
  - lib/ai_record_finder/errors.rb
106
104
  - lib/ai_record_finder/prompt_builder.rb
105
+ - lib/ai_record_finder/providers.rb
106
+ - lib/ai_record_finder/providers/anthropic.rb
107
+ - lib/ai_record_finder/providers/base.rb
108
+ - lib/ai_record_finder/providers/openai.rb
107
109
  - lib/ai_record_finder/query_builder.rb
108
110
  - lib/ai_record_finder/railtie.rb
109
111
  - lib/ai_record_finder/safety_guard.rb
@@ -112,13 +114,16 @@ files:
112
114
  - sig/ai_record_finder.rbs
113
115
  - spec/ai_adapter_spec.rb
114
116
  - spec/ai_record_finder_spec.rb
117
+ - spec/client_spec.rb
118
+ - spec/configuration_spec.rb
119
+ - spec/providers_spec.rb
115
120
  - spec/spec_helper.rb
116
- homepage: https://github.com/JijoBose/ai_record_finder
121
+ homepage: https://ai-record-finder.local/docs
117
122
  licenses:
118
- - MIT
123
+ - AGPL-3.0-only
119
124
  metadata:
120
- homepage_uri: https://github.com/JijoBose/ai_record_finder
121
- changelog_uri: https://github.com/JijoBose/ai_record_finder/blob/main/CHANGELOG.md
125
+ homepage_uri: https://ai-record-finder.local/docs
126
+ changelog_uri: https://ai-record-finder.local/docs/CHANGELOG.md
122
127
  rdoc_options: []
123
128
  require_paths:
124
129
  - lib
data/LICENSE.txt DELETED
@@ -1,21 +0,0 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2026 Jijo Bose
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
Binary file
Binary file