rasti-ai 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e5f39be89f25780d7f9b33adf06b12109c9ddb482c2aa85220d400cfb945c000
4
+ data.tar.gz: e4ce69118debdf636aab53cf8fa25cfd233633ac5c499f9f0bc1a4b5bc05294f
5
+ SHA512:
6
+ metadata.gz: 2f19e650f0908142aedbc09b9d8d372b24da94979f23464d79de17f8c44cd2c3ed86c666dfc3dc46066bf800ecd572c16cc2b3c321692601d29c76c89a38170e
7
+ data.tar.gz: a9da7e1802d9877e1171eb9cfa5b602ad642de251131ee6cdbe0bf77cbf810a24817af8846e246a2a57cf47191dc00542d60b2591ded65ef87860e3ec3104d63
@@ -0,0 +1,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ '**' ]
6
+ pull_request:
7
+ branches: [ '**' ]
8
+
9
+ jobs:
10
+ test:
11
+
12
+ name: Tests
13
+ runs-on: ubuntu-22.04
14
+ strategy:
15
+ matrix:
16
+ ruby-version: ['2.3', '2.4', '2.5', '2.6', '2.7', '3.0', 'jruby-9.2.9.0']
17
+
18
+ steps:
19
+ - uses: actions/checkout@v3
20
+
21
+ - name: Set up Ruby
22
+ uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: ${{ matrix.ruby-version }}
25
+ bundler-cache: false
26
+
27
+ - name: Install native dependencies for Ruby 3.0
28
+ if: ${{ startsWith(matrix.ruby-version, '3.') }}
29
+ run: |
30
+ sudo apt-get update
31
+ sudo apt-get install -y libcurl4-openssl-dev
32
+
33
+ - name: Configure bundler
34
+ if: ${{ startsWith(matrix.ruby-version, '3.') }}
35
+ run: |
36
+ bundle config set --local force_ruby_platform true
37
+ bundle install
38
+
39
+ - name: Install dependencies for other Ruby versions
40
+ if: ${{ !startsWith(matrix.ruby-version, '3.') }}
41
+ run: bundle install
42
+
43
+ - name: Run tests
44
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /.ruby-env
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ rasti-ai
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-2.3.8
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rasti-ai.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Gabriel Naiman
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/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # Rasti::AI
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/rasti-ai.svg)](https://rubygems.org/gems/rasti-ai)
4
+ [![CI](https://github.com/gabynaiman/rasti-ai/actions/workflows/ci.yml/badge.svg)](https://github.com/gabynaiman/rasti-ai/actions/workflows/ci.yml)
5
+
6
+ AI for apps
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'rasti-ai'
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ $ bundle
19
+
20
+ Or install it yourself as:
21
+
22
+ $ gem install rasti-ai
23
+
24
+ ## Usage
25
+
26
+ ### Configuration
27
+ ```ruby
28
+ Rasti::AI.configure do |config|
29
+ config.logger = Logger.new 'log/development.log'
30
+ config.openai_api_key = 'abcd12345' # Default ENV['OPENAI_API_KEY']
31
+ config.openai_default_model = 'gpt-4o-mini' # Default ENV['OPENAI_DEFAULT_MODEL']
32
+ end
33
+ ```
34
+
35
+ ### Open AI
36
+
37
+ #### Assistant
38
+ ```ruby
39
+ assistant = Rasti::AI::OpenAI::Assistant.new
40
+ assistant.call 'who is the best player' # => 'The best player is Lionel Messi'
41
+ ```
42
+
43
+ #### Tools
44
+ ```ruby
45
+ class GetCurrentTime
46
+ def call(params={})
47
+ Time.now.iso8601
48
+ end
49
+ end
50
+
51
+ class GetCurrentWeather
52
+ def self.form
53
+ Rasti::Form[location: Rasti::Types::String]
54
+ end
55
+
56
+ def call(params={})
57
+ response = HTTP.get "https://api.wheater.com/?location=#{params['location']}"
58
+ response.body.to_s
59
+ end
60
+ end
61
+
62
+ tools = [
63
+ GetCurrentTime.new,
64
+ GetCurrentWeather.new
65
+ ]
66
+
67
+ assistant = Rasti::AI::OpenAI::Assistant.new tools: tools
68
+
69
+ assistant.call 'what time is it' # => 'The current time is 3:03 PM on April 28, 2025.'
70
+
71
+ assistant.call 'what is the weather in Buenos Aires' # => 'In Buenos Aires it is 15 degrees'
72
+ ```
73
+
74
+ #### Context and state
75
+ ```ruby
76
+ state = Rasti::AI::OpenAI::AssistantState.new context: 'Act as sports journalist'
77
+
78
+ assistant = Rasti::AI::OpenAI::Assistant.new state: state
79
+
80
+ assistant.call 'who is the best player'
81
+
82
+ state.messages
83
+ # [
84
+ # {
85
+ # role: 'system',
86
+ # content: 'Act as sports journalist'
87
+ # },
88
+ # {
89
+ # role: 'user',
90
+ # content: 'who is the best player'
91
+ # },
92
+ # {
93
+ # role: 'assistant',
94
+ # content: 'The best player is Lionel Messi'
95
+ # }
96
+ # ]
97
+ ```
98
+
99
+ ## Contributing
100
+
101
+ Bug reports and pull requests are welcome on GitHub at https://github.com/gabynaiman/rasti-ai.
102
+
103
+
104
+ ## License
105
+
106
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
107
+
data/Rakefile ADDED
@@ -0,0 +1,25 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:spec) do |t|
5
+ t.libs << 'spec'
6
+ t.libs << 'lib'
7
+ t.pattern = ENV['DIR'] ? File.join(ENV['DIR'], '**', '*_spec.rb') : 'spec/**/*_spec.rb'
8
+ t.verbose = false
9
+ t.warning = false
10
+ t.loader = nil if ENV['TEST']
11
+ ENV['TEST'], ENV['LINE'] = ENV['TEST'].split(':') if ENV['TEST'] && !ENV['LINE']
12
+ t.options = ''
13
+ t.options << "--name=/#{ENV['NAME']}/ " if ENV['NAME']
14
+ t.options << "-l #{ENV['LINE']} " if ENV['LINE'] && ENV['TEST']
15
+ end
16
+
17
+ task default: :spec
18
+
19
+ desc 'Pry console'
20
+ task :console do
21
+ require 'rasti-ai'
22
+ require 'pry'
23
+ ARGV.clear
24
+ Pry.start
25
+ end
@@ -0,0 +1,39 @@
1
+ module Rasti
2
+ module AI
3
+ module Errors
4
+
5
+ class RequestFail < StandardError
6
+
7
+ attr_reader :url, :body, :response
8
+
9
+ def initialize(url, body, response)
10
+ @url = url
11
+ @body = body
12
+ @response = response
13
+ end
14
+
15
+ def message
16
+ "Request fail\nRequest: #{url}\n#{JSON.pretty_generate(body)}\nResponse: #{response.code}\n#{response.body}"
17
+ end
18
+
19
+ end
20
+
21
+ class ToolSerializationError < StandardError
22
+
23
+ def initialize(tool_class)
24
+ super "Tool serialization error: #{tool_class}"
25
+ end
26
+
27
+ end
28
+
29
+ class UndefinedTool < StandardError
30
+
31
+ def initialize(tool_name)
32
+ super "Undefined tool #{tool_name}"
33
+ end
34
+
35
+ end
36
+
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,96 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ class Assistant
5
+
6
+ attr_reader :state
7
+
8
+ def initialize(client:nil, state:nil, model:nil, tools:[], logger:nil)
9
+ @client = client || Client.new
10
+ @state = state || AssistantState.new
11
+ @model = model
12
+ @tools = {}
13
+ @serialized_tools = []
14
+ @logger = logger || Rasti::AI.logger
15
+
16
+ tools.each do |tool|
17
+ serialization = ToolSerializer.serialize tool.class
18
+ @tools[serialization[:function][:name]] = tool
19
+ @serialized_tools << serialization
20
+ end
21
+ end
22
+
23
+ def call(prompt)
24
+ messages << {
25
+ role: Roles::USER,
26
+ content: prompt
27
+ }
28
+
29
+ loop do
30
+ response = client.chat_completions messages: messages,
31
+ model: model,
32
+ tools: serialized_tools
33
+
34
+ choice = response['choices'][0]['message']
35
+
36
+ if choice['tool_calls']
37
+ messages << {
38
+ role: Roles::ASSISTANT,
39
+ tool_calls: choice['tool_calls']
40
+ }
41
+
42
+ choice['tool_calls'].each do |tool_call|
43
+ name = tool_call['function']['name']
44
+ args = JSON.parse tool_call['function']['arguments']
45
+
46
+ result = call_tool name, args
47
+
48
+ messages << {
49
+ role: Roles::TOOL,
50
+ tool_call_id: tool_call['id'],
51
+ content: result
52
+ }
53
+ end
54
+ else
55
+ messages << {
56
+ role: Roles::ASSISTANT,
57
+ content: choice['content']
58
+ }
59
+
60
+ return choice['content']
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :client, :model, :tools, :serialized_tools, :logger
68
+
69
+ def messages
70
+ state.messages
71
+ end
72
+
73
+ def call_tool(name, args)
74
+ raise Errors::UndefinedTool.new(name) unless tools.key? name
75
+
76
+ key = "#{name} -> #{args}"
77
+
78
+ state.fetch(key) do
79
+ logger.info(self.class) { "Calling function #{name} with #{args}" }
80
+
81
+ result = tools[name].call args
82
+
83
+ logger.info(self.class) { "Function result: #{result}" }
84
+
85
+ result
86
+ end
87
+
88
+ rescue => ex
89
+ logger.warn(self.class) { "Function failed: #{ex.message}\n#{ex.backtrace.join("\n")}" }
90
+ "Error: #{ex.message}"
91
+ end
92
+
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,27 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ class AssistantState
5
+
6
+ attr_reader :messages
7
+
8
+ def initialize(context:nil)
9
+ @messages = []
10
+ @cache = {}
11
+
12
+ messages << {role: Roles::SYSTEM, content: context} if context
13
+ end
14
+
15
+ def fetch(key, &block)
16
+ cache[key] = block.call unless cache.key? key
17
+ cache[key]
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :cache
23
+
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,57 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ class Client
5
+
6
+ BASE_URL = 'https://api.openai.com/v1'.freeze
7
+
8
+ def initialize(api_key:nil, logger:nil)
9
+ @api_key = api_key || Rasti::AI.openai_api_key
10
+ @logger = logger || Rasti::AI.logger
11
+ end
12
+
13
+ def chat_completions(messages:, model:nil, tools:[])
14
+ body = {
15
+ model: model || Rasti::AI.openai_default_model,
16
+ messages: messages,
17
+ tools: tools,
18
+ tool_choice: tools.empty? ? 'none' : 'auto'
19
+ }
20
+
21
+ post '/chat/completions', body
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :api_key, :logger
27
+
28
+ def post(relative_url, body)
29
+ url = "#{BASE_URL}#{relative_url}"
30
+ uri = URI(url)
31
+
32
+ logger.info(self.class) { "POST #{url}" }
33
+ logger.debug(self.class) { JSON.pretty_generate(body) }
34
+
35
+ request = Net::HTTP::Post.new uri
36
+ request['Authorization'] = "Bearer #{api_key}"
37
+ request['Content-Type'] = 'application/json'
38
+ request.body = JSON.dump(body)
39
+
40
+ http = Net::HTTP.new uri.host, uri.port
41
+ http.use_ssl = uri.scheme == 'https'
42
+
43
+ response = http.request request
44
+
45
+ logger.info(self.class) { "Response #{response.code}" }
46
+ logger.debug(self.class) { response.body }
47
+
48
+ raise Errors::RequestFail.new(url, body, response) unless response.is_a? Net::HTTPSuccess
49
+
50
+ JSON.parse response.body
51
+ end
52
+
53
+ end
54
+ end
55
+
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ module Roles
5
+
6
+ ASSISTANT = 'assistant'.freeze
7
+ SYSTEM = 'system'.freeze
8
+ TOOL = 'tool'.freeze
9
+ USER = 'user'.freeze
10
+
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,111 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ class ToolSerializer
5
+ class << self
6
+
7
+ def serialize(tool_class)
8
+ {
9
+ type: 'function',
10
+ function: serialize_function(tool_class)
11
+ }
12
+
13
+ rescue => ex
14
+ raise Errors::ToolSerializationError.new(tool_class), cause: ex
15
+ end
16
+
17
+ private
18
+
19
+ def serialize_function(tool_class)
20
+ serialization = {
21
+ name: serialize_name(tool_class)
22
+ }
23
+
24
+ serialization[:description] = normalize_description(tool_class.description) if tool_class.respond_to? :description
25
+
26
+ serialization[:parameters] = serialize_form(tool_class.form) if tool_class.respond_to? :form
27
+
28
+ serialization
29
+ end
30
+
31
+ def serialize_name(tool_class)
32
+ Inflecto.underscore Inflecto.demodulize(tool_class.name)
33
+ end
34
+
35
+ def serialize_form(form_class)
36
+ serialized_attributes = form_class.attributes.each_with_object({}) do |attribute, hash|
37
+ hash[attribute.name] = serialize_attribute attribute
38
+ end
39
+
40
+ serialization = {
41
+ type: 'object',
42
+ properties: serialized_attributes
43
+ }
44
+
45
+ required_attributes = form_class.attributes.select { |a| a.option(:required) }
46
+
47
+ serialization[:required] = required_attributes.map(&:name) unless required_attributes.empty?
48
+
49
+ serialization
50
+ end
51
+
52
+ def serialize_attribute(attribute)
53
+ serialization = {}
54
+
55
+ if attribute.option(:description)
56
+ serialization[:description] = normalize_description attribute.option(:description)
57
+ end
58
+
59
+ serialization.merge! serialize_type(attribute.type)
60
+
61
+ serialization
62
+ end
63
+
64
+ def serialize_type(type)
65
+ if type == Types::String
66
+ {type: 'string'}
67
+
68
+ elsif type == Types::Integer
69
+ {type: 'integer'}
70
+
71
+ elsif type == Types::Float
72
+ {type: 'number'}
73
+
74
+ elsif type == Types::Boolean
75
+ {type: 'boolean'}
76
+
77
+ elsif type.is_a? Types::Time
78
+ {
79
+ type: 'string',
80
+ format: 'date'
81
+ }
82
+
83
+ elsif type.is_a? Types::Enum
84
+ {
85
+ type: 'string',
86
+ enum: type.values
87
+ }
88
+
89
+ elsif type.is_a? Types::Array
90
+ {
91
+ type: 'array',
92
+ items: serialize_type(type.type)
93
+ }
94
+
95
+ elsif type.is_a? Types::Model
96
+ serialize_form(type.model)
97
+
98
+ else
99
+ raise "Type not serializable #{type}"
100
+ end
101
+ end
102
+
103
+ def normalize_description(description)
104
+ description.split("\n").map(&:strip).join(' ').strip
105
+ end
106
+
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ module Rasti
2
+ module AI
3
+ VERSION = '1.0.0'
4
+ end
5
+ end
data/lib/rasti/ai.rb ADDED
@@ -0,0 +1,24 @@
1
+ require 'multi_require'
2
+ require 'rasti-form'
3
+ require 'class_config'
4
+ require 'inflecto'
5
+ require 'net/http'
6
+ require 'uri'
7
+ require 'json'
8
+ require 'logger'
9
+
10
+ module Rasti
11
+ module AI
12
+
13
+ extend MultiRequire
14
+ extend ClassConfig
15
+
16
+ require_relative_pattern 'ai/**/*'
17
+
18
+ attr_config :logger, Logger.new(STDOUT)
19
+
20
+ attr_config :openai_api_key, ENV['OPENAI_API_KEY']
21
+ attr_config :openai_default_model, ENV['OPENAI_DEFAULT_MODEL']
22
+
23
+ end
24
+ end
data/lib/rasti-ai.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative 'rasti/ai'
data/rasti-ai.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'rasti/ai/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'rasti-ai'
8
+ spec.version = Rasti::AI::VERSION
9
+ spec.authors = ['Gabriel Naiman']
10
+ spec.email = ['gabynaiman@gmail.com']
11
+ spec.summary = 'AI for apps'
12
+ spec.description = 'AI for apps'
13
+ spec.homepage = 'https://github.com/gabynaiman/rasti-ai'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_runtime_dependency 'multi_require', '~> 1.0'
22
+ spec.add_runtime_dependency 'rasti-form', '~> 6.0'
23
+ spec.add_runtime_dependency 'inflecto', '~> 0.0'
24
+ spec.add_runtime_dependency 'class_config', '~> 0.0'
25
+
26
+ spec.add_development_dependency 'rake', '~> 12.0'
27
+ spec.add_development_dependency 'minitest', '~> 5.0', '< 5.11'
28
+ spec.add_development_dependency 'minitest-colorin', '~> 0.1'
29
+ spec.add_development_dependency 'minitest-line', '~> 0.6'
30
+ spec.add_development_dependency 'simplecov', '~> 0.12'
31
+ spec.add_development_dependency 'pry-nav', '~> 0.2'
32
+ spec.add_development_dependency 'webmock', '~> 3.0'
33
+ end
@@ -0,0 +1,2 @@
1
+ require 'simplecov'
2
+ SimpleCov.start
@@ -0,0 +1,26 @@
1
+ require 'coverage_helper'
2
+ require 'minitest/autorun'
3
+ require 'minitest/colorin'
4
+ require 'webmock/minitest'
5
+ require 'pry-nav'
6
+ require 'rasti-ai'
7
+ require 'securerandom'
8
+
9
+ require_relative 'support/helpers/erb'
10
+ require_relative 'support/helpers/resources'
11
+
12
+
13
+ Rasti::AI.configure do |config|
14
+ config.logger.level = Logger::FATAL
15
+
16
+ config.openai_api_key = 'test_api_key'
17
+ config.openai_default_model = 'gpt-test'
18
+ end
19
+
20
+
21
+ class Minitest::Test
22
+
23
+ include Support::Helpers::ERB
24
+ include Support::Helpers::Resources
25
+
26
+ end