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
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
It is designed for B2B Rails applications that need strict query safety, tenant boundaries, and model-level authorization.
|
|
6
6
|
|
|
7
|
-
Documentation homepage:
|
|
7
|
+
Documentation homepage: `docs/HOME.md`
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -38,6 +38,49 @@ AIRecordFinder.configure do |config|
|
|
|
38
38
|
end
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
## Providers
|
|
42
|
+
|
|
43
|
+
`ai_record_finder` ships with native adapters for two providers. Select one
|
|
44
|
+
with `config.provider` (default: `:openai`).
|
|
45
|
+
|
|
46
|
+
### OpenAI (default)
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
AIRecordFinder.configure do |config|
|
|
50
|
+
config.provider = :openai
|
|
51
|
+
config.api_key = ENV.fetch("OPENAI_API_KEY")
|
|
52
|
+
# config.model_name defaults to "gpt-4o-mini"
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This also covers any OpenAI-compatible endpoint (Azure OpenAI, OpenRouter,
|
|
57
|
+
LiteLLM, Ollama, vLLM, ...) — point `api_base_url` at the gateway:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
config.api_base_url = "https://openrouter.ai/api/v1"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Anthropic (native Messages API)
|
|
64
|
+
|
|
65
|
+
The Anthropic adapter talks to the native `/v1/messages` API (`x-api-key`
|
|
66
|
+
auth, `anthropic-version` header, top-level `system` prompt, `content`-block
|
|
67
|
+
responses) — not the OpenAI-compatibility shim.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
AIRecordFinder.configure do |config|
|
|
71
|
+
config.provider = :anthropic
|
|
72
|
+
config.api_key = ENV.fetch("ANTHROPIC_API_KEY")
|
|
73
|
+
config.model_name = "claude-sonnet-4-6" # default; override for your account access
|
|
74
|
+
|
|
75
|
+
# Anthropic-specific knobs:
|
|
76
|
+
config.max_tokens = 1024 # required by the Messages API; default 1024
|
|
77
|
+
config.anthropic_version = "2023-06-01" # default
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
When `provider` is set, `model_name` and `api_base_url` resolve to that
|
|
82
|
+
provider's defaults unless you assign them explicitly.
|
|
83
|
+
|
|
41
84
|
## Usage
|
|
42
85
|
|
|
43
86
|
```ruby
|
|
@@ -70,10 +113,14 @@ For associated-table constraints, reference fields as `association.column` in na
|
|
|
70
113
|
|
|
71
114
|
Core components:
|
|
72
115
|
|
|
73
|
-
- `AIRecordFinder::Configuration`: runtime safety and API settings.
|
|
116
|
+
- `AIRecordFinder::Configuration`: runtime safety, provider, and API settings.
|
|
74
117
|
- `AIRecordFinder::SchemaIntrospector`: model table/column/association/enum summary.
|
|
75
118
|
- `AIRecordFinder::PromptBuilder`: strict system prompt with schema and DSL contract.
|
|
76
|
-
- `AIRecordFinder::
|
|
119
|
+
- `AIRecordFinder::Providers`: provider registry and per-vendor transports
|
|
120
|
+
(`Providers::OpenAI`, `Providers::Anthropic`) built on a shared
|
|
121
|
+
`Providers::Base` (Faraday).
|
|
122
|
+
- `AIRecordFinder::Client`: transport facade that selects and delegates to the
|
|
123
|
+
configured provider.
|
|
77
124
|
- `AIRecordFinder::AIAdapter`: AI response extraction and JSON parsing.
|
|
78
125
|
- `AIRecordFinder::DSLParser`: validates DSL structure and values.
|
|
79
126
|
- `AIRecordFinder::SafetyGuard`: model authorization, limit policies, join policies, tenant scope.
|
|
@@ -86,6 +133,7 @@ Core components:
|
|
|
86
133
|
- `AIRecordFinder::InvalidDSL`
|
|
87
134
|
- `AIRecordFinder::AIResponseError`
|
|
88
135
|
- `AIRecordFinder::UnauthorizedModel`
|
|
136
|
+
- `AIRecordFinder::ConfigurationError` (missing API key, unknown provider)
|
|
89
137
|
|
|
90
138
|
## Testing
|
|
91
139
|
|
data/docs/HOME.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# AIRecordFinder Documentation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This repository is private and uses project-owned documentation.
|
|
4
4
|
|
|
5
5
|
## Start Here
|
|
6
6
|
|
|
7
|
-
- Developer guide:
|
|
8
|
-
- Package overview:
|
|
9
|
-
- Release history:
|
|
7
|
+
- Developer guide: `docs/DEVELOPER_GUIDE.md`
|
|
8
|
+
- Package overview: `README.md`
|
|
9
|
+
- Release history: `CHANGELOG.md`
|
|
10
10
|
|
|
11
11
|
## Scope
|
|
12
12
|
|
|
@@ -1,69 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
3
|
+
require_relative "providers"
|
|
5
4
|
|
|
6
5
|
module AIRecordFinder
|
|
7
|
-
#
|
|
6
|
+
# Transport facade. Selects the provider configured via
|
|
7
|
+
# `configuration.provider` and delegates chat completion to it.
|
|
8
|
+
#
|
|
9
|
+
# Kept as a stable entry point so callers (and AIAdapter) depend on a single
|
|
10
|
+
# `#chat_completion(system_prompt:, user_prompt:)` interface regardless of
|
|
11
|
+
# whether the backing provider is OpenAI, Anthropic, or another vendor.
|
|
8
12
|
class Client
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def initialize(configuration:)
|
|
12
|
-
@configuration = configuration
|
|
13
|
-
validate_configuration!
|
|
13
|
+
def initialize(configuration:, connection: nil)
|
|
14
|
+
@provider = Providers.build(configuration: configuration, connection: connection)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def chat_completion(system_prompt:, user_prompt:)
|
|
17
|
-
|
|
18
|
-
req.headers["Authorization"] = "Bearer #{@configuration.api_key}"
|
|
19
|
-
req.headers["Content-Type"] = "application/json"
|
|
20
|
-
req.body = JSON.generate(payload(system_prompt, user_prompt))
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
parsed = parse_body(response.body)
|
|
24
|
-
extract_content(parsed)
|
|
25
|
-
rescue Faraday::Error => e
|
|
26
|
-
raise AIResponseError, "AI request failed: #{e.message}"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def connection
|
|
32
|
-
@connection ||= Faraday.new(url: @configuration.api_base_url) do |f|
|
|
33
|
-
f.options.timeout = @configuration.request_timeout
|
|
34
|
-
f.options.open_timeout = @configuration.request_timeout
|
|
35
|
-
f.adapter Faraday.default_adapter
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def payload(system_prompt, user_prompt)
|
|
40
|
-
{
|
|
41
|
-
model: @configuration.model_name,
|
|
42
|
-
temperature: @configuration.temperature,
|
|
43
|
-
messages: [
|
|
44
|
-
{ role: "system", content: system_prompt },
|
|
45
|
-
{ role: "user", content: user_prompt }
|
|
46
|
-
]
|
|
47
|
-
}
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def parse_body(body)
|
|
51
|
-
JSON.parse(body)
|
|
52
|
-
rescue JSON::ParserError
|
|
53
|
-
raise AIResponseError, "AI response body is not valid JSON"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def extract_content(parsed)
|
|
57
|
-
choices = parsed["choices"]
|
|
58
|
-
return choices.first.dig("message", "content") if choices.is_a?(Array) && choices.first
|
|
59
|
-
|
|
60
|
-
raise AIResponseError, "AI response missing choices.message.content"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def validate_configuration!
|
|
64
|
-
if @configuration.api_key.to_s.strip.empty?
|
|
65
|
-
raise AIResponseError, "Missing API key. Set AIRecordFinder.configure { |c| c.api_key = ... }"
|
|
66
|
-
end
|
|
18
|
+
@provider.chat_completion(system_prompt: system_prompt, user_prompt: user_prompt)
|
|
67
19
|
end
|
|
68
20
|
end
|
|
69
21
|
end
|
|
@@ -3,24 +3,68 @@
|
|
|
3
3
|
module AIRecordFinder
|
|
4
4
|
# Runtime configuration for AIRecordFinder.
|
|
5
5
|
class Configuration
|
|
6
|
-
|
|
7
|
-
DEFAULT_API_BASE_URL = "https://api.openai.com/v1"
|
|
6
|
+
DEFAULT_PROVIDER = :openai
|
|
8
7
|
DEFAULT_MAX_LIMIT = 100
|
|
9
8
|
DEFAULT_TIMEOUT = 15
|
|
9
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
10
|
+
DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Per-provider defaults for endpoint and model. Used only when the
|
|
13
|
+
# corresponding setting is not explicitly assigned.
|
|
14
|
+
PROVIDER_DEFAULTS = {
|
|
15
|
+
openai: {
|
|
16
|
+
api_base_url: "https://api.openai.com/v1",
|
|
17
|
+
model_name: "gpt-4o-mini"
|
|
18
|
+
},
|
|
19
|
+
anthropic: {
|
|
20
|
+
api_base_url: "https://api.anthropic.com/v1",
|
|
21
|
+
model_name: "claude-sonnet-4-6"
|
|
22
|
+
}
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Backwards-compatible constants (OpenAI defaults).
|
|
26
|
+
DEFAULT_MODEL_NAME = PROVIDER_DEFAULTS[:openai][:model_name]
|
|
27
|
+
DEFAULT_API_BASE_URL = PROVIDER_DEFAULTS[:openai][:api_base_url]
|
|
28
|
+
|
|
29
|
+
# `max_tokens` and `anthropic_version` are consumed only by the Anthropic
|
|
30
|
+
# adapter; the OpenAI adapter ignores them.
|
|
31
|
+
attr_accessor :api_key, :max_limit, :allowed_models, :request_timeout,
|
|
32
|
+
:temperature, :allowed_associations, :max_tokens,
|
|
33
|
+
:anthropic_version
|
|
34
|
+
attr_writer :provider, :model_name, :api_base_url
|
|
14
35
|
|
|
15
36
|
def initialize
|
|
16
|
-
@api_key = nil
|
|
17
|
-
@
|
|
37
|
+
@api_key = @model_name = @api_base_url = nil
|
|
38
|
+
@provider = DEFAULT_PROVIDER
|
|
18
39
|
@max_limit = DEFAULT_MAX_LIMIT
|
|
19
40
|
@allowed_models = []
|
|
20
|
-
@api_base_url = DEFAULT_API_BASE_URL
|
|
21
41
|
@request_timeout = DEFAULT_TIMEOUT
|
|
22
42
|
@temperature = 0.0
|
|
23
43
|
@allowed_associations = {}
|
|
44
|
+
@max_tokens = DEFAULT_MAX_TOKENS
|
|
45
|
+
@anthropic_version = DEFAULT_ANTHROPIC_VERSION
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Normalized provider symbol (e.g. :openai, :anthropic).
|
|
49
|
+
def provider
|
|
50
|
+
@provider.to_s.strip.downcase.to_sym
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Explicit model name, or the provider default when unset.
|
|
54
|
+
def model_name
|
|
55
|
+
@model_name || provider_default(:model_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Explicit base URL, or the provider default when unset.
|
|
59
|
+
def api_base_url
|
|
60
|
+
@api_base_url || provider_default(:api_base_url)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def provider_default(key)
|
|
66
|
+
defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS[DEFAULT_PROVIDER]
|
|
67
|
+
defaults.fetch(key)
|
|
24
68
|
end
|
|
25
69
|
end
|
|
26
70
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module AIRecordFinder
|
|
6
|
+
module Providers
|
|
7
|
+
# Anthropic native Messages API (https://api.anthropic.com/v1/messages).
|
|
8
|
+
#
|
|
9
|
+
# Differs from OpenAI: x-api-key auth + anthropic-version header, a
|
|
10
|
+
# top-level `system` prompt, a required `max_tokens`, and a response whose
|
|
11
|
+
# text lives in a `content` array of typed blocks rather than `choices`.
|
|
12
|
+
class Anthropic < Base
|
|
13
|
+
ENDPOINT_PATH = "messages"
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def endpoint_path
|
|
18
|
+
ENDPOINT_PATH
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request_headers
|
|
22
|
+
{
|
|
23
|
+
"x-api-key" => configuration.api_key,
|
|
24
|
+
"anthropic-version" => configuration.anthropic_version,
|
|
25
|
+
"Content-Type" => "application/json"
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def request_payload(system_prompt, user_prompt)
|
|
30
|
+
{
|
|
31
|
+
model: configuration.model_name,
|
|
32
|
+
max_tokens: configuration.max_tokens,
|
|
33
|
+
temperature: configuration.temperature,
|
|
34
|
+
system: system_prompt,
|
|
35
|
+
messages: [
|
|
36
|
+
{ role: "user", content: user_prompt }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_content(parsed)
|
|
42
|
+
blocks = parsed["content"]
|
|
43
|
+
if blocks.is_a?(Array)
|
|
44
|
+
text_block = blocks.find { |block| block.is_a?(Hash) && block["type"] == "text" }
|
|
45
|
+
return text_block["text"] if text_block && !text_block["text"].nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise AIResponseError, "AI response missing content text block"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module AIRecordFinder
|
|
7
|
+
module Providers
|
|
8
|
+
# Shared HTTP transport for chat-style LLM providers.
|
|
9
|
+
#
|
|
10
|
+
# Subclasses implement the provider-specific contract:
|
|
11
|
+
# #endpoint_path, #request_headers, #request_payload, #extract_content
|
|
12
|
+
#
|
|
13
|
+
# Endpoint paths MUST be relative (no leading slash) so they append to the
|
|
14
|
+
# configured base URL's path (e.g. ".../v1") instead of replacing it.
|
|
15
|
+
class Base
|
|
16
|
+
def initialize(configuration:, connection: nil)
|
|
17
|
+
@configuration = configuration
|
|
18
|
+
@connection = connection
|
|
19
|
+
validate_configuration!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chat_completion(system_prompt:, user_prompt:)
|
|
23
|
+
response = post_request(system_prompt, user_prompt)
|
|
24
|
+
ensure_success!(response)
|
|
25
|
+
extract_content(parse_body(response))
|
|
26
|
+
rescue Faraday::Error => e
|
|
27
|
+
raise AIResponseError, "AI request failed: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :configuration
|
|
33
|
+
|
|
34
|
+
def post_request(system_prompt, user_prompt)
|
|
35
|
+
connection.post(endpoint_path) do |req|
|
|
36
|
+
request_headers.each { |name, value| req.headers[name] = value }
|
|
37
|
+
req.body = JSON.generate(request_payload(system_prompt, user_prompt))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def connection
|
|
42
|
+
@connection ||= Faraday.new(url: configuration.api_base_url) do |f|
|
|
43
|
+
f.options.timeout = configuration.request_timeout
|
|
44
|
+
f.options.open_timeout = configuration.request_timeout
|
|
45
|
+
f.adapter Faraday.default_adapter
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_body(response)
|
|
50
|
+
JSON.parse(response.body.to_s)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
raise AIResponseError, "AI response body is not valid JSON"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Faraday does not raise on non-2xx, so check the status explicitly. Error
|
|
56
|
+
# bodies are not guaranteed to be JSON (a gateway may return HTML/plain
|
|
57
|
+
# text), so this must surface a useful detail without depending on a
|
|
58
|
+
# successful parse — hence it runs before parse_body.
|
|
59
|
+
def ensure_success!(response)
|
|
60
|
+
return if response.success?
|
|
61
|
+
|
|
62
|
+
raise AIResponseError, "AI request failed: #{error_detail(response)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Both OpenAI and Anthropic nest the human-readable error under
|
|
66
|
+
# error.message; fall back to the HTTP status for non-JSON error bodies.
|
|
67
|
+
def error_detail(response)
|
|
68
|
+
parsed = JSON.parse(response.body.to_s)
|
|
69
|
+
(parsed.is_a?(Hash) && parsed.dig("error", "message")) || "HTTP #{response.status}"
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
"HTTP #{response.status}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_configuration!
|
|
75
|
+
return unless configuration.api_key.to_s.strip.empty?
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError, "Missing API key. Set AIRecordFinder.configure { |c| c.api_key = ... }"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# --- Provider contract (subclasses must override) ---
|
|
81
|
+
|
|
82
|
+
def endpoint_path
|
|
83
|
+
raise NotImplementedError, "#{self.class} must implement #endpoint_path"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def request_headers
|
|
87
|
+
raise NotImplementedError, "#{self.class} must implement #request_headers"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def request_payload(_system_prompt, _user_prompt)
|
|
91
|
+
raise NotImplementedError, "#{self.class} must implement #request_payload"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_content(_parsed)
|
|
95
|
+
raise NotImplementedError, "#{self.class} must implement #extract_content"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module AIRecordFinder
|
|
6
|
+
module Providers
|
|
7
|
+
# OpenAI (and OpenAI-compatible) Chat Completions API.
|
|
8
|
+
class OpenAI < Base
|
|
9
|
+
ENDPOINT_PATH = "chat/completions"
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def endpoint_path
|
|
14
|
+
ENDPOINT_PATH
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request_headers
|
|
18
|
+
{
|
|
19
|
+
"Authorization" => "Bearer #{configuration.api_key}",
|
|
20
|
+
"Content-Type" => "application/json"
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def request_payload(system_prompt, user_prompt)
|
|
25
|
+
{
|
|
26
|
+
model: configuration.model_name,
|
|
27
|
+
temperature: configuration.temperature,
|
|
28
|
+
messages: [
|
|
29
|
+
{ role: "system", content: system_prompt },
|
|
30
|
+
{ role: "user", content: user_prompt }
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extract_content(parsed)
|
|
36
|
+
choices = parsed["choices"]
|
|
37
|
+
if choices.is_a?(Array) && choices.first
|
|
38
|
+
# content is null when the model replies with tool_calls only.
|
|
39
|
+
content = choices.first.dig("message", "content")
|
|
40
|
+
return content unless content.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
raise AIResponseError, "AI response missing choices[0].message.content"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "providers/base"
|
|
4
|
+
require_relative "providers/openai"
|
|
5
|
+
require_relative "providers/anthropic"
|
|
6
|
+
|
|
7
|
+
module AIRecordFinder
|
|
8
|
+
# Provider strategy registry. Maps a configured provider symbol to the
|
|
9
|
+
# concrete transport that speaks that vendor's chat API.
|
|
10
|
+
module Providers
|
|
11
|
+
REGISTRY = {
|
|
12
|
+
openai: OpenAI,
|
|
13
|
+
anthropic: Anthropic
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# @param configuration [AIRecordFinder::Configuration]
|
|
17
|
+
# @param connection [Faraday::Connection, nil] injectable transport (tests)
|
|
18
|
+
# @return [AIRecordFinder::Providers::Base]
|
|
19
|
+
def self.build(configuration:, connection: nil)
|
|
20
|
+
provider_class = REGISTRY[configuration.provider]
|
|
21
|
+
unless provider_class
|
|
22
|
+
raise ConfigurationError,
|
|
23
|
+
"Unknown provider #{configuration.provider.inspect}. " \
|
|
24
|
+
"Supported providers: #{REGISTRY.keys.join(", ")}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
provider_class.new(configuration: configuration, connection: connection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.supported
|
|
31
|
+
REGISTRY.keys
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/ai_record_finder.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "ai_record_finder/version"
|
|
4
4
|
require_relative "ai_record_finder/errors"
|
|
5
5
|
require_relative "ai_record_finder/configuration"
|
|
6
|
+
require_relative "ai_record_finder/providers"
|
|
6
7
|
require_relative "ai_record_finder/client"
|
|
7
8
|
require_relative "ai_record_finder/schema_introspector"
|
|
8
9
|
require_relative "ai_record_finder/prompt_builder"
|
data/spec/client_spec.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
RSpec.describe AIRecordFinder::Client do
|
|
7
|
+
def stub_connection(base_url, expected_path, response)
|
|
8
|
+
stubs = Faraday::Adapter::Test::Stubs.new
|
|
9
|
+
stubs.post(expected_path) do |_env|
|
|
10
|
+
[200, { "Content-Type" => "application/json" }, JSON.generate(response)]
|
|
11
|
+
end
|
|
12
|
+
Faraday.new(url: base_url) { |f| f.adapter :test, stubs }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "delegates to the OpenAI provider by default" do
|
|
16
|
+
cfg = AIRecordFinder::Configuration.new.tap { |c| c.api_key = "k" }
|
|
17
|
+
conn = stub_connection(
|
|
18
|
+
cfg.api_base_url, "/v1/chat/completions",
|
|
19
|
+
{ "choices" => [{ "message" => { "content" => "openai-out" } }] }
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
client = described_class.new(configuration: cfg, connection: conn)
|
|
23
|
+
|
|
24
|
+
expect(client.chat_completion(system_prompt: "s", user_prompt: "u")).to eq("openai-out")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "delegates to the Anthropic provider when configured" do
|
|
28
|
+
cfg = AIRecordFinder::Configuration.new.tap do |c|
|
|
29
|
+
c.api_key = "k"
|
|
30
|
+
c.provider = :anthropic
|
|
31
|
+
end
|
|
32
|
+
conn = stub_connection(
|
|
33
|
+
cfg.api_base_url, "/v1/messages",
|
|
34
|
+
{ "content" => [{ "type" => "text", "text" => "anthropic-out" }] }
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
client = described_class.new(configuration: cfg, connection: conn)
|
|
38
|
+
|
|
39
|
+
expect(client.chat_completion(system_prompt: "s", user_prompt: "u")).to eq("anthropic-out")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "raises ConfigurationError when the API key is missing" do
|
|
43
|
+
cfg = AIRecordFinder::Configuration.new
|
|
44
|
+
|
|
45
|
+
expect { described_class.new(configuration: cfg) }
|
|
46
|
+
.to raise_error(AIRecordFinder::ConfigurationError, /Missing API key/)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe AIRecordFinder::Configuration do
|
|
6
|
+
subject(:config) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it "defaults to the OpenAI provider with its model and base URL" do
|
|
9
|
+
expect(config.provider).to eq(:openai)
|
|
10
|
+
expect(config.model_name).to eq("gpt-4o-mini")
|
|
11
|
+
expect(config.api_base_url).to eq("https://api.openai.com/v1")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "resolves Anthropic defaults when the provider is :anthropic" do
|
|
15
|
+
config.provider = :anthropic
|
|
16
|
+
|
|
17
|
+
expect(config.model_name).to eq("claude-sonnet-4-6")
|
|
18
|
+
expect(config.api_base_url).to eq("https://api.anthropic.com/v1")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "normalizes provider strings and casing to a symbol" do
|
|
22
|
+
config.provider = "Anthropic"
|
|
23
|
+
|
|
24
|
+
expect(config.provider).to eq(:anthropic)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "prefers explicitly assigned model_name and api_base_url over provider defaults" do
|
|
28
|
+
config.provider = :anthropic
|
|
29
|
+
config.model_name = "claude-opus-4-8"
|
|
30
|
+
config.api_base_url = "https://gateway.internal/v1"
|
|
31
|
+
|
|
32
|
+
expect(config.model_name).to eq("claude-opus-4-8")
|
|
33
|
+
expect(config.api_base_url).to eq("https://gateway.internal/v1")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "falls back to OpenAI defaults for an unrecognized provider" do
|
|
37
|
+
config.provider = :unknown
|
|
38
|
+
|
|
39
|
+
expect(config.model_name).to eq("gpt-4o-mini")
|
|
40
|
+
expect(config.api_base_url).to eq("https://api.openai.com/v1")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "exposes Anthropic-specific defaults" do
|
|
44
|
+
expect(config.max_tokens).to eq(1024)
|
|
45
|
+
expect(config.anthropic_version).to eq("2023-06-01")
|
|
46
|
+
end
|
|
47
|
+
end
|