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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/LICENSE +661 -0
- data/README.md +51 -3
- data/docs/HOME.md +4 -4
- data/lib/ai_record_finder/client.rb +10 -58
- data/lib/ai_record_finder/configuration.rb +52 -8
- data/lib/ai_record_finder/errors.rb +3 -0
- data/lib/ai_record_finder/providers/anthropic.rb +52 -0
- data/lib/ai_record_finder/providers/base.rb +99 -0
- data/lib/ai_record_finder/providers/openai.rb +47 -0
- data/lib/ai_record_finder/providers.rb +34 -0
- data/lib/ai_record_finder/version.rb +1 -1
- data/lib/ai_record_finder.rb +1 -0
- data/spec/client_spec.rb +48 -0
- data/spec/configuration_spec.rb +47 -0
- data/spec/providers_spec.rb +298 -0
- metadata +13 -8
- data/LICENSE.txt +0 -21
- data/ai_record_finder-0.1.0.gem +0 -0
- data/ai_record_finder-0.1.1.gem +0 -0
|
@@ -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.
|
|
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
|
|
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://
|
|
121
|
+
homepage: https://ai-record-finder.local/docs
|
|
117
122
|
licenses:
|
|
118
|
-
-
|
|
123
|
+
- AGPL-3.0-only
|
|
119
124
|
metadata:
|
|
120
|
-
homepage_uri: https://
|
|
121
|
-
changelog_uri: https://
|
|
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.
|
data/ai_record_finder-0.1.0.gem
DELETED
|
Binary file
|
data/ai_record_finder-0.1.1.gem
DELETED
|
Binary file
|