ruby_conversations 1.0.6 → 1.0.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -16
  3. data/app/models/concerns/ruby_conversations/conversation_chat.rb +49 -0
  4. data/app/models/concerns/ruby_conversations/conversation_messages.rb +18 -0
  5. data/app/models/concerns/ruby_conversations/llm_credentials.rb +40 -0
  6. data/app/models/concerns/ruby_conversations/message_api_attributes.rb +39 -0
  7. data/app/models/concerns/ruby_conversations/message_attributes.rb +44 -0
  8. data/app/models/concerns/ruby_conversations/message_processing.rb +48 -0
  9. data/app/models/concerns/ruby_conversations/message_validation.rb +48 -0
  10. data/app/models/concerns/ruby_conversations/storable.rb +13 -0
  11. data/app/models/ruby_conversations/conversation.rb +102 -0
  12. data/app/models/ruby_conversations/message.rb +108 -0
  13. data/app/models/ruby_conversations/message_builder.rb +45 -20
  14. data/app/models/ruby_conversations/message_input.rb +69 -0
  15. data/app/models/ruby_conversations/message_prompt.rb +110 -0
  16. data/app/models/ruby_conversations/prompt.rb +48 -39
  17. data/lib/ruby_conversations/aws_credential_provider.rb +99 -0
  18. data/lib/ruby_conversations/client.rb +90 -0
  19. data/lib/ruby_conversations/configuration.rb +3 -13
  20. data/lib/ruby_conversations/engine.rb +2 -0
  21. data/lib/ruby_conversations/errors.rb +9 -0
  22. data/lib/ruby_conversations/version.rb +1 -1
  23. data/lib/ruby_conversations.rb +16 -30
  24. metadata +55 -76
  25. data/Rakefile +0 -12
  26. data/app/models/concerns/ruby_conversations/messageable.rb +0 -13
  27. data/app/models/concerns/ruby_conversations/versionable.rb +0 -20
  28. data/app/models/ruby_conversations/ai_conversation.rb +0 -116
  29. data/app/models/ruby_conversations/ai_message.rb +0 -28
  30. data/app/models/ruby_conversations/ai_message_input.rb +0 -22
  31. data/app/models/ruby_conversations/ai_message_prompt.rb +0 -28
  32. data/app/models/ruby_conversations/prompt_version.rb +0 -16
  33. data/lib/generators/ruby_conversations/install/install_generator.rb +0 -60
  34. data/lib/generators/ruby_conversations/install/templates/README.md +0 -42
  35. data/lib/generators/ruby_conversations/install/templates/initializer.rb.erb +0 -18
  36. data/lib/generators/ruby_conversations/install/templates/migrations/create_ai_conversations.rb.erb +0 -13
  37. data/lib/generators/ruby_conversations/install/templates/migrations/create_ai_message_inputs.rb.erb +0 -13
  38. data/lib/generators/ruby_conversations/install/templates/migrations/create_ai_message_prompts.rb.erb +0 -18
  39. data/lib/generators/ruby_conversations/install/templates/migrations/create_ai_messages.rb.erb +0 -18
  40. data/lib/generators/ruby_conversations/install/templates/migrations/create_ai_tool_calls.rb.erb +0 -15
  41. data/lib/generators/ruby_conversations/install/templates/migrations/create_prompt_versions.rb.erb +0 -14
  42. data/lib/generators/ruby_conversations/install/templates/migrations/create_prompts.rb.erb +0 -16
  43. data/lib/ruby_conversations/jwt_client.rb +0 -23
  44. data/lib/ruby_conversations/storage/base.rb +0 -28
  45. data/lib/ruby_conversations/storage/local.rb +0 -30
  46. data/lib/ruby_conversations/storage/remote.rb +0 -93
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module RubyConversations
6
+ # Represents an input value for a message prompt
7
+ class MessageInput
8
+ include ActiveModel::Model
9
+
10
+ # Define attributes
11
+ attr_accessor :id, :message_prompt_id, :placeholder_name, :value,
12
+ :created_at, :updated_at
13
+
14
+ # Validations
15
+ validates :placeholder_name, presence: true
16
+ validate :value_not_nil
17
+
18
+ # Association-like methods
19
+ def message_prompt=(prompt)
20
+ @message_prompt = prompt
21
+ @message_prompt_id = prompt&.id
22
+ end
23
+
24
+ attr_reader :message_prompt
25
+
26
+ # Scopes
27
+ def self.ordered
28
+ all.sort_by(&:placeholder_name)
29
+ end
30
+
31
+ def self.all
32
+ @all ||= []
33
+ end
34
+
35
+ # Attributes method for serialization/logging
36
+ def attributes
37
+ base_attributes
38
+ end
39
+
40
+ # Method for API serialization
41
+ def attributes_for_api
42
+ {
43
+ id: id,
44
+ placeholder_name: placeholder_name,
45
+ value: value
46
+ }.compact
47
+ end
48
+
49
+ private
50
+
51
+ def value_not_nil
52
+ errors.add(:value, "can't be blank") if value.nil?
53
+ end
54
+
55
+ def base_attributes
56
+ {
57
+ 'id' => id,
58
+ 'message_prompt_id' => message_prompt_id,
59
+ 'placeholder_name' => placeholder_name,
60
+ 'value' => value,
61
+ 'created_at' => created_at,
62
+ 'updated_at' => updated_at
63
+ }
64
+ end
65
+ end
66
+
67
+ # Alias for compatibility with Zeitwerk
68
+ AIMessageInput = MessageInput
69
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require_relative 'message_input'
5
+
6
+ module RubyConversations
7
+ # Represents a prompt used to generate a message in a conversation
8
+ class MessagePrompt
9
+ include ActiveModel::Model
10
+
11
+ # Define attributes
12
+ attr_accessor :id, :message_id, :prompt_version_id, :name, :role, :content, :metadata,
13
+ :created_at, :updated_at, :message_inputs, :draft, :message
14
+
15
+ # Define nested attributes writer
16
+ def message_inputs_attributes=(attributes)
17
+ @message_inputs ||= []
18
+ attributes.each do |attrs|
19
+ @message_inputs << RubyConversations::MessageInput.new(attrs)
20
+ end
21
+ end
22
+
23
+ # Initialization
24
+ def initialize(attributes = {})
25
+ @message_inputs = [] # Initialize inputs array
26
+ # Extract nested inputs before super
27
+ inputs_attributes = extract_nested_attributes!(attributes, :message_inputs)
28
+
29
+ super
30
+ initialize_message_inputs(inputs_attributes)
31
+ end
32
+
33
+ # Handle nested inputs initialization
34
+ def initialize_message_inputs(attributes_array)
35
+ (attributes_array || []).each do |attrs|
36
+ next if attrs.blank?
37
+
38
+ @message_inputs << RubyConversations::MessageInput.new(attrs)
39
+ end
40
+ end
41
+
42
+ # Attributes method for serialization/logging
43
+ def attributes
44
+ base_attributes.merge(message_inputs_attributes_hash)
45
+ end
46
+
47
+ # Method for API serialization
48
+ def attributes_for_api
49
+ {
50
+ id: id,
51
+ prompt_version_id: prompt_version_id,
52
+ name: name,
53
+ role: role,
54
+ content: content,
55
+ metadata: metadata,
56
+ draft: draft,
57
+ ai_message_inputs_attributes: message_inputs.map(&:attributes_for_api)
58
+ }.compact
59
+ end
60
+
61
+ private
62
+
63
+ def base_attributes
64
+ identity_attributes.merge(content_attributes).merge(timestamp_attributes)
65
+ end
66
+
67
+ def identity_attributes
68
+ {
69
+ 'id' => id,
70
+ 'message_id' => message_id,
71
+ 'prompt_version_id' => prompt_version_id,
72
+ 'message' => message
73
+ }
74
+ end
75
+
76
+ def content_attributes
77
+ {
78
+ 'name' => name,
79
+ 'role' => role,
80
+ 'content' => content,
81
+ 'metadata' => metadata,
82
+ 'draft' => draft
83
+ }
84
+ end
85
+
86
+ def timestamp_attributes
87
+ {
88
+ 'created_at' => created_at,
89
+ 'updated_at' => updated_at
90
+ }
91
+ end
92
+
93
+ def message_inputs_attributes_hash
94
+ {
95
+ 'message_inputs' => message_inputs.map(&:attributes)
96
+ }
97
+ end
98
+
99
+ # Helper method to extract nested attributes
100
+ def extract_nested_attributes!(attributes, key)
101
+ nested = extract_key_attributes(attributes, key)
102
+ nested_attributes = extract_key_attributes(attributes, "#{key}_attributes")
103
+ nested.concat(nested_attributes)
104
+ end
105
+
106
+ def extract_key_attributes(attributes, key)
107
+ attributes.delete(key) || attributes.delete(key.to_s) || attributes.delete(key.to_sym) || []
108
+ end
109
+ end
110
+ end
@@ -1,61 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyConversations
4
- # A prompt is a message template that can be used to generate messages for an AI conversation.
5
- class Prompt < ActiveRecord::Base
6
- include Versionable
3
+ require 'active_model'
7
4
 
