rails_ai_kit 0.1.6 → 0.1.8

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +179 -158
  4. data/lib/generators/rails_ai_kit/dashboard/templates/index.html.erb +38 -0
  5. data/lib/generators/rails_ai_kit/dashboard/templates/traces_controller.rb +25 -0
  6. data/lib/generators/rails_ai_kit/dashboard_generator.rb +25 -0
  7. data/lib/generators/rails_ai_kit/install/templates/create_rails_ai_kit_tables.rb +30 -0
  8. data/lib/generators/rails_ai_kit/install/templates/create_rails_ai_traces.rb +21 -0
  9. data/lib/generators/rails_ai_kit/install/templates/rails_ai_kit.rb +20 -4
  10. data/lib/generators/rails_ai_kit/install_generator.rb +4 -4
  11. data/lib/rails_ai_kit/ai_generate.rb +56 -0
  12. data/lib/rails_ai_kit/configuration.rb +19 -1
  13. data/lib/rails_ai_kit/eval.rb +83 -0
  14. data/lib/rails_ai_kit/guard_result.rb +28 -0
  15. data/lib/rails_ai_kit/guardrails.rb +159 -0
  16. data/lib/rails_ai_kit/guards/base.rb +50 -0
  17. data/lib/rails_ai_kit/guards/hallucination.rb +32 -0
  18. data/lib/rails_ai_kit/guards/pii.rb +30 -0
  19. data/lib/rails_ai_kit/guards/prompt_injection.rb +29 -0
  20. data/lib/rails_ai_kit/guards/toxicity.rb +37 -0
  21. data/lib/rails_ai_kit/llm_client.rb +45 -0
  22. data/lib/rails_ai_kit/llm_providers/anthropic.rb +60 -0
  23. data/lib/rails_ai_kit/llm_providers/base.rb +32 -0
  24. data/lib/rails_ai_kit/llm_providers/custom.rb +59 -0
  25. data/lib/rails_ai_kit/llm_providers/google.rb +59 -0
  26. data/lib/rails_ai_kit/llm_providers/groq.rb +64 -0
  27. data/lib/rails_ai_kit/llm_providers/openai.rb +63 -0
  28. data/lib/rails_ai_kit/trace.rb +26 -0
  29. data/lib/rails_ai_kit/version.rb +1 -1
  30. data/lib/rails_ai_kit.rb +39 -7
  31. metadata +23 -1
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ module LlmProviders
5
+ class Anthropic < Base
6
+ DEFAULT_MODEL = "claude-sonnet-4-20250514"
7
+ BASE_URL = "https://api.anthropic.com/v1"
8
+
9
+ def initialize(api_key:, base_url: nil)
10
+ super(api_key: api_key, base_url: base_url)
11
+ end
12
+
13
+ def generate(messages, model: nil, max_tokens: 1024)
14
+ model ||= DEFAULT_MODEL
15
+ url = "#{base_url}/messages"
16
+ system_msg = messages.find { |m| (m["role"] || m[:role]).to_s == "system" }
17
+ chat_msgs = messages.reject { |m| (m["role"] || m[:role]).to_s == "system" }
18
+ body = {
19
+ model: model,
20
+ max_tokens: max_tokens,
21
+ messages: chat_msgs.map { |m| { role: (m["role"] || m[:role]).to_s, content: m["content"] || m[:content] } }
22
+ }
23
+ body[:system] = system_msg["content"] || system_msg[:content] if system_msg
24
+ resp = client.post(url) do |req|
25
+ req.body = body.to_json
26
+ req.headers["Content-Type"] = "application/json"
27
+ req.headers["x-api-key"] = @api_key
28
+ req.headers["anthropic-version"] = "2023-06-01"
29
+ end
30
+ parse_response(resp, model)
31
+ end
32
+
33
+ private
34
+
35
+ def base_url
36
+ @base_url || BASE_URL
37
+ end
38
+
39
+ def client
40
+ @client ||= Faraday.new(url: base_url) do |f|
41
+ f.response :json
42
+ f.adapter Faraday.default_adapter
43
+ end
44
+ end
45
+
46
+ def parse_response(resp, model)
47
+ raise RailsAiKit::Error, "Anthropic API error: #{resp.body}" unless resp.success?
48
+ data = resp.body
49
+ content = data["content"]&.find { |c| c["type"] == "text" }&.dig("text").to_s
50
+ usage = data["usage"] || {}
51
+ {
52
+ content: content,
53
+ model: data["model"] || model,
54
+ usage: { prompt_tokens: usage["input_tokens"] || 0, completion_tokens: usage["output_tokens"] || 0 },
55
+ raw: data
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ module LlmProviders
5
+ class Base
6
+ class NotImplementedError < RailsAiKit::Error; end
7
+
8
+ # @param api_key [String]
9
+ # @param base_url [String, nil] Optional override (e.g. for custom)
10
+ def initialize(api_key:, base_url: nil)
11
+ @api_key = api_key
12
+ @base_url = base_url
13
+ end
14
+
15
+ # @param messages [Array<Hash>] [{ role: "user"|"assistant"|"system", content: "..." }]
16
+ # @param model [String, nil] Model name override
17
+ # @param max_tokens [Integer]
18
+ # @return [Hash] { content: String, model: String, usage: { prompt_tokens:, completion_tokens: }, raw: Hash }
19
+ def generate(messages, model: nil, max_tokens: 1024)
20
+ raise NotImplementedError, "#{self.class}#generate must be implemented"
21
+ end
22
+
23
+ protected
24
+
25
+ def extract_text(content)
26
+ return content if content.is_a?(String)
27
+ return content["text"] if content.is_a?(Hash) && content["text"]
28
+ content.dig("content", 0, "text") if content.is_a?(Hash)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ module LlmProviders
5
+ # Custom provider: any OpenAI-compatible endpoint (URL + API key).
6
+ # Use for local models, other hosts, or proxy endpoints.
7
+ class Custom < Base
8
+ def initialize(api_key:, base_url:)
9
+ raise ArgumentError, "base_url is required for custom provider" if base_url.blank?
10
+ super(api_key: api_key, base_url: base_url.to_s.sub(%r{/$}, ""))
11
+ end
12
+
13
+ def generate(messages, model: nil, max_tokens: 1024)
14
+ model ||= "default"
15
+ url = "#{@base_url}/chat/completions"
16
+ body = {
17
+ model: model,
18
+ messages: normalize_messages(messages),
19
+ max_tokens: max_tokens
20
+ }
21
+ resp = client.post(url) do |req|
22
+ req.body = body.to_json
23
+ req.headers["Content-Type"] = "application/json"
24
+ req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
25
+ end
26
+ parse_response(resp, model)
27
+ end
28
+
29
+ private
30
+
31
+ def client
32
+ @client ||= Faraday.new do |f|
33
+ f.response :json
34
+ f.adapter Faraday.default_adapter
35
+ end
36
+ end
37
+
38
+ def normalize_messages(messages)
39
+ messages.map do |m|
40
+ { role: m["role"] || m[:role], content: m["content"] || m[:content] }
41
+ end
42
+ end
43
+
44
+ def parse_response(resp, model)
45
+ raise RailsAiKit::Error, "Custom LLM API error: #{resp.body}" unless resp.success?
46
+ data = resp.body.is_a?(Hash) ? resp.body : JSON.parse(resp.body)
47
+ choice = data["choices"]&.first
48
+ content = choice.dig("message", "content").to_s
49
+ usage = data["usage"] || {}
50
+ {
51
+ content: content,
52
+ model: data["model"] || model,
53
+ usage: { prompt_tokens: usage["prompt_tokens"] || 0, completion_tokens: usage["completion_tokens"] || 0 },
54
+ raw: data
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ module LlmProviders
5
+ class Google < Base
6
+ DEFAULT_MODEL = "gemini-1.5-flash"
7
+ BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
8
+
9
+ def initialize(api_key:, base_url: nil)
10
+ super(api_key: api_key, base_url: base_url)
11
+ end
12
+
13
+ def generate(messages, model: nil, max_tokens: 1024)
14
+ model ||= DEFAULT_MODEL
15
+ url = "#{base_url}/models/#{model}:generateContent?key=#{@api_key}"
16
+ system_instruction = messages.find { |m| (m["role"] || m[:role]).to_s == "system" }
17
+ parts = messages.reject { |m| (m["role"] || m[:role]).to_s == "system" }
18
+ .map { |m| (m["content"] || m[:content]).to_s }
19
+ .join("\n\n")
20
+ body = {
21
+ contents: [{ parts: [{ text: parts }] }],
22
+ generationConfig: { maxOutputTokens: max_tokens }
23
+ }
24
+ body[:systemInstruction] = { parts: [{ text: system_instruction["content"] || system_instruction[:content] }] } if system_instruction
25
+ resp = client.post(url) do |req|
26
+ req.body = body.to_json
27
+ req.headers["Content-Type"] = "application/json"
28
+ end
29
+ parse_response(resp, model)
30
+ end
31
+
32
+ private
33
+
34
+ def base_url
35
+ @base_url || BASE_URL
36
+ end
37
+
38
+ def client
39
+ @client ||= Faraday.new(url: base_url) do |f|
40
+ f.response :json
41
+ f.adapter Faraday.default_adapter
42
+ end
43
+ end
44
+
45
+ def parse_response(resp, model)
46
+ raise RailsAiKit::Error, "Google Gemini API error: #{resp.body}" unless resp.success?
47
+ data = resp.body
48
+ text = data.dig("candidates", 0, "content", "parts", 0, "text").to_s
49
+ usage = data.dig("usageMetadata") || {}
50
+ {
51
+ content: text,
52
+ model: model,
53
+ usage: { prompt_tokens: usage["promptTokenCount"] || 0, completion_tokens: usage["candidatesTokenCount"] || 0 },
54
+ raw: data
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ module LlmProviders
5
+ # Groq uses OpenAI-compatible API at https://api.groq.com/openai/v1
6
+ class Groq < Base
7
+ DEFAULT_MODEL = "llama-3.1-70b-versatile"
8
+ BASE_URL = "https://api.groq.com/openai/v1"
9
+
10
+ def initialize(api_key:, base_url: nil)
11
+ super(api_key: api_key, base_url: base_url)
12
+ end
13
+
14
+ def generate(messages, model: nil, max_tokens: 1024)
15
+ model ||= DEFAULT_MODEL
16
+ url = "#{base_url}/chat/completions"
17
+ body = {
18
+ model: model,
19
+ messages: normalize_messages(messages),
20
+ max_tokens: max_tokens
21
+ }
22
+ resp = client.post(url) do |req|
23
+ req.body = body.to_json
24
+ req.headers["Content-Type"] = "application/json"
25
+ req.headers["Authorization"] = "Bearer #{@api_key}"
26
+ end
27
+ parse_response(resp, model)
28
+ end
29
+
30
+ private
31
+
32
+ def base_url
33
+ @base_url || BASE_URL
34
+ end
35
+
36
+ def client
37
+ @client ||= Faraday.new(url: base_url) do |f|
38
+ f.response :json
39
+ f.adapter Faraday.default_adapter
40
+ end
41
+ end
42
+
43
+ def normalize_messages(messages)
44
+ messages.map do |m|
45
+ { role: m["role"] || m[:role], content: m["content"] || m[:content] }
46
+ end
47
+ end
48
+
49
+ def parse_response(resp, model)
50
+ raise RailsAiKit::Error, "Groq API error: #{resp.body}" unless resp.success?
51
+ data = resp.body
52
+ choice = data["choices"]&.first
53
+ content = choice.dig("message", "content").to_s
54
+ usage = data["usage"] || {}
55
+ {
56
+ content: content,
57
+ model: data["model"] || model,
58
+ usage: { prompt_tokens: usage["prompt_tokens"] || 0, completion_tokens: usage["completion_tokens"] || 0 },
59
+ raw: data
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ module LlmProviders
5
+ class Openai < Base
6
+ DEFAULT_MODEL = "gpt-4o-mini"
7
+ BASE_URL = "https://api.openai.com/v1"
8
+
9
+ def initialize(api_key:, base_url: nil)
10
+ super(api_key: api_key, base_url: base_url)
11
+ end
12
+
13
+ def generate(messages, model: nil, max_tokens: 1024)
14
+ model ||= DEFAULT_MODEL
15
+ url = "#{base_url}/chat/completions"
16
+ body = {
17
+ model: model,
18
+ messages: normalize_messages(messages),
19
+ max_tokens: max_tokens
20
+ }
21
+ resp = client.post(url) do |req|
22
+ req.body = body.to_json
23
+ req.headers["Content-Type"] = "application/json"
24
+ req.headers["Authorization"] = "Bearer #{@api_key}"
25
+ end
26
+ parse_response(resp, model)
27
+ end
28
+
29
+ private
30
+
31
+ def base_url
32
+ @base_url || BASE_URL
33
+ end
34
+
35
+ def client
36
+ @client ||= Faraday.new(url: base_url) do |f|
37
+ f.response :json
38
+ f.adapter Faraday.default_adapter
39
+ end
40
+ end
41
+
42
+ def normalize_messages(messages)
43
+ messages.map do |m|
44
+ { role: m["role"] || m[:role], content: m["content"] || m[:content] }
45
+ end
46
+ end
47
+
48
+ def parse_response(resp, model)
49
+ raise RailsAiKit::Error, "OpenAI API error: #{resp.body}" unless resp.success?
50
+ data = resp.body
51
+ choice = data["choices"]&.first
52
+ content = choice.dig("message", "content").to_s
53
+ usage = data["usage"] || {}
54
+ {
55
+ content: content,
56
+ model: data["model"] || model,
57
+ usage: { prompt_tokens: usage["prompt_tokens"] || 0, completion_tokens: usage["completion_tokens"] || 0 },
58
+ raw: data
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiKit
4
+ class Trace < ActiveRecord::Base
5
+ self.table_name = "rails_ai_traces"
6
+
7
+ scope :recent, -> { order(created_at: :desc) }
8
+ scope :blocked, -> { where(action: "block") }
9
+ scope :by_model, ->(model) { where(model: model) }
10
+
11
+ class << self
12
+ def record(prompt:, response:, model:, usage: nil, guard_scores: nil, latency_ms: nil, action: :allow)
13
+ create(
14
+ prompt: prompt.is_a?(String) ? prompt : prompt.to_json,
15
+ response: response,
16
+ model: model,
17
+ prompt_tokens: usage&.dig("prompt_tokens") || usage&.dig(:prompt_tokens),
18
+ completion_tokens: usage&.dig("completion_tokens") || usage&.dig(:completion_tokens),
19
+ guard_scores: guard_scores.is_a?(Hash) ? guard_scores : (guard_scores.present? ? JSON.parse(guard_scores.to_s) : {}),
20
+ latency_ms: latency_ms,
21
+ action: action.to_s
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiKit
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.8"
5
5
  end
data/lib/rails_ai_kit.rb CHANGED
@@ -4,7 +4,7 @@ require_relative "rails_ai_kit/version"
4
4
  require_relative "rails_ai_kit/configuration"
5
5
 
6
6
  module RailsAiKit
7
- class Error < StandardError; end
7
+ class Error < StandardError; end
8
8
  end
9
9
 
10
10
  require_relative "rails_ai_kit/embedding_providers/base"
@@ -12,13 +12,28 @@ require_relative "rails_ai_kit/embedding_providers/openai"
12
12
  require_relative "rails_ai_kit/embedding_providers/cohere"
13
13
  require_relative "rails_ai_kit/embedding_service"
14
14
 
15
+ # LLM and Guardrails (no ActiveRecord dependency for client/guardrails)
16
+ require_relative "rails_ai_kit/llm_providers/base"
17
+ require_relative "rails_ai_kit/llm_providers/openai"
18
+ require_relative "rails_ai_kit/llm_providers/anthropic"
19
+ require_relative "rails_ai_kit/llm_providers/google"
20
+ require_relative "rails_ai_kit/llm_providers/groq"
21
+ require_relative "rails_ai_kit/llm_providers/custom"
22
+ require_relative "rails_ai_kit/llm_client"
23
+ require_relative "rails_ai_kit/guards/base"
24
+ require_relative "rails_ai_kit/guards/toxicity"
25
+ require_relative "rails_ai_kit/guards/pii"
26
+ require_relative "rails_ai_kit/guards/hallucination"
27
+ require_relative "rails_ai_kit/guards/prompt_injection"
28
+ require_relative "rails_ai_kit/guard_result"
29
+ require_relative "rails_ai_kit/guardrails"
30
+ require_relative "rails_ai_kit/ai_generate"
31
+ require_relative "rails_ai_kit/eval"
32
+
15
33
  # Load models and AR integration only after ActiveRecord is ready.
16
- # Neighbor 0.6+ adds has_neighbors in its own on_load(:active_record).
17
- # Gemfile order can make our hook run before Neighbor's (or before neighbor is required).
18
- # So we ensure the neighbor gem is loaded and has_neighbors is on Base before our models.
19
34
  ActiveSupport.on_load(:active_record) do
20
35
  unless ActiveRecord::Base.respond_to?(:has_neighbors)
21
- require "neighbor" # load gem so Neighbor and its lib are available
36
+ require "neighbor"
22
37
  require "neighbor/attribute"
23
38
  require "neighbor/model"
24
39
  require "neighbor/normalized_attribute"
@@ -28,17 +43,34 @@ ActiveSupport.on_load(:active_record) do
28
43
  require_relative "rails_ai_kit/label_record"
29
44
  require_relative "rails_ai_kit/classifier"
30
45
  require_relative "rails_ai_kit/vector_classify"
46
+ require_relative "rails_ai_kit/trace"
31
47
  ActiveRecord::Base.include RailsAiKit::VectorClassify
48
+ ActiveRecord::Base.include RailsAiKit::AiGenerate
32
49
  end
33
50
 
34
51
  module RailsAiKit
35
- # Top-level classifier using default classifier name. For custom name use Classifier.new(classifier_name: "MyClassifier").
36
52
  def self.classifier(classifier_name = nil)
37
53
  Classifier.new(classifier_name: classifier_name)
38
54
  end
39
55
 
40
- # Convenience: embed a single text. Same as RailsAiKit.embedding.embed(text).
41
56
  def self.embed(text)
42
57
  embedding.embed(text)
43
58
  end
59
+
60
+ def self.llm
61
+ @llm_client ||= LlmClient.new
62
+ end
63
+
64
+ # Run guardrails: generate response and evaluate with LLM-as-judge.
65
+ # @return [RailsAiKit::GuardResult]
66
+ def self.guard(prompt: nil, messages: nil, model: nil, checks: %i[toxicity hallucination], threshold: 0.8, system_prompt: nil)
67
+ Guardrails.new.guard(prompt: prompt, messages: messages, model: model, checks: checks, threshold: threshold, system_prompt: system_prompt)
68
+ end
69
+
70
+ # Run evaluation on a dataset.
71
+ # @param dataset [String, Array] Path to JSON or array of { prompt:, expected: (optional) }
72
+ # @return [Hash] { total:, accuracy:, hallucination_rate:, toxicity_failures:, details: }
73
+ def self.eval(dataset:, model: nil, checks: %i[toxicity hallucination])
74
+ Eval.new(dataset: dataset, model: model, checks: checks).run
75
+ end
44
76
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_ai_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails AI Kit Contributors
@@ -76,19 +76,41 @@ extra_rdoc_files: []
76
76
  files:
77
77
  - CHANGELOG.md
78
78
  - README.md
79
+ - lib/generators/rails_ai_kit/dashboard/templates/index.html.erb
80
+ - lib/generators/rails_ai_kit/dashboard/templates/traces_controller.rb
81
+ - lib/generators/rails_ai_kit/dashboard_generator.rb
79
82
  - lib/generators/rails_ai_kit/install/templates/create_rails_ai_kit_labels.rb
83
+ - lib/generators/rails_ai_kit/install/templates/create_rails_ai_kit_tables.rb
84
+ - lib/generators/rails_ai_kit/install/templates/create_rails_ai_traces.rb
80
85
  - lib/generators/rails_ai_kit/install/templates/rails_ai_kit.rb
81
86
  - lib/generators/rails_ai_kit/install_generator.rb
82
87
  - lib/generators/rails_ai_kit/vector_columns/templates/add_vector_columns.rb
83
88
  - lib/generators/rails_ai_kit/vector_columns_generator.rb
84
89
  - lib/rails_ai_kit.rb
90
+ - lib/rails_ai_kit/ai_generate.rb
85
91
  - lib/rails_ai_kit/classifier.rb
86
92
  - lib/rails_ai_kit/configuration.rb
87
93
  - lib/rails_ai_kit/embedding_providers/base.rb
88
94
  - lib/rails_ai_kit/embedding_providers/cohere.rb
89
95
  - lib/rails_ai_kit/embedding_providers/openai.rb
90
96
  - lib/rails_ai_kit/embedding_service.rb
97
+ - lib/rails_ai_kit/eval.rb
98
+ - lib/rails_ai_kit/guard_result.rb
99
+ - lib/rails_ai_kit/guardrails.rb
100
+ - lib/rails_ai_kit/guards/base.rb
101
+ - lib/rails_ai_kit/guards/hallucination.rb
102
+ - lib/rails_ai_kit/guards/pii.rb
103
+ - lib/rails_ai_kit/guards/prompt_injection.rb
104
+ - lib/rails_ai_kit/guards/toxicity.rb
91
105
  - lib/rails_ai_kit/label_record.rb
106
+ - lib/rails_ai_kit/llm_client.rb
107
+ - lib/rails_ai_kit/llm_providers/anthropic.rb
108
+ - lib/rails_ai_kit/llm_providers/base.rb
109
+ - lib/rails_ai_kit/llm_providers/custom.rb
110
+ - lib/rails_ai_kit/llm_providers/google.rb
111
+ - lib/rails_ai_kit/llm_providers/groq.rb
112
+ - lib/rails_ai_kit/llm_providers/openai.rb
113
+ - lib/rails_ai_kit/trace.rb
92
114
  - lib/rails_ai_kit/vector_classify.rb
93
115
  - lib/rails_ai_kit/version.rb
94
116
  homepage: https://github.com/imrrohitt/rails_ai_kit