8
- # Constants
9
- VALID_ROLES = %w[system user assistant].freeze
5
+ module RubyConversations
6
+ # Represents a prompt template used to generate AI messages.
7
+ class Prompt
8
+ include ActiveModel::Model
10
9
 
11
- # Associations
12
- has_many :versions, class_name: RubyConversations.configuration.prompt_version_class
13
- has_many :message_prompts, class_name: RubyConversations.configuration.message_prompt_class, dependent: :nullify
14
- has_many :messages, through: :message_prompts, class_name: RubyConversations.configuration.message_class,
15
- source: :ai_message, dependent: :nullify
16
- belongs_to :organization, optional: true
10
+ # Define attributes
11
+ attr_accessor :id, :name, :role, :message, :valid_placeholders, :temperature, :metadata, :created_at, :updated_at,
12
+ :latest_version_id
17
13
 
18
- # Validations
19
- validates :name, presence: true, uniqueness: true
20
- validates :temperature, numericality: { greater_than_or_equal_to: 0.0 }, allow_nil: true
21
- validates :role, presence: true, inclusion: { in: VALID_ROLES }
22
-
23
- # Scopes
24
- scope :active, -> { where(active: true) }
25
- scope :by_role, ->(role) { where(role: role) }
26
- scope :with_versions, -> { includes(:versions) }
27
- scope :ordered_by_name, -> { order(:name) }
28
- scope :internal, -> { where(internal: true) }
29
- scope :external, -> { where(internal: false) }
14
+ # Constants
15
+ ROLES = %w[system user assistant].freeze
30
16
 
31
17
  # Class methods
18
+ def self.roles
19
+ ROLES
20
+ end
21
+
32
22
  def self.find_by_name!(name)
33
- find_by!(name: name.to_s.strip)
23
+ prompt_data = RubyConversations.client.fetch_prompt(name)
24
+ raise "Prompt not found: #{name}" unless prompt_data
25
+
26
+ new(prompt_data)
34
27
  end
35
28
 
36
- def self.roles
37
- VALID_ROLES
29
+ def self.find_by!(conditions)
30
+ name = conditions[:name]
31
+ find_by_name!(name)
38
32
  end
39
33
 
40
- # Instance methods
41
- def latest_version_id
42
- populate_initial_version!
43
- versions.last&.id
34
+ # Validations
35
+ validates :name, presence: true
36
+ validates :role, presence: true, inclusion: { in: ROLES }
37
+ validates :message, presence: true
38
+
39
+ # Initialization
40
+ def initialize(attributes = {})
41
+ super
44
42
  end
45
43
 
46
- def populate_initial_version!
47
- versions.create!(message:, temperature:, created_at:, updated_at:) if versions.empty?
44
+ # Basic attributes method
45
+ def attributes
46
+ instance_values
48
47
  end
49
48
 
50
- def interpolate(inputs = {})
51
- interpolated = message.dup
52
- format(interpolated, **inputs)
49
+ # Method for API serialization
50
+ def attributes_for_api
51
+ {
52
+ id: id,
53
+ name: name,
54
+ role: role,
55
+ message: message,
56
+ valid_placeholders: valid_placeholders,
57
+ temperature: temperature,
58
+ metadata: metadata,
59
+ latest_version_id: latest_version_id
60
+ }.compact
53
61
  end
54
62
 
55
- private
63
+ # Interpolate placeholders in the message
64
+ def interpolate(variables = {})
65
+ return message if message.nil? || variables.empty?
56
66
 
57
- def versioned_attributes
58
- %i[content role]
67
+ format(message, **variables)
59
68
  end
60
69
  end
61
70
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-core'
4
+ require_relative 'errors'
5
+
6
+ module RubyConversations
7
+ # Manages AWS credentials with automatic refresh capability
8
+ class AwsCredentialProvider
9
+ class << self
10
+ def instance
11
+ @instance ||= new
12
+ end
13
+ end
14
+
15
+ def initialize
16
+ refresh_credentials!
17
+ end
18
+
19
+ def access_key_id
20
+ refresh_if_expired!
21
+ @credentials&.access_key_id
22
+ end
23
+
24
+ def secret_access_key
25
+ refresh_if_expired!
26
+ @credentials&.secret_access_key
27
+ end
28
+
29
+ def session_token
30
+ refresh_if_expired!
31
+ @credentials&.session_token
32
+ end
33
+
34
+ attr_reader :expiration
35
+
36
+ def refresh_credentials!
37
+ fetch_and_set_credentials
38
+ end
39
+
40
+ def refresh_if_expired!
41
+ return unless expired?
42
+
43
+ refresh_credentials!
44
+ end
45
+
46
+ private
47
+
48
+ def fetch_and_set_credentials
49
+ if use_mock_credentials?
50
+ set_mock_credentials
51
+ else
52
+ fetch_and_set_real_credentials
53
+ end
54
+ end
55
+
56
+ def use_mock_credentials?
57
+ ENV['RAILS_ENV'] != 'production'
58
+ end
59
+
60
+ def set_mock_credentials
61
+ @credentials = Aws::Credentials.new(
62
+ 'mock_access_key_id',
63
+ 'mock_secret_access_key',
64
+ 'mock_session_token'
65
+ )
66
+ @expiration = Time.now + 3600 # 1 hour
67
+ end
68
+
69
+ def fetch_and_set_real_credentials
70
+ ecs_credentials = Aws::CredentialProviderChain.new.resolve
71
+ raise ConfigurationError, 'Could not resolve AWS credentials' if ecs_credentials.nil?
72
+
73
+ refresh_if_supported(ecs_credentials)
74
+ assign_credentials(ecs_credentials)
75
+ end
76
+
77
+ def refresh_if_supported(credentials)
78
+ credentials.refresh! if credentials.respond_to?(:refresh!)
79
+ end
80
+
81
+ def assign_credentials(ecs_credentials)
82
+ if ecs_credentials.respond_to?(:credentials)
83
+ @credentials = ecs_credentials.credentials
84
+ @expiration = ecs_credentials.expiration if ecs_credentials.respond_to?(:expiration)
85
+ else
86
+ @credentials = ecs_credentials
87
+ @expiration = nil
88
+ end
89
+ end
90
+
91
+ def expired?
92
+ return false if @expiration.nil?
93
+ return true if @credentials.nil?
94
+
95
+ # Refresh if we're within 5 minutes of expiration
96
+ @expiration < Time.now + 300
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'jwt'
5
+
6
+ module RubyConversations
7
+ # HTTP client for interacting with the conversations API
8
+ class Client
9
+ PROMPT_ATTRIBUTES = %w[
10
+ id name message role temperature valid_placeholders created_at updated_at latest_version_id
11
+ ].freeze
12
+
13
+ def initialize(url:, jwt_secret:)
14
+ @url = url
15
+ @jwt_secret = jwt_secret
16
+ @jwt_token = nil
17
+ @jwt_expiration = nil
18
+ end
19
+
20
+ def store_conversation(conversation)
21
+ response = client.post('api/ai_conversations', conversation.conversation_attributes_for_storage)
22
+ handle_response(response)
23
+ end
24
+
25
+ def fetch_prompt(name)
26
+ response = client.get("api/prompts/#{name}")
27
+ data = handle_response(response)
28
+ map_prompt_attributes(data)
29
+ end
30
+
31
+ private
32
+
33
+ def map_prompt_attributes(data)
34
+ PROMPT_ATTRIBUTES.each_with_object({}) do |attr_name, attrs|
35
+ attrs[attr_name.to_sym] = data[attr_name]
36
+ end
37
+ end
38
+
39
+ def client
40
+ @client ||= build_client
41
+ end
42
+
43
+ def build_client
44
+ Faraday.new(url: @url) do |faraday|
45
+ faraday.request :json
46
+ faraday.response :json
47
+ faraday.request :authorization, 'Bearer', -> { current_jwt }
48
+ faraday.adapter Faraday.default_adapter
49
+ end
50
+ end
51
+
52
+ def current_jwt
53
+ refresh_jwt if token_expired?
54
+ @jwt_token
55
+ end
56
+
57
+ def token_expired?
58
+ @jwt_token.nil? || @jwt_expiration.nil? || Time.now.to_i >= @jwt_expiration
59
+ end
60
+
61
+ def refresh_jwt
62
+ if @jwt_secret.respond_to?(:call)
63
+ refresh_callable_jwt
64
+ else
65
+ refresh_static_jwt
66
+ end
67
+ end
68
+
69
+ def refresh_callable_jwt
70
+ @jwt_token = @jwt_secret.call
71
+ decoded_token = JWT.decode(@jwt_token, nil, false).first
72
+ @jwt_expiration = decoded_token['exp']
73
+ end
74
+
75
+ def refresh_static_jwt
76
+ @jwt_expiration = Time.now.to_i + 3600 # 1 hour
77
+ payload = { exp: @jwt_expiration, iat: Time.now.to_i }
78
+ @jwt_token = JWT.encode(payload, @jwt_secret, 'HS256')
79
+ end
80
+
81
+ def handle_response(response)
82
+ case response.status
83
+ when 200, 201
84
+ response.body
85
+ else
86
+ raise Error, "API request failed: #{response.body}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -3,26 +3,16 @@
3
3
  module RubyConversations
4
4
  # Configuration options for RubyConversations
5
5
  class Configuration
6
- attr_accessor :storage_mode, :api_url, :jwt_secret, :default_llm_model, :default_llm_provider,
7
- :conversation_class, :message_class, :message_input_class, :message_prompt_class,
8
- :prompt_class, :prompt_version_class
6
+ attr_accessor :api_url, :jwt_secret, :default_llm_model, :default_llm_provider
9
7
 
10
8
  def initialize
11
- @storage_mode = :local
12
9
  @default_llm_model = 'claude-3-7-sonnet'
13
10
  @default_llm_provider = 'bedrock'
14
- @conversation_class = 'AiConversation'
15
- @message_class = 'AiMessage'
16
- @message_input_class = 'AiMessageInput'
17
- @message_prompt_class = 'AiMessagePrompt'
18
- @prompt_class = 'Prompt'
19
- @prompt_version_class = 'PromptVersion'
20
11
  end
21
12
 
22
13
  def validate!
23
- return unless storage_mode == :remote
24
- raise ConfigurationError, 'api_url is required for remote storage' unless api_url
25
- raise ConfigurationError, 'jwt_secret is required for remote storage' unless jwt_secret
14
+ raise ConfigurationError, 'api_url is required' unless api_url
15
+ raise ConfigurationError, 'jwt_secret is required' unless jwt_secret
26
16
  end
27
17
  end
28
18
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'strongmind/auth'
4
+
3
5
  module RubyConversations
4
6
  # Defines the Rails engine for RubyConversations.
5
7
  class Engine < ::Rails::Engine
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyConversations
4
+ # Base error class for the gem
5
+ class Error < StandardError; end
6
+
7
+ # Error raised when there is a configuration issue
8
+ class ConfigurationError < Error; end
9
+ end
@@ -3,7 +3,7 @@
3
3
  module RubyConversations
4
4
  MAJOR = 1
5
5
  MINOR = 0
6
- PATCH = 6
6
+ PATCH = 8
7
7
 
8
8
  VERSION = "#{RubyConversations::MAJOR}.#{RubyConversations::MINOR}.#{RubyConversations::PATCH}".freeze
9
9
  end
@@ -1,21 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails'
4
- require 'active_record'
5
- require 'ruby_llm'
6
- require 'faraday'
7
- require 'jwt'
8
3
  require 'zeitwerk'
4
+ require 'ruby_conversations/version'
9
5
  require 'ruby_conversations/configuration'
6
+ require 'ruby_conversations/errors'
7
+ require 'ruby_conversations/client'
8
+ require 'ruby_conversations/aws_credential_provider'
9
+ require 'ruby_llm'
10
10
 
11
- loader = Zeitwerk::Loader.for_gem
12
- loader.ignore("#{__dir__}/generators")
13
-
14
- # Main module for the RubyConversations engine.
11
+ # Main module for RubyConversations
15
12
  module RubyConversations
16
- class Error < StandardError; end
17
- class ConfigurationError < Error; end
18
-
19
13
  class << self
20
14
  attr_writer :configuration
21
15
 
@@ -25,33 +19,22 @@ module RubyConversations
25
19
 
26
20
  def configure
27
21
  yield(configuration)
28
- configuration.validate!
29
22
  end
30
23
 
31
- def storage
32
- @storage ||= case configuration.storage_mode
33
- when :local
34
- build_local_storage
35
- when :remote
36
- build_remote_storage
37
- else
38
- raise ConfigurationError, "Unknown storage mode: #{configuration.storage_mode}"
39
- end
24
+ def client
25
+ @client ||= build_client
40
26
  end
41
27
 
42
28
  def reset!
43
- @configuration = Configuration.new
44
- @storage = nil
29
+ @configuration = nil
30
+ @client = nil
45
31
  end
46
32
 
47
33
  private
48
34
 
49
- def build_local_storage
50
- Storage::Local.new
51
- end
52
-
53
- def build_remote_storage
54
- Storage::Remote.new(
35
+ def build_client
36
+ configuration.validate!
37
+ Client.new(
55
38
  url: configuration.api_url,
56
39
  jwt_secret: configuration.jwt_secret
57
40
  )
@@ -59,4 +42,7 @@ module RubyConversations
59
42
  end
60
43
  end
61
44
 
45
+ # Set up Zeitwerk autoloading
46
+ loader = Zeitwerk::Loader.new
47
+ loader.push_dir("#{__dir__}/../app/models")
62
48
  loader.setup